Skip to content

perf(desktop): render streaming replies incrementally - #171

Merged
GoDiao merged 1 commit into
mainfrom
perf/incremental-streaming-render
Oct 24, 2026
Merged

GoDiao merged 1 commit into
mainfrom
perf/incremental-streaming-render

Conversation

@GoDiao

@GoDiao GoDiao commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Summary

A streaming reply is no longer re-parsed, re-sanitized and replaced in full on every 50ms flush. Finished Markdown blocks are rendered once and stay mounted; only the block still being written is redrawn. MessageList stops re-rendering on text flushes, streaming code uses Prism only, and stick-to-bottom goes through one rAF-batched path.

Design

  • StreamingReply (new): subscribes to streamingText and chatState itself. MessageList now subscribes only to "is there streaming text" (a boolean), so a flush re-renders the reply, not the transcript.
  • splitStreamingBlocks (markdown/streamingBlocks.ts): lexes only the text after the last finished block with marked.lexer and moves top-level tokens into an append-only finished list. A block counts as finished only when the decision can't change: the next block's first line is complete, or it starts with a letter (so 1. a\n\n2 keeps the list open until 2. is decided). Display math ($$ … $$, \[ … \]) that spans blank lines stays in one block; delimiters inside inline code don't count. Line endings are normalized to LF first so token lengths match the text. A reply with link reference definitions ([r]: url) falls back to whole-reply parsing, because marked doesn't keep definitions in token raw and blocks parsed alone can't see them. The split also stays conservative in two more cases, where the rest keeps rendering as one tail block (the pre-change behavior): from the first block-level HTML token onward (so a <details> and its body are never split), and whenever marked's token raw doesn't concatenate back to the text exactly (marked inserts a newline in some paragraph + indented line + --- inputs). If the text no longer starts with the finished prefix, it starts over.
  • StreamingMarkdownRenderer: each finished block is a memoized StreamingMarkdownBlock (parse + DOMPurify once). The tail is keyed by finished.length, so when it finishes, the same instance becomes that block. Finished block DOM is never touched again, so a selection in it survives flushes. Parsing, KaTeX loading, part splitting and the click handler are shared with MarkdownRenderer (extracted helpers). MarkdownRenderer's streaming prop and its small streaming parse cache had no caller left, so they are removed; the cache for committed messages is unchanged.
  • Highlighting: CodeViewer gets highlighter: 'shiki' | 'prism'. Streaming blocks pass prism, which never loads Shiki, so the Prism → Shiki swap no longer flickers on every flush. When the turn ends, the reply moves into messages and renders through MarkdownRenderer, which applies Shiki once.
  • Scroll: scheduleStickToBottom coalesces to one write per animation frame. It is called from the content ResizeObserver and from StreamingReply when the text grows. The streamingText dependency is gone from the message-count effect. Virtual-viewport state is only updated while virtualization is on, so programmatic scrolls in short transcripts don't re-render MessageList.

Timing (replaces the Safari Web Inspector timeline)

The maintainer approved this in place of the Safari timeline in the Done-when. Temporary dev-build instrumentation, removed before committing (see the end of this section).

Method: tauri dev (WKWebView / JavaScriptCore, macOS). A bench entry mounted MessageList with the app CSS and a fake session, then streamed a deterministic Markdown reply (headings, paragraphs with inline math, lists, tables, ts code fences, links), 80 characters per flush, one flush every 50ms. Per flush it measured flushSync(store update) + forced layout (document.body.offsetHeight), so the number covers React render + commit + effects + style/layout. Two runs per size, same harness and machine for both sides. On main, the scroll effect forces layout inside the commit. On this branch, the stick-to-bottom write happens in the next frame and isn't counted; it is one scrollTop write on an already laid-out tree.

Reply Flushes main median main p95 branch median branch p95 MessageList commits / flush (main → branch)
~20 KB 250 50 / 54 ms 106 / 116 ms 45 / 45 ms 109 / 110 ms 2.9 → 1.1
~60 KB 750 135 / 139 ms 364 / 373 ms 46 / 46 ms 110 / 109 ms 3.0 → 1.1

