Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/fix-blocks-cascade-react-185.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"streamdown": patch
---

Fix React #185 (`Maximum update depth exceeded`) cascade under rapid streaming token bursts.

Replaces the internal `useState` + `useEffect`-to-sync + manual `startTransition`
dance that mirrored `blocks` into `displayBlocks` with `useDeferredValue`. The
previous pattern fired `setDisplayBlocks(blocks)` on every render where `blocks`
was a new reference; under SSE bursts that delivered tokens faster than React
committed, those setStates stacked inside one commit cycle and exceeded React's
50-nested-update limit. `useDeferredValue` performs the same semantic role
(low-priority blocks-state update during streaming) without producing setStates
that can cascade.

No behavior change: SSR/hydration still initializes with the current `blocks`,
`animatePlugin` path still uses synchronous (non-deferred) blocks, all 982
existing tests pass.

Closes the cluster of issues tracked in #140; addresses the React #185 reports
in downstream consumers when `experimental_throttle` alone is insufficient.
50 changes: 28 additions & 22 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createContext,
createElement,
memo,
useDeferredValue,
useEffect,
useId,
useLayoutEffect,
Expand Down Expand Up @@ -610,28 +611,6 @@ export const Streamdown = memo(
[processedChildren, parseMarkdownIntoBlocksFn]
);

// Render blocks directly. The previous displayBlocks + useTransition path
// could be starved by sibling urgent updates when animated was off (#550),
// freezing markdown at the first parse. Eager updates are correct here.
const blocksToRender = blocks;

// Pre-compute per-block text directions when dir="auto" so detection
// runs once per block change rather than on every render pass.
const blockDirections = useMemo(
() =>
dir === "auto" ? blocksToRender.map(detectTextDirection) : undefined,
[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
Expand Down Expand Up @@ -695,6 +674,33 @@ export const Streamdown = memo(
}
});

// Defer the blocks reference during streaming so React can drop intermediate
// values under load. Replaces the previous useState+useEffect+startTransition
// dance, which fired setDisplayBlocks on every render where `blocks` was a new
// ref and could exceed React's 50-nested-update limit (React #185) when SSE
// tokens arrived in bursts faster than commit time. Animated path stays
// synchronous because per-block animate plugins read content per-render.
const deferredBlocks = useDeferredValue(blocks);
const blocksToRender =
mode === "streaming" && !animatedKey ? deferredBlocks : blocks;

// Pre-compute per-block text directions when dir="auto" so detection
// runs once per block change rather than on every render pass.
const blockDirections = useMemo(
() =>
dir === "auto" ? blocksToRender.map(detectTextDirection) : undefined,
[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]
);

// Combined context value - single object reduces React tree overhead
const contextValue = useMemo<StreamdownContextType>(
() => ({
Expand Down
Loading