You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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. sample of the wedged process shows the main thread ~96% in mach_msg_trap and 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:
Three things are true simultaneously and stay true forever:
_controller.transitionCoordinator is non-nil on every call, and it is the same object every time — 30 of the last 30 deferrals report pointer 0x121f6a6c0. It reports isAnimated = 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 FIRED is at line 2398 while deferrals continue past line 3434.
Because the completion never fires, _updateScheduled is 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, _updateScheduled latches at YES permanently.
Removing headerSearchBarOptions from 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 wedge branch.
Leave it running; no interaction is needed. Each screen re-renders its header options every 16 ms (mounting/unmounting headerLeft / headerRight, with headerLargeTitle and a search bar) while a timer pushes three screens and calls popToTop() 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.
Description
Under repeated programmatic push /
popToTopon 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:
[driver] tick=910 intent=2 routes=3 depths=[0,1,2] liveScreens=2sampleof the wedged process shows the main thread ~96% inmach_msg_trapand the JS thread ~73% idle.Where the update is lost
I instrumented
RNSScreenStackView. In the wedged state RNS does everything right — the mounts arrive,updateContainerruns, every screen is valid, and it asks for the correct stack:It never applies, because of the deferral branch in
setPushViewControllers::Once wedged, every call takes that
return: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.completion FIREDis at line 2398 while deferrals continue past line 3434._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.countstays 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:
_updateScheduledlatches atYESpermanently.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
wedgebranch.NSGenericExceptionreported in iOS 26: crash in _effectiveSearchControllerForSearchBarGivenTopNavigationItem from re-entrant navigation bar layout during synchronous header shadow-state update #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:iosheaderLeft/headerRight, withheaderLargeTitleand a search bar) while a timer pushes three screens and callspopToTop()every 150 ms.[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.