You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
SafeAreaProvider can emit a transient inset → 0 → inset whose three values land in separate frames. Because they aren't in one batch, React commits the intermediate 0 as its own render, producing a one-frame layout flicker/jump. This affects both <SafeAreaView> and useSafeAreaInsets() (both read from the provider). This is a root-cause write-up + proposed fix for the long-standing #364 (Android flicker) and #485 (iOS insets.top → 0 on orientation change), which appear to be the same underlying behavior.
Environment
react-native-safe-area-context 5.8.0 (also present in earlier 4.x/5.x per the linked issues)
Android edge-to-edge, transparent status bar, windowSoftInputMode="adjustResize" (Pixel 8a, New Architecture / Fabric)
Both SafeAreaProvider.kt and SafeAreaView.kt register an OnPreDrawListener and recompute the inset on every draw via SafeAreaUtils.getSafeAreaInsets():
val windowInsets = getRootWindowInsetsCompat(rootView) ?:returnnull// rootView.rootWindowInsets
view.getGlobalVisibleRect(visibleRect)
top = max(windowInsets.top - visibleRect.top, 0f)
The inset is not stored, it's re-derived every draw from rootView.rootWindowInsets. On Android edge-to-edge the OS re-dispatches WindowInsets on focus/config/redraw events and momentarily reports top = 0 before the real value re-applies. SafeAreaProvider.maybeUpdateInsets() only guards mLastInsets != edgeInsets, so 46 → 0 passes through, then 0 → 46 passes through again, the transient reaches JS.
Why it becomes a visible flicker (the key subtlety)
In SafeAreaContext.tsx, onInsetsChange calls setInsets. React 18 auto-batches, so if 46 → 0 → 46 arrived in one event batch it would net to 46 and never render 0. But the emissions land one frame apart, measured ~19 ms on a 60 Hz device, so React commits the 0 as its own render as its own render. This means same-frame event coalescing (RNscroll-event canCoalesce, floated in #485) does not fix it: the transient is cross-frame.
Evidence
Instrumenting onInsetsChange (raw event) and setInsets (committed):
[RAW] top = 46.095 initial
[RAW] top = 0 transient emitted by the library
[RAW] top = 46.095 reverts 19 ms later
→ React committed 46 → 0 → 46 (a one-frame layout jump)
Proposed fix (JS provider, cross-platform)
Coalesce in onInsetsChange. A spurious value is always momentarily smaller than the real one, so we defer only decreases by a short window and drop them if the inset reverts to the committed value:
committed == null → commit immediately (initial mount)
insets === committed → drop pending (reverted → it was the transient)
increase (all edges ≥) → commit immediately (cold-start restore, keyboard close)
decrease → defer; a revert cancels it, a persistent value commits after the window
This is a mitigation of the propagation; the deeper native fix would stop all (validate/debounce before dispatch, or move Android off per-drawOnPreDrawListener polling onto a WindowInsets listener). Happy to go whichever direction the maintainers prefer, I have a working implementation with on-device validation and can open a PR.
Summary
SafeAreaProvidercan emit a transientinset → 0 → insetwhose three values land in separate frames. Because they aren't in one batch, React commits the intermediate0as its own render, producing a one-frame layout flicker/jump. This affects both<SafeAreaView>anduseSafeAreaInsets()(both read from the provider). This is a root-cause write-up + proposed fix for the long-standing #364 (Android flicker) and #485 (iOSinsets.top→ 0 on orientation change), which appear to be the same underlying behavior.Environment
react-native-safe-area-context5.8.0 (also present in earlier 4.x/5.x per the linked issues)windowSoftInputMode="adjustResize"(Pixel 8a, New Architecture / Fabric)Root cause (native)
Both
SafeAreaProvider.ktandSafeAreaView.ktregister anOnPreDrawListenerand recompute the inset on every draw viaSafeAreaUtils.getSafeAreaInsets():The inset is not stored, it's re-derived every draw from
rootView.rootWindowInsets. On Android edge-to-edge the OS re-dispatchesWindowInsetson focus/config/redraw events and momentarily reportstop = 0before the real value re-applies.SafeAreaProvider.maybeUpdateInsets()only guardsmLastInsets != edgeInsets, so46 → 0passes through, then0 → 46passes through again, the transient reaches JS.Why it becomes a visible flicker (the key subtlety)
In
SafeAreaContext.tsx,onInsetsChangecallssetInsets. React 18 auto-batches, so if46 → 0 → 46arrived in one event batch it would net to46and never render0. But the emissions land one frame apart, measured ~19 ms on a 60 Hz device, so React commits the 0 as its own render as its own render. This means same-frame event coalescing (RNscroll-eventcanCoalesce, floated in #485) does not fix it: the transient is cross-frame.Evidence
Instrumenting
onInsetsChange(raw event) andsetInsets(committed):Proposed fix (JS provider, cross-platform)
Coalesce in
onInsetsChange. A spurious value is always momentarily smaller than the real one, so we defer only decreases by a short window and drop them if the inset reverts to the committed value:0, a persistent0still commits (respects the note in insets.top switching to 0 during iPad orientation change #485 that insets can legitimately be 0).This is a mitigation of the propagation; the deeper native fix would stop all (validate/debounce before dispatch, or move Android off per-draw
OnPreDrawListenerpolling onto aWindowInsetslistener). Happy to go whichever direction the maintainers prefer, I have a working implementation with on-device validation and can open a PR.