Skip to content

[Bug] "Maximum update depth exceeded" from React when streaming many components eventually causing an out-of-memory crash #311

Description

@ml-aixolotl

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)

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions