Skip to content

[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

@Vantrongs

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

  1. On iOS, open a long thread (hundreds of activities; a few expanded reasoning rows make it worse) while a Claude turn with subagents is running.
  2. Stay in the thread for a few minutes, or leave it and open it again.

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_resource reports for T3Code 1.3.0 (85), both while a turn was running:

  • 90 s of CPU over 175 s (51%);
  • 90 s of CPU over 133 s (68%).

In the first report, 22 of 30 samples are on the JS thread (com.facebook.react.runtime.JavaScript):

  • 17 of them are inside Yoga layout, called from Hermes;
  • at least 14 of those end in a measure function in the app binary, which lays out text with TextKit (UIFoundation / CoreText).

The reports are unsymbolicated. T3MarkdownTextShadowNode is 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).

  1. Markdown is measured without a cache.
    • T3MarkdownTextShadowNode::measureContent (apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm) rebuilds the NSAttributedString and runs a full TextKit layout (NSLayoutManager ensureLayoutForTextContainer) on every call.
    • React Native 0.86's own Text caches this (textMeasureCache_ in TextLayoutManager.mm).
    • Every commit that clones a markdown row measures it again. Expanded reasoning rows render through the same component (AssistantMarkdownContent in ThreadFeed.tsx), so each one adds to the cost.
  2. Every event rebuilds the feed and re-renders rows that did not change.
    • 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.updated and re-sent activities take the filter + full sort path in threadReducer.ts.
    • UserMessageContent (ThreadFeed.tsx) calls useThreadSelection(), so every user message row re-renders on every event.
  3. A git status refresh runs for each event of the open thread.
    • The useEffect in apps/mobile/src/state/use-selected-thread-git-actions.ts depends on selectedThread. That object is replaced on every shell update of the thread (use-thread-selection.ts).
    • So each event triggers vcs.refreshStatus, then vcs.listRefs through onSettled: invalidateRefs (packages/client-runtime/src/state/vcs.ts), and then another render.
    • In one hour the phone made 143 refreshStatus calls, 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

  • Cache the measurement. Cache measureContent results by text, attributes and width, as TextMeasureCache does, or measure through TextLayoutManager.
  • Derive per activity, not per array. Derive work log entries per activity instead of per array, and give context-window.updated a replace-in-place path.
  • Stop re-renders from selection. Don't subscribe UserMessageContent to the thread selection.
  • Refresh git on real changes only. Key the git refresh effect on the thread id, cwd and branch, not on the shell object.

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

From T3Code.cpu_resource (1.3.0 (85)). Image names come from a crash report of the same build (`React`, `hermesvm`). The repeated Yoga recursion frames are collapsed.

Event:            cpu usage
CPU:              90 seconds cpu time over 175 seconds (51% cpu average), exceeding limit of 50% cpu over 180 seconds
Hardware model:   iPhone14,3

Heaviest stack (samples of 30):
  22  Foundation + 583716          (NSThread start)
  22  React + 2865780              (+[RCTJSThreadManager runRunLoop])
  21  hermesvm + 132496 ... + 1060644
  18  React + 1909396 ... + 1308736
  17  React + 144056 / 129328 / 139204   (recursion, repeated ~15 levels)
  14  React + 1554616
   9  T3Code + 15555316            (native text measure)
   9  UIFoundation + 825424 ... + 247500
   4  CoreText + 769904
   4  T3Code + 15553852 -> React + 2395540   (attributed string build)

Screenshots, recordings, or supporting files

No response

Workaround

No response

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on Sep 27, 2026
  2. juliusmarminge commented on Sep 27, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed on current main (94f92a7). This is not a duplicate of #10847. #8309 and #11302 are already in 1.3.0 (76cc9b08f is an ancestor of main). 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 the NSAttributedString and runs NSLayoutManager ensureLayoutForTextContainer on 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 (AssistantMarkdownContent in ThreadFeed.tsx). The cpu_resource stacks 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 in applyThreadDetailEvent (packages/client-runtime/src/state/threadReducer.ts) allocate a new array, so the cache misses and deriveWorkLogEntries rebuilds the whole log. The filter-and-sort path is only for a resolvable context-window.updated and for a re-delivered id. Ordinary appends already skip that sort. The feed still rebuilds either way.

    User message rows

    UserMessageContent calls useThreadSelection() and only uses selectedThread?.id when opening a document attachment. LegendList does not refresh a row just because renderItem changed, so this subscription re-renders every user-message row on each shell update.

    Git refresh

    useSelectedThreadGitActions is mounted for the whole thread screen (ThreadRouteContent), not only while a git sheet is open. Its effect depends on the shell object. Every thread.activity-appended writes projection_threads.updated_at, and the shell stream publishes thread-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. refreshStatus drops the git cache and runs local status plus remote status, including the PR lookup. onSettled then invalidates listRefs. 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_resource reports 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.

  3. added
    via-triageFiled through npx t3 triage
    and removed
    needs-triageIssue needs maintainer review and initial categorization.
    on Sep 27, 2026
  4. Vantrongs commented on Sep 27, 2026

    @Vantrongs
    ContributorAuthor

    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_resource report 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.NetworkThread last 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 calling vcs.refreshStatus at 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.

  5. kvnloo commented on Sep 29, 2026

    @kvnloo
    Contributor

    Split the fix along the four independent hot paths from the triage so each can be measured/reviewed without bundling the whole mobile feed:

    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.

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