Skip to content

[Bug]: iOS app stops responding during long turns: Reanimated commits starve React commits #15641

Description

@nekohasekai

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/mobile

Steps to reproduce

Deterministic repro on an iOS simulator. The harness in the gist adds a fake Grok agent that streams fixed markdown, a thread launcher, Instruments helpers, and os_signposts in the markdown text module. It does not change app behavior.

  1. On main @ 4059607, apply the harness and install:
    curl -L https://gist.githubusercontent.com/nekohasekai/914dde51fcef685befbc7be238500991/raw/mobile-perf-harness.patch | git apply && vp i
  2. Run vp run dev:server. Add the grok-perf provider instance from docs/operations/mobile-markdown-profiling.md to <worktree>/.t3/userdata/settings.json.
  3. Build the Debug dev client with node scripts/mobile-native-client.ts ensure ios <udid>. From apps/mobile, serve production JavaScript with EXPO_NO_METRO_LAZY=1 APP_VARIANT=development vp exec expo start --dev-client --scheme t3code-dev --no-dev. Pair the app with the server.
  4. Start a turn over RPC: node apps/server/scripts/launch-thread.ts --origin http://127.0.0.1:<port> --token-file <token> --instance grok-perf --model perf-stream --prompt "perf scenario 30 25 60". It sends 30 units of history at once (about 180 KB of markdown and 135 tool calls), waits 25 s, then streams for 60 s. Issue the token with node apps/server/src/bin.ts auth session issue --base-dir <worktree>/.t3 --token-only.
  5. About 8 s later, open the running thread: xcrun simctl openurl <udid> "t3code-dev://threads/<environment-id>/<thread-id>".
  6. Keep the thread open. To measure, record with node scripts/perf/mobile-markdown/profile-ios.ts record --device <udid> --seconds 75 --out run.trace and read it with profile-ios.ts summarize run.trace.

Without the harness, the same thing happens when a long thread is opened on iOS while its turn is running, as in #14010.

Expected behavior

The thread renders and the app keeps responding while the turn's "Working" row animates. A React commit whose layout takes longer than a frame finishes late, but it finishes.

Actual behavior

The React commit for the thread never lands. The JS thread stays at 99.6–99.9% CPU inside ShadowTree::commit. A Debug client aborts on react_native_assert(attempts < 1024) in ShadowTree::commit: 2 of 3 runs aborted, at about 22 s and 55 s. In the third run the feed did not reach the live edge, so the live row never rendered. Release builds have no assertion and retry forever. That matches the taps that stop responding in #14010, #8118 and #10847.

apps/mobile/package.json sets Reanimated's DISABLE_COMMIT_PAUSING_MECHANISM: true, added in #5451 for react-native-keyboard-controller. Reanimated's feature flag guide says the flag "is safe to enable only if preventShadowTreeCommitExhaustion feature flag from react-native ... is also enabled ... In all other cases it can lead to unresponsiveness of the app due to the starvation of React commits." A Reanimated maintainer repeats this in react-native-reanimated#9111. T3 Code does not enable preventShadowTreeCommitExhaustion; it defaults to false in React Native 0.88 and on React Native main.

With commit pausing off, the work-log shimmer (withRepeat in apps/mobile/src/features/threads/thread-work-log.tsx) commits the shadow tree from the main thread every frame through ReanimatedModuleProxy::commitUpdates. A React commit whose layout takes longer than a frame is then always overtaken. It is rebased, laid out again, and overtaken again. This is the starvation described in react-native-reanimated#4660.

Measured with Instruments on the simulator, from opening the thread until the abort:

Baseline run 1 Baseline run 3 With a markdown measurement cache
Recorded until the abort 21.9 s 54.7 s 17.8 s
JS thread busy 99.6% 99.9% 99.7%
JS thread inside ShadowTree::commit 99.6% 99.9% 99.8%
of which markdown measurement 84.7% 70.5% 24.3%
of which TextKit and CoreText 55.3% 47.8% 1.7%
Markdown measurements 2,738 24,478 70,505
Measurements whose inputs were already measured 100% 100% 100% (cache hits)

All main-thread commits in baseline run 3 come from ReanimatedModuleProxy::commitUpdates.

Every measurement repeats inputs an earlier attempt already measured: the same commit is retried, not new work. With a measurement cache, which is the fix #14010 proposes, each attempt is cheaper, but the commit still never lands and the abort comes sooner. So this is a separate problem from the markdown cost in #14010. That cost only makes the commit slower than a frame. Full tables and stacks are in profiling-summary.md in the gist.

Possible fixes:

  • Enable preventShadowTreeCommitExhaustion. After three failed attempts, React Native takes a lock and commits. Reanimated recommends enabling this flag on its own rather than through the experimental release level, which also turns on unrelated flags.
  • Remove DISABLE_COMMIT_PAUSING_MECHANISM and handle the keyboard case from fix(mobile): improve keyboard avoiding #5451 another way.

IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS would stop transform-only animations such as the shimmer from committing. Layout-prop animations, such as the keyboard, would still commit every frame, so it narrows the window without closing it. I have not measured it.

Impact

Major degradation or frequent failure

Version or commit

main @ 4059607 (react-native 0.88.0-rc.3, react-native-reanimated 4.7.0, react-native-worklets 0.13.0)

Environment

iPhone 17 Pro simulator, iOS 26.5; Xcode 27.0; macOS 27.0.1. Debug dev client serving production JavaScript (--no-dev). Fake Grok provider from the harness.

Logs or stack traces

Baseline run 3: EXC_CRASH (SIGABRT), crashed thread com.facebook.react.runtime.JavaScript
 0 libsystem_kernel.dylib   __pthread_kill
 1 libsystem_pthread.dylib  pthread_kill
 2 libsystem_c.dylib        abort
 3 libsystem_c.dylib        __assert_rtn
 4 React                    facebook::react::ShadowTree::commit(...)
 5 React                    facebook::react::UIManager::completeSurface(...)
12 React                    facebook::react::ShadowTreeRegistry::visit(...)
13 React                    facebook::react::UIManager::completeSurface(...)
14 React                    facebook::react::UIManagerBinding::get(...)::$_9::operator()(...)
19 hermesvm                 facebook::hermes::(anonymous namespace)::HermesRuntimeImpl::HFContext::func(...)

The run with a measurement cache aborts with the same stack.

Competing commits on the main thread (Instruments, baseline run 3):
ShadowTree::commit
  <- reanimated::ReanimatedModuleProxy::commitUpdates
  <- reanimated::ReanimatedModuleProxy::performOperations
  <- -[REANodesManager performOperations]
  <- -[REANodesManager maybeFlushUIUpdatesQueue]

Screenshots, recordings, or supporting files

Gist with mobile-perf-harness.patch, which applies to 4059607, and profiling-summary.md, which has all tables, the main-thread commit callers and the crashed-thread stacks. The .trace files are about 55 MB compressed and can be shared on request.

Workaround

Per #14010, switching to another app and back recovers the app: the socket closes, the event stream stops, and the JS thread catches up. Otherwise, force quit.

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

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions