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
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);
});
});
});
172 changes: 172 additions & 0 deletions packages/streamdown/__tests__/list-animation-retrigger.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<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 to prevent visual re-animation", async () => {
const { rerender, container } = render(
<Streamdown animated={animatedConfig} isAnimating={true}>
{"- Item 1\n- Item 2\n"}
</Streamdown>
);
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(
<Streamdown animated={animatedConfig} isAnimating={true}>
{"- Item 1\n- Item 2\n\n- Item 3\n"}
</Streamdown>
);
});
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");

Check failure on line 106 in packages/streamdown/__tests__/list-animation-retrigger.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

__tests__/list-animation-retrigger.test.tsx > list animation retrigger fix (#410) > sets --sd-duration:0ms on already-rendered content to prevent visual re-animation

AssertionError: expected '--sd-animation: sd-fadeIn; --sd-durat…' to contain '--sd-duration: 0ms' Expected: "--sd-duration: 0ms" Received: "--sd-animation: sd-fadeIn; --sd-duration: 700ms; --sd-easing: ease-in-out;" ❯ __tests__/list-animation-retrigger.test.tsx:106:21

Check failure on line 106 in packages/streamdown/__tests__/list-animation-retrigger.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

__tests__/list-animation-retrigger.test.tsx > list animation retrigger fix (#410) > sets --sd-duration:0ms on already-rendered content to prevent visual re-animation

AssertionError: expected '--sd-animation: sd-fadeIn; --sd-durat…' to contain '--sd-duration: 0ms' Expected: "--sd-duration: 0ms" Received: "--sd-animation: sd-fadeIn; --sd-duration: 700ms; --sd-easing: ease-in-out;" ❯ __tests__/list-animation-retrigger.test.tsx:106:21
}

// 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(
<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);
});
});
48 changes: 46 additions & 2 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useEffect,
useId,
useMemo,
useRef,
useState,
useTransition,
} from "react";
Expand All @@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -217,8 +224,22 @@ export const Block = memo(
shouldNormalizeHtmlIndentation,
index: __,
isIncomplete,
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);
if (animatePluginProp) {
prevContentLengthRef.current = animatePluginProp.getLastRenderCharCount();
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 =
Expand Down Expand Up @@ -382,6 +403,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;
Expand All @@ -390,7 +419,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<StreamdownContextType>(
Expand Down Expand Up @@ -546,6 +589,7 @@ export const Streamdown = memo(
isAnimating && isLastBlock && hasIncompleteCodeFence(block);
return (
<BlockComponent
animatePlugin={animatePlugin}
components={mergedComponents}
content={block}
index={index}
Expand Down
Loading
Loading