Skip to content

fix(streamdown): use useDeferredValue for blocks-state, prevent React #185 cascade#530

Open
cristicretu wants to merge 1 commit into
vercel:mainfrom
cristicretu:cristicretu/fix-blocks-cascade-react-185
Open

fix(streamdown): use useDeferredValue for blocks-state, prevent React #185 cascade#530
cristicretu wants to merge 1 commit into
vercel:mainfrom
cristicretu:cristicretu/fix-blocks-cascade-react-185

Conversation

@cristicretu
Copy link
Copy Markdown

@cristicretu cristicretu commented Jun 2, 2026

We hit React #185 with Streamdown in production at Anara. About 15 events/hr at peak across thousands of users. Bumping experimental_throttle to 100ms (per #140) closed roughly 86% of those, but the rest needed a code change.

The cascade sits in index.tsx:

const [displayBlocks, setDisplayBlocks] = useState<string[]>(blocks);
useEffect(() => {
  if (mode === "streaming" && !animatePlugin) {
    startTransition(() => setDisplayBlocks(blocks));
  } else {
    setDisplayBlocks(blocks);
  }
}, [blocks, mode]);

Each streaming token gives blocks a new ref, the effect fires, setDisplayBlocks queues an update. startTransition lowers the priority but doesn't drop intermediates, so under SSE bursts you stack 50+ updates inside one commit and trip React's nested-update guard.

useDeferredValue does the same job without the setStates:

const deferredBlocks = useDeferredValue(blocks);
const blocksToRender =
  mode === "streaming" && !animatePlugin ? deferredBlocks : blocks;

First render returns blocks directly so SSR hydration is unchanged. The animatePlugin path stays synchronous because the plugin needs the freshest content per render.

…ercel#185 cascade

The internal pattern of mirroring `blocks` into `displayBlocks` via
useState + useEffect-to-sync + manual startTransition fires
setDisplayBlocks(blocks) on every render where `blocks` is a new
reference. Under SSE bursts that deliver tokens faster than React
commits, those setStates stack inside one commit cycle and exceed
React's 50-nested-update limit, triggering Error: Maximum update depth
exceeded (React vercel#185).

Replace with useDeferredValue, which performs the same role
(low-priority blocks-state update during streaming) declaratively and
without producing setStates that can cascade.

- SSR/hydration unchanged (deferred value === current value on first
  render).
- animatePlugin path kept synchronous (non-deferred) because the
  animation plugin reads block content per-render and must see the
  freshest value.
- 982/982 existing tests pass.

Refs: vercel#140 (closed with experimental_throttle workaround; this fixes
the root cause).
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Jun 2, 2026

@cristicretu is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant