Skip to content

fix: iOS 26.4+ can leave reattached views' safeAreaInsets stale (zero-inset latch) - #747

Open
mirceatirea wants to merge 1 commit into
appandflow:mainfrom
mirceatirea:fix/ios-26-reattached-view-insets-latch
Open

fix: iOS 26.4+ can leave reattached views' safeAreaInsets stale (zero-inset latch)#747
mirceatirea wants to merge 1 commit into
appandflow:mainfrom
mirceatirea:fix/ios-26-reattached-view-insets-latch

Conversation

@mirceatirea

@mirceatirea mirceatirea commented Aug 11, 2026

Copy link
Copy Markdown

Summary

On iOS 26.4+ (reproduced on 26.5 and 26.6), UIKit can apply safeAreaInsets to a reattached subtree without calling safeAreaInsetsDidChange and without a subsequent layout pass. RNCSafeAreaProviderComponentView trusts that callback: a layout pass on the detached subtree caches zero insets with _initialInsetsSent = YES, and after reattach nothing ever re-reads — every consumer under that provider renders with zero insets (content under the status bar) until an unrelated relayout (e.g. a stack push/pop) heals it.

Any app whose navigation reparents mounted subtrees hits this — we found it via expo-router's NativeTabs (UITabBarController reparenting) as an intermittent ~1-in-8 cold-launch bug in a production app. iOS ≤ 26.2 is unaffected (the callback still fires there).

Captured failure sequence — NSLog instrumentation at every decision point in both Fabric components, Release build, iOS 26.5 simulator (P = provider component view, V = SafeAreaView component view; the real top inset is 62):

V attach found=self win=0            # detached during tab reparenting; findNearestProvider falls back to self
P invalidate EMIT top=0.0 win=0      # provider (superview ≠ nil, frame set, window == nil) caches+emits ZERO
V attach found=P win=1               # reattached; view re-finds the real provider
V update SKIP same top=0.0           # reads provider's UIKit insets: still 0 (not yet applied)
P didMoveToWindow top=0.0
P invalidate SKIP unchanged top=0.0  # cached 0 == current 0 → suppressed
P invalidate SKIP unchanged top=0.0  # re-checks up to +150ms: UIKit STILL reports 0
...silence...                        # UIKit then applies top=62 — no safeAreaInsetsDidChange, no layout pass, ever

The zero is latched: the provider's cache says "sent", the view's threshold check says "unchanged", and the OS never delivers another trigger.

The fix (Fabric components only):

  1. RNCSafeAreaProviderComponentView: don't cache/emit while window == nil — a detached subtree's insets are meaningless; emitting them poisons the JS context and the sent-flag. Mirrors the RNCSafeAreaViewComponentView guard from fix: skip Fabric SafeAreaView state updates while detached from window #735. didMoveToWindow re-invalidates on attach, plus deferred re-checks after the runloop settles (idempotent through the existing threshold check).
  2. Window-geometry derivation (both components): when an attached view reads all-zero safeAreaInsets while window.safeAreaInsets is non-zero, derive the view's insets geometrically from the window — the same computation UIKit propagation performs. Substituted only in that precisely-broken state, so legitimate zero-inset cases (status bar hidden, landscape, views positioned outside the unsafe area) are untouched.
  3. Replay the initial event when the Fabric event emitter attachesinvalidateSafeAreaInsets previously set _initialInsetsSent = YES before checking _eventEmitter, so an emit attempted pre-attach was silently dropped and never retried; JS then never received the initial insets.
  4. RNCSafeAreaViewComponentView: retry findNearestProvider when it fell back to self — during reparenting the provider may not be in the ancestor chain yet at didMoveToWindow time; the view then binds its RNCSafeAreaDidChange observer to a view that never posts. Apps genuinely without a provider still converge to the legacy self-fallback.

The Paper (old-architecture) components share the same callback assumption and likely deserve equivalent treatment; this PR only changes the Fabric files, where we could reproduce and validate. Happy to follow up if useful.

Test Plan

yarn test on this branch — passes (prettier check, clang-format check, spotless check, tsc --noEmit, jest):

Test Suites: 3 passed, 3 total
Tests:       13 passed, 13 total
Snapshots:   11 passed, 11 total

Statistical cold-launch harness (the bug is an intermittent race, so we validated statistically): Release build of a production app using expo-router NativeTabs on an iOS 26.5 simulator; each run = terminate → cold launch → screenshot → pixel-classifier (content-under-status-bar detection):

Build Result
Unpatched (three separate builds) 4 occurrences / 40 launches
Patched, with NSLog instrumentation 0 / 40
Patched, instrumentation stripped 0 / 60
Patched, fresh pod artifacts 0 / 20
iOS 26.2, patched (regression check) clean — and 26.2 never reproduced unpatched

Field verification: the original report came from a physical iPhone 16 Pro on iOS 26.6 (intermittent cold-launch overlap, self-healing on any push/pop); the patched build launches clean there.

Note on the example app: the bundled example doesn't exercise a subtree-reparenting navigator (the trigger), so it can't reproduce the bug — hence the harness above. Happy to run any additional verification the maintainers would like, and to share the full instrumentation logs.

…rd detached emits and derive from window

On iOS 26.4+, when a subtree is detached and reattached (e.g. native
tab bars reparenting their content), UIKit can apply safeAreaInsets to
the reattached views WITHOUT calling safeAreaInsetsDidChange and
without a subsequent layout pass. RNCSafeAreaProviderComponentView
caches the detached-read zero insets with _initialInsetsSent = YES, so
every consumer under that provider is stuck at zero until an unrelated
relayout (a stack push/pop "heals" it).

Captured with native instrumentation on an iOS 26.5 simulator
(intermittent ~1-in-8 cold launches; sequence in the PR description):

- provider emits/caches top=0 while window == nil (detached layout pass)
- on reattach, provider and views read top=0 ("unchanged", suppressed)
- UIKit applies the real insets afterwards with no callback, ever

Fix, all in the Fabric components:

- RNCSafeAreaProviderComponentView: skip invalidateSafeAreaInsets while
  window == nil (mirror of the SafeAreaView guard from appandflow#735); re-check
  on didMoveToWindow and after the runloop settles; replay the initial
  event when the event emitter attaches (previously a pre-attach emit
  was silently dropped and never retried).
- Both components: when an attached view reads all-zero safeAreaInsets
  while its window reports non-zero, derive the view's insets
  geometrically from window.safeAreaInsets (the same computation UIKit
  propagation performs) — substituted only in that precisely-broken
  state, so status-bar-hidden/landscape zero-inset cases are untouched.
- RNCSafeAreaViewComponentView: retry findNearestProvider on later
  passes when it fell back to self — during reparenting the provider
  may not be in the ancestor chain yet at didMoveToWindow time, and the
  RNCSafeAreaDidChange observer would stay bound to a view that never
  posts.

Validated with a statistical cold-launch harness on iOS 26.5:
0 occurrences in 120 launches patched vs 4-in-40 baseline; iOS 26.2
unaffected before and after.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant