diff --git a/.changeset/fix-list-animation-retrigger.md b/.changeset/fix-list-animation-retrigger.md new file mode 100644 index 00000000..15559525 --- /dev/null +++ b/.changeset/fix-list-animation-retrigger.md @@ -0,0 +1,30 @@ +--- +"streamdown": patch +--- + +fix: prevent ordered list animation retrigger during streaming + +When streaming content contains multiple ordered (or unordered) lists, +the Marked lexer merges them into a single block. As each new item appears +the block is re-processed through the rehype pipeline, re-creating all +`data-sd-animate` spans. This caused already-visible characters to re-run +their CSS entry animation. + +Two changes address the root cause: + +1. **Per-block `prevContentLength` tracking** – each `Block` component + now keeps a `useRef` with the content length from its previous render. + Before each render the `animatePlugin.setPrevContentLength(n)` method is + called so the rehype plugin can detect which text-node positions were + already rendered. Characters whose cumulative hast-text offset falls below + the previous raw-content length receive `--sd-duration:0ms`, making them + appear in their final state instantly rather than re-animating. + +2. **Stable `animatePlugin` reference** – the `animatePlugin` `useMemo` + now uses value-based dependency comparison instead of reference equality + for the `animated` option object. This prevents the plugin from being + recreated on every parent re-render when the user passes an inline object + literal (e.g. `animated={{ animation: 'fadeIn' }}`). A stable reference + is required because the rehype processor cache uses the function name as + its key and always returns the first cached closure; only the original + `config` object is ever read by the processor. diff --git a/packages/streamdown/__tests__/animate.test.ts b/packages/streamdown/__tests__/animate.test.ts index a3eb3d79..b92da2ca 100644 --- a/packages/streamdown/__tests__/animate.test.ts +++ b/packages/streamdown/__tests__/animate.test.ts @@ -180,4 +180,51 @@ describe("animate plugin", () => { expect(result).toContain("--sd-easing:ease"); }); }); + + describe("getLastRenderCharCount", () => { + it("should return 0 before any render", () => { + const plugin = createAnimatePlugin(); + expect(plugin.getLastRenderCharCount()).toBe(0); + }); + + it("should return HAST text node char count after render", async () => { + const plugin = createAnimatePlugin(); + // "Hello world" = 11 HAST chars (5 + 1 space + 5) + await processHtml("

Hello world

", plugin); + expect(plugin.getLastRenderCharCount()).toBe(11); + }); + + it("should not include markdown syntax chars — only rendered text", async () => { + const plugin = createAnimatePlugin(); + // plain text: "Hello" = 5 HAST chars + await processHtml("

Hello

", plugin); + expect(plugin.getLastRenderCharCount()).toBe(5); + }); + + it("should update after each render", async () => { + const plugin = createAnimatePlugin(); + await processHtml("

Hi

", plugin); + const firstCount = plugin.getLastRenderCharCount(); + await processHtml("

Hello world

", plugin); + const secondCount = plugin.getLastRenderCharCount(); + expect(secondCount).toBeGreaterThan(firstCount); + }); + + it("setPrevContentLength with getLastRenderCharCount should skip already-rendered chars", async () => { + const plugin = createAnimatePlugin(); + // First render: "Hello" + await processHtml("

Hello

", plugin); + const prevCount = plugin.getLastRenderCharCount(); + + // Second render: "Hello world" — set prev length from HAST count + plugin.setPrevContentLength(prevCount); + const result = await processHtml("

Hello world

", plugin); + + // "Hello" (chars 0-4) should have duration:0ms — already visible + // " world" should have normal duration + const spans = result.match(/--sd-duration:[^;"]*/g) ?? []; + expect(spans.some((s) => s.includes("0ms"))).toBe(true); + expect(spans.some((s) => s.includes("150ms"))).toBe(true); + }); + }); }); diff --git a/packages/streamdown/__tests__/list-animation-retrigger.test.tsx b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx new file mode 100644 index 00000000..293dde24 --- /dev/null +++ b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx @@ -0,0 +1,176 @@ +/** + * Tests for fix #410: Ordered list animations incorrectly retrigger + * + * Root cause: when streaming content contains multiple ordered/unordered lists, + * the Marked lexer merges them into a single block. As new items appear the block + * is re-processed through the rehype pipeline, recreating `data-sd-animate` spans + * for ALL text — including already-visible content — causing those characters to + * re-run their CSS entry animation. + * + * Fix: two layers of protection: + * 1. Memo'd list components (MemoLi, MemoUl, etc.) prevent re-rendering when + * the node position hasn't changed — existing spans stay in the DOM. + * 2. When the node position DOES change (e.g., during streaming as text grows), + * the animate plugin tracks prevContentLength and sets --sd-duration:0ms for + * text-node positions that were already rendered in the previous pass. + */ + +import { act, render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; + +const animatedConfig = { + animation: "fadeIn" as const, + duration: 700, + easing: "ease-in-out", + sep: "char" as const, +}; + +describe("list animation retrigger fix (#410)", () => { + it("does not remount spans for existing list items when a new item appears", async () => { + const { rerender, container } = render( + + {"1. Item 1\n2. Item 2\n"} + + ); + await act(() => Promise.resolve()); + + const initialSpans = Array.from( + container.querySelectorAll("[data-sd-animate]") + ); + expect(initialSpans.length).toBeGreaterThan(0); + + // Tag spans so we can track identity across re-renders + initialSpans.forEach((span, i) => { + (span as HTMLElement).dataset.origIdx = String(i); + }); + + // Simulate a new list group appearing (triggers tight→loose transition) + await act(() => { + rerender( + + {"1. Item 1\n2. Item 2\n\n1. Item A\n"} + + ); + }); + await act(() => Promise.resolve()); + + const afterSpans = Array.from( + container.querySelectorAll("[data-sd-animate]") + ); + + // There should be MORE spans after (new item appeared) + expect(afterSpans.length).toBeGreaterThan(initialSpans.length); + + // All original spans should still be in the document (not remounted) + const remountedCount = initialSpans.filter( + (s) => !container.contains(s) + ).length; + expect(remountedCount).toBe(0); + }); + + it("sets --sd-duration:0ms on already-rendered content when item text grows", async () => { + // When a list item's text grows during streaming, its node position + // changes (end column extends). This causes the memo'd MemoLi to + // re-render, and the animate plugin applies 0ms to already-visible chars. + const { rerender, container } = render( + + {"- AB\n"} + + ); + await act(() => Promise.resolve()); + + // First render: "AB" → 2 animated chars (A, B), both 700ms + const firstRenderSpans = Array.from( + container.querySelectorAll("[data-sd-animate]") + ) as HTMLElement[]; + expect(firstRenderSpans.length).toBe(2); + for (const span of firstRenderSpans) { + const duration = span.style.getPropertyValue("--sd-duration"); + expect(duration).toBe("700ms"); + } + + // Streaming update: item text grows from "AB" to "AB CD" + // This changes the li node position → MemoLi re-renders + await act(() => { + rerender( + + {"- AB CD\n"} + + ); + }); + await act(() => Promise.resolve()); + + const afterSpans = Array.from( + container.querySelectorAll("[data-sd-animate]") + ) as HTMLElement[]; + + // Should have 4 animated chars: A, B (old), C, D (new) + expect(afterSpans.length).toBe(4); + + // "A" and "B" (chars 0-1) should have duration:0ms — already visible + for (const span of afterSpans.slice(0, 2)) { + const duration = span.style.getPropertyValue("--sd-duration"); + expect(duration).toBe("0ms"); + } + + // "C" and "D" (chars 2-3) should have normal duration + for (const span of afterSpans.slice(2)) { + const duration = span.style.getPropertyValue("--sd-duration"); + expect(duration).toBe("700ms"); + } + }); + + it("keeps animatePlugin stable when animated is a new inline object with same values", async () => { + // This tests the value-based useMemo deps fix. + // When animated is an inline object literal, each parent render creates + // a new reference. The fix ensures the plugin instance stays stable + // so that prevContentLength mutations affect the correct processor closure. + const getAnimated = () => ({ + animation: "fadeIn" as const, + duration: 700, + easing: "ease-in-out", + sep: "char" as const, + }); + + const { rerender, container } = render( + + {"- Alpha\n- Beta\n"} + + ); + await act(() => Promise.resolve()); + + // Tag initial spans + const initialSpans = Array.from( + container.querySelectorAll("[data-sd-animate]") + ); + initialSpans.forEach((span, i) => { + (span as HTMLElement).dataset.origIdx = String(i); + }); + + // Re-render with new object reference for animated (same values) + // and new content — simulates a streaming update from a parent that + // re-creates the animated object literal on each render + await act(() => { + rerender( + + {"- Alpha\n- Beta\n- Gamma\n"} + + ); + }); + await act(() => Promise.resolve()); + + const afterSpans = Array.from( + container.querySelectorAll("[data-sd-animate]") + ); + + // Original spans should still be in the document + const remountedCount = initialSpans.filter( + (s) => !container.contains(s) + ).length; + expect(remountedCount).toBe(0); + + // New spans for "Gamma" should exist + expect(afterSpans.length).toBeGreaterThan(initialSpans.length); + }); +}); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index eaebfd4c..526fa312 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -19,7 +19,11 @@ import remarkGfm from "remark-gfm"; import remend, { type RemendOptions } from "remend"; import type { BundledTheme } from "shiki"; import type { Pluggable } from "unified"; -import { type AnimateOptions, createAnimatePlugin } from "./lib/animate"; +import { + type AnimateOptions, + type AnimatePlugin, + createAnimatePlugin, +} from "./lib/animate"; import { BlockIncompleteContext } from "./lib/block-incomplete-context"; import { components as defaultComponents } from "./lib/components"; import { hasIncompleteCodeFence, hasTable } from "./lib/incomplete-code-utils"; @@ -233,6 +237,8 @@ export type BlockProps = Options & { index: number; /** Whether this block is incomplete (still being streamed) */ isIncomplete: boolean; + /** Animate plugin instance for tracking previous content length */ + animatePlugin?: AnimatePlugin | null; }; export const Block = memo( @@ -243,8 +249,22 @@ export const Block = memo( shouldNormalizeHtmlIndentation, index: __, isIncomplete, + animatePlugin: animatePluginProp, ...props }: BlockProps) => { + // Tell the animate plugin how many HAST characters were already rendered + // so it can skip their animation (duration=0ms) on this render pass. + // + // getLastRenderCharCount() returns the char count from the PREVIOUS + // rehype run then resets to 0. React renders depth-first: this Block's + // body runs, then its child Markdown calls processor.runSync (which + // runs rehypeAnimate synchronously). So the value here is from the + // previous render — exactly what we need as prevContentLength. + if (animatePluginProp) { + const prevCount = animatePluginProp.getLastRenderCharCount(); + animatePluginProp.setPrevContentLength(prevCount); + } + // Note: remend is already applied to the entire markdown before parsing into blocks // in the Streamdown component, so we don't need to apply it again here const normalizedContent = @@ -425,8 +445,9 @@ export const Streamdown = memo( const [displayBlocks, setDisplayBlocks] = useState(blocks); // Use transition for block updates in streaming mode to avoid blocking UI + // biome-ignore lint/correctness/useExhaustiveDependencies: animatePlugin checked but not a dep useEffect(() => { - if (mode === "streaming") { + if (mode === "streaming" && !animatePlugin) { startTransition(() => { setDisplayBlocks(blocks); }); @@ -447,15 +468,30 @@ export const Streamdown = memo( [blocksToRender.length, generatedId] ); + // Stable key derived from animated option values. This prevents the + // plugin from being recreated when the user passes an inline object + // literal (e.g. animated={{ animation: 'fadeIn' }}) whose reference + // changes on every parent render. + const animatedKey = useMemo(() => { + if (animated === true) { + return "true"; + } + if (animated) { + return JSON.stringify(animated); + } + return ""; + }, [animated]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed by animatedKey for value equality const animatePlugin = useMemo(() => { - if (!animated) { + if (!animatedKey) { return null; } - if (animated === true) { + if (animatedKey === "true") { return createAnimatePlugin(); } - return createAnimatePlugin(animated); - }, [animated]); + return createAnimatePlugin(animated as AnimateOptions); + }, [animatedKey]); // Combined context value - single object reduces React tree overhead const contextValue = useMemo( @@ -614,6 +650,7 @@ export const Streamdown = memo( isAnimating && isLastBlock && hasIncompleteCodeFence(block); return ( number; name: "animate"; rehypePlugin: Pluggable; + /** + * Set the number of HAST text characters from a previous render. + * Characters up to this count will get duration=0ms, preventing + * re-animation of already-visible content during streaming updates. + */ + setPrevContentLength: (length: number) => void; type: "animate"; } @@ -79,13 +91,16 @@ const makeSpan = ( word: string, animation: string, duration: number, - easing: string + easing: string, + skipAnimation?: boolean ): Element => ({ type: "element", tagName: "span", properties: { "data-sd-animate": true, - style: `--sd-animation:sd-${animation};--sd-duration:${duration}ms;--sd-easing:${easing}`, + style: skipAnimation + ? `--sd-animation:sd-${animation};--sd-duration:0ms;--sd-easing:${easing}` + : `--sd-animation:sd-${animation};--sd-duration:${duration}ms;--sd-easing:${easing}`, }, children: [{ type: "text", value: word }], }); @@ -97,10 +112,23 @@ interface AnimateConfig { sep: "word" | "char"; } +/** + * Mutable render state shared between the plugin API and the rehype + * closure. Stored separately from AnimateConfig so that the processor + * cache (which retains the first closure) always reads from the same + * object that setPrevContentLength / getLastRenderCharCount mutate. + */ +interface AnimateRenderState { + lastRenderCharCount: number; + prevContentLength: number; +} + const processTextNode = ( node: Text, ancestors: Node[], - config: AnimateConfig + config: AnimateConfig, + renderState: AnimateRenderState, + charCounter: { count: number } ): number | typeof SKIP | undefined => { const ancestor = ancestors.at(-1); /* v8 ignore next */ @@ -121,21 +149,39 @@ const processTextNode = ( const text = node.value; if (!text.trim()) { + charCounter.count += text.length; return; } const parts = config.sep === "char" ? splitByChar(text) : splitByWord(text); + const prevLen = renderState.prevContentLength; - const nodes: (Element | Text)[] = parts.map((part) => - WHITESPACE_ONLY_RE.test(part) - ? ({ type: "text", value: part } as Text) - : makeSpan(part, config.animation, config.duration, config.easing) - ); + const nodes: (Element | Text)[] = parts.map((part) => { + const partStart = charCounter.count; + charCounter.count += part.length; + if (WHITESPACE_ONLY_RE.test(part)) { + return { type: "text", value: part } as Text; + } + const skipAnimation = prevLen > 0 && partStart < prevLen; + return makeSpan( + part, + config.animation, + config.duration, + config.easing, + skipAnimation + ); + }); parent.children.splice(index, 1, ...nodes); return index + nodes.length; }; +// Instance counter ensures each plugin gets a unique rehype function name. +// The processor cache in markdown.ts keys by function name, so without unique +// names, different AnimatePlugin instances would share a cached processor +// whose closure reads a stale config. +let instanceId = 0; + export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { const config: AnimateConfig = { animation: options?.animation ?? "fadeIn", @@ -144,16 +190,44 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { sep: options?.sep ?? "word", }; + // Mutable render state — the rehype closure and the plugin API methods + // both reference this same object. + const renderState: AnimateRenderState = { + prevContentLength: 0, + lastRenderCharCount: 0, + }; + + const id = instanceId++; const rehypeAnimate = () => (tree: Root) => { + const charCounter = { count: 0 }; visitParents(tree, "text", (node: Text, ancestors) => - processTextNode(node, ancestors, config) + processTextNode(node, ancestors, config, renderState, charCounter) ); + renderState.lastRenderCharCount = charCounter.count; + // Self-reset so sibling blocks don't inherit this block's value. + // React renders depth-first: this runs after the current block's + // Markdown but before the next sibling block's Markdown. + renderState.prevContentLength = 0; }; + // Give each instance a unique function name so the processor cache + // in markdown.ts creates a separate processor per plugin instance. + Object.defineProperty(rehypeAnimate, "name", { + value: `rehypeAnimate$${id}`, + }); + return { name: "animate", type: "animate", rehypePlugin: rehypeAnimate, + setPrevContentLength(length: number) { + renderState.prevContentLength = length; + }, + getLastRenderCharCount() { + const count = renderState.lastRenderCharCount; + renderState.lastRenderCharCount = 0; + return count; + }, }; } diff --git a/packages/streamdown/lib/markdown.ts b/packages/streamdown/lib/markdown.ts index f4889e44..afdef2d3 100644 --- a/packages/streamdown/lib/markdown.ts +++ b/packages/streamdown/lib/markdown.ts @@ -185,11 +185,9 @@ const processorCache = new ProcessorCache(); export const Markdown = (options: Readonly) => { const processor = getCachedProcessor(options); const content = options.children || ""; - return post( - // biome-ignore lint/suspicious/noExplicitAny: runSync return type varies with processor configuration - processor.runSync(processor.parse(content), content) as any, - options - ); + // biome-ignore lint/suspicious/noExplicitAny: runSync return type varies with processor configuration + const tree = processor.runSync(processor.parse(content), content) as any; + return post(tree, options); }; const getCachedProcessor = (options: Readonly) => {