We hit this a few times while testing some extreme use cases with json-render in our app, while streaming the elements into the canvas React's Maximum update depth exceeded is hit at times and in extreme cases this can result in a out of memory crash.
What's your policy on AI bug details? I had Claude write this up after we built in our workaround to prevent this from crashing our app.
(Long Claude generated description follows)
useJsonRenderMessage + streaming causes tree-wide re-render on every patch, defeating ElementRenderer's memo — triggers React's "Maximum update depth exceeded" under a large $bindState-heavy spec
Summary
During AI-driven streaming generation of a large spec (~20-26 elements, several using $bindState two-way bindings), the canvas repeatedly hits React's Maximum update depth exceeded guard mid-stream — before any element has painted. It's recoverable (React logs + retries), but under sustained streaming it happens dozens of times per generation, and in our app that repeated failed-render/retry cycle was never garbage collected, eventually OOM-crashing the tab (confirmed via Chrome heap snapshot: 13k+ live FiberNode, 7.4k+ live Error instances).
Repro shape
useJsonRenderMessage(message.parts) fed by an AI SDK message where each streamed JSONL patch line becomes its own data-spec "patch" part (the documented streaming pattern).
- Spec has ~20+ elements across
PieChart/BarChart/Metric ($state bound) and 2+ GanttChart/similar ($bindState bound on data + selections), spanning 3 named datasets.
- Model streams patches at a real (SSE) pace, not synchronously — this appears to be timing/scheduler-sensitive (see "What I could NOT confirm" below).
Mechanism I could confirm (source-read, packages/react/src/hooks.ts + renderer.tsx @ main)
buildSpecFromParts (hooks.ts) rebuilds the entire Spec object from scratch — const spec: Spec = { root: "", elements: {} } — replaying every accumulated patch on every call. useJsonRenderMessage's memo guard (partsChanged) only skips the recompute, not this reference churn: every time it does recompute (which is every time the AI SDK appends a new patch part — i.e. once per streamed line), it returns a brand-new spec object, even for elements whose patches haven't changed at all this tick.
ElementRenderer (renderer.tsx) is wrapped in React.memo, but spec is one of its props. Since spec is a new reference on literally every streamed patch, the memo never bails — every ElementRenderer in the entire tree re-executes on every single patch line, not just the elements the patch actually touched.
- Each re-execution calls
resolveElementProps/resolveBindings (packages/core/src/props.ts) fresh, unmemoized. These do return stable underlying values for $state/$bindState (getByPath returns the same array/object reference when the store path hasn't changed) — but they always wrap the result in a new outer object (resolveElementProps's resolved = {}, resolveBindings's bindings = {}). So a consumer component whose own effect depends on the whole resolved-props or bindings object (rather than the specific bound value) sees a "changed" dependency on every tick even when nothing it cares about actually changed.
Net effect: an N-element spec streaming M patch lines does O(N × M) ElementRenderer executions during generation — for N≈20 and M in the hundreds (plausible for "generate a lot of components"), that's thousands of full-tree re-renders in a few seconds. That's expensive on its own, and it's the surface a consumer component's mis-keyed effect can turn into a genuine update-depth loop.
What I could NOT confirm
I was not able to pin down a specific effect inside json-render itself that unconditionally writes back to state on every render (the obvious suspect, useBoundProp in hooks.ts, is a pure passthrough + writer — it does not call set itself). I checked our own GanttChart wrapper (the one consumer in our app using $bindState) and it has no useEffect at all, so the loop isn't originating there either. I did not have a source-mapped debugger session into the exact minified stack to catch the specific setState call that trips React's depth counter — this write-up documents the amplifying mechanism (tree-wide re-render defeating memo during streaming) precisely, but not the exact final trigger.
Suggested angles for the maintainers
buildSpecFromParts could return the same spec reference (or only mutate/replace the touched element keys) when a patch doesn't touch already-rendered subtrees, instead of a from-scratch object every time.
ElementRenderer's memo comparator could special-case spec (e.g. compare spec.elements[elementKey] identity plus a coarse "state generation" counter) instead of the whole spec object, so untouched elements actually skip re-rendering during streaming.
- Might be worth a note in the docs: consumers of
bindings/resolved array-typed props should treat them as unstable-reference-but-stable-value during streaming, and never key an effect on the whole bindings/props object.
Our mitigation (app-side, not a json-render fix)
We now throttle the parts array fed into useJsonRenderMessage to a 200ms wall-clock cadence (a custom hook, since useDeferredValue didn't reliably reduce update frequency in this scenario — nothing else competes for scheduler priority during generation). This cut the crash rate from every attempt to 0/4 in testing, but the underlying tree-wide re-render-per-patch mechanism above is still present — we're just triggering it far less often.
We hit this a few times while testing some extreme use cases with json-render in our app, while streaming the elements into the canvas React's
Maximum update depth exceededis hit at times and in extreme cases this can result in a out of memory crash.What's your policy on AI bug details? I had Claude write this up after we built in our workaround to prevent this from crashing our app.
(Long Claude generated description follows)
useJsonRenderMessage+ streaming causes tree-wide re-render on every patch, defeatingElementRenderer's memo — triggers React's "Maximum update depth exceeded" under a large$bindState-heavy specSummary
During AI-driven streaming generation of a large spec (~20-26 elements, several using
$bindStatetwo-way bindings), the canvas repeatedly hits React'sMaximum update depth exceededguard mid-stream — before any element has painted. It's recoverable (React logs + retries), but under sustained streaming it happens dozens of times per generation, and in our app that repeated failed-render/retry cycle was never garbage collected, eventually OOM-crashing the tab (confirmed via Chrome heap snapshot: 13k+ liveFiberNode, 7.4k+ liveErrorinstances).Repro shape
useJsonRenderMessage(message.parts)fed by an AI SDK message where each streamed JSONL patch line becomes its owndata-spec"patch" part (the documented streaming pattern).PieChart/BarChart/Metric($statebound) and 2+GanttChart/similar ($bindStatebound ondata+selections), spanning 3 named datasets.Mechanism I could confirm (source-read,
packages/react/src/hooks.ts+renderer.tsx@main)buildSpecFromParts(hooks.ts) rebuilds the entireSpecobject from scratch —const spec: Spec = { root: "", elements: {} }— replaying every accumulated patch on every call.useJsonRenderMessage's memo guard (partsChanged) only skips the recompute, not this reference churn: every time it does recompute (which is every time the AI SDK appends a new patch part — i.e. once per streamed line), it returns a brand-newspecobject, even for elements whose patches haven't changed at all this tick.ElementRenderer(renderer.tsx) is wrapped inReact.memo, butspecis one of its props. Sincespecis a new reference on literally every streamed patch, the memo never bails — everyElementRendererin the entire tree re-executes on every single patch line, not just the elements the patch actually touched.resolveElementProps/resolveBindings(packages/core/src/props.ts) fresh, unmemoized. These do return stable underlying values for$state/$bindState(getByPathreturns the same array/object reference when the store path hasn't changed) — but they always wrap the result in a new outer object (resolveElementProps'sresolved = {},resolveBindings'sbindings = {}). So a consumer component whose own effect depends on the whole resolved-props orbindingsobject (rather than the specific bound value) sees a "changed" dependency on every tick even when nothing it cares about actually changed.Net effect: an N-element spec streaming M patch lines does O(N × M)
ElementRendererexecutions during generation — for N≈20 and M in the hundreds (plausible for "generate a lot of components"), that's thousands of full-tree re-renders in a few seconds. That's expensive on its own, and it's the surface a consumer component's mis-keyed effect can turn into a genuine update-depth loop.What I could NOT confirm
I was not able to pin down a specific effect inside json-render itself that unconditionally writes back to state on every render (the obvious suspect,
useBoundPropinhooks.ts, is a pure passthrough + writer — it does not callsetitself). I checked our ownGanttChartwrapper (the one consumer in our app using$bindState) and it has nouseEffectat all, so the loop isn't originating there either. I did not have a source-mapped debugger session into the exact minified stack to catch the specificsetStatecall that trips React's depth counter — this write-up documents the amplifying mechanism (tree-wide re-render defeating memo during streaming) precisely, but not the exact final trigger.Suggested angles for the maintainers
buildSpecFromPartscould return the samespecreference (or only mutate/replace the touched element keys) when a patch doesn't touch already-rendered subtrees, instead of a from-scratch object every time.ElementRenderer's memo comparator could special-casespec(e.g. comparespec.elements[elementKey]identity plus a coarse "state generation" counter) instead of the wholespecobject, so untouched elements actually skip re-rendering during streaming.bindings/resolved array-typed props should treat them as unstable-reference-but-stable-value during streaming, and never key an effect on the whole bindings/props object.Our mitigation (app-side, not a json-render fix)
We now throttle the
partsarray fed intouseJsonRenderMessageto a 200ms wall-clock cadence (a custom hook, sinceuseDeferredValuedidn't reliably reduce update frequency in this scenario — nothing else competes for scheduler priority during generation). This cut the crash rate from every attempt to 0/4 in testing, but the underlying tree-wide re-render-per-patch mechanism above is still present — we're just triggering it far less often.