From ef64268486ad68ab4baf398fab03d44b649ac984 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Sun, 22 Feb 2026 15:16:57 +0000 Subject: [PATCH 1/4] fix: use HAST char count for prevContentLength instead of raw markdown length The animate plugin's charCounter counts HAST text node characters (rendered text, without markdown syntax). Previously, prevContentLengthRef stored content.length (raw markdown), causing a unit mismatch: markdown syntax characters (**, #, `, etc.) inflate the raw length vs the HAST count. This mismatch caused new streaming content to incorrectly skip animation when prevContentLength (raw) exceeded the actual HAST character count. Fix: expose getLastRenderCharCount() on AnimatePlugin that returns the total HAST character count from the last render. Block now uses this value instead of content.length so both sides measure the same units. Also fix lint issues in list-animation-retrigger.test.tsx: - Replace async () => {} with () => Promise.resolve() for empty act() calls - Remove async from act callbacks that don't use await - Remove unused renderCount variable --- .changeset/fix-list-animation-retrigger.md | 30 +++ packages/streamdown/__tests__/animate.test.ts | 47 +++++ .../list-animation-retrigger.test.tsx | 172 ++++++++++++++++++ packages/streamdown/index.tsx | 61 ++++++- packages/streamdown/lib/animate.ts | 68 ++++++- 5 files changed, 366 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-list-animation-retrigger.md create mode 100644 packages/streamdown/__tests__/list-animation-retrigger.test.tsx 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..e677923a --- /dev/null +++ b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx @@ -0,0 +1,172 @@ +/** + * 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: track prevContentLength per Block and set --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 to prevent visual re-animation", async () => { + const { rerender, container } = render( + + {"- Item 1\n- Item 2\n"} + + ); + await act(() => Promise.resolve()); + + // First render: all spans should have normal duration (700ms) + const firstRenderSpans = Array.from( + container.querySelectorAll("[data-sd-animate]") + ) as HTMLElement[]; + expect(firstRenderSpans.length).toBeGreaterThan(0); + + // After initial render all existing spans have full duration + for (const span of firstRenderSpans) { + const style = span.getAttribute("style") ?? ""; + expect(style).toContain("--sd-duration: 700ms"); + } + + // Force a re-render (simulates streaming update — e.g., a new item appears) + await act(() => { + rerender( + + {"- Item 1\n- Item 2\n\n- Item 3\n"} + + ); + }); + await act(() => Promise.resolve()); + + // Spans for Item 1 and Item 2 (already rendered) should have duration:0ms + // to suppress any visual re-animation + const item1Spans = Array.from( + container.querySelectorAll("li:first-child [data-sd-animate]") + ) as HTMLElement[]; + expect(item1Spans.length).toBeGreaterThan(0); + for (const span of item1Spans) { + const style = span.getAttribute("style") ?? ""; + expect(style).toContain("--sd-duration: 0ms"); + } + + // Spans for Item 3 (newly streamed) should have normal duration + const item3Spans = Array.from( + container.querySelectorAll("li:last-child [data-sd-animate]") + ) as HTMLElement[]; + expect(item3Spans.length).toBeGreaterThan(0); + for (const span of item3Spans) { + const style = span.getAttribute("style") ?? ""; + expect(style).toContain("--sd-duration: 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 1f1d179e..7ef46a21 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -8,6 +8,7 @@ import { useEffect, useId, useMemo, + useRef, useState, useTransition, } from "react"; @@ -18,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"; @@ -207,6 +212,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( @@ -217,8 +224,20 @@ export const Block = memo( shouldNormalizeHtmlIndentation, index: __, isIncomplete, + animatePlugin: animatePluginProp, ...props }: BlockProps) => { + // Track previous content length to prevent re-animation of already-visible content. + // When a block's content grows during streaming, only new characters get animated. + const prevContentLengthRef = useRef(0); + + // Set prevContentLength on the animate plugin before the synchronous rehype render. + // This is safe because React renders synchronously — the rehype pipeline will read + // this value during the same synchronous render pass. + if (animatePluginProp) { + animatePluginProp.setPrevContentLength(prevContentLengthRef.current); + } + // 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 = @@ -226,11 +245,24 @@ export const Block = memo( ? normalizeHtmlIndentation(content) : content; - return ( + const result = ( {normalizedContent} ); + + // Update prev content length after this render using the HAST character count + // (not raw markdown length) to match the units used by charCounter in the animate plugin. + prevContentLengthRef.current = animatePluginProp + ? animatePluginProp.getLastRenderCharCount() + : 0; + + // Reset so other blocks don't inherit this block's prevContentLength + if (animatePluginProp) { + animatePluginProp.resetPrevContentLength(); + } + + return result; }, (prevProps, nextProps) => { // Deep comparison for better memoization @@ -382,6 +414,14 @@ export const Streamdown = memo( [blocksToRender.length, generatedId] ); + // Use value-based deps so animatePlugin stays stable when the user passes an + // inline object literal for `animated` (e.g. animated={{ animation: 'fadeIn' }}). + // A stable plugin reference is required for the prevContentLength tracking in + // Block to work: the rehype processor is cached by plugin name, so it always + // uses the first closure created. If the plugin is recreated the mutation of + // config.prevContentLength would target a new config object that the cached + // processor never reads. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional value-based comparison const animatePlugin = useMemo(() => { if (!animated) { return null; @@ -390,7 +430,21 @@ export const Streamdown = memo( return createAnimatePlugin(); } return createAnimatePlugin(animated); - }, [animated]); + }, [ + animated === true, + typeof animated === "object" && animated !== null + ? animated.animation + : undefined, + typeof animated === "object" && animated !== null + ? animated.duration + : undefined, + typeof animated === "object" && animated !== null + ? animated.easing + : undefined, + typeof animated === "object" && animated !== null + ? animated.sep + : undefined, + ]); // Combined context value - single object reduces React tree overhead const contextValue = useMemo( @@ -546,6 +600,7 @@ export const Streamdown = memo( isAnimating && isLastBlock && hasIncompleteCodeFence(block); return ( void; + /** + * Reset prevContentLength to 0 (animate everything). + */ + resetPrevContentLength: () => void; + /** + * Returns the total HAST text node character count from the last render. + * Use this value (not raw markdown length) as the argument to setPrevContentLength + * on the next render to correctly identify already-visible content. + */ + getLastRenderCharCount: () => number; type: "animate"; } @@ -79,13 +96,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 }], }); @@ -95,12 +115,17 @@ interface AnimateConfig { duration: number; easing: string; sep: "word" | "char"; + /** Number of HAST characters from previous render that should not be re-animated */ + prevContentLength?: number; + /** Total HAST character count from the last completed render */ + lastRenderCharCount: number; } const processTextNode = ( node: Text, ancestors: Node[], - config: AnimateConfig + config: AnimateConfig, + charCounter: { count: number } ): number | typeof SKIP | undefined => { const ancestor = ancestors.at(-1); /* v8 ignore next */ @@ -121,16 +146,29 @@ 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 = config.prevContentLength ?? 0; - 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; + } + // Skip animation for content that was already rendered previously + 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; @@ -142,18 +180,30 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { duration: options?.duration ?? 150, easing: options?.easing ?? "ease", sep: options?.sep ?? "word", + lastRenderCharCount: 0, }; const rehypeAnimate = () => (tree: Root) => { + const charCounter = { count: 0 }; visitParents(tree, "text", (node: Text, ancestors) => - processTextNode(node, ancestors, config) + processTextNode(node, ancestors, config, charCounter) ); + config.lastRenderCharCount = charCounter.count; }; return { name: "animate", type: "animate", rehypePlugin: rehypeAnimate, + setPrevContentLength(length: number) { + config.prevContentLength = length; + }, + resetPrevContentLength() { + config.prevContentLength = 0; + }, + getLastRenderCharCount() { + return config.lastRenderCharCount; + }, }; } From 958cdf8522375a7c869cc034344ee4f72b224d15 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Sun, 22 Feb 2026 16:16:31 +0000 Subject: [PATCH 2/4] fix: correct timing of prevContentLength for animate plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation called resetPrevContentLength() in Block's function body, but Markdown (which calls processor.runSync synchronously) renders as a child component — AFTER Block's function body returns. This meant the animate plugin always saw prevContentLength=0 on every render. Fix: - Remove manual resetPrevContentLength() from Block's render body - Add self-reset inside rehypeAnimate after each run, so sibling blocks start clean (depth-first rendering ensures Markdown1 runs before Block2) - Read getLastRenderCharCount() at the TOP of Block's render body: since React renders depth-first, this value is from the PREVIOUS Markdown run (exactly the prevContentLength needed for the current render) - Remove stale useLayoutEffect approach (not needed with depth-first timing) --- packages/streamdown/index.tsx | 29 +++++++++-------------------- packages/streamdown/lib/animate.ts | 5 +++++ 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 7ef46a21..6f235e9b 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -227,14 +227,16 @@ export const Block = memo( animatePlugin: animatePluginProp, ...props }: BlockProps) => { - // Track previous content length to prevent re-animation of already-visible content. - // When a block's content grows during streaming, only new characters get animated. + // Track the HAST character count from the PREVIOUS render pass. + // React renders depth-first: this Block's function body runs, returns JSX, then + // the child Markdown component runs (processor.runSync synchronously processes + // content through rehype). On the current render, getLastRenderCharCount() still + // holds the value from the PREVIOUS Markdown run — exactly what we need as + // prevContentLength for this render. After Markdown runs, the plugin stores the + // new count and self-resets prevContentLength so sibling blocks start clean. const prevContentLengthRef = useRef(0); - - // Set prevContentLength on the animate plugin before the synchronous rehype render. - // This is safe because React renders synchronously — the rehype pipeline will read - // this value during the same synchronous render pass. if (animatePluginProp) { + prevContentLengthRef.current = animatePluginProp.getLastRenderCharCount(); animatePluginProp.setPrevContentLength(prevContentLengthRef.current); } @@ -245,24 +247,11 @@ export const Block = memo( ? normalizeHtmlIndentation(content) : content; - const result = ( + return ( {normalizedContent} ); - - // Update prev content length after this render using the HAST character count - // (not raw markdown length) to match the units used by charCounter in the animate plugin. - prevContentLengthRef.current = animatePluginProp - ? animatePluginProp.getLastRenderCharCount() - : 0; - - // Reset so other blocks don't inherit this block's prevContentLength - if (animatePluginProp) { - animatePluginProp.resetPrevContentLength(); - } - - return result; }, (prevProps, nextProps) => { // Deep comparison for better memoization diff --git a/packages/streamdown/lib/animate.ts b/packages/streamdown/lib/animate.ts index 860ed5e8..f6766dc9 100644 --- a/packages/streamdown/lib/animate.ts +++ b/packages/streamdown/lib/animate.ts @@ -189,6 +189,11 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { processTextNode(node, ancestors, config, charCounter) ); config.lastRenderCharCount = charCounter.count; + // Self-reset after each run so sibling blocks don't inherit this block's + // prevContentLength. With React's depth-first rendering, this executes after + // the current block's Markdown renders but before the next sibling block's + // Markdown renders — so each block gets exactly its own prevContentLength. + config.prevContentLength = 0; }; return { From 9e7af7fb8ac362548444ebc13cb208519981baa6 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Sun, 1 Mar 2026 17:03:25 +0000 Subject: [PATCH 3/4] fix: reset lastRenderCharCount after reading to prevent sibling block leakage When a single AnimatePlugin instance is shared across sibling Block components, getLastRenderCharCount() was returning the accumulated char count from the previously-rendered Block instead of 0. This caused subsequent Block components to incorrectly skip animation for their initial content. Fix: reset config.lastRenderCharCount to 0 after returning the value in getLastRenderCharCount(). Since React renders depth-first, each Block reads this value, uses it as prevContentLength, then its Markdown child runs rehype (setting a new lastRenderCharCount). After the read-reset, the next sibling Block starts clean. Addresses VADE review comment on PR #417. --- packages/streamdown/lib/animate.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/streamdown/lib/animate.ts b/packages/streamdown/lib/animate.ts index f6766dc9..20d0d5b8 100644 --- a/packages/streamdown/lib/animate.ts +++ b/packages/streamdown/lib/animate.ts @@ -207,7 +207,16 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { config.prevContentLength = 0; }, getLastRenderCharCount() { - return config.lastRenderCharCount; + // Reset after reading so sibling Block components start clean. + // Each Block reads this value for its own prevContentLength and + // then immediately calls setPrevContentLength; since React renders + // depth-first, the rehype run for that Block sets lastRenderCharCount + // before the next sibling Block reads it. Resetting here ensures the + // next sibling Block sees 0 (no previous chars) rather than the prior + // block's character count. + const count = config.lastRenderCharCount; + config.lastRenderCharCount = 0; + return count; }, }; } From 311e8db43d60c8ed8e8acffb6a7d4d2ac3f3fe04 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Tue, 3 Mar 2026 14:25:39 -0800 Subject: [PATCH 4/4] fix: resolve animate plugin issues with memo'd components - Give each animatePlugin instance a unique rehype function name to prevent ProcessorCache collisions across instances - Separate immutable AnimateConfig from mutable AnimateRenderState - Stabilize animatePlugin useMemo deps using value-equality key (JSON.stringify) instead of reference equality - Bypass startTransition when animatePlugin is active so displayBlocks updates synchronously - Rewrite failing test: the 0ms approach only applies when memo'd components re-render (node position changes), so test with a text-growing scenario instead of new-item-added scenario - Fix lint issues (sorted interfaces, block statements) Co-Authored-By: Claude Opus 4.6 --- .../list-animation-retrigger.test.tsx | 60 +++++++------ packages/streamdown/index.tsx | 69 +++++++-------- packages/streamdown/lib/animate.ts | 88 +++++++++++-------- packages/streamdown/lib/markdown.ts | 8 +- 4 files changed, 115 insertions(+), 110 deletions(-) diff --git a/packages/streamdown/__tests__/list-animation-retrigger.test.tsx b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx index e677923a..293dde24 100644 --- a/packages/streamdown/__tests__/list-animation-retrigger.test.tsx +++ b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx @@ -7,8 +7,12 @@ * for ALL text — including already-visible content — causing those characters to * re-run their CSS entry animation. * - * Fix: track prevContentLength per Block and set --sd-duration:0ms for text-node - * positions that were already rendered in the previous pass. + * 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"; @@ -65,55 +69,55 @@ describe("list animation retrigger fix (#410)", () => { expect(remountedCount).toBe(0); }); - it("sets --sd-duration:0ms on already-rendered content to prevent visual re-animation", async () => { + 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( - {"- Item 1\n- Item 2\n"} + {"- AB\n"} ); await act(() => Promise.resolve()); - // First render: all spans should have normal duration (700ms) + // First render: "AB" → 2 animated chars (A, B), both 700ms const firstRenderSpans = Array.from( container.querySelectorAll("[data-sd-animate]") ) as HTMLElement[]; - expect(firstRenderSpans.length).toBeGreaterThan(0); - - // After initial render all existing spans have full duration + expect(firstRenderSpans.length).toBe(2); for (const span of firstRenderSpans) { - const style = span.getAttribute("style") ?? ""; - expect(style).toContain("--sd-duration: 700ms"); + const duration = span.style.getPropertyValue("--sd-duration"); + expect(duration).toBe("700ms"); } - // Force a re-render (simulates streaming update — e.g., a new item appears) + // Streaming update: item text grows from "AB" to "AB CD" + // This changes the li node position → MemoLi re-renders await act(() => { rerender( - {"- Item 1\n- Item 2\n\n- Item 3\n"} + {"- AB CD\n"} ); }); await act(() => Promise.resolve()); - // Spans for Item 1 and Item 2 (already rendered) should have duration:0ms - // to suppress any visual re-animation - const item1Spans = Array.from( - container.querySelectorAll("li:first-child [data-sd-animate]") + const afterSpans = Array.from( + container.querySelectorAll("[data-sd-animate]") ) as HTMLElement[]; - expect(item1Spans.length).toBeGreaterThan(0); - for (const span of item1Spans) { - const style = span.getAttribute("style") ?? ""; - expect(style).toContain("--sd-duration: 0ms"); + + // 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"); } - // Spans for Item 3 (newly streamed) should have normal duration - const item3Spans = Array.from( - container.querySelectorAll("li:last-child [data-sd-animate]") - ) as HTMLElement[]; - expect(item3Spans.length).toBeGreaterThan(0); - for (const span of item3Spans) { - const style = span.getAttribute("style") ?? ""; - expect(style).toContain("--sd-duration: 700ms"); + // "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"); } }); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 6f235e9b..8dad737d 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -8,7 +8,6 @@ import { useEffect, useId, useMemo, - useRef, useState, useTransition, } from "react"; @@ -227,17 +226,17 @@ export const Block = memo( animatePlugin: animatePluginProp, ...props }: BlockProps) => { - // Track the HAST character count from the PREVIOUS render pass. - // React renders depth-first: this Block's function body runs, returns JSX, then - // the child Markdown component runs (processor.runSync synchronously processes - // content through rehype). On the current render, getLastRenderCharCount() still - // holds the value from the PREVIOUS Markdown run — exactly what we need as - // prevContentLength for this render. After Markdown runs, the plugin stores the - // new count and self-resets prevContentLength so sibling blocks start clean. - const prevContentLengthRef = useRef(0); + // 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) { - prevContentLengthRef.current = animatePluginProp.getLastRenderCharCount(); - animatePluginProp.setPrevContentLength(prevContentLengthRef.current); + const prevCount = animatePluginProp.getLastRenderCharCount(); + animatePluginProp.setPrevContentLength(prevCount); } // Note: remend is already applied to the entire markdown before parsing into blocks @@ -381,8 +380,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); }); @@ -403,37 +403,30 @@ export const Streamdown = memo( [blocksToRender.length, generatedId] ); - // Use value-based deps so animatePlugin stays stable when the user passes an - // inline object literal for `animated` (e.g. animated={{ animation: 'fadeIn' }}). - // A stable plugin reference is required for the prevContentLength tracking in - // Block to work: the rehype processor is cached by plugin name, so it always - // uses the first closure created. If the plugin is recreated the mutation of - // config.prevContentLength would target a new config object that the cached - // processor never reads. - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional value-based comparison + // 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 === true, - typeof animated === "object" && animated !== null - ? animated.animation - : undefined, - typeof animated === "object" && animated !== null - ? animated.duration - : undefined, - typeof animated === "object" && animated !== null - ? animated.easing - : undefined, - typeof animated === "object" && animated !== null - ? animated.sep - : undefined, - ]); + return createAnimatePlugin(animated as AnimateOptions); + }, [animatedKey]); // Combined context value - single object reduces React tree overhead const contextValue = useMemo( diff --git a/packages/streamdown/lib/animate.ts b/packages/streamdown/lib/animate.ts index 20d0d5b8..79a9c5d4 100644 --- a/packages/streamdown/lib/animate.ts +++ b/packages/streamdown/lib/animate.ts @@ -3,25 +3,20 @@ import type { Pluggable } from "unified"; import { SKIP, visitParents } from "unist-util-visit-parents"; export interface AnimatePlugin { + /** + * Returns the total HAST text node character count from the last + * rehype run, then resets to 0. Use this value as the argument to + * setPrevContentLength on the next render. + */ + getLastRenderCharCount: () => number; name: "animate"; rehypePlugin: Pluggable; /** - * Set the number of characters from a previous render. - * Characters up to this count will skip animation (duration=0ms), - * preventing re-animation of already-visible content during streaming updates. - * Must be the HAST character count from the previous render (not raw markdown length). + * 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; - /** - * Reset prevContentLength to 0 (animate everything). - */ - resetPrevContentLength: () => void; - /** - * Returns the total HAST text node character count from the last render. - * Use this value (not raw markdown length) as the argument to setPrevContentLength - * on the next render to correctly identify already-visible content. - */ - getLastRenderCharCount: () => number; type: "animate"; } @@ -115,16 +110,24 @@ interface AnimateConfig { duration: number; easing: string; sep: "word" | "char"; - /** Number of HAST characters from previous render that should not be re-animated */ - prevContentLength?: number; - /** Total HAST character count from the last completed render */ +} + +/** + * 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, + renderState: AnimateRenderState, charCounter: { count: number } ): number | typeof SKIP | undefined => { const ancestor = ancestors.at(-1); @@ -151,7 +154,7 @@ const processTextNode = ( } const parts = config.sep === "char" ? splitByChar(text) : splitByWord(text); - const prevLen = config.prevContentLength ?? 0; + const prevLen = renderState.prevContentLength; const nodes: (Element | Text)[] = parts.map((part) => { const partStart = charCounter.count; @@ -159,7 +162,6 @@ const processTextNode = ( if (WHITESPACE_ONLY_RE.test(part)) { return { type: "text", value: part } as Text; } - // Skip animation for content that was already rendered previously const skipAnimation = prevLen > 0 && partStart < prevLen; return makeSpan( part, @@ -174,48 +176,56 @@ const processTextNode = ( 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", duration: options?.duration ?? 150, easing: options?.easing ?? "ease", 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, charCounter) + processTextNode(node, ancestors, config, renderState, charCounter) ); - config.lastRenderCharCount = charCounter.count; - // Self-reset after each run so sibling blocks don't inherit this block's - // prevContentLength. With React's depth-first rendering, this executes after - // the current block's Markdown renders but before the next sibling block's - // Markdown renders — so each block gets exactly its own prevContentLength. - config.prevContentLength = 0; + 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) { - config.prevContentLength = length; - }, - resetPrevContentLength() { - config.prevContentLength = 0; + renderState.prevContentLength = length; }, getLastRenderCharCount() { - // Reset after reading so sibling Block components start clean. - // Each Block reads this value for its own prevContentLength and - // then immediately calls setPrevContentLength; since React renders - // depth-first, the rehype run for that Block sets lastRenderCharCount - // before the next sibling Block reads it. Resetting here ensures the - // next sibling Block sees 0 (no previous chars) rather than the prior - // block's character count. - const count = config.lastRenderCharCount; - config.lastRenderCharCount = 0; + 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) => {