-
Notifications
You must be signed in to change notification settings - Fork 229
fix: prevent ordered list animation retrigger during streaming #417
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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
|
||
| } | ||
|
|
||
| // 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); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.