Skip to content

Commit ef64268

Browse files
committed
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
1 parent 89b44cd commit ef64268

File tree

5 files changed

+366
-12
lines changed

5 files changed

+366
-12
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"streamdown": patch
3+
---
4+
5+
fix: prevent ordered list animation retrigger during streaming
6+
7+
When streaming content contains multiple ordered (or unordered) lists,
8+
the Marked lexer merges them into a single block. As each new item appears
9+
the block is re-processed through the rehype pipeline, re-creating all
10+
`data-sd-animate` spans. This caused already-visible characters to re-run
11+
their CSS entry animation.
12+
13+
Two changes address the root cause:
14+
15+
1. **Per-block `prevContentLength` tracking** – each `Block` component
16+
now keeps a `useRef` with the content length from its previous render.
17+
Before each render the `animatePlugin.setPrevContentLength(n)` method is
18+
called so the rehype plugin can detect which text-node positions were
19+
already rendered. Characters whose cumulative hast-text offset falls below
20+
the previous raw-content length receive `--sd-duration:0ms`, making them
21+
appear in their final state instantly rather than re-animating.
22+
23+
2. **Stable `animatePlugin` reference** – the `animatePlugin` `useMemo`
24+
now uses value-based dependency comparison instead of reference equality
25+
for the `animated` option object. This prevents the plugin from being
26+
recreated on every parent re-render when the user passes an inline object
27+
literal (e.g. `animated={{ animation: 'fadeIn' }}`). A stable reference
28+
is required because the rehype processor cache uses the function name as
29+
its key and always returns the first cached closure; only the original
30+
`config` object is ever read by the processor.

