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(
() => ({