Skip to content

Fix proposal for the inset flicker (#364, #485): SafeAreaProvider emits a transient inset → 0 → inset across separate frames #738

Description

@magrinj

Summary

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)
  • Per insets.top switching to 0 during iPad orientation change  #485, also reproduces on iOS during orientation changes

Root cause (native)

Both SafeAreaProvider.kt and SafeAreaView.kt register an OnPreDrawListener and recompute the inset on every draw via SafeAreaUtils.getSafeAreaInsets():

val windowInsets = getRootWindowInsetsCompat(rootView) ?: return null // 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions