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

[iOS][New Arch] Native stack stops rendering permanently while JavaScript keeps navigating

Open
#4,503 0 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

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:

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:

  1. _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.
  2. 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.
  3. 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
  1. Clone the reproducer and check out the wedge branch.
  2. On stock 4.27.0 this harness usually dies first from the NSGenericException reported in #4483, before it can wedge. To observe the wedge, stop that crash locally by iterating a copy in RNSScreenStackHeaderConfig.mm: for (RNSScreenStackHeaderSubview *subview in [self.reactSubviews copy]) {
  3. yarn && yarn expo run:ios
  4. 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.
  5. 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

  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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.