Repository navigation
[Bug]: iOS app stops responding during long turns: Reanimated commits starve React commits #15641
Copy link
Copy link
Open
Labels
bugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.via-triageFiled through npx t3 triageFiled through npx t3 triage
Description
Activity
- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.Issue needs maintainer review and initial categorization.
on Oct 4, 2026 Note
Grok responding on behalf of Julius.
Triage
Thank you for this, @nekohasekai. A deterministic harness, Instruments numbers, and a run with a measurement cache to rule out #14010 made this much easier to pin down.
What I found
I checked this against current
main(4ee6bfd50e, which includes40596072eb) and React Native 0.88.0-rc.3. I went through the code only and didn't run it on a device, so the traces and abort numbers here are from your runs.- Separate from [Bug]: iOS app's JS thread saturates while a turn runs: markdown text is re-measured without a cache on every streamed event #14010.
T3MarkdownTextShadowNode::measureContent(apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm) still rebuilds the attributed string and runs TextKit layout on every call, with no cache. That cost is what [Bug]: iOS app's JS thread saturates while a turn runs: markdown text is re-measured without a cache on every streamed event #14010 tracks. A cache makes each attempt cheaper but doesn't stop the retry loop, which matches your cached run still aborting. Both issues stay open. - Not the path behind closed [Bug] Controls become unresponsive in long-running threads #8118 and [Bug]: Mobile app freezes / very laggy when opening long thread #10847. Those were closed after the client-runtime changes in fix(client-runtime): preserve cached turns and older-page loading #8309 and perf(client-runtime): speed up message sync on desktop and mobile #11302, which cut down how often React commits. That doesn't help a commit that never lands because the revision keeps moving, so stuck taps can still come from this.
- Why the commit never lands.
apps/mobile/package.jsonsets Reanimated'sDISABLE_COMMIT_PAUSING_MECHANISMtotrue, added in fix(mobile): improve keyboard avoiding #5451 for keyboard-controller animations. In React Nativev0.88.0-rc.3,preventShadowTreeCommitExhaustiondefaults tofalse.apps/mobile/app.config.tsdoesn't set a release level, and nothing inpatches/turns it on. SoShadowTree::commitkeeps retrying. Debug builds assert onattempts < 1024, and Release builds retry forever. - What keeps winning the race. While a turn is open, the work-log shimmer in
apps/mobile/src/features/threads/thread-work-log.tsxruns awithRepeattranslateXanimation. WithoutIOS_SYNCHRONOUSLY_UPDATE_UI_PROPS, that transform commits throughReanimatedModuleProxy::commitUpdatesevery frame. Any React commit whose layout takes longer than a frame gets rebased again and again. Markdown measurement is what makes the commit slow, and the shimmer is what keeps overtaking it.ConnectionStatusDotonly repeats whilepulseis set, so it isn't the steady source on a connected thread.
Workaround, same as #14010: switch away from the app and back so the socket closes and the JS thread catches up, or force quit. Long threads are still usable on web and desktop.
Likely fix area
These options have different tradeoffs:
- Keep the Reanimated flag and enable only
preventShadowTreeCommitExhaustion. This has to be compiled into React Native, so iOS can't keep using the prebuilt core, and the native fingerprint changes. - Turn off
DISABLE_COMMIT_PAUSING_MECHANISMand re-check the keyboard behavior from fix(mobile): improve keyboard avoiding #5451. The thread screen no longer mountsKeyboardChatScrollView, and keyboard motion is now mostly translation plus LegendListcontentInsetshared values inThreadDetailScreen.tsx, so this needs a keyboard recording to confirm. IOS_SYNCHRONOUSLY_UPDATE_UI_PROPSwould take transform-only animations like the shimmer offShadowTree::commit. Layout-prop animations such as the keyboard inset would still commit, so it narrows the problem without closing it. This hasn't been measured.
A maintainer will decide on the fix direction.
- Separate from [Bug]: iOS app's JS thread saturates while a turn runs: markdown text is re-measured without a cache on every streamed event #14010.
- addedvia-triageFiled through npx t3 triageFiled through npx t3 triageand removedneeds-triageIssue needs maintainer review and initial categorization.Issue needs maintainer review and initial categorization.
on Oct 4, 2026 - added a commit that references this issue
on Oct 4, 2026
Metadata
Metadata
Assignees
Labels
bugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.via-triageFiled through npx t3 triageFiled through npx t3 triage
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.