Skip to content

fix: stop re-parsing the whole markdown message on every streaming delta - #98

Merged
saucam merged 2 commits into
mainfrom
perf/web-stream-render
Jul 3, 2026
Merged

fix: stop re-parsing the whole markdown message on every streaming delta#98
saucam merged 2 commits into
mainfrom
perf/web-stream-render

Conversation

@saucam

@saucam saucam commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Problem (#87)

MarkdownBlock handed the entire accumulated msg.content to SolidMarkdown on every streaming delta. Each chunk re-ran a full unified() markdown parse and (with the default renderingStrategy: "memo") rebuilt the message's rendered DOM subtree — O(L²) parse cost over a stream plus per-chunk DOM churn. A 10–20 KB answer streaming at 20–40 deltas/s visibly stuttered the tab, worse the longer it ran.

Fix — three layers, streaming-only

New web/src/lib/streaming-markdown.ts:

  1. rAF coalescingcreateFrameThrottled mirrors the content signal but, while streaming, batches updates to at most one per animation frame (latest value wins). Inactive → synchronous passthrough.
  2. Stable-block splittingsplitStreamingBlocks splits the accumulated text at safe boundaries: blank lines outside fenced code blocks, plus fence-opens (so a long streaming code block doesn't drag the finished paragraph before it into every re-parse). Completed blocks render as their own SolidMarkdown documents inside <For>, which keys by string identity — a completed block never re-parses. Only the small live tail re-parses per frame.
  3. Reconciled tail — the tail document uses renderingStrategy="reconcile" so its per-frame re-render diffs the DOM instead of rebuilding it.

When streaming ends, the message renders as ONE document again — the streaming segmentation is transient, so the final DOM is byte-identical to today's non-streaming path (plan-mode markdown and historical messages are untouched). That containment is what makes the conservative splitter safe: constructs spanning blank lines (reference-link definitions, loose lists) can render slightly differently across a boundary while text is still arriving, and self-heal on completion.

Micro-bench

20 KB assistant answer (paragraphs + GFM tables + code fences) streamed in 50 B deltas, same unified pipeline solid-markdown runs:

OLD  full re-parse per delta:            6972 ms total parse work
NEW  block-split + tail parse per event:  279 ms total (25×)

That 25× counts every delta as a parse event; in a real tab the rAF throttle further divides the tail parses by the delta:frame ratio, and the avoided full-subtree DOM rebuilds (the other half of the stutter) come on top.

Tests

streaming-markdown.test.ts (vitest): paragraph splitting + tail retention, reassembly (no line lost), never splitting inside a fence, unclosed streaming fence stays in tail, ~~~/indented fences, no-boundary and empty inputs; throttle passthrough when inactive, single scheduled frame for a burst of updates, latest-value flush, and immediate flush on deactivation (manual rAF queue, assertions outside createRoot since writes inside the root body are batched).

tsc -b ✓ · eslint: 20 warnings, identical set to main (none in changed files) ✓ · vitest run 160/160 ✓

Note: web coverage is not uploaded to codecov, so codecov/patch is unaffected by this PR.

Fixes #87

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved live Markdown rendering for streaming assistant messages, keeping completed content stable while updates flow smoothly for the newest text.
    • Added streaming-aware Markdown utilities that throttle rapid text updates and split streamed content into safely-rendered segments plus a live tail.
  • Tests

    • Added new automated coverage for streamed Markdown splitting and frame-throttled update behavior to ensure reliable, consistent rendering as text arrives.

MarkdownBlock handed the entire accumulated content to SolidMarkdown per
delta — a full unified() re-parse plus a DOM subtree rebuild per chunk,
O(L²) over a stream. Long answers visibly stuttered the tab.

While streaming: (1) deltas are coalesced to one renderer update per
animation frame; (2) the text is split at safe block boundaries (blank
lines and fence-opens outside code fences) so completed blocks parse
once — <For> keys them by string identity — and only the small live tail
re-parses, with renderingStrategy="reconcile" so its DOM updates in
place. When streaming ends the message renders as one document again, so
the final output is byte-identical to the non-streaming path.

Simulated 20 KB answer streamed in 50 B deltas: total parse work drops
6972 ms → 279 ms (25x) — before counting the rAF coalescing (which
divides per-event work by the delta:frame ratio) and the avoided
per-delta DOM rebuilds.

Fixes #87

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0cae5a5a-eedd-4620-a6c4-4944b18277cf

📥 Commits

Reviewing files that changed from the base of the PR and between 87184b3 and 7c94f37.

📒 Files selected for processing (3)
  • web/src/components/transcript/MessageRow.tsx
  • web/src/lib/streaming-markdown.test.ts
  • web/src/lib/streaming-markdown.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • web/src/lib/streaming-markdown.test.ts
  • web/src/components/transcript/MessageRow.tsx

📝 Walkthrough

Walkthrough

This PR adds streaming markdown helpers for frame-throttled updates and fence-aware block splitting, tests both utilities, and updates MessageRow.tsx to render streamed markdown as memoized completed blocks plus a reconciled live tail.

Changes

Streaming Markdown Rendering

Layer / File(s) Summary
Streaming markdown helper functions
web/src/lib/streaming-markdown.ts
Adds createFrameThrottled for animation-frame-coalesced updates and splitStreamingBlocks for fence-aware splitting into blocks and tail, plus the exported StreamingBlocks type.
Unit tests for streaming helpers
web/src/lib/streaming-markdown.test.ts
Adds Vitest coverage for markdown block splitting rules and frame-throttled update behavior.
MessageRow segmented streaming render
web/src/components/transcript/MessageRow.tsx
Reworks streamed markdown rendering to throttle incoming text, split completed blocks from the tail, and use a shared Md wrapper with "memo" and "reconcile" rendering strategies.

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

Sequence Diagram(s)

sequenceDiagram
  participant Msg as msg.content
  participant MarkdownBlock
  participant FrameThrottle as createFrameThrottled
  participant Splitter as splitStreamingBlocks
  participant Md

  Msg->>MarkdownBlock: content delta
  MarkdownBlock->>FrameThrottle: throttle text updates
  FrameThrottle->>Splitter: throttled text
  Splitter-->>MarkdownBlock: blocks and tail
  MarkdownBlock->>Md: render completed blocks (memo)
  MarkdownBlock->>Md: render tail (reconcile)
Loading

Related issues: #87 — addresses repeated full markdown re-parsing during streaming by throttling updates and splitting stable blocks from the live tail.

Suggested labels: performance, frontend

Suggested reviewers: saucam

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main performance fix: avoiding full markdown re-parsing during streaming.
Linked Issues check ✅ Passed The PR implements the listed fixes: throttled streaming updates, block splitting, and reconciled tail rendering.
Out of Scope Changes check ✅ Passed The added streaming utilities, MessageRow changes, and tests are directly tied to the markdown streaming performance issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/web-stream-render

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.06%. Comparing base (f7487bc) to head (7c94f37).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main      #98   +/-   ##
=======================================
  Coverage   73.06%   73.06%           
=======================================
  Files          65       65           
  Lines       11032    11032           
=======================================
  Hits         8060     8060           
  Misses       2972     2972           
Flag Coverage Δ
daemon 73.06% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
web/src/components/transcript/MessageRow.tsx (1)

136-136: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Prefer <Index> for segments().blocks. The list is append-only, and repeated markdown blocks can still produce duplicate string values, which makes keyed For reconciliation ambiguous. Index keeps completed blocks stable and avoids that pitfall.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/transcript/MessageRow.tsx` at line 136, The rendering of
segments().blocks in MessageRow should use an Index-based loop instead of For,
since the blocks list is append-only and may contain duplicate markdown strings
that make keyed reconciliation ambiguous. Update the JSX in MessageRow so each
block is rendered through Index with Md, keeping completed blocks stable while
preserving the existing display behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/src/lib/streaming-markdown.ts`:
- Around line 69-99: The fence handling in splitStreamingBlocks is too
permissive because FENCE_LINE.test(line) toggles on any fence-like line, which
can incorrectly close an open code block. Update splitStreamingBlocks to record
the current opener’s fence character and length when entering a fence, and only
treat a later line as the matching closer if it uses the same character, has at
least the same length, and contains no non-whitespace after the fence. Keep the
conservative blank-run splitting behavior unchanged.

---

Nitpick comments:
In `@web/src/components/transcript/MessageRow.tsx`:
- Line 136: The rendering of segments().blocks in MessageRow should use an
Index-based loop instead of For, since the blocks list is append-only and may
contain duplicate markdown strings that make keyed reconciliation ambiguous.
Update the JSX in MessageRow so each block is rendered through Index with Md,
keeping completed blocks stable while preserving the existing display behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: aa214861-ce79-47fa-ad98-f1384f0fe8af

📥 Commits

Reviewing files that changed from the base of the PR and between f7487bc and 87184b3.

📒 Files selected for processing (3)
  • web/src/components/transcript/MessageRow.tsx
  • web/src/lib/streaming-markdown.test.ts
  • web/src/lib/streaming-markdown.ts

Comment thread web/src/lib/streaming-markdown.ts Outdated
CodeRabbit review on #98: a closing fence must use the opener's char, be
at least as long, and carry no trailing text (CommonMark) — the naive
toggle let a literal fence-like line inside a block close it early and
split mid-fence. Also swap <For> for <Index> on the completed-blocks
list: it's append-only with possibly-duplicate strings, so positional
keying is unambiguous and keeps blocks stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@saucam
saucam merged commit 4bfc19d into main Jul 3, 2026
5 checks passed
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.

perf: web UI re-parses full markdown + rebuilds message subtree on every streaming delta

1 participant