software-mansion / software-mansion/react-native-screens
[iOS][New Arch] Native stack stops rendering permanently while JavaScript keeps navigating
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 3.7k
- Forks
- 714
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 71
Description
Description
Under repeated programmatic push / popToTop on a screen whose header options change frequently, the native stack stops rendering and never recovers. JavaScript keeps navigating correctly; the screen stays frozen showing a stale back button and no content.
I expected the native stack to keep following the JS navigation state, or at worst to glitch and recover. Instead it stops permanently while JS carries on.
This is not the app being busy:
- The driver logs every navigation and keeps logging indefinitely — past tick 900 — with a correct route stack that grows and resets, and screens mounting and unmounting:
[driver] tick=910 intent=2 routes=3 depths=[0,1,2] liveScreens=2 - Both threads are idle.
sampleof the wedged process shows the main thread ~96% inmach_msg_trapand the JS thread ~73% idle. - The process stays alive; there is no crash.
Where the update is lost
I instrumented RNSScreenStackView. In the wedged state RNS does everything right — the mounts arrive, updateContainer runs, every screen is valid, and it asks for the correct stack:
WEDGEDBG mount idx=3 reactSubviews=4
WEDGEDBG updateContainer subviews=4 push=4 modal=0 window=ok controllerVCs=2
WEDGEDBG screen dismissed=0 ctrl=ok activity=2 removedFromParent=0 (x4)
WEDGEDBG setPush APPLYING want=4 have=2
It never applies, because of the deferral branch in setPushViewControllers::
if (_controller.transitionCoordinator != nil) {
if (!_updateScheduled) {
_updateScheduled = YES;
[_controller.transitionCoordinator animateAlongsideTransition:... completion:^(...) {
self->_updateScheduled = NO;
[weakSelf updateContainer];
}];
}
return;
}
Once wedged, every call takes that return:
WEDGEDBG setPush DEFER coordinator=0x121f6a6c0 animating=1 cancelled=0 interactive=0 updateScheduled=1 want=2
WEDGEDBG setPush DEFER coordinator=0x121f6a6c0 animating=1 cancelled=0 interactive=0 updateScheduled=1 want=3
WEDGEDBG setPush DEFER coordinator=0x121f6a6c0 animating=1 cancelled=0 interactive=0 updateScheduled=1 want=4
Three things are true simultaneously and stay true forever:
_controller.transitionCoordinatoris non-nil on every call, and it is the same object every time — 30 of the last 30 deferrals report pointer0x121f6a6c0. It reportsisAnimated = YES,isCancelled = NO,isInteractive = NO, so UIKit believes a non-interactive animated transition is still in progress.- That coordinator's completion block never fires again. In my log the last
completion FIREDis at line 2398 while deferrals continue past line 3434. - Because the completion never fires,
_updateScheduledis never reset, so RNS never registers another completion callback. Nothing retries.
The navigation controller is left holding a transition coordinator that never completes, and viewControllers.count stays at 2 regardless of what JavaScript does.
I have not established why UIKit ends up with a permanently active coordinator. Since the same harness also triggers the synchronous-commit re-entrancy from #4483 — a mounting transaction running inside -[RNSNavigationController viewDidLayoutSubviews], forcing a nested navigation bar layout — my guess is that interleaving a commit into an in-flight transition can strand UIKit's transition state. That is a guess.
Two further notes:
- The deferral has no recovery path: if a completion is ever missed,
_updateScheduledlatches atYESpermanently. - Removing
headerSearchBarOptionsfrom the harness makes the wedge disappear, but I cannot explain the connection, and a harness written against the raw RNS API (no React Navigation) wedged with and without it.
Happy to run further experiments against the reproducer.
Steps to reproduce
- Clone the reproducer and check out the
wedgebranch. - On stock 4.27.0 this harness usually dies first from the
NSGenericExceptionreported in #4483, before it can wedge. To observe the wedge, stop that crash locally by iterating a copy inRNSScreenStackHeaderConfig.mm:for (RNSScreenStackHeaderSubview *subview in [self.reactSubviews copy]) { yarn && yarn expo run:ios- Leave it running; no interaction is needed. Each screen re-renders its header options every 16 ms (mounting/unmounting
headerLeft/headerRight, withheaderLargeTitleand a search bar) while a timer pushes three screens and callspopToTop()every 150 ms. - Within about a minute the screen freezes on a stale back button with no content, and stays that way. Watch the Metro log: the
[driver] tick=… routes=… depths=…lines keep coming with a correct route stack.
Snack or a link to a repository
https://github.com/chrisnojima/react-native-screens-crash/tree/wedge
Screens version
4.27.0
React Native version
0.86.2
Platforms
iOS
JavaScript runtime
Hermes
Workflow
Expo bare workflow
Build type
Release mode
Device
iOS simulator
Device model
iOS 26 simulator (also reproduced in Debug mode)
Acknowledgements
Yes
Investigated and written with Claude Code.
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
Reproduce the freeze from the linked repository's wedge branch, then inspect RNSScreenStackView and the setPushViewControllers: transition-coordinator deferral described in the report. Compare the coordinator and _updateScheduled logs with the update path; done means the native stack continues applying updates after the reproduced wedge instead of remaining at two view controllers while JavaScript navigates.
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
- Mostly clear
- Newbie friendliness
- 45/100