Skip to content

[iOS][Fabric][Bridgeless] 0.86 Release-only boot hang: first commit of a react-navigation Tabs tree never mounts - all threads idle in mach_msg, JS event loop alive, no JS error #57511

Description

@sjconnolly

Description

On React Native 0.86.0 (bridgeless / New Architecture, Hermes), iOS Release builds can hang on the native splash screen forever at cold launch: the first Fabric commit that mounts the navigation tree never completes, while every thread in the process sits idle and the JS event loop stays healthy. Debug builds with Metro connected never hang.

What tips it over differs by environment, which is why this looks like a timing race rather than a structural failure:

  • On physical devices (production app, iPhone 17 Pro Max + iPad Air M2, iOS 26.5): a bottom-Tabs app whose tab screens each host a nested native Stack (react-native-screens 4.25.2 via expo-router 57.0.4) hangs 10/10 launches; the identical app with the tab screens flattened boots 10/10. Even a bare nested directory route level (no Stack _layout) reproduced the hang there.
  • In the minimal repro (linked below): any variant using the classic Tabs navigator (react-navigation bottom-tabs) hangs - nested or flattened, navigator mounted in the very first commit or deferred one async tick, with or without instrumentation - on both the iOS 26.5 simulator and a physical iPad Air M2 (iPadOS 26.5.2). The stock create-expo-app default template (which uses NativeTabs) boots normally on the same simulator and Xcode. The repro is small and fast enough that any react-navigation Tabs tree loses the race; the full-size production app only loses it when a navigator is nested inside a tab.

Failure signature, identical in every hung configuration (instrumented Release builds, device and simulator):

  • The first Fabric commit never completes. React renders (JS executes past the navigator render), but effects that should fire after the navigator's first commit never run, no UI ever appears, and the native splash stays up indefinitely (left >10 minutes).
  • No JS error surfaces on any channel. An ErrorUtils global handler, a console.error wrap, and a globalThis.reportError wrap all stay silent for the entire hang.
  • The JS event loop stays alive. A setTimeout heartbeat chain started at bundle-execution time keeps firing once per second for as long as the hung app is left running, and TurboModule calls made from those timers keep working (the heartbeat writes a file through expo-file-system every second).
  • All threads are idle. Sending the hung process SIGABRT and reading the resulting thread dump shows no thread executing app, React, or Hermes code: the main thread is parked in mach_msg runloop wait, and com.facebook.react.runtime.JavaScript is likewise parked in mach_msg runloop wait. Nothing spins, and there is no visible lock cycle - it looks like a lost wakeup / boot-time race rather than a deadlock, consistent with it being Release-only (timing-dependent) behavior.

In the production app, flattening the tab screens fixed it with everything else unchanged: each tab becomes a plain screen (no nested Stack; the Tabs navigator renders headers itself). Same RN, same libraries, same app code otherwise - 0/10 boots before, 10/10 after. In the minimal repro the flattened branch still hangs, consistent with the race being timing-dependent rather than structural: nesting is one way to tip it over, a small fast bundle is another.

Cold-launch census (each run classified by marker files the app writes into its Documents directory - see the reproducer):

Environment (all Release, all cold launches) Structure Result
iPhone 17 simulator, iOS 26.5 stock create-expo-app template (NativeTabs, flat, immediate mount) boots - UI verified
iPhone 17 simulator Tabs + nested Stack per tab, splash-held mount (repro main) 0/10 mounted, 10/10 hung
iPhone 17 simulator Tabs flattened, splash-held mount (repro flattened) 0/10 mounted, 10/10 hung
iPhone 17 simulator Tabs flattened, navigator in first commit 0/5 mounted, 5/5 hung
iPhone 17 simulator Tabs flattened, first commit, all instrumentation removed hung (splash verified by screenshot)
iPad Air M2 (iPadOS 26.5.2, physical) repro main (nested) 0/10 mounted, 10/10 hung
iPad Air M2 (physical) repro flattened 0/10 mounted, 10/10 hung
iPhone 17 Pro Max + iPad Air M2 (physical), production app nested Stack per tab 0/10 mounted (shipped to TestFlight this way)
Same devices, production app flattened, same commit otherwise 10/10 mounted

In every hung simulator run the heartbeat file showed exactly 13 beats in the 13 seconds before classification and boot-error.txt was never written - the JS event loop is healthy, silently, forever.

