Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/fix-animate-serial-zwprny.md
Original file line number Diff line number Diff line change
@@ -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
73 changes: 72 additions & 1 deletion packages/streamdown/__tests__/animate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+<span/;
const CODE_CONTENT_RE = /<code>([^<]*)<\/code>/;
Expand Down Expand Up @@ -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("<p>Hello world</p>", block0);
const result1 = await processHtml("<p>foo bar</p>", 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("<p>Hello world</p>", block0);

// Second render pass — reset cursor so block1 starts from 0 again
cursor.current = 0;
await processHtml("<p>Hello world</p>", block0);
const result1 = await processHtml("<p>foo bar</p>", 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("<p>Hello world foo</p>", 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("<p>Hello world foo</p>", block0);
const prevCount = block0.getLastRenderCharCount();

// Second render: mark first render's content as already visible
cursor.current = 0;
block0.setPrevContentLength(prevCount);
await processHtml("<p>Hello world foo bar</p>", block0);
// Only "bar" is new → cursor should be 1
expect(cursor.current).toBe(1);
});
});
});
123 changes: 98 additions & 25 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -550,9 +552,8 @@ export const Streamdown = memo(
const [displayBlocks, setDisplayBlocks] = useState<string[]>(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);
});
Expand Down Expand Up @@ -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<AnimateCursor | null>(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<AnimatePlugin[]>([]);
// Stable arrays of per-block merged rehype plugins (base + per-block animate).
// Keyed by block index; rebuilt only when mergedRehypePlugins changes.
const blockRehypePluginsRef = useRef<Pluggable[][]>([]);
const prevMergedRehypePluginsRef = useRef<Pluggable[] | null>(null);

// Keep track of the resolved options key so we can recreate plugins
// when the animation options change.
const prevAnimatedKeyRef = useRef<string>("");

// 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<StreamdownContextType>(
Expand Down Expand Up @@ -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) {
Expand All @@ -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 (
Expand Down Expand Up @@ -818,9 +889,11 @@ export const Streamdown = memo(
isAnimating &&
isLastBlock &&
hasIncompleteCodeFence(block);
const { blockAnimatePlugin, blockRehypePlugins } =
getBlockPlugins(index);
return (
<BlockComponent
animatePlugin={animatePlugin}
animatePlugin={blockAnimatePlugin}
components={mergedComponents}
content={block}
dir={
Expand All @@ -830,7 +903,7 @@ export const Streamdown = memo(
index={index}
isIncomplete={isIncomplete}
key={blockKeys[index]}
rehypePlugins={mergedRehypePlugins}
rehypePlugins={blockRehypePlugins}
remarkPlugins={mergedRemarkPlugins}
shouldNormalizeHtmlIndentation={
shouldNormalizeHtmlIndentation
Expand Down
Loading
Loading