diff --git a/.changeset/fix-animate-serial-zwprny.md b/.changeset/fix-animate-serial-zwprny.md new file mode 100644 index 00000000..4d3fbfb5 --- /dev/null +++ b/.changeset/fix-animate-serial-zwprny.md @@ -0,0 +1,18 @@ +--- +"streamdown": patch +--- + +fix(animate): serialize stagger delays across sibling blocks to prevent concurrent animation + +Previously all blocks shared a single animate plugin instance and a fixed +`startIndex` of 0, so when a new block appeared during streaming its words +began animating at delay 0 while the preceding block's words were still +animating — resulting in multiple sections revealing concurrently. + +This change introduces an `AnimateCursor` — a small shared counter object +that resets to 0 at the start of each React render pass. Each block now gets +its own `AnimatePlugin` instance; the plugin reads the cursor for its start +index, animates its words, and advances the cursor by its word count. Sibling +blocks automatically chain after one another without any manual wiring. + +Fixes #482 diff --git a/packages/streamdown/__tests__/animate.test.ts b/packages/streamdown/__tests__/animate.test.ts index a4a92cbc..065c8b00 100644 --- a/packages/streamdown/__tests__/animate.test.ts +++ b/packages/streamdown/__tests__/animate.test.ts @@ -2,7 +2,11 @@ import rehypeParse from "rehype-parse"; import rehypeStringify from "rehype-stringify"; import { unified } from "unified"; import { describe, expect, it } from "vitest"; -import { animate, createAnimatePlugin } from "../lib/animate"; +import { + animate, + createAnimateCursor, + createAnimatePlugin, +} from "../lib/animate"; const SPAN_GAP_RE = /<\/span>\s+([^<]*)<\/code>/; @@ -281,4 +285,71 @@ describe("animate plugin", () => { expect(delays).toEqual([]); }); }); + + describe("shared cursor (cross-block chaining)", () => { + it("should chain delays across sibling blocks", async () => { + const cursor = createAnimateCursor(); + const block0 = createAnimatePlugin({ stagger: 50, cursor }); + const block1 = createAnimatePlugin({ stagger: 50, cursor }); + + // Simulate a render pass: reset cursor, then render block0, block1 + cursor.current = 0; + const result0 = await processHtml("

Hello world

", block0); + const result1 = await processHtml("

foo bar

", block1); + + // block0: "Hello"=0ms (omitted), "world"=50ms + // block1: "foo"=100ms, "bar"=150ms (cursor was 2 after block0) + const delays0 = result0.match(/--sd-delay:\d+ms/g) ?? []; + const delays1 = result1.match(/--sd-delay:\d+ms/g) ?? []; + + expect(delays0).toEqual(["--sd-delay:50ms"]); + expect(delays1).toEqual(["--sd-delay:100ms", "--sd-delay:150ms"]); + }); + + it("should reset delays when cursor is reset to 0", async () => { + const cursor = createAnimateCursor(); + const block0 = createAnimatePlugin({ stagger: 50, cursor }); + const block1 = createAnimatePlugin({ stagger: 50, cursor }); + + // First render pass + cursor.current = 0; + await processHtml("

Hello world

", block0); + + // Second render pass — reset cursor so block1 starts from 0 again + cursor.current = 0; + await processHtml("

Hello world

", block0); + const result1 = await processHtml("

foo bar

", block1); + + // block1 should chain after block0's 2 new words: "foo"=100ms, "bar"=150ms + const delays1 = result1.match(/--sd-delay:\d+ms/g) ?? []; + expect(delays1).toEqual(["--sd-delay:100ms", "--sd-delay:150ms"]); + }); + + it("cursor.current advances by the number of newly animated words", async () => { + const cursor = createAnimateCursor(); + const block0 = createAnimatePlugin({ stagger: 50, cursor }); + + cursor.current = 0; + await processHtml("

Hello world foo

", block0); + // block0 animated 3 words → cursor should be 3 + expect(cursor.current).toBe(3); + }); + + it("cursor should not advance for skipped (already-rendered) words", async () => { + const cursor = createAnimateCursor(); + const block0 = createAnimatePlugin({ stagger: 50, cursor }); + + // First render: 3 words + cursor.current = 0; + await processHtml("

Hello world foo

", block0); + const prevCount = block0.getLastRenderCharCount(); + + // Second render: mark first render's content as already visible + cursor.current = 0; + block0.setPrevContentLength(prevCount); + await processHtml("

Hello world foo bar

