software-mansion / software-mansion/react-native-screens
[iOS] Two RNSScreenStack instances dismissing the same modal chain leave _updatingModals stuck and an orphaned presented VC (black window, JS alive)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 3.7k
- Forks
- 713
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 71
Description
Description
When a modally-presented screen contains a nested native stack whose screens use
presentation: "fullScreenModal", a single JS navigation action that unwinds past both the
nested stack and the modal group causes two different RNSScreenStackView instances to
dismiss the same UIKit presented-VC chain:
- the root stack calls
dismissViewControllerAnimated:YES completion:finish, - the nested stack, being unmounted by the same commit, calls
dismissViewControllerAnimated:NO completion:nilfromprepareForRecycle.
The non-animated dismissal wins the race. The root stack's finish block is never invoked, so
_updatingModals is never reset to NO, _presentedModals is never pruned, and one presented
view controller is left on screen with no corresponding React content — a black window. JS is
still alive and responsive (state updates, timers, and network calls keep running; navigation
events fire), but nothing further is ever presented or dismissed by that stack, because every
later setModalViewControllers: returns early at the re-entry guard.
Steps to reproduce
expo-router layout (no third-party code involved):
app/
_layout.tsx // root Stack
home.tsx // screen in the root stack
(flow)/
_layout.tsx // Stack -> screenOptions: { presentation: 'fullScreenModal' }
step-a.tsx
step-b.tsx
step-c.tsx
// app/_layout.tsx
<Stack>
<Stack.Screen name="home" />
<Stack.Screen name="(flow)" options={{ presentation: 'modal' }} />
</Stack>
// app/(flow)/_layout.tsx
<Stack screenOptions={{ presentation: 'fullScreenModal' }}>
<Stack.Screen name="step-a" />
<Stack.Screen name="step-b" />
<Stack.Screen name="step-c" />
</Stack>
Steps:
- From
home, navigate to(flow)/step-a— the group is presented modally by the root stack. - Inside the group, push
step-b, thenstep-c— each is presented as a fullScreenModal, so
the nested stack owns its own chain of presented VCs on top of the group's VC. - From
step-c, run a single JS action that unwinds all the way back to the root stack, e.g.
router.dismissTo('/home')(one call, one commit — not a sequence ofgoBack()s).
Frequency observed:
| Nested modal depth | Simulator | Device |
|---|---|---|
3 (step-c) |
deterministic | deterministic |
2 (step-b) |
~1 in 4 | near-certain |
| 1 | not reproduced | not reproduced |
Expected
The modal chain unwinds and the root stack's home screen is visible and interactive.
Actual
The dismissal animation runs, then the window is black. The React tree is untouched and JS
keeps executing normally (logs, timers, navigation state updates all continue) — only UIKit is in
a bad state. Nothing recovers it: subsequent navigations produce no visible change, and the app
must be killed. No exception, no RCTAssert, no red box.
Analysis (ios/RNSScreenStack.mm, 4.25.2)
The relevant sequence, in commit order:
-
Root stack,
setModalViewControllers:sets the guard and builds the completion blocks:408: _updatingModals = YES; ... 444: void (^afterTransitions)(void) = ^{ 445: [weakSelf emitOnFinishTransitioningEvent]; 446: weakSelf.updatingModals = NO; // <- the ONLY reset on this path ... 459: void (^finish)(void) = ^{ ... afterTransitions(); ... }; -
Root stack starts the animated dismissal of the group VC:
541: if (!firstModalToBeDismissed.isBeingDismissed) { ... 548: [changeRootController dismissViewControllerAnimated:firstModalToBeDismissedPrefersAnimation 549: completion:finish]; 550: } else { 551: // We need to wait for its dismissal and then run our presentation code. 555: [[firstModalToBeDismissed transitionCoordinator] 556: animateAlongsideTransition:nil 557: completion:^(id<UIViewControllerTransitionCoordinatorContext> _) { 558: finish(); 559: }]; 560: } -
In the same commit, the nested stack is unmounted and recycled:
1419: - (void)prepareForRecycle 1420: { 1421: [super prepareForRecycle]; 1422: _reactSubviews = [NSMutableArray new]; 1423: 1424: for (UIViewController *controller in _presentedModals) { 1425: [controller dismissViewControllerAnimated:NO completion:nil]; 1426: } ...This tears down VCs that are ancestors or descendants of the same chain the root stack is
animating, without any coordination with the root stack, and withcompletion:nil.
Two failure modes follow from that, both leaving _updatingModals == YES forever:
(a) finish is dropped at line 549. The non-animated dismissal at 1425 collapses the chain
UIKit is mid-animating; the animated dismissal started at 548 is superseded and its completion:
is never delivered. afterTransitions (446) never runs.
(b) The else branch attaches finish to a nil transition coordinator. If the recycle wins
the ordering race, firstModalToBeDismissed.isBeingDismissed is already YES at line 541, so
control goes to 550. But the dismissal that set that flag was non-animated, so
[firstModalToBeDismissed transitionCoordinator] returns nil. Messaging nil is a no-op, the
completion block is silently discarded, and finish() is never called — same end state, with no
diagnostic. The code at 555 assumes the in-flight dismissal is animated (its comment describes a
foreign-controller case), which does not hold for a prepareForRecycle-initiated dismissal.
Once _updatingModals is stuck, every later update is swallowed at the re-entry guard:
387: - (void)setModalViewControllers:(NSArray<UIViewController *> *)controllers
388: {
389: // prevent re-entry
390: if (_updatingModals) {
391: _scheduleModalsUpdate = YES;
392: return;
393: }
_scheduleModalsUpdate is only drained inside afterTransitions (447-453), which is exactly the
block that never runs — so the flag latches and the stack is permanently inert. _presentedModals
also still contains the VCs dismissed at 1425, so the bookkeeping no longer matches UIKit. The
orphaned presented VC that remains on the window has had its React content torn down: black screen.
A related early return inside finish can produce the same latch independently:
493: if (previous.beingDismissed) {
494: return; // returns without calling afterTransitions()
495: }
This path also leaves _updatingModals == YES, and is reachable whenever another stack is
dismissing the chain concurrently.
Workaround
Removing the second modal presentation layer avoids it entirely: give every screen inside a
nested stack that already lives in a modally-presented group presentation: "card" (i.e. push,
not present), so the nested stack never owns presented VCs and prepareForRecycle has an empty
_presentedModals. With that change the bug is not reproducible at any depth, on simulator or
device.
// app/(flow)/_layout.tsx
<Stack screenOptions={{ presentation: 'card' }}>
This is a layout restriction rather than a fix — nested fullScreenModal inside a modal group is a
legitimate configuration.
Possible directions
Offered as suggestions, not a preferred design:
- Have
prepareForRecycleskip (or coordinate) dismissal for VCs whose presentation chain is
already being torn down by anotherRNSScreenStackView, instead of unconditionally dismissing
every entry in_presentedModals. - Guard the 550-560 branch: only rely on
transitionCoordinatorwhen it is non-nil, and
otherwise schedulefinish()(e.g. on the next main-queue turn, or from
presentationControllerDidDismiss) so the guard cannot latch. - Make the reset of
_updatingModalsfailure-proof — e.g. always runafterTransitionson the
early return at 493-495, or reset the flag from a dismissal-observation callback rather than
only from a UIKit completion block that a competing dismissal can drop.
Happy to test a patch against the repro above (deterministic at nested depth 3) and report back on
both simulator and device.
Snack or a link to a repository
No public repository yet. The three-file expo-router layout above is the complete reproduction (no other libraries involved). I can publish a minimal Expo project on request.
Screens version
4.25.2
React Native version
0.85.3 (New Architecture / Fabric)
Platforms
iOS
JavaScript runtime
Hermes
Workflow
Expo managed workflow (expo 56.0.19, expo-router 56.2.15, expo-dev-client)
Build type
Debug mode (also reproduced in a release build on device)
Device
iOS Simulator (iPhone 17 Pro) and a physical iPhone; iOS deployment target 16.4. Not reproducible on Android.
Acknowledgements
Yes
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in ios/RNSScreenStack.mm at setModalViewControllers: and prepareForRecycle, then trace the dismissal completion and _updatingModals guard described in the issue. Reproduce with the nested Expo Router layout and a single dismissTo('/home') action. Done means the modal chain unwinds to an interactive home screen, later navigation still works, and no presented view controller remains orphaned.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ios, react-native, typescript
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100