Before submitting
Area
apps/mobile
Steps to reproduce
- On Android, open a long thread that has many tool-call groups and assistant messages with code blocks.
- Wait for the turn to settle, so nothing is streaming.
- Scroll up and down through the transcript.
Expected behavior
Scrolling is smooth, the same as it is on web and desktop for the same thread.
Actual behavior
Scrolling is clunky: frames drop, and the drag sometimes hitches or sticks.
Impact
Long threads are hard to read on Android. Most turns end with a long message, so the cost grows over a thread's lifetime.
Version or commit
Play Store 1.3.0 (57). It also reproduces on preview builds of main at c9a0e8a1 (1.3.1).
Environment
Pixel 8 Pro, Android 17 beta, 120 Hz. The server is on Linux, reached over Tailscale. It still happens after the turn has settled; everything below was measured on a settled thread.
Workaround
Read long threads on web or desktop.
Logs or stack traces: measurements
Pixel 8 Pro, Android 17 beta, T3 Code 1.3.0 (57). The test thread was settled and had about 640 messages and 3.4k activities. I flung it 8 times up and 8 times down with adb shell input swipe. The numbers come from dumpsys gfxinfo, plus a Perfetto trace with the view, gfx and input atrace categories.
- Frame times: 14% janky frames, p90 34–44 ms, p95 61–150 ms, p99 150–350 ms. The worst single frame took 511 ms. GPU p99 was only 6–8 ms, so rendering on the GPU isn't the problem.
- Where the time goes: the UI thread is the bottleneck, not JS. It averaged about 70% CPU during the scroll, against about 26% for
mqt_v_js, and gfxinfo counted 94 "slow UI thread" frames.
- What happens in the four frames over 50 ms (894 ms in total): almost all of it is Fabric mounting text for rows that scroll into view.
ReactTextView.setText(ReactTextUpdate): 264 ms self time over 29 calls, with one call taking 132 ms.
ReactTextViewManager.updateState: 125 ms.
IntBufferBatchMountItem INSERT: 111 ms.
Generating StaticLayout For DynamicLayout: 47 ms.
- 454
createViewUnsafe(RCTText) calls.
At first glance this points at selectable text. The DynamicLayout means those ReactTextViews are selectable, because the Android markdown fallback renders every paragraph and code block as RN <Text selectable>, and highlighted code nests one <Text> per Shiki token (NativeMarkdownBlock.tsx:102–145). The experiments below show that selection is only part of the cost.
Experiments (same thread and swipe script; preview builds of main at c9a0e8a1, arm64 release, two runs each)
| Build |
Janky frames |
p95 |
p99 |
| Play Store 1.3.0 |
14.0% / 13.9% |
150 / 61 ms |
350 / 150 ms |
main, unchanged |
10.9% / 12.3% |
44 / 93 ms |
350 / 150 ms |
Android markdown not selectable |
10.9% / 9.1% |
129 / 40 ms |
150 / 150 ms |
| …and code blocks as plain text (no Shiki spans) |
9.6% / 7.8% |
46 / 36 ms |
150 / 150 ms |
- Turning off selection: this halves the worst stalls. In the trace, frames over 50 ms dropped from 894 ms to 459 ms in total.
setText time fell from 322 ms to 53 ms across the frames over budget, and DynamicLayout disappeared. However, it only trims the jank rate.
- What's left:
traversal self time (about 235 ms) and ReactTextViewManager.updateState (about 178 ms), both spent when large rows mount.
- Conclusion: no single text feature is the cause. Each assistant message is one list row, so a long message mounts its entire text tree on the UI thread in a single frame. That happens again each time the row re-enters the draw distance, because rows aren't recycled. At 120 Hz the budget is 8.3 ms per frame.
Contributing factors from reading the code (not isolated yet)
thread-work-log.tsx:640: every grouped tool log is its own nested vertical AnimatedLegendList, with nestedScrollEnabled and recycleItems={false}.
ThreadFeed.tsx: recycleItems isn't set, so rows remount when they come back into view. Every row is also wrapped in ThreadMediaVisibility (useViewabilityAmount), which is only needed to gate video thumbnails.
Related: #10847. It was closed after #8309 and #11302 fixed the event-apply path, but its triage flagged the remount and markdown cost, which is what this trace shows.
Measured and written up with Claude Code (Claude Opus 5.5), driving the device over adb.
Separately, while doing this: a local expo run:android-style release build of main crashed at startup for me with libfbjni.so / __cxa_init_primary_exception. fbjni 0.8.1 wins Gradle conflict resolution over React Native's pinned 0.7.0, and its prebuilt library needs a newer libc++ than the NDK 27.1 libc++_shared.so that gets packaged. Building with NDK r28 (-PndkVersion=28.2.13676358) fixed it. I haven't checked whether EAS builds are affected.
Before submitting
Area
apps/mobile
Steps to reproduce
Expected behavior
Scrolling is smooth, the same as it is on web and desktop for the same thread.
Actual behavior
Scrolling is clunky: frames drop, and the drag sometimes hitches or sticks.
Impact
Long threads are hard to read on Android. Most turns end with a long message, so the cost grows over a thread's lifetime.
Version or commit
Play Store 1.3.0 (57). It also reproduces on preview builds of
mainatc9a0e8a1(1.3.1).Environment
Pixel 8 Pro, Android 17 beta, 120 Hz. The server is on Linux, reached over Tailscale. It still happens after the turn has settled; everything below was measured on a settled thread.
Workaround
Read long threads on web or desktop.
Logs or stack traces: measurements
Pixel 8 Pro, Android 17 beta, T3 Code 1.3.0 (57). The test thread was settled and had about 640 messages and 3.4k activities. I flung it 8 times up and 8 times down with
adb shell input swipe. The numbers come fromdumpsys gfxinfo, plus a Perfetto trace with theview,gfxandinputatrace categories.mqt_v_js, and gfxinfo counted 94 "slow UI thread" frames.ReactTextView.setText(ReactTextUpdate): 264 ms self time over 29 calls, with one call taking 132 ms.ReactTextViewManager.updateState: 125 ms.IntBufferBatchMountItem INSERT: 111 ms.Generating StaticLayout For DynamicLayout: 47 ms.createViewUnsafe(RCTText)calls.At first glance this points at selectable text. The
DynamicLayoutmeans thoseReactTextViews are selectable, because the Android markdown fallback renders every paragraph and code block as RN<Text selectable>, and highlighted code nests one<Text>per Shiki token (NativeMarkdownBlock.tsx:102–145). The experiments below show that selection is only part of the cost.Experiments (same thread and swipe script; preview builds of
mainatc9a0e8a1, arm64 release, two runs each)main, unchangedselectablesetTexttime fell from 322 ms to 53 ms across the frames over budget, andDynamicLayoutdisappeared. However, it only trims the jank rate.traversalself time (about 235 ms) andReactTextViewManager.updateState(about 178 ms), both spent when large rows mount.Contributing factors from reading the code (not isolated yet)
thread-work-log.tsx:640: every grouped tool log is its own nested verticalAnimatedLegendList, withnestedScrollEnabledandrecycleItems={false}.ThreadFeed.tsx:recycleItemsisn't set, so rows remount when they come back into view. Every row is also wrapped inThreadMediaVisibility(useViewabilityAmount), which is only needed to gate video thumbnails.Related: #10847. It was closed after #8309 and #11302 fixed the event-apply path, but its triage flagged the remount and markdown cost, which is what this trace shows.
Measured and written up with Claude Code (Claude Opus 5.5), driving the device over adb.
Separately, while doing this: a local
expo run:android-style release build ofmaincrashed at startup for me withlibfbjni.so/__cxa_init_primary_exception. fbjni 0.8.1 wins Gradle conflict resolution over React Native's pinned 0.7.0, and its prebuilt library needs a newerlibc++than the NDK 27.1libc++_shared.sothat gets packaged. Building with NDK r28 (-PndkVersion=28.2.13676358) fixed it. I haven't checked whether EAS builds are affected.