Skip to content

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

Description

@chrisnojima

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 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 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    platform:iosIssue related to iOS part of the libraryrepro-providedA reproduction with a snack or repo is provided

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions