Skip to content

perf(mobile): convert only the changed block of a streaming assistant message - #31037

Open
Pyseph wants to merge 2 commits into
pingdotgg:mainfrom
Pyseph:perf/mobile-markdown-streaming
Open

Pyseph wants to merge 2 commits into
pingdotgg:mainfrom
Pyseph:perf/mobile-markdown-streaming

Conversation

@Pyseph

@Pyseph Pyseph commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Problem

While an assistant reply streams, MobileEnrichedMarkdownText (apps/mobile/src/native/EnrichedMarkdownText.tsx) re-runs the whole Markdown-to-native conversion over the full text on every update, because its useMemo is keyed on props.markdown. The thread feed always passes fileEmbedLabel, so assistant text takes the long branch: up to four remark parses per update (HTML tags and Windows link destinations, inline file-path links, file embeds, table breaks). The cost per update grows with the message, so a streamed reply costs more than linearly in its length.

Host measurements, Node 26.13 (V8) and node --jitless, a synthetic reply of headings, paragraphs with file-path spans, fenced code and lists, grown 500 characters per update; whole-stream conversion time, median of 5 alternating runs (minimum in brackets):

size V8 now V8 with change --jitless now --jitless with change
10 KB 210 ms (144) 80 ms (56) 576 ms (561) 145 ms (122)
50 KB 3823 ms (3374) 211 ms (193) 15556 ms (14928) 620 ms (530)
200 KB 68310 ms (63813) 859 ms (704) not run not run

For scale, one conversion of the finished text (median of 11, dense in file paths): 5 KB 26 ms, 20 KB 83 ms, 50 KB 181 ms, 200 KB 877 ms on V8; 52, 238, 537 and 2397 ms under --jitless. The other per-update passes the feed runs over the same text (citations, wide-block check, <details> split, Codex directives) cost under 0.4 ms at 200 KB, so the conversion is the part that grows.

Nothing here was measured on a phone or on Hermes; --jitless is an interpreter-only stand-in, not Hermes. Replies of 10 KB and more are rare in chat, so most replies save little (see Verification, "Real documents").

The web client fixed the same problem for its renderer (#11193, #27417, apps/web/src/components/chat/markdownStreamBlocks.ts).

Change

  • apps/mobile/src/lib/markdownStreamBlocks.ts (new). While a message streams and its text is at least 2048 characters, convertStreamingMarkdown cuts the text at blank lines that close every open block and converts each block on its own. It keeps the converted blocks of the previous update in a cache (keyed on block text, valid for one fileEmbedLabel) and converts only blocks that changed, normally the last one. The cache holds only the latest update's blocks and is dropped when streaming ends.
  • It returns null, and the caller converts the whole text as before, when the text is not streaming, is shorter than 2048 characters, or has syntax that can tie blocks together. The component's conversion is moved unchanged into convertNativeMarkdown, so the non-streaming path is main's code.
  • New optional prop streaming on SelectableMarkdownText. Only the assistant message passes it (message.streaming, through AssistantMarkdownContent and AssistantMarkdownBlocks, including <details> folds). Finished messages, reasoning, user messages, file previews, pull request bodies and review comments never set it, so they never split.
  • Two document-wide decisions are handled instead of approximated:
    • The file-link decision ("some inline-code span looks like a path") is taken for the whole text from the parsed blocks and passed to every block. For that, markdownDocument.ts gets an exported markdownMayLinkFiles, an exported markdownRenderParsed (the second half of markdownForNativeRendering), and an optional linkFiles argument on markdownWithInlineFileLinks and the internal transform. With the argument left out, behaviour is as before.
    • The rewritten text that the later passes parse (after HTML tags are rewritten) must split at the same places as the original; if not, the update converts whole.
  • The splitter refuses (whole-text conversion) on: ]: anywhere outside a top-level fence (reference definitions, including in quotes, lists and over several lines, and footnote definitions); HTML at the start of a line, quote or list item; a line that starts with a backtick fence marker but has a backtick in its info string (a file-link rewrite can turn it into a fence); an unindented line inside an indented fence; carriage returns, U+FEFF and NUL. Only spaces and tabs count as blank, as in CommonMark.
  • fileEmbedLabel keeps one identity across streamed updates (React Compiler memoizes it in ThreadFeed); if it ever changed per render the cache would miss and behave like main.

Scope and approval

One defect (full re-conversion on every streamed update). It is not a "very small, focused fix for an obvious bug": it adds a heuristic module of about 230 lines, a prop, and touches markdownDocument.ts. There is no triaged issue and no maintainer agreement yet. Under CONTRIBUTING.md this needs an Ideas discussion first, and this PR should not be opened before one agrees the direction. Open PRs touch the same files (markdownDocument.ts: #21278, #29355, #29657, #30021, #30280, #24735, #29739, #28093; EnrichedMarkdownText.tsx: #28523, #29657, #27984, #30021, #24735, #29739; ThreadFeed.tsx: #30021, #28093). #29657 and #30021 change which inline code becomes a file link, which is exactly the decision this change passes between blocks, so whichever lands second has to re-check it.

Behaviour differences

  • When streaming ends, and for every other use of the markdown component, the conversion is main's, run on the whole text. When the streaming flag flips to false the text is converted once more, which costs as much as one streamed update of main.
  • While a message streams, the string handed to the native view is intended to equal main's for the same text, and no difference was found (counts under Verification). That is not proven: the safeguards above are heuristics, and the fuzzing covers the constructs it generates. A difference could only show until the message completes.
  • A streaming message with any ]:, line-start HTML, or the other refused syntax converts whole on every update and costs what it does today.
  • Reasoning text does not use the streaming path.
  • The flag comes from message.streaming; a message whose flag were left true would keep the streamed conversion.

Verification

Scripts, fuzz harness and raw rows: https://gist.github.com/Pyseph/fbc7b50d6a7be2d5670d5c5dbdeb1106.

Windows 11, Node 26 through vp, scoped to the touched files.

  • vp test run src/lib/markdownStreamBlocks.test.ts in apps/mobile: 14 tests pass. They compare every prefix of 32 hand-written documents (the shapes in the repo's markdown tests plus one case per failure found while building this: file link in a block without a path of its own, a fence opened by <br> or by a removed tag, definitions in quotes and lists and over several lines, image definitions, an HTML block inside a list item, a non-breaking space where a fence closes or a blank line ends a paragraph, U+FEFF, CRLF), and 250 generated documents at every prefix, against the whole-text conversion, through one shared cache and both branches (with and without fileEmbedLabel). Also: only changed blocks are converted, non-streaming and short text are never split, the cache is dropped when the owner changes, and the first path appearing late re-renders earlier blocks. Switching off the per-document file-link decision, the ]: check, the rewritten-text re-split or the fence-info rule each makes tests fail. The module is new, so the tests cannot run on main.
  • Related existing tests (markdownHtml, markdownInlineFileLinks, markdownTableBreaks, enrichedLinkPresentation): pass. reactCompilerCoverage.test.ts -t "EnrichedMarkdownText|ThreadFeed|markdownStreamBlocks": pass.
  • vp lint --report-unused-disable-directives and vp fmt --check on the six changed files: clean. tsc --noEmit in apps/mobile: no errors in the changed files (5 errors in packages/client-runtime/src/state/review.ts in a file this change does not touch, from my scratch install).
  • Differential fuzz outside the repo (scripts in the evidence folder): token-level documents (fences of every kind, lists, quotes, definitions, HTML tags, tables, Windows paths, file paths, backtick runs, U+FEFF, non-breaking spaces, CR), block conversion against the whole-text conversion with the minimum length forced to 0 so short texts split. Whole documents: 234,629 documents (about 16% split into several blocks), 0 differences; the last two seeds (91,759 documents) ran on the final code, the others on the code before a cosmetic edit. Every prefix of each document through one cache (a chunk boundary at every offset): 20,603 documents, 0 differences. Repo Markdown (564 files: docs, READMEs, changelogs, skill files): 183,133 comparisons including 182,005 streamed prefixes (168,434 of them took the block path), 0 differences; three files at every offset: 17,334 prefixes, 0 differences. Each safeguard was switched off in turn to check that the fuzz can see it: per-block file-link decision 383 failing documents, ]: check 18, rewritten-text re-split 48, U+FEFF check 33, trim() for blank 12, closing fence with trim() 3, fence-info rule 1, HTML-in-list prefix 1 (needed a longer run), carriage-return check 0 (apparently redundant: a line ending in CR is never blank, so nothing is cut). 0 differences is evidence, not proof.
  • Timing (above, and below), host only, 5 alternating old/new runs per cell in fresh processes, node --jitless in place of Hermes.

Real documents (repo Markdown of 2.4 to 28 KB, 20 files spread over the size range, grown 200 characters per update, total conversion time for all 20 files): V8 4923 ms (min 3534) with main, 514 ms (min 394) with the change; --jitless 22894 ms (min 21814) and 2238 ms (min 1903). Per file the saving is small for most: median 15 ms on V8 (29 ms jitless), 90th percentile 827 ms (3965 ms), largest 2255 ms (11571 ms, a 28 KB document). Files with nothing to convert cost under 1 ms either way; 654 of the 900 updates took the block path, the rest fell back (for example CONTRIBUTING.md starts lines with <a id=...>, so it converts whole and gains nothing). Examples on V8 (main to change): 6.4 KB 172 to 28 ms, 12.9 KB 393 to 26 ms, 16.9 KB 873 to 46 ms, 28 KB 2336 to 81 ms. Repo documents are not chat replies; I have no measure of reply sizes in T3 Code mobile.

Shapes that cannot gain: one 20 KB tight list or one numbered list with continuation paragraphs and indented fences never splits, because the list is a single block. 14 alternating runs each. V8: tight list 735 to 647 ms (min 458 to 486), numbered steps 488 to 496 ms (min 369 to 382). --jitless: tight list 2621 to 3234 ms (min 2183 to 2351), numbered steps 1997 to 2042 ms (min 1498 to 1555). The extra cost is the failed split (0.04 to 0.5 ms per update at 20 KB) and the one extra whole conversion when streaming ends; run-to-run spread under --jitless was about 30% on this machine, so I cannot rule out a few percent of slowdown in those shapes.

Not checked

  • Nothing ran on a device, simulator or Hermes. Whether the saving is visible on a phone is unmeasured; so is native layout, which still runs on every update.
  • Reply sizes in T3 Code mobile threads, and how many replies are long enough for the saving to matter.
  • Whether the server clears message.streaming on every path that ends a reply (interrupt, failure, disconnect).
  • The full mobile test suite, other platforms, repo-wide checks, and the commit hooks of an upstream branch.

Claude Sonnet 5.5 in T3 Code (Claude Code harness).

… message

A streamed reply re-ran the whole Markdown-to-native conversion on every update. While a message is streaming, split its text at blank lines that close every open block, keep the previous update's converted blocks, and convert only the blocks that changed. Finished text, every other consumer and text with syntax that ties blocks together keep the whole-text conversion.
@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Nov 4, 2026
@macroscopeapp

macroscopeapp Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at 6ca128e

Macroscope's review found this PR approvable — This optimizes mobile streaming Markdown by reusing unchanged blocks. Finished messages and unsupported inputs retain whole-document conversion, keeping the exposure limited to transient rendering.

You can add or adjust custom eligibility rules. Learn more.

@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

The mobile Markdown path now supports cached conversion of safely separated blocks during assistant-message streaming. Streaming state reaches native Markdown rendering, which falls back to whole-text conversion when block conversion cannot be used.

Changes: Mobile Markdown streaming

