fix: iOS 26.4+ can leave reattached views' safeAreaInsets stale (zero-inset latch) - #747
Open
mirceatirea wants to merge 1 commit into
Open
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On iOS 26.4+ (reproduced on 26.5 and 26.6), UIKit can apply
safeAreaInsetsto a reattached subtree without callingsafeAreaInsetsDidChangeand without a subsequent layout pass.RNCSafeAreaProviderComponentViewtrusts 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):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):
RNCSafeAreaProviderComponentView: don't cache/emit whilewindow == nil— a detached subtree's insets are meaningless; emitting them poisons the JS context and the sent-flag. Mirrors theRNCSafeAreaViewComponentViewguard from fix: skip Fabric SafeAreaView state updates while detached from window #735.didMoveToWindowre-invalidates on attach, plus deferred re-checks after the runloop settles (idempotent through the existing threshold check).safeAreaInsetswhilewindow.safeAreaInsetsis 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.invalidateSafeAreaInsetspreviously set_initialInsetsSent = YESbefore checking_eventEmitter, so an emit attempted pre-attach was silently dropped and never retried; JS then never received the initial insets.RNCSafeAreaViewComponentView: retryfindNearestProviderwhen it fell back toself— during reparenting the provider may not be in the ancestor chain yet atdidMoveToWindowtime; the view then binds itsRNCSafeAreaDidChangeobserver 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 teston this branch — passes (prettier check, clang-format check, spotless check,tsc --noEmit, jest):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):
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.