(run 1 / run 2; WebKit clamps performance.now() to 1ms.)

  • On main, per-flush cost grows with the reply. On this branch it stays flat. React render + commit on the branch is 1–2ms median (p95 3ms).
  • What remains on the branch (~45ms median, ~110ms p95) is WebKit style/layout for the boxes the tail adds: a new list item, table row or code line, a new block, or a re-inserted KaTeX span. Inserting one KaTeX paragraph alone measured ~46ms. This cost is the same at 20KB and 60KB. Profiling it further is a possible follow-up; it isn't React work.
  • At the end of the branch run, the DOM held 386 blocks: 64 tables, 64 code viewers (all prism), 65 KaTeX renders, no raw $.

The timing code (src/__bench.tsx, a main.tsx hook, a vite middleware) isn't in this diff: git diff main | grep -E "__bench|VITE_STREAM_BENCH|bench-sink" is empty.

Tests

  • streamingBlocks.test.ts: finished blocks are append-only at 1/3/17-char steps; a reply streamed at 1/5/40-char steps renders the same HTML as the whole reply; lists stay open until the next block is decided; display math with blank lines stays together and $$ in inline code is ignored; CRLF text splits at the right offsets; replies with link reference definitions fall back to whole-reply parsing; block HTML and everything after it stays in the tail; a token-text mismatch keeps the remainder in the tail; unclosed fences stay in the tail; replaced text starts over.
  • StreamingMarkdownRenderer.test.tsx: half-written list, table, code fence (rendered with prism) and math render correctly while streaming; finished blocks stay the same DOM nodes and marked.parse only sees the tail; a finished code block's CodeViewer is not re-rendered when the tail grows; reference-style links render while streaming; a streaming <details> keeps its body inside it; a selection in the finished part stays selected across flushes.
  • streamingRenderCounts.test.tsx: MessageList subtree commits during the scenario reply drop 62 → 44. The remaining one per text flush is StreamingReply itself, which sits inside the profiled subtree.
  • AssistantMessage.test.tsx and MarkdownRenderer.test.tsx: a streaming reply renders incrementally with prism; a committed reply renders in full and its code blocks get shiki. The test for the removed streaming cache is deleted.
  • StreamingReply.test.tsx: StreamingReply renders the reply incrementally with prism; a committed reply in MessageList renders in full with shiki.
  • CodeViewer.test.tsx: with Shiki available, highlighter="prism" never loads it and the default switches to Shiki.
  • MessageList.test.tsx: scroll tests now wait one frame before asserting, because following the bottom is rAF-batched.
  • bun run lint (tsc) and bun run test -- --run in desktop/: 121 files, 1237 passed, 4 skipped. Root bun test: 1337 pass, 10 fail; the same 10 fail on main on this machine (agent/skill inheritance, ripgrep fallback, slash-command merge), and this PR changes nothing outside desktop/ except the CHANGELOG. Each new regression test was checked to fail with its fix or guard removed (memo on finished blocks, the prism guard, the reference-definition fallback, CRLF normalization, inline-code skipping, Shiki for committed replies, AssistantMessage choosing the renderer, the HTML stop, the token-text check, and both call sites passing or omitting incremental).

Not covered

  • Lowering the virtualization threshold or content-visibility for off-screen rows (from the issue's Direction, not its Done-when).
  • Replies with link reference definitions are parsed whole on every flush, as before this change.
  • From the first block-level HTML token on, a reply is redrawn in full on every flush, as before this change.
  • The selection survives flushes but not the hand-off at turn end, when the committed message replaces the streaming DOM.

Closes #154

Split the streaming reply into finished Markdown blocks and a growing tail; finished blocks are parsed and sanitized once and stay mounted, so only the tail is redrawn on each flush and selections in the finished part survive. Code in the streaming reply uses Prism only; Shiki applies once the reply is committed. The streaming reply subscribes to the store itself, so MessageList no longer re-renders on text flushes, and following the bottom goes through one rAF-batched path. MarkdownRenderer's unused streaming cache is removed.
@GoDiao
GoDiao merged commit 377f306 into main Oct 24, 2026
4 checks passed
@GoDiao
GoDiao deleted the perf/incremental-streaming-render branch October 24, 2026 03:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render streaming replies incrementally instead of re-parsing the whole reply

1 participant