Environment
streamdown: 2.6.0
@streamdown/math: 1.0.2 (createMathPlugin/default math, singleDollarTextMath: false)
react / react-dom: 19.2.7
katex: 0.18.4 (katex/dist/katex.min.css imported)
- Consumed via
@copilotkit/react-core@1.67.1's CopilotChatAssistantMessage.MarkdownRenderer (which spreads extra props straight onto <Streamdown>), inside an Rspack/Module-Federation app — but the same corruption reproduces regardless of animated/isAnimating/parseIncompleteMarkdown settings (see "Ruled out" below), so this doesn't look CopilotKit-specific.
What happens
Streaming this exact markdown in from an LLM response, token/chunk by token (the realistic shape: growing string, re-rendered on every chunk):
$$\begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}$$
...ends up, once streaming completes, rendering as neither valid KaTeX markup nor the raw text alone, but a mix of both, corrupted:
document.querySelectorAll('.katex') → 0 elements anywhere in the message.
- The block's
<p>/wrapper contains a <span> whose children are a mix of:
- one child that is itself literal source text:
"\begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}" (with the $$ delimiters stripped, but otherwise raw, unrendered)
- a nested tree of ~40
<span> elements with zero attributes (no class, no style, no data-* — checked via getAttribute/.attributes.length, not just an innerHTML string dump) arranged exactly like a KaTeX matrix layout (open paren, 2x2 grid via nested wrapper spans, close paren, with zero-width-space separators matching KaTeX's own vlist positioning technique)
- several of the deepest of those spans contain bare, unwrapped, adjacent TEXT NODES, one character each — e.g. a parent
<span> whose childNodes are ["(", "1", "0", "0", "1", ")", "\begin{pmatrix}\n1 & 0 \\\n0 & 1\n\end{pmatrix}"] as 7 separate text nodes, not wrapped in per-character elements. This is a strong signal that somewhere in the pipeline, an array of plain strings is being handed to React as children directly (React renders each array entry as its own text node with no wrapper), rather than a properly built HAST/JSX tree.
Visually this displays as the LaTeX source jammed right next to a squished, unstyled render of the same matrix.
What does NOT reproduce it
A single, non-incremental render of the exact same final markdown string — via renderToStaticMarkup (Node, no DOM) and via a real react-dom/client mount in a real browser tab (React DevTools/fiber inspection, see below) — renders correctly: proper class="katex" markup, correct visual output, annotation properly hidden via CSS. I could not get an isolated repro script (single render, or even several discrete root.render() calls with growing content, run outside the actual app) to reproduce the corruption — it only reproduces inside the real app's live streaming loop (many renders per second as SSE/agent tokens arrive). I was not able to build a minimal standalone repro within the time I had; see "What I could not verify" below.
Confirmed via React fiber inspection (not just DOM dump)
Walked from the corrupted <p> back up to Streamdown's own Block component via __reactFiber$... and read memoizedProps directly:
{
"content": "$$\\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}$$",
"index": 0
}
The content prop Block actually received for this render is the fully correct, complete markdown — a valid, standalone $$...$$ block on one line, no code fence, no incomplete delimiters. So the corruption is not caused by bad input reaching Block; something inside Block's own render (or a stale sibling that never got cleaned up across the many prior streaming renders) is at fault.
Ruled out, with how I verified each
- Code fence around the math (
```latex\n$$...$$\n```): ruled out — the exact same corruption occurs with plain unfenced $$...$$, confirmed via a raw-markdown diagnostic log on every parseMarkdownIntoBlocksFn call.
- The
animate plugin / isAnimating: ruled out — explicitly set isAnimating={false} and even omitted animated entirely for any message containing $/\begin{; corruption unchanged. Also confirmed the corrupted spans have zero data-sd-animate attributes and zero inline style, so animate.ts's own span-wrapping isn't the source either.
parseIncompleteMarkdown / remend's mid-stream "close the unclosed $$" repair: ruled out — explicitly set parseIncompleteMarkdown={false} from the very first tick that could plausibly contain math (eager $/\begin{ detection, not waiting for a complete pair); corruption unchanged.
- Missing
katex/dist/katex.min.css: real, separate issue (undocumented anywhere except the plugin's own docs page) — fixed by importing it — but fixing it had no effect on this specific corruption (0 .katex elements either way).
- Our own
parseMarkdownIntoBlocksFn wrapper (bare-\begin{...} → $$...$$ wrapping, $$...$$ re-fencing onto separate lines): confirmed a no-op for the exact final string above (it's already a valid, complete, single-line $$...$$ span) — verified by logging its exact input/output.
- A stale block key from
parseMarkdownIntoBlocks re-lexing differently as content grows: restored a previously-working block-freeze/stabilization wrapper (cache the already-settled blocks, only re-parse the still-growing tail) specifically to rule this out — corruption unchanged.
- Wrong/duplicate
streamdown version resolving at runtime (e.g. CopilotKit's own older internal streamdown@1.6.11 dependency shadowing ours via bundler dedup): considered, but code blocks and Mermaid diagrams render perfectly via the exact same <Streamdown plugins={...}> instance in the same message stream, which a version-mismatch theory can't explain.
What I could not verify
I don't have a project-level test harness (jsdom/@testing-library/react/vitest) available in the consuming app to build a proper incremental-streaming repro, and a hand-rolled react-dom/client + act() loop feeding the 3 captured real chunks did not reproduce it either (though that may just mean 3 chunks isn't a fine-enough granularity — the real app re-renders far more often, on every SSE token). So I can't hand you a guaranteed-reproducing minimal script, only the exact captured evidence above. If it'd help, I can supply the exact sequence of raw markdown chunks the Block actually saw across the whole stream (I logged them) so you can replay them at 1:1 granularity.
Suspicion (not confirmed)
Given the corrupted spans are plain, attribute-less, and include bare adjacent text-node characters (rather than a well-formed HAST tree), my best guess is something in the per-block caching/memoization path — either lib/markdown.ts's ProcessorCache (a single unified() processor instance reused via an LRU cache keyed by a serialized plugin signature, not by call/render identity) being reused across many rapid-fire .parse()/.runSync() calls for the same logical block as its content grows, or something in rehype-katex/remark-math's own internal state not expecting to be invoked many times in quick succession against incrementally-different-but-related input strings for what is conceptually "the same" block. I have not been able to pin down the exact line — flagging the observation in case it's a useful lead.
Happy to provide more diagnostics (exact chunk sequence, React profiler trace, etc.) if useful.
Environment
streamdown: 2.6.0@streamdown/math: 1.0.2 (createMathPlugin/defaultmath,singleDollarTextMath: false)react/react-dom: 19.2.7katex: 0.18.4 (katex/dist/katex.min.cssimported)@copilotkit/react-core@1.67.1'sCopilotChatAssistantMessage.MarkdownRenderer(which spreads extra props straight onto<Streamdown>), inside an Rspack/Module-Federation app — but the same corruption reproduces regardless ofanimated/isAnimating/parseIncompleteMarkdownsettings (see "Ruled out" below), so this doesn't look CopilotKit-specific.What happens
Streaming this exact markdown in from an LLM response, token/chunk by token (the realistic shape: growing string, re-rendered on every chunk):
...ends up, once streaming completes, rendering as neither valid KaTeX markup nor the raw text alone, but a mix of both, corrupted:
document.querySelectorAll('.katex')→ 0 elements anywhere in the message.<p>/wrapper contains a<span>whose children are a mix of:"\begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}"(with the$$delimiters stripped, but otherwise raw, unrendered)<span>elements with zero attributes (noclass, nostyle, nodata-*— checked viagetAttribute/.attributes.length, not just aninnerHTMLstring dump) arranged exactly like a KaTeX matrix layout (open paren, 2x2 grid via nested wrapper spans, close paren, withzero-width-space separators matching KaTeX's ownvlistpositioning technique)<span>whosechildNodesare["(", "1", "0", "0", "1", ")", "\begin{pmatrix}\n1 & 0 \\\n0 & 1\n\end{pmatrix}"]as 7 separate text nodes, not wrapped in per-character elements. This is a strong signal that somewhere in the pipeline, an array of plain strings is being handed to React aschildrendirectly (React renders each array entry as its own text node with no wrapper), rather than a properly built HAST/JSX tree.Visually this displays as the LaTeX source jammed right next to a squished, unstyled render of the same matrix.
What does NOT reproduce it
A single, non-incremental render of the exact same final markdown string — via
renderToStaticMarkup(Node, no DOM) and via a realreact-dom/clientmount in a real browser tab (React DevTools/fiber inspection, see below) — renders correctly: properclass="katex"markup, correct visual output, annotation properly hidden via CSS. I could not get an isolated repro script (single render, or even several discreteroot.render()calls with growing content, run outside the actual app) to reproduce the corruption — it only reproduces inside the real app's live streaming loop (many renders per second as SSE/agent tokens arrive). I was not able to build a minimal standalone repro within the time I had; see "What I could not verify" below.Confirmed via React fiber inspection (not just DOM dump)
Walked from the corrupted
<p>back up to Streamdown's ownBlockcomponent via__reactFiber$...and readmemoizedPropsdirectly:{ "content": "$$\\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}$$", "index": 0 }The
contentpropBlockactually received for this render is the fully correct, complete markdown — a valid, standalone$$...$$block on one line, no code fence, no incomplete delimiters. So the corruption is not caused by bad input reachingBlock; something insideBlock's own render (or a stale sibling that never got cleaned up across the many prior streaming renders) is at fault.Ruled out, with how I verified each
```latex\n$$...$$\n```): ruled out — the exact same corruption occurs with plain unfenced$$...$$, confirmed via a raw-markdown diagnostic log on everyparseMarkdownIntoBlocksFncall.animateplugin /isAnimating: ruled out — explicitly setisAnimating={false}and even omittedanimatedentirely for any message containing$/\begin{; corruption unchanged. Also confirmed the corrupted spans have zerodata-sd-animateattributes and zero inlinestyle, soanimate.ts's own span-wrapping isn't the source either.parseIncompleteMarkdown/remend's mid-stream "close the unclosed $$" repair: ruled out — explicitly setparseIncompleteMarkdown={false}from the very first tick that could plausibly contain math (eager$/\begin{detection, not waiting for a complete pair); corruption unchanged.katex/dist/katex.min.css: real, separate issue (undocumented anywhere except the plugin's own docs page) — fixed by importing it — but fixing it had no effect on this specific corruption (0.katexelements either way).parseMarkdownIntoBlocksFnwrapper (bare-\begin{...}→$$...$$wrapping,$$...$$re-fencing onto separate lines): confirmed a no-op for the exact final string above (it's already a valid, complete, single-line$$...$$span) — verified by logging its exact input/output.parseMarkdownIntoBlocksre-lexing differently as content grows: restored a previously-working block-freeze/stabilization wrapper (cache the already-settled blocks, only re-parse the still-growing tail) specifically to rule this out — corruption unchanged.streamdownversion resolving at runtime (e.g. CopilotKit's own older internalstreamdown@1.6.11dependency shadowing ours via bundler dedup): considered, but code blocks and Mermaid diagrams render perfectly via the exact same<Streamdown plugins={...}>instance in the same message stream, which a version-mismatch theory can't explain.What I could not verify
I don't have a project-level test harness (
jsdom/@testing-library/react/vitest) available in the consuming app to build a proper incremental-streaming repro, and a hand-rolledreact-dom/client+act()loop feeding the 3 captured real chunks did not reproduce it either (though that may just mean 3 chunks isn't a fine-enough granularity — the real app re-renders far more often, on every SSE token). So I can't hand you a guaranteed-reproducing minimal script, only the exact captured evidence above. If it'd help, I can supply the exact sequence of raw markdown chunks theBlockactually saw across the whole stream (I logged them) so you can replay them at 1:1 granularity.Suspicion (not confirmed)
Given the corrupted spans are plain, attribute-less, and include bare adjacent text-node characters (rather than a well-formed HAST tree), my best guess is something in the per-block caching/memoization path — either
lib/markdown.ts'sProcessorCache(a singleunified()processor instance reused via an LRU cache keyed by a serialized plugin signature, not by call/render identity) being reused across many rapid-fire.parse()/.runSync()calls for the same logical block as its content grows, or something inrehype-katex/remark-math's own internal state not expecting to be invoked many times in quick succession against incrementally-different-but-related input strings for what is conceptually "the same" block. I have not been able to pin down the exact line — flagging the observation in case it's a useful lead.Happy to provide more diagnostics (exact chunk sequence, React profiler trace, etc.) if useful.