Repository navigation
[Bug]: iOS app's JS thread saturates while a turn runs: markdown text is re-measured without a cache on every streamed event #14010
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 Sep 27, 2026 Triage
Confirmed on current
main(94f92a7). This is not a duplicate of #10847. #8309 and #11302 are already in 1.3.0 (76cc9b08fis an ancestor ofmain). The hot paths below are unchanged since that commit. Later edits in these files only add auto-settle fields and a list remount key.Markdown measurement
T3MarkdownTextShadowNode::measureContent(apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm) rebuilds theNSAttributedStringand runsNSLayoutManager ensureLayoutForTextContaineron every call. There is no measure cache. It is the only native text measured through Yoga in the app. Expanded reasoning rows render through the same component (AssistantMarkdownContentinThreadFeed.tsx). Thecpu_resourcestacks are unsymbolicated, so naming this function is an inference, but the frames match it: attributed-string build, UIFoundation / CoreText, Yoga recursion on the JS thread. React Native in this tree is 0.86.3.Feed derivation
getThreadFeedActivityEntries(apps/mobile/src/lib/threadActivity.ts) caches by the identity of the activities array. Both the fast append path and the context-window path inapplyThreadDetailEvent(packages/client-runtime/src/state/threadReducer.ts) allocate a new array, so the cache misses andderiveWorkLogEntriesrebuilds the whole log. The filter-and-sort path is only for a resolvablecontext-window.updatedand for a re-delivered id. Ordinary appends already skip that sort. The feed still rebuilds either way.User message rows
UserMessageContentcallsuseThreadSelection()and only usesselectedThread?.idwhen opening a document attachment. LegendList does not refresh a row just becauserenderItemchanged, so this subscription re-renders every user-message row on each shell update.Git refresh
useSelectedThreadGitActionsis mounted for the whole thread screen (ThreadRouteContent), not only while a git sheet is open. Its effect depends on the shell object. Everythread.activity-appendedwritesprojection_threads.updated_at, and the shell stream publishesthread-upserted, coalesced over 50 ms. At the reported ~0.55 events/s that window rarely merges events, so the effect can fire about once per activity while the thread is open.refreshStatusdrops the git cache and runs local status plus remote status, including the PR lookup.onSettledthen invalidateslistRefs. Desktop refreshes on window focus, keyed by environment and cwd, not the shell. That matches the desktop making none of these calls during the turn.The 143 calls / 144 s of server time and the two
cpu_resourcereports are your measurements. This pass checked the code, not a device. A feed that stays empty until the turn ends fits a saturated JS thread and was not verified separately.The tap freeze after leaving the thread is still open. #8118 described the same class of stuck controls and was closed with #10847. A sysdiagnose is needed before treating that as this bug.
The four fixes in the report match the code: cache markdown measurement, derive work-log entries per activity, stop subscribing user-message rows to the shell, and key the git refresh on thread id, cwd, and branch.
- 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 Sep 27, 2026 Follow-up with the sysdiagnose you asked for. I captured it during a freeze of the same kind: a Claude turn was running in a long thread, and the app stopped reacting to taps. I then switched to iOS Settings and back; the app reconnected and worked again. iOS also wrote another
cpu_resourcereport for that minute.spindump in the sysdiagnose (2 s window starting when the freeze was captured, T3Code 1.3.0 (85)):
- JS thread (
com.facebook.react.runtime.JavaScript): 1.733 s of CPU in 2 s. All 8 of 8 samples are in Yoga layout called from Hermes, and nearly all end in the same frames as the reports above:T3Code + 15555316/T3Code + 15553852→ UIFoundation / CoreText. The thread is running, not waiting on a lock. - Main thread: 5 of 8 samples idle in the run loop, 3 in a display-link callback that runs Hermes (animation worklets). That is why press animations still play while taps do nothing.
com.facebook.SocketRocket.NetworkThreadlast ran 17 s earlier. The server trace shows why: the thread subscription stayed open, but the server sends the next batch only after the client acknowledges the previous one (LiveStreamBudget.ts), and the JS thread never got to it. The phone also stopped callingvcs.refreshStatusat that moment, while events kept arriving on the server at about 2 per second.
cpu_resource for the same minute: 90 s of CPU over 105 s (86%). 25 of 28 samples are on the JS thread, 24 of them in Yoga layout, and all of those go through the measure callback into
T3Code + 15555316/15553852(TextKit). This report names the images directly (React,hermesvm).So the stuck taps are the same bug: the JS thread is saturated by markdown measurement, not blocked. Backgrounding helps because the socket closes, the event stream stops, and the JS thread catches up; after returning, the app resubscribes and works. I'm not attaching the sysdiagnose itself because it contains system logs and device identifiers; I can extract other parts of it if they help.
- JS thread (
kvnloo commented
on Sep 29, 2026 ContributorMore actionsSplit the fix along the four independent hot paths from the triage so each can be measured/reviewed without bundling the whole mobile feed:
- perf(mobile): stop git refreshes on activity updates #14022 — git refresh keyed to stable git-target identity, not every shell/activity update.
- fix(mobile): stop user messages subscribing to thread updates #14033 — historical user-message rows stop subscribing to the live selected-thread shell.
- perf(mobile): cache iOS markdown TextKit measurements #14227 — iOS
T3MarkdownTextShadowNodecaches the expensive TextKit measurement by exact styled text + layout inputs; draft pending native/Instruments verification. - perf(mobile): cache activity projection across streamed appends #14228 — mobile caches presentation per immutable activity, preserves historical row identity across new activity arrays, skips sorting already-ordered live arrays, and fast-replaces same-turn
context-window.updatedsnapshots without sorting the full history.
I kept these separate intentionally: if the #14010 repro is re-run after each slice, we can attribute the CPU/ack-latency change instead of landing one opaque performance bundle. The native cache should stay draft until an iOS build + Dynamic Type/width controls + the original long-thread CPU repro are green.
Before submitting
Area
apps/mobile
Steps to reproduce
Expected behavior
The thread stays responsive. A streamed event costs work proportional to what it changed.
Actual behavior
Taps respond late, and sometimes the feed stays empty until the turn ends.
iOS CPU reports. iOS wrote two
cpu_resourcereports for T3Code 1.3.0 (85), both while a turn was running:In the first report, 22 of 30 samples are on the JS thread (
com.facebook.react.runtime.JavaScript):The reports are unsymbolicated.
T3MarkdownTextShadowNodeis the app's only native text measured through Yoga, which is why I attribute this frame to it.Server side. The server was fine. The 12-minute turn produced about 0.55 events/s (about 440 B/s). But the phone took 5–8 s to acknowledge stream batches, against 0.2–0.4 s normally.
Cause. On
main@ 94f92a7, these files are the same as in the 1.3.0 build (76cc9b0).T3MarkdownTextShadowNode::measureContent(apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm) rebuilds theNSAttributedStringand runs a full TextKit layout (NSLayoutManager ensureLayoutForTextContainer) on every call.Textcaches this (textMeasureCache_inTextLayoutManager.mm).AssistantMarkdownContentinThreadFeed.tsx), so each one adds to the cost.getThreadFeedActivityEntries(apps/mobile/src/lib/threadActivity.ts) caches by the identity of the activities array. The reducer creates a new array for each activity event, so the whole work log is derived again.context-window.updatedand re-sent activities take the filter + full sort path inthreadReducer.ts.UserMessageContent(ThreadFeed.tsx) callsuseThreadSelection(), so every user message row re-renders on every event.useEffectinapps/mobile/src/state/use-selected-thread-git-actions.tsdepends onselectedThread. That object is replaced on every shell update of the thread (use-thread-selection.ts).vcs.refreshStatus, thenvcs.listRefsthroughonSettled: invalidateRefs(packages/client-runtime/src/state/vcs.ts), and then another render.refreshStatuscalls, with a median of 660 ms and 144 s of server time in total. Each call runs git and a GitHub PR lookup. The desktop app made none.Separately, after leaving such a thread, the app sometimes stops reacting to taps until it is force-quit. I'm collecting a sysdiagnose for that and will add it here.
Suggested fix
measureContentresults by text, attributes and width, asTextMeasureCachedoes, or measure throughTextLayoutManager.context-window.updateda replace-in-place path.UserMessageContentto the thread selection.Related: #10847 (the same long-thread freeze, closed as fixed by #8309 and #11302; both are already in 1.3.0), #8118.
Impact
Major degradation or frequent failure
Version or commit
iOS app 1.3.0 (85); desktop server 0.0.43-nightly.20260926.2282; main @ 94f92a7
Environment
iPhone 13 Pro Max, iOS 26.5, connected through the T3 Connect relay; desktop server on Linux (NixOS); Claude Code 2.1.283
Logs or stack traces
Screenshots, recordings, or supporting files
No response
Workaround
No response