The production data comes from a device bisect on 2026-07-10: a pristine failing build hung 0/10 launches; the same code with only the three per-tab stacks flattened booted 10/10 (the fix commit in our app deletes the per-tab Stack layouts and renders the tab screens flat - nothing else).

Ruled out during the production bisect (each variable toggled on-device, in Release, with the same launch census): splash-screen handling, react-native-reanimated, Hermes variant, RN feature-flag overrides, Metro inlineRequires, deferring the navigator mount behind an extra async tick, and JS-side scheduler watchdogs. None changed the outcome; only the navigator-inside-tab structure did. One triage caveat from that work: crashes can masquerade as hangs in a naive launch loop, so the census distinguishes them (a crashed process stops writing heartbeats; a hung one keeps writing them forever - these launches are all genuinely hung, not crashed).

I'm aware the issue template routes Expo apps to the expo repo - filing here because the observable failure is core Fabric behavior in a bridgeless Release build (first commit/mount never completes while both the main and JS threads sit idle in their runloops, with the JS event loop demonstrably healthy). The NativeTabs-boots vs Tabs-hangs split could equally implicate react-navigation or react-native-screens, but the hang state itself - a rendered-but-never-mounted tree with commit/mount scheduling idle - belongs to the renderer, and in the full-size production app the very same Tabs tree boots reliably on device once no navigator is nested inside a tab. Happy to have this re-routed if triage disagrees; a cross-referenced report is filed on expo/expo (link in the first comment).

Steps to reproduce

Simulator path (fastest):

  1. Clone the reproducer: https://github.com/sjconnolly/rn086-tabs-stack-hang-repro (stock create-expo-app default template for Expo SDK 57 / RN 0.86.0, restructured to 3 Tabs tabs that each host a nested native Stack, plus boot-marker instrumentation)
  2. npm install && npx expo prebuild -p ios
  3. xcodebuild -workspace ios/rn086tabsstackhangrepro.xcworkspace -scheme rn086tabsstackhangrepro -configuration Release -destination 'generic/platform=iOS Simulator' -derivedDataPath build-sim build
  4. tools/sim-census.sh <sim-udid> <path-to-built-.app> 10 - installs, cold-launches 10×, classifies each run from the marker files
  5. Observe: native splash forever on every launch; boot-js-start.txt written and boot-heartbeat.txt advancing every second for as long as the app is left running; boot-mounted.txt never written; boot-error.txt never written

Device path (mirrors how this was found; needs a signing team in app.jsonios.appleTeamId):

  1. Build the same workspace with -destination 'id=<device-udid>', install with xcrun devicectl device install app, then tools/launch-census.sh <device-udid> 10
  2. git checkout flattened (deletes the per-tab Stacks - the only change), rebuild: in this minimal repro the flattened variant still hangs on both device and simulator; in our full-size production app the identical flattening change took device boots from 0/10 to 10/10
  3. Debug builds with Metro never reproduce the hang (verified throughout the production bisect; the failure is Release-only)

React Native Version

0.86.0

Affected Platforms

Runtime - iOS, Build - MacOS

Areas

Fabric - The New Renderer, Bridgeless - The New Initialization Flow

Output of npx @react-native-community/cli info

System:
  OS: macOS 26.5
  CPU: (18) arm64 Apple M5 Max
Binaries:
  Node: 22.16.0
  npm: 10.9.2
  Watchman: Not Found
Managers:
  CocoaPods: 1.17.0
SDKs:
  iOS SDK:
    Platforms: DriverKit 25.5, iOS 26.5, macOS 26.5, tvOS 26.5, visionOS 26.5, watchOS 26.5
IDEs:
  Xcode: 26.6/17F113
Languages:
  Ruby: 4.0.5
npmPackages:
  react: 19.2.3
  react-native: 0.86.0
  expo: 57.0.4
  expo-router: 57.0.4
  react-native-screens: 4.25.2
  react-native-safe-area-context: 5.7.x
Runtime targets:
  iPad Air 11-inch (M2), iPadOS 26.5.2 (physical)
  iPhone 17 Pro Max, iOS 26.5 (physical, production app)
  iPhone 17 simulator, iOS 26.5
