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