Skip to content

Streaming mode without 'animated': block updates deferred via useTransition are starved by sibling urgent updates - markdown freezes at first parse #550

Description

@rdtiv

Summary

In streamdown@2.5.0, a <Streamdown> in the default streaming mode without an animated config pushes its parsed-block state updates through useTransition. Those low-priority updates can be starved indefinitely by sibling urgent updates — e.g. another component ticking state at animation cadence — so the rendered markdown freezes at its first parse and never updates again until the urgent-update pressure stops, even though children keeps growing and the component keeps re-rendering.

The practical hit: a chat UI that renders a model's reasoning/thinking stream in one plain <Streamdown> while the reply streams in another (animated, or otherwise driving frequent state updates). The thinking panel freezes at its first delta — often a single character — for the whole turn, then snaps to the full text when streaming ends. It looks exactly like a data bug, and we spent a while exonerating our wire/persistence before finding it here.

Where in the source

In the main component (paraphrasing the 2.5.0 bundle):

const blocks = useMemo(() => parseMarkdownIntoBlocksFn(processedChildren), [...]);
const [deferredBlocks, setDeferredBlocks] = useState(blocks);
useEffect(() => {
  mode === "streaming" && !animatePlugin
    ? startTransition(() => { setDeferredBlocks(blocks); })   // <-- starvable
    : setDeferredBlocks(blocks);
}, [blocks, mode]);
const rendered = mode === "streaming" ? deferredBlocks : blocks;

With animated set, updates take the direct setDeferredBlocks(blocks) branch and everything streams fine — which is also the workaround.

Repro sketch

function Repro() {
  const [reasoning, setReasoning] = useState("");   // grows ~10x/sec
  const [, tick] = useState(0);                      // sibling urgent updates, 40ms
  useEffect(() => {
    const a = setInterval(() => setReasoning((t) => t + " lorem"), 100);
    const b = setInterval(() => tick((n) => n + 1), 40);
    return () => { clearInterval(a); clearInterval(b); };
  }, []);
  return <Streamdown>{reasoning}</Streamdown>; // freezes at the first parse while `b` runs
}

Observed in a real app (Next 16.2.6 / React 19, streamdown@2.5.0): instrumented the element with data-len={text.length} — the prop grew 22 → 139 chars and the attribute updated live (so the component re-rendered), while the rendered markdown text stayed frozen at 3 chars until the sibling stream ended. Passing animated={{ animation: "fadeIn", duration: 150, sep: "word" }} + isAnimating fixed it instantly, no other change.

Expected

Either:

  • streaming-mode block updates shouldn't be starvable (e.g. fall back to a direct update if a transition hasn't committed within some budget, or don't route through useTransition at all when the update is just "the tail block grew"), or
  • the behavior/trade-off (and the animated escape hatch) documented — right now un-animated streaming markdown next to any busy sibling silently freezes, which reads as data corruption rather than scheduling.

Happy to provide a full repro repo if useful.

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