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

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