Streaming Markdown Rendering#
@assistant-ui/react-streamdown renders AI-generated markdown as it streams in. The core component, StreamdownTextPrimitive, binds to the current message part, optionally smooths the text (typewriter animation), and then hands off to the upstream Streamdown renderer .
Because streaming text is often syntactically incomplete at any given flush (e.g., an open code fence or dangling **), a repair step runs before rendering to close dangling constructs. This repair — provided by the remend library — must run on every streaming flush, making its performance directly visible to users.
Active issues: Issue #6935 (quadratic
findRemendWindowStart) is open; PR #6885 (prefix-escape fix) is open as of 2026-09-07.
Incomplete-Markdown Repair: tailBoundedRemend#
The repair pipeline lives in packages/react-streamdown/src/remend.ts. Two exported functions implement a "tail-bounded" strategy:
-
findRemendWindowStart(text)— single-pass scan that returns the character index of the start of the last top-level block (the position after the most recent blank line that falls outside any open code fence or$$math block). -
tailBoundedRemend(text, options?)— callsremendon only the trailing block identified by the above scan, not the full message.
Why tail-only is correct: Inline constructs cannot cross blank lines, and Streamdown splits blocks before rendering each independently. A dangling opener can therefore only exist in the last block, making tail repair render-equivalent to full-message repair .
How it's wired: In StreamdownTextPrimitive, when mode="streaming" (the default) and no custom parseMarkdownIntoBlocksFn is provided, tailBoundedRemend is called and parseIncompleteMarkdown is set to false to prevent duplicate repair .
This optimization was introduced in PR #4418 to avoid running remend over the entire accumulated message on every flush.
Known Performance Issues and Fixes#
Quadratic findRemendWindowStart on paragraph-dense messages (open issue)#
Issue #6935 identifies that findRemendWindowStart is O(lines × length) on messages with many short paragraphs. The root cause is at remend.ts:63-70:
for (
let s = text.indexOf("$$", lineStart);
s !== -1 && s < lineEnd - 1;
s = text.indexOf("$$", s + 2)
) {
String.prototype.indexOf has no end-bound parameter, so each line scans to the end of the full message string before the loop condition can reject an out-of-range match. Benchmarks on a tilde-dense corpus show the quadratic signature clearly :
| Corpus size | Window scan | tailBoundedRemend | Plain remend |
|---|---|---|---|
| 5 KB (732 paragraphs) | 0.20 ms | 0.28 ms | 0.34 ms |
| 20 KB (2926 paragraphs) | 2.08 ms | 4.24 ms | 1.24 ms |
| 50 KB (7315 paragraphs) | 9.05 ms | 17.55 ms | 4.14 ms |
At 20 KB+, tailBoundedRemend is already slower than skipping it entirely. The proposed fix is to bound the $$ search to the current line using a manual charCodeAt scan . Because tailBoundedRemend fires on every streaming flush (and on settled messages, since mode defaults to "streaming"), this regression affects all users.
Prefix-escape regression after tail-bounded repair (fixed in PR #6885)#
When only the final block received repair, earlier paragraphs lost numeric-range escapes, comparison-operator escapes, and custom handler changes. For example, 20~25 and 30~35\n\nTail could render as strikethrough .
PR #6885 (open as of this writing) fixes this with a two-pass strategy in tailBoundedRemend:
- Prefix pass:
remend(prefix, { ...options, ...COMPLETION_OFF })— applies escapes and custom handlers but disables all completion of incomplete constructs. - Tail pass:
remend(tail, options)— full repair including completion.
COMPLETION_OFF is enforced at compile time (satisfies Record<...>) so that a new completion option in remend will fail the build rather than silently reaching earlier blocks .
Performance cost of the two-pass approach: negligible on ordinary prose (1.0–1.1× vs. single-pass at 5–82 KB); up to 5.9× on tilde-dense text, but bounded at 3.1 ms for an 82 KB message. The pre-existing quadratic scan in findRemendWindowStart dominates in the paragraph-dense case .
Quadratic MessageRepository.import() (fixed in PR #6159)#
PR #6159 fixed an unrelated but analogous O(N²) regression in MessageRepository.import(). Each import of a new message called performOp(), which walked the full ancestor chain to check for ID conflicts. For 30,000 messages this took 8.55 s; after the fix, 120 ms .
The fix: skip the ancestor walk for fresh link operations (since addOrUpdateMessage already validates ID uniqueness before insertion) and retain the O(depth) scan only for relink operations, which can introduce cycles .
Repeated child-session scans in OpenCode streaming (fixed in PR #5431)#
PR #5431 fixed OpenCodeThreadController rescanning all transcript parts on every part.updated streaming event. The fix introduced an incremental childSessionIdByPartId index updated per-event rather than rebuilt from scratch, reducing repeated 50-part scans to a single-part lookup per event .
Key Source References#
| File / PR | Purpose |
|---|---|
remend.ts | findRemendWindowStart and tailBoundedRemend implementation |
StreamdownText.tsx | Wires repair into the render pipeline; sets parseIncompleteMarkdown=false |
| PR #4418 | Introduced tail-bounded repair for streaming performance |
| PR #6885 | Two-pass fix retaining prefix escapes and custom handlers |
| Issue #6935 | Open: quadratic $$ scan in findRemendWindowStart |
| PR #6159 | Fixed O(N²) ancestor walk in MessageRepository.import() |
| PR #5431 | Fixed repeated child-session scans in OpenCode streaming |