", block0); + // Only "bar" is new → cursor should be 1 + expect(cursor.current).toBe(1); + }); + }); }); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index be32d9fd..1e71c35c 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -21,8 +21,10 @@ import remarkGfm from "remark-gfm"; import remend, { type RemendOptions } from "remend"; import type { Pluggable } from "unified"; import { + type AnimateCursor, type AnimateOptions, type AnimatePlugin, + createAnimateCursor, createAnimatePlugin, } from "./lib/animate"; import { BlockIncompleteContext } from "./lib/block-incomplete-context"; @@ -53,7 +55,7 @@ export type { } from "shiki"; export type { AnimateOptions } from "./lib/animate"; // biome-ignore lint/performance/noBarrelFile: "required" -export { createAnimatePlugin } from "./lib/animate"; +export { createAnimateCursor, createAnimatePlugin } from "./lib/animate"; export { useIsCodeFenceIncomplete } from "./lib/block-incomplete-context"; export { CodeBlock } from "./lib/code-block"; export { CodeBlockContainer } from "./lib/code-block/container"; @@ -550,9 +552,8 @@ 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" && !animatePlugin) { + if (mode === "streaming" && !animateCursorRef.current) { startTransition(() => { setDisplayBlocks(blocks); }); @@ -595,16 +596,59 @@ export const Streamdown = memo( return ""; }, [animated]); - // biome-ignore lint/correctness/useExhaustiveDependencies: keyed by animatedKey for value equality - const animatePlugin = useMemo(() => { - if (!animatedKey) { - return null; + // Shared cursor resets to 0 at the start of every render pass and is + // incremented by each block's rehype plugin as it runs, so sibling + // blocks automatically chain their stagger delays in render order. + const animateCursorRef = useRef(null); + // Stable array of per-block animate plugins — one plugin per block so + // each block independently tracks its own prevContentLength while the + // shared cursor serialises the stagger delays across all blocks. + const blockAnimatePluginsRef = useRef([]); + // Stable arrays of per-block merged rehype plugins (base + per-block animate). + // Keyed by block index; rebuilt only when mergedRehypePlugins changes. + const blockRehypePluginsRef = useRef([]); + const prevMergedRehypePluginsRef = useRef(null); + + // Keep track of the resolved options key so we can recreate plugins + // when the animation options change. + const prevAnimatedKeyRef = useRef(""); + + // Derive the per-block animate plugin for a given index. Creates a + // new plugin lazily when needed; recreates all plugins when the options + // key changes. + if (animatedKey) { + // (Re)create cursor when options change. + if (prevAnimatedKeyRef.current !== animatedKey) { + prevAnimatedKeyRef.current = animatedKey; + animateCursorRef.current = createAnimateCursor(); + blockAnimatePluginsRef.current = []; + blockRehypePluginsRef.current = []; } - if (animatedKey === "true") { - return createAnimatePlugin(); + // Reset cursor to 0 at the start of this render pass. + if (animateCursorRef.current) { + animateCursorRef.current.current = 0; } - return createAnimatePlugin(animated as AnimateOptions); - }, [animatedKey]); + } else { + // Animation disabled — clear any cached plugins and cursor. + animateCursorRef.current = null; + blockAnimatePluginsRef.current = []; + blockRehypePluginsRef.current = []; + } + + // Provide a stable single-plugin reference for external consumers that + // still use the animatePlugin prop (e.g. custom BlockComponent). + // Internal rendering uses blockAnimatePluginsRef directly. + const _animatePlugin = animateCursorRef.current + ? (blockAnimatePluginsRef.current[0] ?? + (() => { + const p = createAnimatePlugin({ + ...(animatedKey !== "true" ? (animated as AnimateOptions) : {}), + cursor: animateCursorRef.current ?? undefined, + }); + blockAnimatePluginsRef.current[0] = p; + return p; + })()) + : null; // Combined context value - single object reduces React tree overhead const contextValue = useMemo( @@ -722,19 +766,8 @@ export const Streamdown = memo( result = [...result, plugins.math.rehypePlugin]; } - if (animatePlugin && isAnimating) { - result = [...result, animatePlugin.rehypePlugin]; - } - return result; - }, [ - rehypePlugins, - plugins?.math, - animatePlugin, - isAnimating, - allowedTags, - literalTagContent, - ]); + }, [rehypePlugins, plugins?.math, allowedTags, literalTagContent]); const shouldHideCaret = useMemo(() => { if (!isAnimating || blocksToRender.length === 0) { @@ -754,6 +787,44 @@ export const Streamdown = memo( [caret, isAnimating, shouldHideCaret] ); + // Helper: lazily create a per-block animate plugin and return the + // combined rehype plugins array for a given block index. Extracted + // from the render map to keep cognitive complexity within biome limits. + const getBlockPlugins = ( + index: number + ): { + blockAnimatePlugin: AnimatePlugin | null; + blockRehypePlugins: Pluggable[]; + } => { + let blockAnimatePlugin: AnimatePlugin | null = null; + if (animateCursorRef.current && isAnimating) { + if (!blockAnimatePluginsRef.current[index]) { + blockAnimatePluginsRef.current[index] = createAnimatePlugin({ + ...(animatedKey !== "true" ? (animated as AnimateOptions) : {}), + cursor: animateCursorRef.current, + }); + } + blockAnimatePlugin = blockAnimatePluginsRef.current[index]; + } + // Rebuild per-block rehypePlugins only when the base set changes, so the + // Block memo's reference-equality check doesn't force unnecessary re-renders. + if (prevMergedRehypePluginsRef.current !== mergedRehypePlugins) { + blockRehypePluginsRef.current = []; + prevMergedRehypePluginsRef.current = mergedRehypePlugins; + } + if (blockAnimatePlugin && !blockRehypePluginsRef.current[index]) { + blockRehypePluginsRef.current[index] = [ + ...mergedRehypePlugins, + blockAnimatePlugin.rehypePlugin, + ]; + } + const blockRehypePlugins = + blockAnimatePlugin && blockRehypePluginsRef.current[index] + ? blockRehypePluginsRef.current[index] + : mergedRehypePlugins; + return { blockAnimatePlugin, blockRehypePlugins }; + }; + // Static mode: simple rendering without streaming features if (mode === "static") { return ( @@ -818,9 +889,11 @@ export const Streamdown = memo( isAnimating && isLastBlock && hasIncompleteCodeFence(block); + const { blockAnimatePlugin, blockRehypePlugins } = + getBlockPlugins(index); return ( number; + /** + * Returns the number of newly-animated words from the last rehype run, + * then resets to 0. Pass this value to setStartIndex() on the next + * sibling block so its stagger delays chain after ours. + */ + getLastRenderNewWordCount: () => number; name: "animate"; rehypePlugin: Pluggable; /** @@ -17,11 +38,27 @@ export interface AnimatePlugin { * re-animation of already-visible content during streaming updates. */ setPrevContentLength: (length: number) => void; + /** + * Set the animation word index to start from. Used when no shared + * cursor is provided. Use the value returned by + * getLastRenderNewWordCount() from the previous sibling block to + * ensure that a new block's stagger delays begin after the previous + * block's animation is still completing, preventing concurrent reveals. + */ + setStartIndex: (index: number) => void; type: "animate"; } export interface AnimateOptions { animation?: "fadeIn" | "blurIn" | "slideUp" | (string & {}); + /** + * Shared cursor for automatic cross-block stagger chaining. When + * provided the plugin reads `cursor.current` as the start index and + * increments it by the number of newly animated words after each run, + * so sibling blocks automatically pick up where the previous one left + * off without any manual `setStartIndex` calls. + */ + cursor?: AnimateCursor; duration?: number; easing?: string; sep?: "word" | "char"; @@ -113,6 +150,7 @@ const makeSpan = ( interface AnimateConfig { animation: string; + cursor?: AnimateCursor; duration: number; easing: string; sep: "word" | "char"; @@ -127,7 +165,9 @@ interface AnimateConfig { */ interface AnimateRenderState { lastRenderCharCount: number; + lastRenderNewWordCount: number; prevContentLength: number; + startIndex: number; } const processTextNode = ( @@ -170,7 +210,9 @@ const processTextNode = ( return { type: "text", value: part } as Text; } const skipAnimation = prevLen > 0 && partStart < prevLen; - const delay = skipAnimation ? 0 : charCounter.newIndex++ * config.stagger; + const delay = skipAnimation + ? 0 + : (renderState.startIndex + charCounter.newIndex++) * config.stagger; return makeSpan( part, config.animation, @@ -194,6 +236,7 @@ let instanceId = 0; export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { const config: AnimateConfig = { animation: options?.animation ?? "fadeIn", + cursor: options?.cursor, duration: options?.duration ?? 150, easing: options?.easing ?? "ease", sep: options?.sep ?? "word", @@ -205,15 +248,28 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { const renderState: AnimateRenderState = { prevContentLength: 0, lastRenderCharCount: 0, + lastRenderNewWordCount: 0, + startIndex: 0, }; const id = instanceId++; const rehypeAnimate = () => (tree: Root) => { const charCounter = { count: 0, newIndex: 0 }; + // When a shared cursor is provided, read the current cumulative word + // index from it so this block's stagger delays continue after all + // preceding sibling blocks. + if (config.cursor) { + renderState.startIndex = config.cursor.current; + } visitParents(tree, "text", (node: Text, ancestors) => processTextNode(node, ancestors, config, renderState, charCounter) ); renderState.lastRenderCharCount = charCounter.count; + renderState.lastRenderNewWordCount = charCounter.newIndex; + // Advance the shared cursor so the next sibling block starts after us. + if (config.cursor) { + config.cursor.current += charCounter.newIndex; + } // 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. @@ -233,11 +289,19 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { setPrevContentLength(length: number) { renderState.prevContentLength = length; }, + setStartIndex(index: number) { + renderState.startIndex = index; + }, getLastRenderCharCount() { const count = renderState.lastRenderCharCount; renderState.lastRenderCharCount = 0; return count; }, + getLastRenderNewWordCount() { + const count = renderState.lastRenderNewWordCount; + renderState.lastRenderNewWordCount = 0; + return count; + }, }; }