Hermes: enabled  |  New Architecture (bridgeless): enabled (SDK 57 default)

Stacktrace or Logs

There is no stack trace - nothing crashes and nothing errors. Below is a sample of a hung Release process on the iOS 26.5 simulator (1689 samples over 2s, all threads). SIGABRT crash reports forced from hung repro processes on both the simulator and the physical iPad (iPadOS 26.5.2), as well as the SIGABRT dump from our production bisect, all show the identical wait states. Every thread is idle. The only activity in the entire sample window is the display-link timer delivering our heartbeat setTimeouts to the JS thread:

Thread: DispatchQueue_1: com.apple.main-thread  (serial)
  1689 main → UIApplicationMain → -[UIApplication _run]
    1689 GSEventRunModal → _CFRunLoopRunSpecificWithOptions → __CFRunLoopRun
      1689 __CFRunLoopServiceMachPort → mach_msg → mach_msg2_trap   ← idle, all 1689 samples

Thread: com.facebook.react.runtime.JavaScript
  1689 +[RCTJSThreadManager runRunLoop] → _CFRunLoopRunSpecificWithOptions
    1681 __CFRunLoopRun → __CFRunLoopServiceMachPort → mach_msg → mach_msg2_trap   ← idle
       6 __CFRunLoopDoSource1 → __CFMachPortPerform → display_timer_callback
         → CA::Display::DisplayLink::dispatch_items → -[RCTDisplayLink _jsThreadUpdate:]   ← our 1 Hz heartbeat timers

Thread: hades (Hermes GC)
  1689 hermes::vm::HadesGC::Executor::worker() → std::condition_variable::wait   ← idle

No thread is executing React, Fabric, or app code. No lock cycle is visible. The first commit's mount work appears to have been scheduled and lost - consistent with a Release-only (timing-dependent) lost-wakeup race during bridgeless boot.

MANDATORY Reproducer

https://github.com/sjconnolly/rn086-tabs-stack-hang-repro

Screenshots and Videos

The app sits on the native splash indefinitely; there is nothing else to see. Screenshots in the reproducer repo: hung repro on splash vs stock template booted on the same simulator.

Census output for the repro main branch on the iOS 26.5 simulator (tools/sim-census.sh, 15s wait per launch):

run 1: HUNG (js-start=2026-07-10T16:08:59.448Z heartbeat=13 2026-07-10T16:09:12.666Z mounted=MISSING error=MISSING)
run 2: HUNG (js-start=2026-07-10T16:09:14.889Z heartbeat=13 2026-07-10T16:09:28.099Z mounted=MISSING error=MISSING)
run 3: HUNG (js-start=2026-07-10T16:09:30.418Z heartbeat=13 2026-07-10T16:09:43.633Z mounted=MISSING error=MISSING)
run 4: HUNG (js-start=2026-07-10T16:09:46.045Z heartbeat=13 2026-07-10T16:09:59.249Z mounted=MISSING error=MISSING)
run 5: HUNG (js-start=2026-07-10T16:10:01.539Z heartbeat=13 2026-07-10T16:10:14.749Z mounted=MISSING error=MISSING)
run 6: HUNG (js-start=2026-07-10T16:10:17.085Z heartbeat=13 2026-07-10T16:10:30.300Z mounted=MISSING error=MISSING)
run 7: HUNG (js-start=2026-07-10T16:10:32.639Z heartbeat=13 2026-07-10T16:10:45.850Z mounted=MISSING error=MISSING)
run 8: HUNG (js-start=2026-07-10T16:10:48.214Z heartbeat=13 2026-07-10T16:11:01.433Z mounted=MISSING error=MISSING)
run 9: HUNG (js-start=2026-07-10T16:11:03.765Z heartbeat=13 2026-07-10T16:11:16.983Z mounted=MISSING error=MISSING)
run 10: HUNG (js-start=2026-07-10T16:11:19.275Z heartbeat=13 2026-07-10T16:11:32.483Z mounted=MISSING error=MISSING)

census: 0/10 mounted, 10/10 hung on splash, 0/10 dead

js-start written means the bundle executed; heartbeat=13 means the JS event loop delivered all 13 one-second timer ticks during the 13s wait; mounted=MISSING means the first screen's useEffect never ran; error=MISSING means ErrorUtils/console.error/reportError never fired.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions