diff --git a/.changeset/fix-blocks-cascade-react-185.md b/.changeset/fix-blocks-cascade-react-185.md new file mode 100644 index 00000000..7090bd64 --- /dev/null +++ b/.changeset/fix-blocks-cascade-react-185.md @@ -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. diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index b8e77042..11005fc2 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -6,6 +6,7 @@ import { createContext, createElement, memo, + useDeferredValue, useEffect, useId, useLayoutEffect, @@ -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 @@ -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( () => ({