Summary
useAgentChat's clientToolResults stale-entry cleanup effect calls setClientToolResults unconditionally on every chatMessages change. Because the dispatch is not actually free, it consumes one unit of React's nested-update budget per streamed chunk, and any sufficiently long answer ends in Maximum update depth exceeded (React #185).
This is a distinct dispatch source from the ones covered by #1361 / #1732 / #1913 (per-frame setMessages, replay merging). It also explains why experimental_throttle — the guidance given on #1732 — reduced the failure rate for people without closing it.
packages/agents/src/chat/react.tsx, unchanged on main @ ec93caf6 and in the published agents@0.22.0:
useEffect(() => {
const currentToolCallIds = new Set<string>();
// ...collect ids from chatMessages...
// Use functional update to check and clean stale entries atomically
setClientToolResults((prev) => {
if (prev.size === 0) return prev;
// ...
if (!hasStaleEntries) return prev; // <-- believed to be a bailout
// ...
});
}, [chatMessages]);
Why returning prev is not a bailout
React's useState dispatch only takes the eager-bailout path when the fiber has no pending work. During a streamed turn the next chunk has usually already scheduled an update, so the eager path is skipped, the update is queued, and the component re-renders even though the map is identical.
That alone would only be wasteful. The failure comes from which lane it lands on:
- This is a passive effect, and a SyncLane commit flushes passive effects inside the commit itself (
0 !== (pendingEffectsLanes & 3) && flushPendingEffects()).
- So the dispatch schedules a DefaultLane update while
root.pendingLanes is still non-empty.
- That is exactly the condition under which React increments
nestedUpdateCount instead of resetting it. The reset only happens on a commit that ends with no SyncLane | InputContinuousLane | DefaultLane pending.
nestedUpdateCount is a monotonic accumulator, not a loop detector. There is no render loop here — each streamed chunk contributes exactly one count, the counter never resets while the turn is live, and at 51 React throws from getRootForUpdatedFiber. The throw therefore lands on whatever update happens to come next, which in an AI SDK v6 setup is the ReactChatState store notification inside Chat.makeRequest's try/catch — so it is swallowed and reassigned to chat error, with no error boundary, no component stack, and nothing in server logs. The server-side turn completes normally.
Measurement
Reproduced against agents@0.21.0 + @ai-sdk/react@3.0.204 + React 19.2.6, with an instrumented react-dom-client.development.js logging the owner fiber, lane, and stack in scheduleUpdateOnFiber whenever isFlushingPassiveEffects, plus every nestedUpdateCount++:
- 353 dispatches from this effect in a single answer
- all on lane 32 (DefaultLane), owner fiber = the transcript component
nestedUpdateCount climbing monotonically, remaining=32 on every increment
- reproduced on long answers, never on short ones — consistent with an accumulator rather than a loop
Fix
Compute staleness from a ref mirror of the map before dispatching, so a turn that prunes nothing schedules no update at all. The updater still recomputes from prev, so a concurrent write between the check and the update is not clobbered.
Branch and commit on my fork, since PR creation against this repo 404s for external accounts (same as #1363, #2181, #2182):
pnpm --filter agents typecheck, oxlint, and oxfmt --check pass on the change. Happy to add a regression test in packages/agents/src/react-tests/ if you'd like one in the same shape as default-throttle.test.tsx — it needs a counted-dispatch assertion rather than a render count, so I'd rather match whatever you prefer there.
Verified downstream
Carried as a pnpm patch in our app against 0.21.0. Long streamed answers that reliably produced the banner before now complete cleanly (3/3), with no change in tool-result rendering behaviour.
One gotcha worth recording for anyone reproducing: with Vite, agents/chat/react gets inlined into the pre-bundled dep chunk, so rewriting node_modules does not invalidate it. Clear the dep cache and restart, or you will silently test the unpatched code.
Summary
useAgentChat'sclientToolResultsstale-entry cleanup effect callssetClientToolResultsunconditionally on everychatMessageschange. Because the dispatch is not actually free, it consumes one unit of React's nested-update budget per streamed chunk, and any sufficiently long answer ends inMaximum update depth exceeded(React #185).This is a distinct dispatch source from the ones covered by #1361 / #1732 / #1913 (per-frame
setMessages, replay merging). It also explains whyexperimental_throttle— the guidance given on #1732 — reduced the failure rate for people without closing it.packages/agents/src/chat/react.tsx, unchanged onmain@ec93caf6and in the publishedagents@0.22.0:Why returning
previs not a bailoutReact's
useStatedispatch only takes the eager-bailout path when the fiber has no pending work. During a streamed turn the next chunk has usually already scheduled an update, so the eager path is skipped, the update is queued, and the component re-renders even though the map is identical.That alone would only be wasteful. The failure comes from which lane it lands on:
0 !== (pendingEffectsLanes & 3) && flushPendingEffects()).root.pendingLanesis still non-empty.nestedUpdateCountinstead of resetting it. The reset only happens on a commit that ends with noSyncLane | InputContinuousLane | DefaultLanepending.nestedUpdateCountis a monotonic accumulator, not a loop detector. There is no render loop here — each streamed chunk contributes exactly one count, the counter never resets while the turn is live, and at 51 React throws fromgetRootForUpdatedFiber. The throw therefore lands on whatever update happens to come next, which in an AI SDK v6 setup is theReactChatStatestore notification insideChat.makeRequest'stry/catch— so it is swallowed and reassigned to chaterror, with no error boundary, no component stack, and nothing in server logs. The server-side turn completes normally.Measurement
Reproduced against
agents@0.21.0+@ai-sdk/react@3.0.204+ React 19.2.6, with an instrumentedreact-dom-client.development.jslogging the owner fiber, lane, and stack inscheduleUpdateOnFiberwheneverisFlushingPassiveEffects, plus everynestedUpdateCount++:nestedUpdateCountclimbing monotonically,remaining=32on every incrementFix
Compute staleness from a ref mirror of the map before dispatching, so a turn that prunes nothing schedules no update at all. The updater still recomputes from
prev, so a concurrent write between the check and the update is not clobbered.Branch and commit on my fork, since PR creation against this repo 404s for external accounts (same as #1363, #2181, #2182):
pnpm --filter agents typecheck,oxlint, andoxfmt --checkpass on the change. Happy to add a regression test inpackages/agents/src/react-tests/if you'd like one in the same shape asdefault-throttle.test.tsx— it needs a counted-dispatch assertion rather than a render count, so I'd rather match whatever you prefer there.Verified downstream
Carried as a
pnpmpatch in our app against0.21.0. Long streamed answers that reliably produced the banner before now complete cleanly (3/3), with no change in tool-result rendering behaviour.One gotcha worth recording for anyone reproducing: with Vite,
agents/chat/reactgets inlined into the pre-bundled dep chunk, so rewritingnode_modulesdoes not invalidate it. Clear the dep cache and restart, or you will silently test the unpatched code.