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:
- 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).
- The same sheet re-opened ~15 minutes later read
insets.bottom === 48.
- 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.
- 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.
Environment
Summary
This is adjacent to #738 / #364, but reports a different (worse) consequence: when the transient
inset → 0 → insetsequence 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:
insets.bottom === 0from context (its fallback padding rendered; its last row measured 81px inside the navigation-bar zone via the accessibility tree).insets.bottom === 48.SafeAreaInsetsContext.Providerfiber'svaluedirectly, 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.Why one lost event wedges JS permanently (source)
SafeAreaProvider.kt:mLastInsetsis committed whether or not the emitted event was ever applied on the JS side.onPreDrawthen recomputes the same (correct) value every frame, compares it to the native cache, and never re-sends. So the sequenceleaves 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 throughStateWrapper.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
mLastInsetsafter delivery is known-good, orinitialMetrics-style reads can re-sync, and/orRepro 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.