Layer / File(s) Summary
Native Markdown render pipeline
apps/mobile/src/lib/markdownDocument.ts
The native render path adds whole-document file-link detection and a parsed-Markdown rendering helper with an optional file-linking override.
Safe block splitting and cached conversion
apps/mobile/src/lib/markdownStreamBlocks.ts, apps/mobile/src/lib/markdownStreamBlocks.test.ts
Streaming conversion splits Markdown at safe boundaries, checks that parsing preserves those boundaries, and caches parsed and rendered blocks. Tests cover whole-text equivalence, splitting rules, and cache behavior.
Streaming state and native renderer integration
apps/mobile/src/native/SelectableMarkdownText.types.ts, apps/mobile/src/features/threads/ThreadFeed.tsx, apps/mobile/src/native/EnrichedMarkdownText.tsx
Assistant message streaming state reaches each selectable Markdown block. The native renderer uses streaming conversion when available and otherwise converts the whole Markdown text.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant ThreadFeed
  participant AssistantMarkdownContent
  participant AssistantMarkdownBlocks
  participant SelectableMarkdownText
  participant MobileEnrichedMarkdownText
  participant convertStreamingMarkdown
  participant convertNativeMarkdown
  ThreadFeed->>AssistantMarkdownContent: Pass message.streaming
  AssistantMarkdownContent->>AssistantMarkdownBlocks: Forward streaming state
  AssistantMarkdownBlocks->>SelectableMarkdownText: Pass streaming state to each block
  SelectableMarkdownText->>MobileEnrichedMarkdownText: Provide Markdown and streaming state
  MobileEnrichedMarkdownText->>convertStreamingMarkdown: Convert with block cache and pipeline
  alt Streaming conversion returns a result
    convertStreamingMarkdown-->>MobileEnrichedMarkdownText: Return rendered Markdown and file hrefs
  else Streaming conversion returns null
    MobileEnrichedMarkdownText->>convertNativeMarkdown: Convert whole Markdown
    convertNativeMarkdown-->>MobileEnrichedMarkdownText: Return rendered Markdown and file hrefs
  end
Loading

Possibly related issues

  • pingdotgg/t3code#27325: The issue describes repeated parsing of the growing assistant message; this PR adds reuse of converted blocks and reconverts changed blocks for eligible streaming messages.

Possibly related PRs

  • pingdotgg/t3code#24263: Both PRs change native Markdown conversion for streaming updates; this PR adds block-level conversion and cache reuse.
  • pingdotgg/t3code#30021: Both PRs handle file-link eligibility and file hrefs in native Markdown rendering.
  • pingdotgg/t3code#29657: Both PRs route inline-code file links through native Markdown rendering and cache rendered block output.

Suggested reviewers: juliusmarminge, maria-rcks

Merge Risk: Minimal · up to f5b98

No actionable merge-blocking issue is established; the change is mergeable after normal checks.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: converting only changed blocks in streaming assistant messages.
Description check Passed The description includes the required Problem, Change, Scope and approval, and Verification sections, with detailed behavior and test results. It also states that the required Ideas discussion or main…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description includes the required Problem, Change, Scope and approval, and Verification sections, with detailed behavior and test results. It also states that the required Ideas discussion or maintainer agreement is not yet available, so that approval evidence remains missing.

Finishing Touches

Generate unit tests (beta)

  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 6ca128e and f5b98db.

Files selected for processing: 4

  • apps/mobile/src/features/threads/ThreadFeed.tsx
  • apps/mobile/src/lib/markdownDocument.ts
  • apps/mobile/src/native/EnrichedMarkdownText.tsx
  • apps/mobile/src/native/SelectableMarkdownText.types.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

…streaming

# Conflicts:
#	apps/mobile/src/features/threads/ThreadFeed.tsx
#	apps/mobile/src/native/EnrichedMarkdownText.tsx
@maria-rcks

Copy link
Copy Markdown
Collaborator

Note

Written by claude-opus-5-5 on behalf of Maria

Merged current main into this branch (merge commit f5b98dbb08, no rebase) to clear the conflicts with #31207 (model mentions):

  • EnrichedMarkdownText.tsx: kept the streaming conversion and took main's enrichedTextLinkRegex / model-reference imports and ReactElement.
  • ThreadFeed.tsx: AssistantMarkdownBlocks now takes both models and streaming.

No other changes. Main's whole-text conversion path is unchanged, so convertNativeMarkdown still matches it. vp run check:changed and markdownStreamBlocks.test.ts (14 tests, including the differential against whole-text conversion) pass. Bot reviews had no actionable findings.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants