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
Nobody has claimed this yet.
- 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):
_updatingModalsis set toYES.changeRootController.presentedViewControlleris the nested stack's sheet, sofirstModalToBeDismissedIsOwnedByThisStackisNOwhilefirstModalToBeDismissedIsOwnedisYES, and the dismissal branch is skipped.changeRootControlleris then reassigned totopMostVc, which is that same nested sheet.finish()runs and hitsif (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
- Root native stack containing a nested navigator, with a
formSheetroute registered on the root stack. - Open a
formSheetroute that belongs to the nested navigator. - From inside it, call
dismiss()and push the root stack'sformSheetroute onerequestAnimationFramelater. - 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
- 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 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