Summary
With animated + isAnimating, the per-word <span data-sd-animate> wrappers stay in the DOM permanently after isAnimating goes false. They accumulate for the life of the page — in a chat UI, every settled message keeps one span per word forever.
It's visually invisible (the spans sit at opacity 1), but it changes the DOM contract for anything that walks text nodes. In our case it broke browser-style find-in-page: settled text is one text node per word, so no multi-word query can match. Text extraction and selection heuristics have the same exposure.
streamdown@2.5.0, React 19.
Reproduction
import { render } from "@testing-library/react";
import { Streamdown } from "streamdown";
import { expect, test } from "vitest";
const FADE = { animation: "fadeIn", duration: 200, easing: "ease-out", sep: "word", stagger: 0 } as const;
const MD = "First paragraph with several words here.\n\nSecond paragraph also has words.\n\nThird paragraph ends it.";
test("spans come off when isAnimating goes false", () => {
const { container, rerender } = render(
<Streamdown animated={FADE} isAnimating>{MD}</Streamdown>
);
expect(container.querySelectorAll("[data-sd-animate]").length).toBeGreaterThan(0);
// Stream ends: same children, animation off.
rerender(
<Streamdown animated={FADE} isAnimating={false}>{MD}</Streamdown>
);
expect(container.querySelectorAll("[data-sd-animate]").length).toBe(0);
});
Expected: 0 spans after the flip.
Actual: 19 spans (all of them, minus the final block — see below).
Cause
The parse side is fine. On the flip, mergedRehypePlugins changes identity (the animate plugin is excluded, index.tsx:689-737), Block's comparator busts on the rehypePlugins reference check (index.tsx:415), and every block reparses without the plugin. The span-free HAST is computed correctly.
It never commits. The default markdown components — MemoParagraph, MemoLi, headings, MemoStrong, table cells — are memoized on className + hast source position and never compare children:
// lib/components.tsx
function sameClassAndNode(prev, next) {
return prev.className === next.className && sameNodePosition(prev.node, next.node);
}
A settled block has the same text at the same source position, so every comparator returns "equal" and React throws away the span-free children the reparse just produced. The stale markup — still carrying the streaming-era --sd-duration — stays in the DOM.
This also explains the one block that does come out clean: whichever block was still being written when the stream ended had its content (hence its source position) change in that same commit, so its comparator busts on its own.
The general shape: any rehype plugin toggled on/off for identical source text cannot affect the output, because position-only memoization treats "same text, different plugin pipeline" as a no-op.
Possible fixes
Comparing children in sameClassAndNode would fix it but costs on the hot streaming path. A cheaper option: have the Block layer participate — e.g. when the rehype pipeline identity changes, force a fresh subtree (a key derived from the pipeline) rather than relying on the leaf comparators to notice a change they can't see.
Workaround (for anyone hitting this before it's fixed)
Append a host rehype plugin that stamps a marker class on every element while isAnimating, dropping it at settle. The class changes className, which the comparators do check, so the reparse commits. Verified: N → 0 spans.
Secondary: Block reads mutable plugin state during render, so the 0ms suppression is dead under StrictMode
Separate bug, found while pinning the above.
Block's render body calls animatePlugin.getLastRenderCharCount() (index.tsx:347-350), which reads and resets shared mutable state, then calls setPrevContentLength. That's a side effect during render. StrictMode double-invokes the render body: the first call reads N and sets prevContentLength = N, the second reads 0 (already reset by the first) and sets prevContentLength = 0. The already-seen-text suppression never engages.
const durations = (c) => [...c.querySelectorAll("[data-sd-animate]")]
.map((el) => el.getAttribute("style").match(/--sd-duration:\s*(\d+)ms/)?.[1]);
// two streaming ticks: "one two three" -> "one two three four five"
// plain: [ '0', '0', '0', '200', '200' ] <- seen words suppressed, correct
// strict: [ '200', '200', '200', '200', '200' ] <- every word re-animates
Effect in a real app: under StrictMode (dev only — production doesn't double-invoke), every already-visible word re-fades on every streaming tick instead of holding steady. Moving the read out of the render body — into the rehype pass itself, or into a ref keyed by render — would make it StrictMode-safe.
Summary
With
animated+isAnimating, the per-word<span data-sd-animate>wrappers stay in the DOM permanently afterisAnimatinggoes false. They accumulate for the life of the page — in a chat UI, every settled message keeps one span per word forever.It's visually invisible (the spans sit at opacity 1), but it changes the DOM contract for anything that walks text nodes. In our case it broke browser-style find-in-page: settled text is one text node per word, so no multi-word query can match. Text extraction and selection heuristics have the same exposure.
streamdown@2.5.0, React 19.Reproduction
Expected: 0 spans after the flip.
Actual: 19 spans (all of them, minus the final block — see below).
Cause
The parse side is fine. On the flip,
mergedRehypePluginschanges identity (the animate plugin is excluded,index.tsx:689-737),Block's comparator busts on therehypePluginsreference check (index.tsx:415), and every block reparses without the plugin. The span-free HAST is computed correctly.It never commits. The default markdown components —
MemoParagraph,MemoLi, headings,MemoStrong, table cells — are memoized onclassName+ hast source position and never compare children:A settled block has the same text at the same source position, so every comparator returns "equal" and React throws away the span-free children the reparse just produced. The stale markup — still carrying the streaming-era
--sd-duration— stays in the DOM.This also explains the one block that does come out clean: whichever block was still being written when the stream ended had its content (hence its source position) change in that same commit, so its comparator busts on its own.
The general shape: any rehype plugin toggled on/off for identical source text cannot affect the output, because position-only memoization treats "same text, different plugin pipeline" as a no-op.
Possible fixes
Comparing children in
sameClassAndNodewould fix it but costs on the hot streaming path. A cheaper option: have theBlocklayer participate — e.g. when the rehype pipeline identity changes, force a fresh subtree (a key derived from the pipeline) rather than relying on the leaf comparators to notice a change they can't see.Workaround (for anyone hitting this before it's fixed)
Append a host rehype plugin that stamps a marker class on every element while
isAnimating, dropping it at settle. The class changesclassName, which the comparators do check, so the reparse commits. Verified: N → 0 spans.Secondary:
Blockreads mutable plugin state during render, so the 0ms suppression is dead under StrictModeSeparate bug, found while pinning the above.
Block's render body callsanimatePlugin.getLastRenderCharCount()(index.tsx:347-350), which reads and resets shared mutable state, then callssetPrevContentLength. That's a side effect during render. StrictMode double-invokes the render body: the first call reads N and setsprevContentLength = N, the second reads 0 (already reset by the first) and setsprevContentLength = 0. The already-seen-text suppression never engages.Effect in a real app: under StrictMode (dev only — production doesn't double-invoke), every already-visible word re-fades on every streaming tick instead of holding steady. Moving the read out of the render body — into the rehype pass itself, or into a ref keyed by render — would make it StrictMode-safe.