software-mansion / software-mansion/react-native-screens

[iOS] Modal push from an outer stack is dropped while a nested stack's modal is dismissing, and the outer stack stays locked

Open
#4,446 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

platform:ios repro-provided
Dominant language
TypeScript
Stars
3.7k
Forks
714
Avg merge
2d 23h
Merged PRs (30d)
71

Description

Description

Two native stacks: a root stack, and a nested navigator inside it (ours is a tab navigator). A formSheet route belonging to the NESTED stack is on screen. We dismiss it and, one requestAnimationFrame later, push a formSheet route that is registered on the ROOT stack. Ordinary "close this sheet, open that one" navigation.

The root sheet never presents. The route is in navigation state, the screen mounts and lays out, and nothing appears. After that the root stack cannot present anything at all, so every other sheet in our app stopped opening until a reload.

Where it goes wrong in RNSScreenStack.mm, setModalViewControllers (4.26.2):

  • _updatingModals is set to YES.
  • changeRootController.presentedViewController is the nested stack's sheet, so firstModalToBeDismissedIsOwnedByThisStack is NO while firstModalToBeDismissedIsOwned is YES, and the dismissal branch is skipped.
  • changeRootController is then reassigned to topMostVc, which is that same nested sheet.
  • finish() runs and hits if (previous.beingDismissed) { return; } (line 480).

The early return does two things. It drops the presentation, and the comment above it says presentationControllerDidDismiss will call updateContainer again, but that delegate callback goes to the stack that presented the sheet, which is the nested one, so this stack never hears about it. The return also skips afterTransitions(), so _updatingModals stays YES for the life of the view: every later update bails at the re-entry guard and sets _scheduleModalsUpdate, which only afterTransitions() ever consumes.

The same flow inside a single stack is fine, because there the dismissal and the presentation are chained through dismissViewControllerAnimated:completion:finish higher up.

The fix we are running as a patch, which reuses the transition coordinator the same way the foreign-modal branch above already does:

if (previous.beingDismissed) {
  id<UIViewControllerTransitionCoordinator> dismissalCoordinator = previous.transitionCoordinator;
  if (dismissalCoordinator == nil) {
    afterTransitions();
    return;
  }
  [dismissalCoordinator
      animateAlongsideTransition:nil
                      completion:^(id<UIViewControllerTransitionCoordinatorContext> _) {
                        weakSelf.scheduleModalsUpdate = YES;
                        afterTransitions();
                      }];
  return;
}

I am happy to open a PR with it if the shape looks right to you.

Steps to reproduce
  1. Root native stack containing a nested navigator, with a formSheet route registered on the root stack.
  2. Open a formSheet route that belongs to the nested navigator.
  3. From inside it, call dismiss() and push the root stack's formSheet route one requestAnimationFrame later.
  4. The root sheet does not present, and neither does any modal pushed on the root stack afterwards.

Waiting around 600 ms instead of one frame avoids it, since the dismissal has finished by then, but that is a race we would rather not bet a purchase flow on.

Snack or a link to a repository

I don't have a minimal repro repository yet. The trace above comes from reading 4.26.2 against the failure in our app, and the patch fixes it there. Tell me if you need a standalone repro and I will put one together.

Screens version

4.26.2

React Native version

0.86.0

Platforms

iOS

JavaScript runtime

Hermes

Workflow

Expo managed workflow

Architecture

Fabric (New Architecture)

Build type

Debug mode

Device

iOS simulator

Device model

iPhone 17 (iOS 26.5)

Acknowledgements

Yes

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in RNSScreenStack.mm, in setModalViewControllers, and trace the previous.beingDismissed branch around line 480. Compare it with the foreign-modal transition-coordinator branch above, then reproduce the nested-to-root formSheet sequence on iOS. Done means the root sheet presents after dismissal, later root modals still open, and _updatingModals does not remain set.

Written by the indexing model from the issue text.

Assessment

Tech stack
ios, objective-c, react-native
Domain
mobile-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.