From d4df360315019c9b0826756bc68703a4b85b5587 Mon Sep 17 00:00:00 2001 From: Shridhar Gupta Date: Wed, 10 Jun 2026 19:04:31 +0800 Subject: [PATCH] fix(iOS, Tabs): make bottom accessory content switching robust and survive window detach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three failure modes in the dual-content-view switching workaround: 1. RCTViewComponentView.invalidateLayer unconditionally resets layer.opacity to the React props value on arbitrary commits, re-showing the off-environment content copy. Since that copy is mounted absoluteFill on top of the active one, it also captured all hit-testing, making the accessory unresponsive to taps. 2. The UITraitTabAccessoryEnvironment registration callback is occasionally missed during the minimize transition, leaving the wrong copy visible until the next trait change. 3. didMoveToWindow invalidates and nils the helper + shadow proxy whenever the accessory leaves the window (any full-screen push covering the tab bar), but never recreated them on re-entry — so all callbacks and content switching stayed permanently dead afterwards. Fixes: - Use hidden as the sole carrier of invisibility, with alpha normalized to 1.0 on both copies: invalidateLayer never touches hidden, and the opacity reset becomes a no-op by construction. Hidden views are also excluded from hit-testing. Apply to whichever content views are registered instead of requiring both. - Re-evaluate visibility from notifyWrapperViewFrameHasChanged (the existing KVO on the wrapper's center), so a missed trait callback self-corrects on the next frame change. - Recreate the helper + shadow proxy in didMoveToWindow on window re-entry, with a lighter window-leave teardown that preserves the Fabric state (only React can re-deliver it via updateState; true unmount still fully invalidates via RNSTabsHostComponentView). Fixes #3798 --- .../RNSTabsBottomAccessoryComponentView.mm | 30 ++++++++++- .../RNSTabsBottomAccessoryHelper.mm | 51 ++++++++++++------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryComponentView.mm b/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryComponentView.mm index ecaa8bb71e..52bd33b83f 100644 --- a/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryComponentView.mm +++ b/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryComponentView.mm @@ -49,9 +49,37 @@ - (void)initState - (void)didMoveToWindow { if (self.window != nil) { + // The helper & shadow proxy are torn down below whenever the accessory + // leaves the window (e.g. a full-screen push covering the tab bar), so + // they must be recreated on re-entry — previously + // `registerForAccessoryFrameChanges` was messaged at nil here and all + // trait/frame callbacks and content-view switching stayed permanently + // dead after the first full-screen push, freezing the accessory on + // whichever content copy was last visible. The content views' own + // `didMoveToWindow` runs after this one (window changes propagate + // top-down), so they re-register into the fresh helper. + if (@available(iOS 26, *)) { + if (_helper == nil) { + _helper = [[RNSTabsBottomAccessoryHelper alloc] initWithBottomAccessoryView:self]; + } + if (_shadowStateProxy == nil) { + _shadowStateProxy = [[RNSTabsBottomAccessoryShadowStateProxy alloc] initWithBottomAccessoryView:self]; + } + } [_helper registerForAccessoryFrameChanges]; } else { - [self invalidate]; + // Lighter teardown than `invalidate` — keep the Fabric `_state` alive + // across a detach/reattach cycle. Only React can re-deliver the state + // (via `updateState` on a commit), so resetting it on a mere window + // detach would leave the recreated shadow proxy unable to publish + // accessory frame updates until an unrelated commit happens to arrive. + // True unmount still fully invalidates via RNSTabsHostComponentView. + if (@available(iOS 26, *)) { + [_helper invalidate]; + _helper = nil; + [_shadowStateProxy invalidate]; + _shadowStateProxy = nil; + } } } diff --git a/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm b/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm index 9095a8bd43..3dc7be6963 100644 --- a/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm +++ b/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm @@ -39,11 +39,6 @@ - (void)initState #pragma mark - Content view switching workaround -- (BOOL)isContentViewSwitchingWorkaroundActive -{ - return _regularContentView != nil && _inlineContentView != nil; -} - - (void)setContentView:(RNSTabsBottomAccessoryContentComponentView *)contentView forEnvironment:(RNSTabsBottomAccessoryEnvironment)environment { @@ -65,20 +60,29 @@ - (void)setContentView:(RNSTabsBottomAccessoryContentComponentView *)contentView - (void)handleContentViewVisibilityForEnvironmentIfNeeded { - if (!self.isContentViewSwitchingWorkaroundActive) { - return; - } - - switch (self->_bottomAccessoryView.traitCollection.tabAccessoryEnvironment) { - case UITabAccessoryEnvironmentInline: - _regularContentView.layer.opacity = 0.0; - _inlineContentView.layer.opacity = 1.0; - break; - default: - _regularContentView.layer.opacity = 1.0; - _inlineContentView.layer.opacity = 0.0; - break; - } + // `hidden` is used as the sole carrier of invisibility, with alpha + // normalized to 1.0 on both content views: + // - `RCTViewComponentView.invalidateLayer` unconditionally resets + // `layer.opacity` to the React props value on arbitrary commits, which + // used to re-show the off-environment copy. It never touches `hidden`, + // and with alpha kept at 1.0 the reset becomes a no-op by construction. + // - The off-environment copy is mounted absoluteFill ON TOP of the active + // one (later sibling); when it regained opacity it also captured all + // hit-testing, making the accessory unresponsive to taps. Hidden views + // are excluded from hit-testing. + // Applied to whichever content views are registered (messages to nil are + // no-ops) — the previous both-registered early-return left a lone + // registered view fully visible until its sibling registered. + BOOL isInline = + self->_bottomAccessoryView.traitCollection.tabAccessoryEnvironment == UITabAccessoryEnvironmentInline; + + UIView *viewToShow = isInline ? _inlineContentView : _regularContentView; + UIView *viewToHide = isInline ? _regularContentView : _inlineContentView; + + viewToShow.hidden = NO; + viewToShow.alpha = 1.0; + viewToHide.hidden = YES; + viewToHide.alpha = 1.0; } #pragma mark - Observing environment changes @@ -149,6 +153,15 @@ - (void)notifyWrapperViewFrameHasChanged // We use self.nativeWrapperView because it has both the size and the origin // that we want to send to the ShadowNode. [_bottomAccessoryView.shadowStateProxy updateShadowStateWithFrame:self.nativeWrapperView.frame]; + + // The wrapper frame changes on every regular <-> inline move, so re-evaluate + // content-view visibility here as well. The one-shot + // UITraitTabAccessoryEnvironment registration callback is occasionally + // missed during the minimize transition, which left the wrong copy visible + // inside the inline accessory (or vice versa); this KVO fires reliably on + // each transition (and once on registration via + // NSKeyValueObservingOptionInitial) and self-corrects that. + [self handleContentViewVisibilityForEnvironmentIfNeeded]; } #pragma mark - Invalidation