Summary
In streamdown@2.5.0, a <Streamdown mode="streaming"> with an animated config exhausts React's nested-update budget over a long stream, throwing:
Maximum update depth exceeded. This can happen when a component repeatedly calls setState
inside componentWillUpdate or componentDidUpdate.
This is the other half of #550. That issue reports the useTransition branch (taken when animated is absent) being starved, and names animated as the workaround. Enabling animated moves the block-promotion update onto a counted lane instead, which is this bug. Both branches of the same ternary are unsafe under sustained streaming; picking either one trades one failure for the other.
It is also, we believe, the undiagnosed cause of #140 — same error text, same trigger ("longer responses", "more than 30sec"), closed without a mechanism.
The condition
dist/chunk-BO2N2NFS.js, in the main component:
const blocks = useMemo(() => parseMarkdownIntoBlocksFn(processed), [processed, parseFn]);
const [deferred, setDeferred] = useState(blocks);
useEffect(() => {
mode === "streaming" && !animatePlugin
? startTransition(() => { setDeferred(blocks); }) // uncounted lane
: setDeferred(blocks); // counted lane <-- animated lands here
}, [blocks, mode]);
blocks is a fresh array on every chunk, so [blocks, mode] always changes and the effect always runs.
Mechanism
This is not a render loop — there is no oscillation, which is why it presents intermittently and resists a search for a cycle. It is a monotonic accumulator:
- The host's per-chunk state update is forced onto SyncLane (any
useSyncExternalStore subscription does this; @ai-sdk/react's useChat is one).
- A commit whose lanes include SyncLane flushes passive effects synchronously inside that commit —
react-dom flushSpawnedWork: 0 !== (pendingEffectsLanes & 3) && flushPendingEffects().
- The effect above runs and calls
setDeferred(blocks). Inside flushPassiveEffects, ReactDOMSharedInternals.p is DefaultEventPriority, so that update lands on DefaultLane.
- React then counts the commit as a nested update:
0 !== (endTime & 261930) && 0 !== (remainingLanes & 42) ? nestedUpdateCount++ : (nestedUpdateCount = 0). Mask 42 is SyncLane | InputContinuousLane | DefaultLane, so DefaultLane pending means increment, not reset.
- DefaultLane is handed to the Scheduler as a macrotask. While chunks keep arriving faster than that macrotask runs, the counter never resets. Past 50, the next update from anywhere throws in
getRootForUpdatedFiber.
Step 4 is why startTransition matters: a transition lane is outside mask 42, so the counter resets on every commit.
Consequence worth noting for triage: the throw lands on whichever update happens to be next, which is usually the host's own store notification. If that runs inside the host's stream-consumption try (it does in @ai-sdk/react), the error is caught and reassigned to chat state — so it surfaces with no error boundary, no component stack and no dev overlay, which is what makes it hard to attribute to Streamdown. That matches the screenshot in #140.
Reproduction
Self-contained, no host framework. jsdom + react-dom/client, real scheduler (IS_REACT_ACT_ENVIRONMENT = false), growing prefixes of a long document with a zero-delay macrotask between frames:
const SENTENCE = "the archivist kept her ledgers in the old way one column for what arrived ";
const WORDS = SENTENCE.repeat(200).trim().split(" ");
const DOC = (() => {
const lines = ["## The Ledger", ""];
for (let i = 0; i < 60; i += 1) lines.push(`${WORDS.slice(i * 30, i * 30 + 30).join(" ")}.`, "");
return lines.join("\n");
})();
const FRAMES: string[] = [];
for (let end = 6; end <= DOC.length; end += 6) FRAMES.push(DOC.slice(0, end));
const view = (text: string) => (
<Streamdown
mode="streaming"
animated={{ animation: "blurIn", duration: 250, easing: "ease-out", sep: "word" }}
isAnimating
>
{text}
</Streamdown>
);
const root = createRoot(host);
root.render(view(""));
for (const frame of FRAMES) {
root.render(view(frame));
await new Promise((r) => setTimeout(r, 0));
}
// count console.error calls containing "Maximum update depth exceeded"
Single-variable results, same repro, same machine:
streamdown@2.5.0 |
animated |
update-depth reports |
| stock |
set |
5 |
| stock |
omitted |
0 |
&& !animatePlugin removed from the condition |
set |
0 |
Two things that stop it reproducing, in case a first attempt comes up green: the frames must be prefixes of a long accumulated document (the per-chunk reparse has to cost more than the gap — 80 short appends drain fine and report 0), and the driver must not be act(), which flushes to quiescence after every frame and resets the counter.
We also see it through our own wrapper component with the full prop set (components, plugins, rehypePlugins, controls, mermaid, caret) over ~1600 frames — same re-render driver as above, not a host-framework integration: 5 of 7 streaming cases fail on stock 2.5.0 and 7 of 7 pass with the condition removed. In both cases the rendered output is intact at the end of the stream; the only failing assertion on the red runs is the update-depth count.
What a fix has to satisfy
Removing && !animatePlugin fixes this and regresses #550. Keeping it fixes #550 and causes this. So the ternary cannot be the answer either way — the block promotion needs to be both uncounted and non-starvable. useDeferredValue on blocks is one option that is scheduled rather than starvable; scheduling the promotion outside the passive-effect flush (so it is not attributed to the host's sync commit) is another.
One caveat on the workaround currently recommended in #550: animated also makes the animate plugin mutate prevContentLength / lastRenderCharCount during render, so any fix that defers the consumer of that state should be checked against the animation, not just against the counter.
Summary
In
streamdown@2.5.0, a<Streamdown mode="streaming">with ananimatedconfig exhausts React's nested-update budget over a long stream, throwing:This is the other half of #550. That issue reports the
useTransitionbranch (taken whenanimatedis absent) being starved, and namesanimatedas the workaround. Enablinganimatedmoves the block-promotion update onto a counted lane instead, which is this bug. Both branches of the same ternary are unsafe under sustained streaming; picking either one trades one failure for the other.It is also, we believe, the undiagnosed cause of #140 — same error text, same trigger ("longer responses", "more than 30sec"), closed without a mechanism.
The condition
dist/chunk-BO2N2NFS.js, in the main component:blocksis a fresh array on every chunk, so[blocks, mode]always changes and the effect always runs.Mechanism
This is not a render loop — there is no oscillation, which is why it presents intermittently and resists a search for a cycle. It is a monotonic accumulator:
useSyncExternalStoresubscription does this;@ai-sdk/react'suseChatis one).react-domflushSpawnedWork:0 !== (pendingEffectsLanes & 3) && flushPendingEffects().setDeferred(blocks). InsideflushPassiveEffects,ReactDOMSharedInternals.pisDefaultEventPriority, so that update lands on DefaultLane.0 !== (endTime & 261930) && 0 !== (remainingLanes & 42) ? nestedUpdateCount++ : (nestedUpdateCount = 0). Mask42isSyncLane | InputContinuousLane | DefaultLane, so DefaultLane pending means increment, not reset.getRootForUpdatedFiber.Step 4 is why
startTransitionmatters: a transition lane is outside mask42, so the counter resets on every commit.Consequence worth noting for triage: the throw lands on whichever update happens to be next, which is usually the host's own store notification. If that runs inside the host's stream-consumption
try(it does in@ai-sdk/react), the error is caught and reassigned to chat state — so it surfaces with no error boundary, no component stack and no dev overlay, which is what makes it hard to attribute to Streamdown. That matches the screenshot in #140.Reproduction
Self-contained, no host framework. jsdom +
react-dom/client, real scheduler (IS_REACT_ACT_ENVIRONMENT = false), growing prefixes of a long document with a zero-delay macrotask between frames:Single-variable results, same repro, same machine:
streamdown@2.5.0animated&& !animatePluginremoved from the conditionTwo things that stop it reproducing, in case a first attempt comes up green: the frames must be prefixes of a long accumulated document (the per-chunk reparse has to cost more than the gap — 80 short appends drain fine and report 0), and the driver must not be
act(), which flushes to quiescence after every frame and resets the counter.We also see it through our own wrapper component with the full prop set (
components,plugins,rehypePlugins,controls,mermaid,caret) over ~1600 frames — same re-render driver as above, not a host-framework integration: 5 of 7 streaming cases fail on stock 2.5.0 and 7 of 7 pass with the condition removed. In both cases the rendered output is intact at the end of the stream; the only failing assertion on the red runs is the update-depth count.What a fix has to satisfy
Removing
&& !animatePluginfixes this and regresses #550. Keeping it fixes #550 and causes this. So the ternary cannot be the answer either way — the block promotion needs to be both uncounted and non-starvable.useDeferredValueonblocksis one option that is scheduled rather than starvable; scheduling the promotion outside the passive-effect flush (so it is not attributed to the host's sync commit) is another.One caveat on the workaround currently recommended in #550:
animatedalso makes the animate plugin mutateprevContentLength/lastRenderCharCountduring render, so any fix that defers the consumer of that state should be checked against the animation, not just against the counter.