Description
On the New Architecture (iOS), the first presentation of a <Modal> after a specific sequence renders fully — UIKit's presentation completes (onShow fires at the normal latency) — but the presented content is absent from the accessibility hierarchy. VoiceOver/XCUITest/Maestro see the host chain and nothing below it. It stays that way indefinitely (measured 17 s). Closing the modal and opening it again exposes the full tree.
We root-caused it to Fabric view recycling: Modal returns null when hidden (Modal.js), so its RCTModalHostViewComponentView is unmounted on every close and dequeued from RCTComponentViewRegistry's recycle pool on every open. When the previous screen's modal hosts were just enqueued, the next presentation is served from one of them, and it carries non-prop native state from its previous life.
Controlled experiment (same app, same sequence, same session): clearing the recycle pool — via Simulator's Debug → Simulate Memory Warning, which triggers _recyclePool.clear() in handleApplicationDidReceiveMemoryWarningNotification — immediately before the modal opens yields a healthy first presentation 2/2. Clearing it earlier so the pool refills with the previous screen's hosts yields the collapsed presentation, like every un-cleared trial (5/5).
Ruled out by direct test: accessibility-tree snapshot starvation (138 successful 0.02 s in-process snapshots during the 17 s), timing (6 s settle), accessibilityViewIsModal (removed → still collapses), stale props on reuse (RCTViewComponentView updateProps diffs against the view's own last _props, so changed props are re-applied), a stuck presentation (onShow fires at ~566 ms, identical to healthy presentations).
Steps to reproduce
Standalone reproduction (fresh create-expo-app, RN 0.86.3 / Expo SDK 57, New Architecture): https://github.com/DepkaCZ/rn-modal-recycled-a11y-repro — App.tsx plus Maestro flows.
- Open and close a header-menu
<Modal>.
- Open a form-sheet
<Modal> and save: the sheet's visible goes to false and a toast mounts in the same React commit.
- Open another sheet
<Modal> (a picker with a list row and a confirm button).
- Query the accessibility hierarchy (XCUITest / Maestro
hierarchy / Accessibility Inspector): the sheet is fully rendered on screen, but the hierarchy contains only the status bar — none of the app's elements, not even the screen behind the sheet.
In the minimal app this reproduces 3/3 and persists across closing and reopening the sheet; with the reset below installed the same flow passes 3/3, and clearing the recycle pool right before step 3 (control) passes 2/2. In the production app it surfaced in, the same class of sequence (push sheet confirmed → leave the screen → header menu) collapses the first presentation only; the second is healthy.
Control: run steps 1–2, fire Simulator's Debug → Simulate Memory Warning (which makes RCTComponentViewRegistry clear its recycle pool), then step 3: the sheet's content is exposed normally.
Expected: The presented content is in the accessibility hierarchy on every presentation, or RCTModalHostViewComponentView is not recycled.
Actual: First presentation after the sequence: modal rendered, accessibility hierarchy empty below the host. Second presentation: correct.
Analysis
Root cause, pinned by instrumentation
A didMoveToWindow probe logging the accessibility state of every RCTViewComponentView inserted under the presented view controller shows the collapsed and healthy presentations are identical except for one property on the full-screen wrapper inside the presented content:
collapsed: RCTViewComponentView frame={{0,0},{393,852}} isAXElement=0 elementsHidden=0 viewIsModal=1 hidden=0 subviews=1
healthy: RCTViewComponentView frame={{0,0},{393,852}} isAXElement=0 elementsHidden=0 viewIsModal=0 hidden=0 subviews=1
accessibilityViewIsModal=YES on that wrapper makes UIKit ignore its siblings — the menu card — which is exactly the tree that was observed (container present, no children). The value is stale: nothing in the app or in Modal.js passes that prop to that view (Modal.js forwards a fixed prop list to the host and nothing to its inner container), so it arrived with the recycled view. RCTViewComponentView.updateProps re-applies accessibilityViewIsModal only when oldViewProps != newViewProps (RCTViewComponentView.mm:418), so native state that was changed outside the prop diff survives a recycle whenever the incoming prop equals the previous occupant's prop; prepareForRecycle does not reset it.
Fix (verified)
Resetting the leaked state when the view is recycled cures it with recycling fully enabled — 2/2 healthy first presentations on the same sequence, and the probe sees no viewIsModal=1 view afterwards:
// in RCTViewComponentView.prepareForRecycle
self.accessibilityViewIsModal = NO;
self.accessibilityElementsHidden = NO;
Where the stale YES comes from. A swizzle on -[UIView setAccessibilityViewIsModal:] logging every call with YES on an RCTViewComponentView recorded zero hits across the whole session (launch, login, the full sequence) while the collapsed wrapper still read YES — so the flag is not set through the public setter by app or React Native code. The only remaining author is UIKit itself, which marks a presented modal's content as modal for accessibility through a private path during presentation and does not clear it when the view is pulled out mid-dismissal and reused; prepareForRecycle should reset it defensively.
(We ship this as an app-side swizzle of -[RCTViewComponentView prepareForRecycle] until it lands upstream; React Native Core is prebuilt in Expo projects, so a source patch is not an option there.)
For completeness: opting only RCTModalHostViewComponentView out of recycling does not help (3/3 collapses), while opting all RCTViewComponentViews out does (2/2) — consistent with the wrapper, not the host, being the stale view. There is precedent for the opt-out approach — react-native-screens 4.26 returns NO from +shouldBeRecycled for RNSScreen, RNSSearchBar, RNSScreenStackHeaderSubview and RNSFormSheetContentWrapperComponentView, as does @react-native-community/datetimepicker — but resetting the property is the narrower fix.
React Native Version
0.86.3 in the reproducer (also 0.86.2 in the production app where it surfaced)
Affected Platforms
Runtime - iOS
Areas
Fabric - The New Renderer
Output of npx @react-native-community/cli info
System:
OS: macOS 26.4.1
CPU: (8) arm64 Apple M2
Memory: 317.14 MB / 16.00 GB
Shell:
version: "5.9"
path: /bin/zsh
Binaries:
Node:
version: 22.23.2
path: /Users/depka/.nvm/versions/node/v22.23.2/bin/node
Yarn: Not Found
npm:
version: 10.9.8
path: /Users/depka/.nvm/versions/node/v22.23.2/bin/npm
Watchman: Not Found
Managers:
CocoaPods:
version: 1.15.2
path: /opt/homebrew/bin/pod
SDKs:
iOS SDK:
Platforms:
- DriverKit 25.5
- iOS 26.5
- macOS 26.5
- tvOS 26.5
- visionOS 26.5
- watchOS 26.5
Android SDK:
API Levels:
- "36"
Build Tools:
- 35.0.0
- 36.0.0
System Images:
- android-36 | Google APIs ARM 64 v8a
Android NDK: Not Found
IDEs:
Android Studio: Not Found
Xcode:
version: 26.6/17F113
path: /usr/bin/xcodebuild
Languages:
Java:
version: 17.0.19
path: /opt/homebrew/opt/openjdk@17/bin/javac
Ruby:
version: 2.6.10
path: /usr/bin/ruby
npmPackages:
"@react-native-community/cli": Not Found
react:
installed: 19.2.3
wanted: 19.2.3
react-native:
installed: 0.86.2
wanted: 0.86.2
react-native-macos: Not Found
npmGlobalPackages:
"*react-native*": Not Found
Android:
hermesEnabled: Not found
newArchEnabled: Not found
iOS:
hermesEnabled: Not found
newArchEnabled: Not found
info React Native v0.87.1 is now available (your project is running on v0.86.2).
info Changelog: https://github.com/facebook/react-native/releases/tag/v0.87.1
info Diff: https://react-native-community.github.io/upgrade-helper/?from=0.86.2&to=0.87.1
info For more info, check out "https://reactnative.dev/docs/upgrading?os=macos".
Stacktrace or Logs
No crash. Accessibility hierarchy captured at the failing step (Maestro hierarchy, XCUITest-backed) contains only the status bar while the sheet is rendered on screen: https://github.com/DepkaCZ/rn-modal-recycled-a11y-repro/blob/main/artifacts/hierarchy-at-failure.json. Screenshot at the same moment: https://github.com/DepkaCZ/rn-modal-recycled-a11y-repro/blob/main/artifacts/push-sheet-rendered-tree-empty.png.
MANDATORY Reproducer
https://github.com/DepkaCZ/rn-modal-recycled-a11y-repro — App.tsx plus Maestro flows (repro.yaml fails at push-dog-d1 3/3; repro-part1.yaml → Simulator Debug → Simulate Memory Warning → repro-part2.yaml passes 2/2; with fix/fabric-recycle-a11y-reset copied into modules/ and pods reinstalled, repro.yaml passes 3/3).
Screenshots and Videos
See artifacts/ in the reproducer.
Environment
- React Native 0.86.2 (prebuilt React Native Core), Expo SDK 57.0.9, New Architecture enabled, Hermes
- iOS 18.2 simulator (iPhone 16), Xcode 26.6, macOS 26.4.1
- Observed via Maestro (XCUITest driver) and in-process XCTAutomationSupport logs
Description
On the New Architecture (iOS), the first presentation of a
<Modal>after a specific sequence renders fully — UIKit's presentation completes (onShowfires at the normal latency) — but the presented content is absent from the accessibility hierarchy. VoiceOver/XCUITest/Maestro see the host chain and nothing below it. It stays that way indefinitely (measured 17 s). Closing the modal and opening it again exposes the full tree.We root-caused it to Fabric view recycling:
Modalreturnsnullwhen hidden (Modal.js), so itsRCTModalHostViewComponentViewis unmounted on every close and dequeued fromRCTComponentViewRegistry's recycle pool on every open. When the previous screen's modal hosts were just enqueued, the next presentation is served from one of them, and it carries non-prop native state from its previous life.Controlled experiment (same app, same sequence, same session): clearing the recycle pool — via Simulator's Debug → Simulate Memory Warning, which triggers
_recyclePool.clear()inhandleApplicationDidReceiveMemoryWarningNotification— immediately before the modal opens yields a healthy first presentation 2/2. Clearing it earlier so the pool refills with the previous screen's hosts yields the collapsed presentation, like every un-cleared trial (5/5).Ruled out by direct test: accessibility-tree snapshot starvation (138 successful 0.02 s in-process snapshots during the 17 s), timing (6 s settle),
accessibilityViewIsModal(removed → still collapses), stale props on reuse (RCTViewComponentView updatePropsdiffs against the view's own last_props, so changed props are re-applied), a stuck presentation (onShowfires at ~566 ms, identical to healthy presentations).Steps to reproduce
Standalone reproduction (fresh
create-expo-app, RN 0.86.3 / Expo SDK 57, New Architecture): https://github.com/DepkaCZ/rn-modal-recycled-a11y-repro —App.tsxplus Maestro flows.<Modal>.<Modal>and save: the sheet'svisiblegoes tofalseand a toast mounts in the same React commit.<Modal>(a picker with a list row and a confirm button).hierarchy/ Accessibility Inspector): the sheet is fully rendered on screen, but the hierarchy contains only the status bar — none of the app's elements, not even the screen behind the sheet.In the minimal app this reproduces 3/3 and persists across closing and reopening the sheet; with the reset below installed the same flow passes 3/3, and clearing the recycle pool right before step 3 (control) passes 2/2. In the production app it surfaced in, the same class of sequence (push sheet confirmed → leave the screen → header menu) collapses the first presentation only; the second is healthy.
Control: run steps 1–2, fire Simulator's Debug → Simulate Memory Warning (which makes
RCTComponentViewRegistryclear its recycle pool), then step 3: the sheet's content is exposed normally.Expected: The presented content is in the accessibility hierarchy on every presentation, or
RCTModalHostViewComponentViewis not recycled.Actual: First presentation after the sequence: modal rendered, accessibility hierarchy empty below the host. Second presentation: correct.
Analysis
RCTModalHostViewComponentView.prepareForRecycleonly nils_viewControllerand resets_isPresented/_shouldPresent;_propsis not reset (by design,updatePropsdiffs against it).RCTFabricModalHostViewControllerkeeps the recycled host as its_delegate, so lateboundsDidChange/ presentation-controller callbacks from the dismissing controller land on whatever the host is presenting next. This was flagged during feat(iOS): modal detents #34834 ("we're holding some state in view controller that does not get reset when component is reused") and closed as stale.UIViewproperties are not reset to default values when recycled #42732 (UIView properties not reset on recycle) and iOS (Fabric): Recycled TextInput after Password Autofill becomes non-editable (yellow background) and stops updating #53050 (recycled TextInput unusable after Password Autofill).Root cause, pinned by instrumentation
A
didMoveToWindowprobe logging the accessibility state of everyRCTViewComponentViewinserted under the presented view controller shows the collapsed and healthy presentations are identical except for one property on the full-screen wrapper inside the presented content:accessibilityViewIsModal=YESon that wrapper makes UIKit ignore its siblings — the menu card — which is exactly the tree that was observed (container present, no children). The value is stale: nothing in the app or inModal.jspasses that prop to that view (Modal.jsforwards a fixed prop list to the host and nothing to its inner container), so it arrived with the recycled view.RCTViewComponentView.updatePropsre-appliesaccessibilityViewIsModalonly whenoldViewProps != newViewProps(RCTViewComponentView.mm:418), so native state that was changed outside the prop diff survives a recycle whenever the incoming prop equals the previous occupant's prop;prepareForRecycledoes not reset it.Fix (verified)
Resetting the leaked state when the view is recycled cures it with recycling fully enabled — 2/2 healthy first presentations on the same sequence, and the probe sees no
viewIsModal=1view afterwards:Where the stale YES comes from. A swizzle on
-[UIView setAccessibilityViewIsModal:]logging every call with YES on anRCTViewComponentViewrecorded zero hits across the whole session (launch, login, the full sequence) while the collapsed wrapper still read YES — so the flag is not set through the public setter by app or React Native code. The only remaining author is UIKit itself, which marks a presented modal's content as modal for accessibility through a private path during presentation and does not clear it when the view is pulled out mid-dismissal and reused;prepareForRecycleshould reset it defensively.(We ship this as an app-side swizzle of
-[RCTViewComponentView prepareForRecycle]until it lands upstream; React Native Core is prebuilt in Expo projects, so a source patch is not an option there.)For completeness: opting only
RCTModalHostViewComponentViewout of recycling does not help (3/3 collapses), while opting allRCTViewComponentViews out does (2/2) — consistent with the wrapper, not the host, being the stale view. There is precedent for the opt-out approach — react-native-screens 4.26 returnsNOfrom+shouldBeRecycledforRNSScreen,RNSSearchBar,RNSScreenStackHeaderSubviewandRNSFormSheetContentWrapperComponentView, as does@react-native-community/datetimepicker— but resetting the property is the narrower fix.React Native Version
0.86.3 in the reproducer (also 0.86.2 in the production app where it surfaced)
Affected Platforms
Runtime - iOS
Areas
Fabric - The New Renderer
Output of
npx @react-native-community/cli infoStacktrace or Logs
No crash. Accessibility hierarchy captured at the failing step (Maestro
hierarchy, XCUITest-backed) contains only the status bar while the sheet is rendered on screen:https://github.com/DepkaCZ/rn-modal-recycled-a11y-repro/blob/main/artifacts/hierarchy-at-failure.json. Screenshot at the same moment:https://github.com/DepkaCZ/rn-modal-recycled-a11y-repro/blob/main/artifacts/push-sheet-rendered-tree-empty.png.MANDATORY Reproducer
https://github.com/DepkaCZ/rn-modal-recycled-a11y-repro —
App.tsxplus Maestro flows (repro.yamlfails atpush-dog-d13/3;repro-part1.yaml→ Simulator Debug → Simulate Memory Warning →repro-part2.yamlpasses 2/2; withfix/fabric-recycle-a11y-resetcopied intomodules/and pods reinstalled,repro.yamlpasses 3/3).Screenshots and Videos
See
artifacts/in the reproducer.Environment