From 87645ca325851413550c9943c5a449bdce8536bd Mon Sep 17 00:00:00 2001 From: Mircea Tirea Date: Tue, 11 Aug 2026 16:20:56 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20iOS=2026.4+=20can=20leave=20reattached?= =?UTF-8?q?=20views'=20safeAreaInsets=20stale=20=E2=80=94=20guard=20detach?= =?UTF-8?q?ed=20emits=20and=20derive=20from=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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. --- .../RNCSafeAreaProviderComponentView.mm | 89 ++++++++++++++++++- ios/Fabric/RNCSafeAreaViewComponentView.mm | 79 +++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/ios/Fabric/RNCSafeAreaProviderComponentView.mm b/ios/Fabric/RNCSafeAreaProviderComponentView.mm index f315ccd..54b4380 100644 --- a/ios/Fabric/RNCSafeAreaProviderComponentView.mm +++ b/ios/Fabric/RNCSafeAreaProviderComponentView.mm @@ -71,18 +71,58 @@ - (void)safeAreaInsetsDidChange [self invalidateSafeAreaInsets]; } +static UIEdgeInsets RNCDeriveInsetsFromWindow(UIView *view, UIEdgeInsets current) +{ +#if TARGET_OS_IPHONE + // iOS 26.4+ reattach bug: UIKit can leave a reattached view's + // safeAreaInsets at zero indefinitely (no propagation, no callback, no + // layout pass). The window's insets are always correct — derive the + // view's geometric share of them, exactly as UIKit propagation would. + // Only substitutes in the broken case: attached + all-zero + window + // non-zero. + UIWindow *window = view.window; + if (window == nil || !UIEdgeInsetsEqualToEdgeInsets(current, UIEdgeInsetsZero)) { + return current; + } + UIEdgeInsets w = window.safeAreaInsets; + if (UIEdgeInsetsEqualToEdgeInsets(w, UIEdgeInsetsZero)) { + return current; + } + CGRect fw = [view convertRect:view.bounds toView:window]; + CGFloat winW = window.bounds.size.width; + CGFloat winH = window.bounds.size.height; + UIEdgeInsets derived; + derived.top = MAX(0, w.top - MAX(0, CGRectGetMinY(fw))); + derived.left = MAX(0, w.left - MAX(0, CGRectGetMinX(fw))); + derived.bottom = MAX(0, w.bottom - MAX(0, winH - CGRectGetMaxY(fw))); + derived.right = MAX(0, w.right - MAX(0, winW - CGRectGetMaxX(fw))); + return derived; +#else + return current; +#endif +} + - (void)invalidateSafeAreaInsets { if (self.superview == nil) { return; } +#if TARGET_OS_IPHONE + // A detached subtree legitimately reports zero insets; caching/emitting + // them poisons the JS context and the sent-flag until the next + // threshold-exceeding change (which iOS 26.4+ may never deliver). Wait + // for the window; didMoveToWindow re-invalidates on attach. + if (self.window == nil) { + return; + } +#endif // This gets called before the view size is set by react-native so // make sure to wait so we don't set wrong insets to JS. if (CGSizeEqualToSize(self.frame.size, CGSizeZero)) { return; } - UIEdgeInsets safeAreaInsets = self.safeAreaInsets; + UIEdgeInsets safeAreaInsets = RNCDeriveInsetsFromWindow(self, self.safeAreaInsets); CGRect frame = [self convertRect:self.bounds toView:RNCParentViewController(self).view]; if (_initialInsetsSent && @@ -129,6 +169,53 @@ - (void)layoutSubviews [self invalidateSafeAreaInsets]; } +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + + // Safe area insets are only real once the view is in a window. A layout + // pass on a detached subtree (e.g. a native tab's content) can cache + // zero insets with _initialInsetsSent = YES; if UIKit's + // safeAreaInsetsDidChange lands while the early-return guards above + // still apply, nothing re-invalidates after attach and zero is latched + // until an unrelated relayout (observed on iOS 26.4+). Re-reading here + // is idempotent: the threshold check suppresses no-op changes. + if (self.window != nil) { + [self invalidateSafeAreaInsets]; + // iOS 26.4+: after a subtree reattach (e.g. native tabs reparenting), + // UIKit can apply safe-area insets to this view WITHOUT calling + // safeAreaInsetsDidChange, and with no further layout pass — a stale + // (often zero) value then stays cached forever. Observed directly via + // instrumentation: reattach reads top=0, the real value lands a beat + // later, no callback follows. Re-check on the next runloop turns; the + // threshold guard makes these free when nothing changed. + __weak __typeof__(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf invalidateSafeAreaInsets]; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf invalidateSafeAreaInsets]; + }); + }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.15 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [weakSelf invalidateSafeAreaInsets]; + }); + } +} + +- (void)updateEventEmitter:(const facebook::react::EventEmitter::Shared &)eventEmitter +{ + [super updateEventEmitter:eventEmitter]; + + // invalidateSafeAreaInsets caches values and sets _initialInsetsSent + // BEFORE checking _eventEmitter, so an emit attempted pre-attach is + // silently dropped and never retried — JS then never receives the + // initial insets. Replay through the freshly attached emitter. + if (_initialInsetsSent) { + _initialInsetsSent = NO; + [self invalidateSafeAreaInsets]; + } +} + #pragma mark - RCTComponentViewProtocol + (ComponentDescriptorProvider)componentDescriptorProvider diff --git a/ios/Fabric/RNCSafeAreaViewComponentView.mm b/ios/Fabric/RNCSafeAreaViewComponentView.mm index d622610..9e99210 100644 --- a/ios/Fabric/RNCSafeAreaViewComponentView.mm +++ b/ios/Fabric/RNCSafeAreaViewComponentView.mm @@ -17,6 +17,33 @@ @interface RNCSafeAreaViewComponentView () @end +#if TARGET_OS_IPHONE +// Mirror of the provider-side derivation (see +// RNCSafeAreaProviderComponentView.mm): substitute window-derived insets +// when an attached provider view still reads all-zero (iOS 26.4+ reattach +// propagation bug). +static UIEdgeInsets RNCViewDeriveInsetsFromWindow(UIView *view, UIEdgeInsets current) +{ + UIWindow *window = view.window; + if (window == nil || !UIEdgeInsetsEqualToEdgeInsets(current, UIEdgeInsetsZero)) { + return current; + } + UIEdgeInsets w = window.safeAreaInsets; + if (UIEdgeInsetsEqualToEdgeInsets(w, UIEdgeInsetsZero)) { + return current; + } + CGRect fw = [view convertRect:view.bounds toView:window]; + CGFloat winW = window.bounds.size.width; + CGFloat winH = window.bounds.size.height; + UIEdgeInsets derived; + derived.top = MAX(0, w.top - MAX(0, CGRectGetMinY(fw))); + derived.left = MAX(0, w.left - MAX(0, CGRectGetMinX(fw))); + derived.bottom = MAX(0, w.bottom - MAX(0, winH - CGRectGetMaxY(fw))); + derived.right = MAX(0, w.right - MAX(0, winW - CGRectGetMaxX(fw))); + return derived; +} +#endif + @implementation RNCSafeAreaViewComponentView { RNCSafeAreaViewShadowNode::ConcreteState::Shared _state; UIEdgeInsets _currentSafeAreaInsets; @@ -71,6 +98,11 @@ - (NSString *)description } - (void)didMoveToWindow +{ + [self attachToProviderView]; +} + +- (void)attachToProviderView { UIView *previousProviderView = _providerView; _providerView = [self findNearestProvider]; @@ -84,6 +116,37 @@ - (void)didMoveToWindow name:RNCSafeAreaDidChange object:_providerView]; } + + // Mirror of the provider's deferred re-check (see + // RNCSafeAreaProviderComponentView didMoveToWindow): on iOS 26.4+ a + // reattached subtree can receive its safe-area insets without any + // callback or layout pass following, so the value read at attach time + // (often zero) would stick. Re-read after the runloop settles. + if (self.window != nil) { + __weak __typeof__(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf updateStateIfNecessary]; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf updateStateIfNecessary]; + }); + }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.15 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [weakSelf updateStateIfNecessary]; + }); + } +} + +// findNearestProvider can race view reparenting (e.g. native tabs on +// iOS 26.4+): at didMoveToWindow time the provider may not be in the +// ancestor chain yet, so the fallback binds to `self` — a view that never +// posts RNCSafeAreaDidChange — and this view reads its own (possibly zero) +// insets until the NEXT detach/reattach re-resolves it (which is why a +// push/pop "healed" it). Keep retrying on later passes until a real +// provider is found; apps genuinely without a provider still converge to +// the legacy self-fallback because retries keep returning self. +- (BOOL)needsProviderReattach +{ + return _providerView == nil || _providerView == (UIView *)self; } - (void)safeAreaProviderInsetsDidChange:(NSNotification *)notification @@ -103,7 +166,7 @@ - (void)updateStateIfNecessary return; } #if TARGET_OS_IPHONE - UIEdgeInsets safeAreaInsets = _providerView.safeAreaInsets; + UIEdgeInsets safeAreaInsets = RNCViewDeriveInsetsFromWindow(_providerView, _providerView.safeAreaInsets); if (UIEdgeInsetsEqualToEdgeInsetsWithThreshold(safeAreaInsets, _currentSafeAreaInsets, 1.0 / RCTScreenScale())) { return; @@ -160,7 +223,19 @@ - (void)updateState:(State::Shared const &)state oldState:(State::Shared const & - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { [super finalizeUpdates:updateMask]; - [self updateStateIfNecessary]; + if ([self needsProviderReattach]) { + [self attachToProviderView]; + } else { + [self updateStateIfNecessary]; + } +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + if ([self needsProviderReattach]) { + [self attachToProviderView]; + } } - (void)prepareForRecycle