Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .changeset/fix-list-animation-retrigger.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/streamdown/__tests__/animate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>Hello world</p>", 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("<p>Hello</p>", plugin);
expect(plugin.getLastRenderCharCount()).toBe(5);
});

it("should update after each render", async () => {
const plugin = createAnimatePlugin();
await processHtml("<p>Hi</p>", plugin);
const firstCount = plugin.getLastRenderCharCount();
await processHtml("<p>Hello world</p>", 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("<p>Hello</p>", plugin);
const prevCount = plugin.getLastRenderCharCount();

// Second render: "Hello world" — set prev length from HAST count
plugin.setPrevContentLength(prevCount);
const result = await processHtml("<p>Hello world</p>", 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);
});
});
});
176 changes: 176 additions & 0 deletions packages/streamdown/__tests__/list-animation-retrigger.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Streamdown animated={animatedConfig} isAnimating={true}>
{"1. Item 1\n2. Item 2\n"}
</Streamdown>
);
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(
<Streamdown animated={animatedConfig} isAnimating={true}>
{"1. Item 1\n2. Item 2\n\n1. Item A\n"}
</Streamdown>
);
});
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(
<Streamdown animated={animatedConfig} isAnimating={true}>
{"- AB\n"}
</Streamdown>
);
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(
<Streamdown animated={animatedConfig} isAnimating={true}>
{"- AB CD\n"}
</Streamdown>
);
});
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(
<Streamdown animated={getAnimated()} isAnimating={true}>
{"- Alpha\n- Beta\n"}
</Streamdown>
);
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(
<Streamdown animated={getAnimated()} isAnimating={true}>
{"- Alpha\n- Beta\n- Gamma\n"}
</Streamdown>
);
});
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);
});
});
49 changes: 43 additions & 6 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -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 =
Expand Down Expand Up @@ -425,8 +445,9 @@ 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") {
if (mode === "streaming" && !animatePlugin) {
startTransition(() => {
setDisplayBlocks(blocks);
});
Expand All @@ -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<StreamdownContextType>(
Expand Down Expand Up @@ -614,6 +650,7 @@ export const Streamdown = memo(
isAnimating && isLastBlock && hasIncompleteCodeFence(block);
return (
<BlockComponent
animatePlugin={animatePlugin}
components={mergedComponents}
content={block}
index={index}
Expand Down
Loading
Loading