Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion ios/Fabric/RNCSafeAreaProviderComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -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
Expand Down
79 changes: 77 additions & 2 deletions ios/Fabric/RNCSafeAreaViewComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,33 @@
@interface RNCSafeAreaViewComponentView () <RCTRNCSafeAreaViewViewProtocol>
@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;
Expand Down Expand Up @@ -71,6 +98,11 @@ - (NSString *)description
}

- (void)didMoveToWindow
{
[self attachToProviderView];
}

- (void)attachToProviderView
{
UIView *previousProviderView = _providerView;
_providerView = [self findNearestProvider];
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down