Skip to content

[Bug][Mobile] Android: scrolling a long, settled thread is janky #13925

Description

@basmith7

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 Android, open a long thread that has many tool-call groups and assistant messages with code blocks.
  2. Wait for the turn to settle, so nothing is streaming.
  3. 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.

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

    acceptedfeature request acceptedbugSomething 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