Skip to content

Animation spans are never removed from settled text — memoized components discard the span-free reparse #570

Description

@ericksonc

Summary

With animated + isAnimating, the per-word <span data-sd-animate> wrappers stay in the DOM permanently after isAnimating goes false. They accumulate for the life of the page — in a chat UI, every settled message keeps one span per word forever.

It's visually invisible (the spans sit at opacity 1), but it changes the DOM contract for anything that walks text nodes. In our case it broke browser-style find-in-page: settled text is one text node per word, so no multi-word query can match. Text extraction and selection heuristics have the same exposure.

streamdown@2.5.0, React 19.

Reproduction

import { render } from "@testing-library/react";
import { Streamdown } from "streamdown";
import { expect, test } from "vitest";

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.";

test("spans come off when isAnimating goes false", () => {
  const { container, rerender } = render(
    <Streamdown animated={FADE} isAnimating>{MD}</Streamdown>
  );
  expect(container.querySelectorAll("[data-sd-animate]").length).toBeGreaterThan(0);

  // Stream ends: same children, animation off.
  rerender(
    <Streamdown animated={FADE} isAnimating={false}>{MD}</Streamdown>
  );
  expect(container.querySelectorAll("[data-sd-animate]").length).toBe(0);
});

Expected: 0 spans after the flip.
Actual: 19 spans (all of them, minus the final block — see below).

Cause

The parse side is fine. On the flip, mergedRehypePlugins changes identity (the animate plugin is excluded, index.tsx:689-737), Block's comparator busts on the rehypePlugins reference check (index.tsx:415), and every block reparses without the plugin. The span-free HAST is computed correctly.

It never commits. The default markdown components — MemoParagraph, MemoLi, headings, MemoStrong, table cells — are memoized on className + hast source position and never compare children:

// lib/components.tsx
function sameClassAndNode(prev, next) {
  return prev.className === next.className && sameNodePosition(prev.node, next.node);
}

A settled block has the same text at the same source position, so every comparator returns "equal" and React throws away the span-free children the reparse just produced. The stale markup — still carrying the streaming-era --sd-duration — stays in the DOM.

This also explains the one block that does come out clean: whichever block was still being written when the stream ended had its content (hence its source position) change in that same commit, so its comparator busts on its own.

The general shape: any rehype plugin toggled on/off for identical source text cannot affect the output, because position-only memoization treats "same text, different plugin pipeline" as a no-op.

Possible fixes

Comparing children in sameClassAndNode would fix it but costs on the hot streaming path. A cheaper option: have the Block layer participate — e.g. when the rehype pipeline identity changes, force a fresh subtree (a key derived from the pipeline) rather than relying on the leaf comparators to notice a change they can't see.

Workaround (for anyone hitting this before it's fixed)

Append a host rehype plugin that stamps a marker class on every element while isAnimating, dropping it at settle. The class changes className, which the comparators do check, so the reparse commits. Verified: N → 0 spans.


Secondary: Block reads mutable plugin state during render, so the 0ms suppression is dead under StrictMode

Separate bug, found while pinning the above.

Block's render body calls animatePlugin.getLastRenderCharCount() (index.tsx:347-350), which reads and resets shared mutable state, then calls setPrevContentLength. That's a side effect during render. StrictMode double-invokes the render body: the first call reads N and sets prevContentLength = N, the second reads 0 (already reset by the first) and sets prevContentLength = 0. The already-seen-text suppression never engages.

const durations = (c) => [...c.querySelectorAll("[data-sd-animate]")]
  .map((el) => el.getAttribute("style").match(/--sd-duration:\s*(\d+)ms/)?.[1]);

// two streaming ticks: "one two three" -> "one two three four five"
// plain:  [ '0', '0', '0', '200', '200' ]   <- seen words suppressed, correct
// strict: [ '200', '200', '200', '200', '200' ]  <- every word re-animates

Effect in a real app: under StrictMode (dev only — production doesn't double-invoke), every already-visible word re-fades on every streaming tick instead of holding steady. Moving the read out of the render body — into the rehype pass itself, or into a ref keyed by render — would make it StrictMode-safe.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions