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
{{ message }}
Repository navigation
[Bug]: iOS app stops responding during long turns: Reanimated commits starve React commits #15641
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.
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
Run vp run dev:server. Add the grok-perf provider instance from docs/operations/mobile-markdown-profiling.md to <worktree>/.t3/userdata/settings.json.
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.
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.
About 8 s later, open the running thread: xcrun simctl openurl <udid> "t3code-dev://threads/<environment-id>/<thread-id>".
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.
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.
Before submitting
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.
main@ 4059607, apply the harness and install:curl -L https://gist.githubusercontent.com/nekohasekai/914dde51fcef685befbc7be238500991/raw/mobile-perf-harness.patch | git apply && vp ivp run dev:server. Add thegrok-perfprovider instance fromdocs/operations/mobile-markdown-profiling.mdto<worktree>/.t3/userdata/settings.json.node scripts/mobile-native-client.ts ensure ios <udid>. Fromapps/mobile, serve production JavaScript withEXPO_NO_METRO_LAZY=1 APP_VARIANT=development vp exec expo start --dev-client --scheme t3code-dev --no-dev. Pair the app with the server.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 withnode apps/server/src/bin.ts auth session issue --base-dir <worktree>/.t3 --token-only.xcrun simctl openurl <udid> "t3code-dev://threads/<environment-id>/<thread-id>".node scripts/perf/mobile-markdown/profile-ios.ts record --device <udid> --seconds 75 --out run.traceand read it withprofile-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 onreact_native_assert(attempts < 1024)inShadowTree::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.jsonsets Reanimated'sDISABLE_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 ifpreventShadowTreeCommitExhaustionfeature flag fromreact-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 enablepreventShadowTreeCommitExhaustion; it defaults tofalsein React Native 0.88 and on React Nativemain.With commit pausing off, the work-log shimmer (
withRepeatinapps/mobile/src/features/threads/thread-work-log.tsx) commits the shadow tree from the main thread every frame throughReanimatedModuleProxy::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:
ShadowTree::commitAll 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.mdin the gist.Possible fixes:
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.DISABLE_COMMIT_PAUSING_MECHANISMand handle the keyboard case from fix(mobile): improve keyboard avoiding #5451 another way.IOS_SYNCHRONOUSLY_UPDATE_UI_PROPSwould 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
Screenshots, recordings, or supporting files
Gist with
mobile-perf-harness.patch, which applies to 4059607, andprofiling-summary.md, which has all tables, the main-thread commit callers and the crashed-thread stacks. The.tracefiles 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.