packages/streamdown/__tests__/animate.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,51 @@ describe("animate plugin", () => {
180180
expect(result).toContain("--sd-easing:ease");
181181
});
182182
});
183+
184+
describe("getLastRenderCharCount", () => {
185+
it("should return 0 before any render", () => {
186+
const plugin = createAnimatePlugin();
187+
expect(plugin.getLastRenderCharCount()).toBe(0);
188+
});
189+
190+
it("should return HAST text node char count after render", async () => {
191+
const plugin = createAnimatePlugin();
192+
// "Hello world" = 11 HAST chars (5 + 1 space + 5)
193+
await processHtml("<p>Hello world</p>", plugin);
194+
expect(plugin.getLastRenderCharCount()).toBe(11);
195+
});
196+
197+
it("should not include markdown syntax chars — only rendered text", async () => {
198+
const plugin = createAnimatePlugin();
199+
// plain text: "Hello" = 5 HAST chars
200+
await processHtml("<p>Hello</p>", plugin);
201+
expect(plugin.getLastRenderCharCount()).toBe(5);
202+
});
203+
204+
it("should update after each render", async () => {
205+
const plugin = createAnimatePlugin();
206+
await processHtml("<p>Hi</p>", plugin);
207+
const firstCount = plugin.getLastRenderCharCount();
208+
await processHtml("<p>Hello world</p>", plugin);
209+
const secondCount = plugin.getLastRenderCharCount();
210+
expect(secondCount).toBeGreaterThan(firstCount);
211+
});
212+
213+
it("setPrevContentLength with getLastRenderCharCount should skip already-rendered chars", async () => {
214+
const plugin = createAnimatePlugin();
215+
// First render: "Hello"
216+
await processHtml("<p>Hello</p>", plugin);
217+
const prevCount = plugin.getLastRenderCharCount();
218+
219+
// Second render: "Hello world" — set prev length from HAST count
220+
plugin.setPrevContentLength(prevCount);
221+
const result = await processHtml("<p>Hello world</p>", plugin);
222+
223+
// "Hello" (chars 0-4) should have duration:0ms — already visible
224+
// " world" should have normal duration
225+
const spans = result.match(/--sd-duration:[^;"]*/g) ?? [];
226+
expect(spans.some((s) => s.includes("0ms"))).toBe(true);
227+
expect(spans.some((s) => s.includes("150ms"))).toBe(true);
228+
});
229+
});
183230
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Tests for fix #410: Ordered list animations incorrectly retrigger
3+
*
4+
* Root cause: when streaming content contains multiple ordered/unordered lists,
5+
* the Marked lexer merges them into a single block. As new items appear the block
6+
* is re-processed through the rehype pipeline, recreating `data-sd-animate` spans
7+
* for ALL text — including already-visible content — causing those characters to
8+
* re-run their CSS entry animation.
9+
*
10+
* Fix: track prevContentLength per Block and set --sd-duration:0ms for text-node
11+
* positions that were already rendered in the previous pass.
12+
*/
13+
14+
import { act, render } from "@testing-library/react";
15+
import { describe, expect, it } from "vitest";
16+
import { Streamdown } from "../index";
17+
18+
const animatedConfig = {
19+
animation: "fadeIn" as const,
20+
duration: 700,
21+
easing: "ease-in-out",
22+
sep: "char" as const,
23+
};
24+
25+
describe("list animation retrigger fix (#410)", () => {
26+
it("does not remount spans for existing list items when a new item appears", async () => {
27+
const { rerender, container } = render(
28+
<Streamdown animated={animatedConfig} isAnimating={true}>
29+
{"1. Item 1\n2. Item 2\n"}
30+
</Streamdown>
31+
);
32+
await act(() => Promise.resolve());
33+
34+
const initialSpans = Array.from(
35+
container.querySelectorAll("[data-sd-animate]")
36+
);
37+
expect(initialSpans.length).toBeGreaterThan(0);
38+
39+
// Tag spans so we can track identity across re-renders
40+
initialSpans.forEach((span, i) => {
41+
(span as HTMLElement).dataset.origIdx = String(i);
42+
});
43+
44+
// Simulate a new list group appearing (triggers tight→loose transition)
45+
await act(() => {
46+
rerender(
47+
<Streamdown animated={animatedConfig} isAnimating={true}>
48+
{"1. Item 1\n2. Item 2\n\n1. Item A\n"}
49+
</Streamdown>
50+
);
51+
});
52+
await act(() => Promise.resolve());
53+
54+
const afterSpans = Array.from(
55+
container.querySelectorAll("[data-sd-animate]")
56+
);
57+
58+
// There should be MORE spans after (new item appeared)
59+
expect(afterSpans.length).toBeGreaterThan(initialSpans.length);
60+
61+
// All original spans should still be in the document (not remounted)
62+
const remountedCount = initialSpans.filter(
63+
(s) => !container.contains(s)
64+
).length;
65+
expect(remountedCount).toBe(0);
66+
});
67+
68+
it("sets --sd-duration:0ms on already-rendered content to prevent visual re-animation", async () => {
69+
const { rerender, container } = render(
70+
<Streamdown animated={animatedConfig} isAnimating={true}>
71+
{"- Item 1\n- Item 2\n"}
72+
</Streamdown>
73+
);
74+
await act(() => Promise.resolve());
75+
76+
// First render: all spans should have normal duration (700ms)
77+
const firstRenderSpans = Array.from(
78+
container.querySelectorAll("[data-sd-animate]")
79+
) as HTMLElement[];
80+
expect(firstRenderSpans.length).toBeGreaterThan(0);
81+
82+
// After initial render all existing spans have full duration
83+
for (const span of firstRenderSpans) {
84+
const style = span.getAttribute("style") ?? "";
85+
expect(style).toContain("--sd-duration: 700ms");
86+
}
87+
88+
// Force a re-render (simulates streaming update — e.g., a new item appears)
89+
await act(() => {
90+
rerender(
91+
<Streamdown animated={animatedConfig} isAnimating={true}>
92+
{"- Item 1\n- Item 2\n\n- Item 3\n"}
93+
</Streamdown>
94+
);
95+
});
96+
await act(() => Promise.resolve());
97+
98+
// Spans for Item 1 and Item 2 (already rendered) should have duration:0ms
99+
// to suppress any visual re-animation
100+
const item1Spans = Array.from(
101+
container.querySelectorAll("li:first-child [data-sd-animate]")
102+
) as HTMLElement[];
103+
expect(item1Spans.length).toBeGreaterThan(0);
104+
for (const span of item1Spans) {
105+
const style = span.getAttribute("style") ?? "";
106+
expect(style).toContain("--sd-duration: 0ms");
107+
}
108+
109+
// Spans for Item 3 (newly streamed) should have normal duration
110+
const item3Spans = Array.from(
111+
container.querySelectorAll("li:last-child [data-sd-animate]")
112+
) as HTMLElement[];
113+
expect(item3Spans.length).toBeGreaterThan(0);
114+
for (const span of item3Spans) {
115+
const style = span.getAttribute("style") ?? "";
116+
expect(style).toContain("--sd-duration: 700ms");
117+
}
118+
});
119+
120+
it("keeps animatePlugin stable when animated is a new inline object with same values", async () => {
121+
// This tests the value-based useMemo deps fix.
122+
// When animated is an inline object literal, each parent render creates
123+
// a new reference. The fix ensures the plugin instance stays stable
124+
// so that prevContentLength mutations affect the correct processor closure.
125+
const getAnimated = () => ({
126+
animation: "fadeIn" as const,
127+
duration: 700,
128+
easing: "ease-in-out",
129+
sep: "char" as const,
130+
});
131+
132+
const { rerender, container } = render(
133+
<Streamdown animated={getAnimated()} isAnimating={true}>
134+
{"- Alpha\n- Beta\n"}
135+
</Streamdown>
136+
);
137+
await act(() => Promise.resolve());
138+
139+
// Tag initial spans
140+
const initialSpans = Array.from(
141+
container.querySelectorAll("[data-sd-animate]")
142+
);
143+
initialSpans.forEach((span, i) => {
144+
(span as HTMLElement).dataset.origIdx = String(i);
145+
});
146+
147+
// Re-render with new object reference for animated (same values)
148+
// and new content — simulates a streaming update from a parent that
149+
// re-creates the animated object literal on each render
150+
await act(() => {
151+
rerender(
152+
<Streamdown animated={getAnimated()} isAnimating={true}>
153+
{"- Alpha\n- Beta\n- Gamma\n"}
154+
</Streamdown>
155+
);
156+
});
157+
await act(() => Promise.resolve());
158+
159+
const afterSpans = Array.from(
160+
container.querySelectorAll("[data-sd-animate]")
161+
);
162+
163+
// Original spans should still be in the document
164+
const remountedCount = initialSpans.filter(
165+
(s) => !container.contains(s)
166+
).length;
167+
expect(remountedCount).toBe(0);
168+
169+
// New spans for "Gamma" should exist
170+
expect(afterSpans.length).toBeGreaterThan(initialSpans.length);
171+
});
172+
});

packages/streamdown/index.tsx

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
useEffect,
99
useId,
1010
useMemo,
11+
useRef,
1112
useState,
1213
useTransition,
1314
} from "react";
@@ -18,7 +19,11 @@ import remarkGfm from "remark-gfm";
1819
import remend, { type RemendOptions } from "remend";
1920
import type { BundledTheme } from "shiki";
2021
import type { Pluggable } from "unified";
21-
import { type AnimateOptions, createAnimatePlugin } from "./lib/animate";
22+
import {
23+
type AnimateOptions,
24+
type AnimatePlugin,
25+
createAnimatePlugin,
26+
} from "./lib/animate";
2227
import { BlockIncompleteContext } from "./lib/block-incomplete-context";
2328
import { components as defaultComponents } from "./lib/components";
2429
import { hasIncompleteCodeFence, hasTable } from "./lib/incomplete-code-utils";
@@ -207,6 +212,8 @@ export type BlockProps = Options & {
207212
index: number;
208213
/** Whether this block is incomplete (still being streamed) */
209214
isIncomplete: boolean;
215+
/** Animate plugin instance for tracking previous content length */
216+
animatePlugin?: AnimatePlugin | null;
210217
};
211218

212219
export const Block = memo(
@@ -217,20 +224,45 @@ export const Block = memo(
217224
shouldNormalizeHtmlIndentation,
218225
index: __,
219226
isIncomplete,
227+
animatePlugin: animatePluginProp,
220228
...props
221229
}: BlockProps) => {
230+
// Track previous content length to prevent re-animation of already-visible content.
231+
// When a block's content grows during streaming, only new characters get animated.
232+
const prevContentLengthRef = useRef(0);
233+
234+
// Set prevContentLength on the animate plugin before the synchronous rehype render.
235+
// This is safe because React renders synchronously — the rehype pipeline will read
236+
// this value during the same synchronous render pass.
237+
if (animatePluginProp) {
238+
animatePluginProp.setPrevContentLength(prevContentLengthRef.current);
239+
}
240+
222241
// Note: remend is already applied to the entire markdown before parsing into blocks
223242
// in the Streamdown component, so we don't need to apply it again here
224243
const normalizedContent =
225244
typeof content === "string" && shouldNormalizeHtmlIndentation
226245
? normalizeHtmlIndentation(content)
227246
: content;
228247

229-
return (
248+
const result = (
230249
<BlockIncompleteContext.Provider value={isIncomplete}>
231250
<Markdown {...props}>{normalizedContent}</Markdown>
232251
</BlockIncompleteContext.Provider>
233252
);
253+
254+
// Update prev content length after this render using the HAST character count
255+
// (not raw markdown length) to match the units used by charCounter in the animate plugin.
256+
prevContentLengthRef.current = animatePluginProp
257+
? animatePluginProp.getLastRenderCharCount()
258+
: 0;
259+
260+
// Reset so other blocks don't inherit this block's prevContentLength
261+
if (animatePluginProp) {
262+
animatePluginProp.resetPrevContentLength();
263+
}
264+
265+
return result;
234266
},
235267
(prevProps, nextProps) => {
236268
// Deep comparison for better memoization
@@ -382,6 +414,14 @@ export const Streamdown = memo(
382414
[blocksToRender.length, generatedId]
383415
);
384416

417+
// Use value-based deps so animatePlugin stays stable when the user passes an
418+
// inline object literal for `animated` (e.g. animated={{ animation: 'fadeIn' }}).
419+
// A stable plugin reference is required for the prevContentLength tracking in
420+
// Block to work: the rehype processor is cached by plugin name, so it always
421+
// uses the first closure created. If the plugin is recreated the mutation of
422+
// config.prevContentLength would target a new config object that the cached
423+
// processor never reads.
424+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional value-based comparison
385425
const animatePlugin = useMemo(() => {
386426
if (!animated) {
387427
return null;
@@ -390,7 +430,21 @@ export const Streamdown = memo(
390430
return createAnimatePlugin();
391431
}
392432
return createAnimatePlugin(animated);
393-
}, [animated]);
433+
}, [
434+
animated === true,
435+
typeof animated === "object" && animated !== null
436+
? animated.animation
437+
: undefined,
438+
typeof animated === "object" && animated !== null
439+
? animated.duration
440+
: undefined,
441+
typeof animated === "object" && animated !== null
442+
? animated.easing
443+
: undefined,
444+
typeof animated === "object" && animated !== null
445+
? animated.sep
446+
: undefined,
447+
]);
394448

395449
// Combined context value - single object reduces React tree overhead
396450
const contextValue = useMemo<StreamdownContextType>(
@@ -546,6 +600,7 @@ export const Streamdown = memo(
546600
isAnimating && isLastBlock && hasIncompleteCodeFence(block);
547601
return (
548602
<BlockComponent
603+
animatePlugin={animatePlugin}
549604
components={mergedComponents}
550605
content={block}
551606
index={index}

0 commit comments

Comments
 (0)