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..e19a0b88 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -572,15 +572,6 @@ 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 - // biome-ignore lint/correctness/useExhaustiveDependencies: "we're using the blocksToRender length" - const blockKeys = useMemo( - () => blocksToRender.map((_block, idx) => `${generatedId}-${idx}`), - [blocksToRender.length, generatedId] - ); - // 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 @@ -606,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( () => ({