Skip to content

fix(iOS, Tabs): make bottom accessory content switching robust and survive window detach#4152

Open
sgup wants to merge 1 commit into
software-mansion:mainfrom
sgup:fix/bottom-accessory-content-switching-lifecycle
Open

fix(iOS, Tabs): make bottom accessory content switching robust and survive window detach#4152
sgup wants to merge 1 commit into
software-mansion:mainfrom
sgup:fix/bottom-accessory-content-switching-lifecycle

Conversation

@sgup

@sgup sgup commented Jun 11, 2026

Copy link
Copy Markdown

Note: The code / PR is made by Claude Fable, however I've manually tested and confirmed with my app on simulator and device.

Description

The bottom accessory's dual-content-view switching (the "content view switching workaround") has three failure modes we root-caused on a physical iOS 26 device in a production app (RN 0.85, new arch):

  1. RCTViewComponentView.invalidateLayer resets layer.opacity to the React props value on arbitrary commits, re-showing the off-environment copy (the bug reported in Bottom accessory content view opacity glitch: invalidateLayer resets visibility #3798). Because that copy is mounted absoluteFill as the later sibling, it sits on top of the active copy and also captures all hit-testing — every tap on the accessory goes dead, not just "wrong content shown".
  2. The UITraitTabAccessoryEnvironment registration callback is occasionally missed during the minimize transition, leaving the wrong copy visible until the next trait change.
  3. The helper dies permanently after any full-screen push. didMoveToWindow invalidates and nils _helper + _shadowStateProxy when the accessory leaves the window, but nothing recreated them on re-entry — registerForAccessoryFrameChanges was messaged at nil, so all callbacks and content switching stayed dead and the accessory froze on whichever copy was last visible. This likely explains the "full-screen modal dismissal also causes bottom accessory glitches" report in the Bottom accessory content view opacity glitch: invalidateLayer resets visibility #3798 thread.

Closes #3798.

Changes

  • RNSTabsBottomAccessoryHelper:
    • handleContentViewVisibilityForEnvironmentIfNeeded now uses hidden as the sole carrier of invisibility, with alpha normalized to 1.0 on both copies. invalidateLayer never touches hidden, and with alpha kept at 1.0 its opacity reset becomes a no-op by construction (no reliance on the private invalidateLayer, no KVO loops). Hidden views are also excluded from hit-testing, fixing the dead-taps half. Applied to whichever content views are registered (nil messages are no-ops) — the both-registered early-return previously left a lone registered view fully visible.
    • notifyWrapperViewFrameHasChanged re-evaluates visibility: the wrapper-frame KVO fires reliably on every regular↔inline move (and once at registration via NSKeyValueObservingOptionInitial), so a missed trait callback self-corrects.
    • Removed the now-unused isContentViewSwitchingWorkaroundActive.
  • RNSTabsBottomAccessoryComponentView.didMoveToWindow:
    • Recreates the helper + shadow proxy on window re-entry.
    • Window-leave does a lighter teardown that preserves the Fabric _state — only React can re-deliver it (updateState on a commit), so resetting it on a mere detach left the recreated shadow proxy unable to publish accessory frame updates. True unmount still fully invalidates via RNSTabsHostComponentView.

Test plan

Tested in a production Expo SDK 56 app (RN 0.85, new arch, Fabric) on a physical iOS 26 device, with these changes applied to 4.25.2 via package patching (identical code paths — this area is unchanged between 4.25.2 and main apart from the removed RN < 0.82 guards):

  • Repeated scroll-driven minimize/expand cycles across multiple tabs: correct copy visible in every environment, no stuck/overlaid content, accessory taps always responsive.
  • Full-screen push covering the tab bar → pop: content switching keeps working afterwards (previously permanently dead).
  • Accessory mount/unmount cycles (conditionally rendered accessory) and app backgrounding.

Reproduction shape for the original bugs: a NativeTabs (expo-router) app with a BottomAccessory whose content differs per placement, scroll-minimizing tab bars, and any full-screen push — see #3798 for the opacity repro.

Checklist

  • Included code example that can be used to test this change.
  • For API changes, updated relevant public types. (No public API changes.)
  • Ensured that CI passes

…rvive window detach

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 software-mansion#3798
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bottom accessory content view opacity glitch: invalidateLayer resets visibility

1 participant