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:nil from prepareForRecycle.
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, then step-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 of goBack()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 with completion: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
prepareForRecycle skip (or coordinate) dismissal for VCs whose presentation chain is
already being torn down by another RNSScreenStackView, instead of unconditionally dismissing
every entry in _presentedModals.
- Guard the 550-560 branch: only rely on
transitionCoordinator when it is non-nil, and
otherwise schedule finish() (e.g. on the next main-queue turn, or from
presentationControllerDidDismiss) so the guard cannot latch.
- Make the reset of
_updatingModals failure-proof — e.g. always run afterTransitions on 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
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 thenested stack and the modal group causes two different
RNSScreenStackViewinstances todismiss the same UIKit presented-VC chain:
dismissViewControllerAnimated:YES completion:finish,dismissViewControllerAnimated:NO completion:nilfromprepareForRecycle.The non-animated dismissal wins the race. The root stack's
finishblock is never invoked, so_updatingModalsis never reset toNO,_presentedModalsis never pruned, and one presentedview 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):
Steps:
home, navigate to(flow)/step-a— the group is presented modally by the root stack.step-b, thenstep-c— each is presented as a fullScreenModal, sothe nested stack owns its own chain of presented VCs on top of the group's VC.
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:
step-c)step-b)Expected
The modal chain unwinds and the root stack's
homescreen 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:Root stack starts the animated dismissal of the group VC:
In the same commit, the nested stack is unmounted and recycled:
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 with
completion:nil.Two failure modes follow from that, both leaving
_updatingModals == YESforever:(a)
finishis dropped at line 549. The non-animated dismissal at 1425 collapses the chainUIKit is mid-animating; the animated dismissal started at 548 is superseded and its
completion:is never delivered.
afterTransitions(446) never runs.(b) The
elsebranch attachesfinishto aniltransition coordinator. If the recycle winsthe ordering race,
firstModalToBeDismissed.isBeingDismissedis alreadyYESat line 541, socontrol goes to 550. But the dismissal that set that flag was non-animated, so
[firstModalToBeDismissed transitionCoordinator]returnsnil. Messagingnilis a no-op, thecompletion block is silently discarded, and
finish()is never called — same end state, with nodiagnostic. 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
_updatingModalsis stuck, every later update is swallowed at the re-entry guard:_scheduleModalsUpdateis only drained insideafterTransitions(447-453), which is exactly theblock that never runs — so the flag latches and the stack is permanently inert.
_presentedModalsalso 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
finishcan produce the same latch independently:This path also leaves
_updatingModals == YES, and is reachable whenever another stack isdismissing 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
prepareForRecyclehas an empty_presentedModals. With that change the bug is not reproducible at any depth, on simulator ordevice.
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:
prepareForRecycleskip (or coordinate) dismissal for VCs whose presentation chain isalready being torn down by another
RNSScreenStackView, instead of unconditionally dismissingevery entry in
_presentedModals.transitionCoordinatorwhen it is non-nil, andotherwise schedule
finish()(e.g. on the next main-queue turn, or frompresentationControllerDidDismiss) so the guard cannot latch._updatingModalsfailure-proof — e.g. always runafterTransitionson theearly 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