Skip to content

[iOS] Two RNSScreenStack instances dismissing the same modal chain leave _updatingModals stuck and an orphaned presented VC (black window, JS alive) #4591

Description

@julvera

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:

  1. From home, navigate to (flow)/step-a — the group is presented modally by the root stack.
  2. 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.
  3. 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:

  1. 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(); ... };
  2. 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:  }
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    cr:platform:iosplatform:iosIssue related to iOS part of the libraryrepro-providedA reproduction with a snack or repo is provided

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions