Skip to content

SafeAreaProvider never re-emits after a lost insets event — a transient 0 becomes a permanently stale useSafeAreaInsets value (Fabric, Android) #758

Description

@SatyamBansal

Environment

  • react-native-safe-area-context 5.7.0 (issue present by inspection through 5.9.1)
  • react-native 0.86.0, New Architecture (Fabric + Hermes), edge-to-edge Android
  • Reproduced on a physical Samsung SM-A356E, Android 16 (API 36), 3-button navigation

Summary

This is adjacent to #738 / #364, but reports a different (worse) consequence: when the transient inset → 0 → inset sequence those issues describe crosses to JS with the final corrective event lost, useSafeAreaInsets() does not flicker — it stays wrong indefinitely (we measured minutes), while <SafeAreaView> on the same screen is correct. The cause is visible in the provider source: recovery after a lost event is structurally impossible.

What we measured (production app, via React DevTools fiber inspection)

Same app process, no reload between samples:

  1. A bottom sheet rendered ~4 minutes after cold start read insets.bottom === 0 from context (its fallback padding rendered; its last row measured 81px inside the navigation-bar zone via the accessibility tree).
  2. The same sheet re-opened ~15 minutes later read insets.bottom === 48.
  3. Whenever we sampled the SafeAreaInsetsContext.Provider fiber's value directly, it read 48 — i.e. the JS state eventually healed, but only after some unrelated later inset change; components mounted during the stale window rendered wrong and stayed wrong.
  4. With the soft keyboard fully open, the provider still read 48 (keyboard resize ruled out as the trigger on this setup).

Why one lost event wedges JS permanently (source)

SafeAreaProvider.kt:

private fun maybeUpdateInsets() {
  val insetsChangeHandler = mInsetsChangeHandler ?: return
  val edgeInsets = getSafeAreaInsets(this) ?: return
  val frame = getFrame(rootView as ViewGroup, this) ?: return
  if (mLastInsets != edgeInsets || mLastFrame != frame) {
    insetsChangeHandler(this, edgeInsets, frame)  // single event
    mLastInsets = edgeInsets                      // cache updated regardless of delivery
    mLastFrame = frame
  }
}

mLastInsets is committed whether or not the emitted event was ever applied on the JS side. onPreDraw then recomputes the same (correct) value every frame, compares it to the native cache, and never re-sends. So the sequence

emit 48 (applied) → emit 0 (applied) → emit 48 (lost)

leaves JS at 0 forever: every subsequent predraw computes 48, finds mLastInsets == 48, and stays silent. The dedupe is against the provider's own memory, not against what JS actually received. <SafeAreaView> is unaffected because it recomputes per-view on every predraw and pushes through StateWrapper.updateState — no JS event hop to lose.

Where the lost/transient emission comes from in the first place is exactly what #738 documents ("SafeAreaProvider emits a transient inset → 0 → inset across separate frames"); on Fabric there are also known windows where events/state updates are dropped (cf. the 5.8.1 fix "skip Fabric SafeAreaView state updates while detached from window").

Suggested directions

Repro status

No minimal standalone repro yet — the trigger is a nondeterministic startup race (edge-to-edge window-flag churn is our prime suspect; the app also uses react-native-keyboard-controller). Happy to test patches or add instrumentation from the app where we can reproduce the stale window.

Workaround

Replacing the hook-derived padding at the affected edges with an empty <SafeAreaView edges={['bottom']}> spacer fixed it reliably for us — native inset resolution per view, nothing to lose in transit.

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