From e6c71ae3b47b00edc09d0b47354835887401634e Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:57:15 +0000 Subject: [PATCH 1/2] fix: remove animation spans after streaming ends (Fixes #570) --- .changeset/clean-settled-animation-spans.md | 5 +++ .../__tests__/animate-integration.test.tsx | 37 +++++++++++++++++++ packages/streamdown/index.tsx | 16 +++++--- 3 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 .changeset/clean-settled-animation-spans.md create mode 100644 packages/streamdown/__tests__/animate-integration.test.tsx diff --git a/.changeset/clean-settled-animation-spans.md b/.changeset/clean-settled-animation-spans.md new file mode 100644 index 00000000..629bffb8 --- /dev/null +++ b/.changeset/clean-settled-animation-spans.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +Fix animation spans not being removed from settled text when `isAnimating` goes false. Include `isAnimating` in block keys so React mounts fresh subtrees when the animate rehype plugin is removed from the pipeline, allowing the span-free reparse to commit. diff --git a/packages/streamdown/__tests__/animate-integration.test.tsx b/packages/streamdown/__tests__/animate-integration.test.tsx new file mode 100644 index 00000000..3419c21a --- /dev/null +++ b/packages/streamdown/__tests__/animate-integration.test.tsx @@ -0,0 +1,37 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; + +const FADE = { + animation: "fadeIn", + duration: 200, + easing: "ease-out", + sep: "word", + stagger: 0, +} as const; + +const MD = + "First paragraph with several words here.\n\nSecond paragraph also has words.\n\nThird paragraph ends it."; + +describe("Animate integration", () => { + it("should remove animation spans when isAnimating goes false", () => { + const { container, rerender } = render( + + {MD} + + ); + expect( + container.querySelectorAll("[data-sd-animate]").length + ).toBeGreaterThan(0); + + // Stream ends: same children, animation off. + rerender( + + {MD} + + ); + expect( + container.querySelectorAll("[data-sd-animate]").length + ).toBe(0); + }); +}); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index be32d9fd..729a755d 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -572,13 +572,19 @@ export const Streamdown = memo( [blocksToRender, dir] ); - // Generate stable keys based on index only - // Don't use content hash - that causes unmount/remount when content changes - // React will handle content updates via props changes and memo comparison + // Generate stable keys based on index and animation state. + // Don't use content hash - that causes unmount/remount when content changes. + // Include isAnimating so that when streaming ends and the animate rehype + // plugin is removed from the pipeline, block keys change and React mounts + // fresh subtrees. Without this, memoized leaf components that compare only + // className + source position discard the span-free reparse. // biome-ignore lint/correctness/useExhaustiveDependencies: "we're using the blocksToRender length" const blockKeys = useMemo( - () => blocksToRender.map((_block, idx) => `${generatedId}-${idx}`), - [blocksToRender.length, generatedId] + () => + blocksToRender.map( + (_block, idx) => `${generatedId}-${idx}-${isAnimating ? "a" : "s"}` + ), + [blocksToRender.length, generatedId, isAnimating] ); // Stable key derived from animated option values. This prevents the From e77076f6c64eef1a54b9e8488a478896a018bf07 Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:59:41 +0000 Subject: [PATCH 2/2] perf: gate isAnimating blockKey suffix on animatePlugin --- packages/streamdown/index.tsx | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 729a755d..e19a0b88 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -572,21 +572,6 @@ export const Streamdown = memo( [blocksToRender, dir] ); - // Generate stable keys based on index and animation state. - // Don't use content hash - that causes unmount/remount when content changes. - // Include isAnimating so that when streaming ends and the animate rehype - // plugin is removed from the pipeline, block keys change and React mounts - // fresh subtrees. Without this, memoized leaf components that compare only - // className + source position discard the span-free reparse. - // biome-ignore lint/correctness/useExhaustiveDependencies: "we're using the blocksToRender length" - const blockKeys = useMemo( - () => - blocksToRender.map( - (_block, idx) => `${generatedId}-${idx}-${isAnimating ? "a" : "s"}` - ), - [blocksToRender.length, generatedId, isAnimating] - ); - // 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 @@ -612,6 +597,21 @@ export const Streamdown = memo( return createAnimatePlugin(animated as AnimateOptions); }, [animatedKey]); + // Generate stable keys based on index. Don't use content hash — that + // causes unmount/remount when content changes. When the animate plugin is + // active, also key on isAnimating so that when streaming ends and the + // plugin is removed from the rehype pipeline, React mounts fresh subtrees. + // Without this, memoized leaf components that compare only className + + // source position discard the span-free reparse. When animated is absent, + // skip the extra suffix — no spans exist to discard. + // biome-ignore lint/correctness/useExhaustiveDependencies: "we're using the blocksToRender length" + const blockKeys = useMemo(() => { + const suffix = animatePlugin ? (isAnimating ? "a" : "s") : ""; + return blocksToRender.map( + (_block, idx) => `${generatedId}-${idx}-${suffix}` + ); + }, [blocksToRender.length, generatedId, isAnimating, animatePlugin]); + // Combined context value - single object reduces React tree overhead const contextValue = useMemo( () => ({