fix(react): throttle published useChat snapshots - #18525
Conversation
Bugfix reviewOutcome: changes-required Fixes issueStatus: partially-addresses The change correctly stops unrelated renders from exposing message snapshots ahead of a configured throttle, but it does not address maximum-depth reports using the default unthrottled behavior. Concerns:
Side effectsRisk: medium Separately published message and status snapshots can leave useChat reporting ready or error while its returned messages remain stale until the next throttle callback. Concerns:
PerformanceRisk: low The implementation retains one additional messages-array reference and allocates a small snapshot wrapper per publication, while substantially reducing avoidable React renders. Backwards compatibilityRisk: medium No persisted format or migration changes are introduced, but applications that persist returned messages when status becomes ready can now store an incomplete snapshot. Concerns:
Breaking changesRisk: medium Public types, exports, inputs, and defaults are unchanged, but the observable relationship between status and messages changes incompatibly for throttled consumers. Concerns:
ArchitectureRisk: low The fix remains localized to the React adapter, preserves package boundaries, and caches the external-store snapshot consistently with React's useSyncExternalStore contract. Change scopeStatus: minimal The implementation, regression test, deterministic reproduction page, route adjustment, and required patch changeset are all directly related to fixing and verifying the reported behavior. SecurityRisk: none The change adds no new input handling, network destinations, credential flow, or security-sensitive dependency behavior. TestingStatus: needs-more The new test covers the unrelated-render snapshot bypass, but it does not cover terminal status/message coherence introduced by the new publication model. Concerns:
VerificationInspected every changed hunk and the underlying React chat state and request-completion ordering; the React package's 90 tests passed, package type-checking passed, changed files passed formatting and lint checks, the Next.js example TypeScript project compiled, and a focused runtime probe confirmed the terminal stale-snapshot regression. Relevant Documentation |
|
Addressed in 3e8f458:
|
| @@ -16,7 +16,7 @@ export async function POST(req: Request) { | |||
| type: 'text-start', | |||
| id: 'text-1', | |||
| }, | |||
| ...Array(5000).fill({ type: 'text-delta', id: 'text-1', delta: 'T\n' }), | |||
There was a problem hiding this comment.
500 is sufficient and matches EXPECTED_ASSISTANT_CHARACTERS
## Background `useChat` in AI SDK 6 throttles its messages subscription callback, but its `useSyncExternalStore` snapshot always reads the latest `chat.messages` array. Because streaming replaces that array for every chunk, an unrelated React render can observe a new snapshot before the throttled callback publishes it. In high-frequency streams this bypasses `experimental_throttle`, causes per-chunk renders, and can contribute to the "Maximum update depth exceeded" failures reported in #6166. This is a branch-native backport of #18525 to `release-v6.0`. ## Summary - Keep a published messages snapshot per `useChat` hook and advance it from that hook's throttled subscription callback. - Publish the latest message snapshot before `ready` or `error` becomes observable, including normal completion and aborts. - Synchronize updates that occur between render and subscription, and ignore delayed callbacks after a hook unsubscribes or changes chat instances. - Add regression coverage for unrelated renders, terminal status/message coherence, aborts, errors, and delayed callbacks after chat replacement. - Turn the existing Next.js throttle route into a deterministic end-to-end reproduction that streams 500 chunks while forcing unrelated renders, reports pass/fail from observed snapshot identities, and verifies the complete message is visible when status becomes `ready`. - Update the reproduction's stale text stream-part shape to the v6 protocol and avoid a render-counter hydration mismatch. - Add a patch changeset for `@ai-sdk/react`. ## Contributor Credit - @brahmveda-arkin reported #6166. - @takumiz19 isolated the snapshot/subscription mismatch and proposed #17893. - @ben-reitz demonstrated the practical value of a conservative UI update cadence in cloudflare/agents#2058. ## Manual Verification Ran `/chat/throttle` in `examples/ai-e2e-next` in a real browser. The route streamed 500 chunks (1,000 assistant characters) with `experimental_throttle: 50` while a zero-delay timer independently re-rendered the component. The patched v6 run passed with 15 distinct message snapshots in 860ms (maximum expected: 22), across 462 total React renders. All 1,000 assistant characters were visible on the first render where status became `ready`, with no Next.js error overlay. Also ran: - `pnpm --filter @ai-sdk/react test -- use-chat.ui.test.tsx` (60 tests passed) - `pnpm check` - `pnpm type-check:full` ## Checklist - [x] Tests have been added / updated (for bug fixes / features) - [ ] Documentation has been added / updated (for bug fixes / features) - [x] A _patch_ changeset for relevant packages has been added (for bug fixes / features - run `pnpm changeset` in the project root) - [x] I have reviewed this pull request (self-review) ## Future Work This backport intentionally leaves the v6 opt-in default unchanged, so applications must continue to set `experimental_throttle` to benefit from paced React publications. For v8, I recommend making a 50ms UI publication cadence the default when `throttle` is omitted, with `throttle: 0` as the explicit unthrottled opt-out. Stream processing, tool handling, and callbacks should remain immediate; only snapshots exposed to React should be paced. The default should guarantee an immediate leading publication and a terminal flush so final messages and `ready` status stay coherent. This would cap the normal rendering rate at about 20 updates per second and protect applications that do not know they need to opt in today. The tradeoff is up to 50ms of additional visible text latency and an explicit opt-out for applications that intentionally need per-chunk rendering, which makes the behavior change appropriate for a major release. ## Related Issues Backport of #18525. Addresses the throttled snapshot bypass discussed in #6166. Reports using the default unthrottled behavior remain outside this PR. Related to cloudflare/agents#2058.
## Background `useChat` in AI SDK 5 throttles its messages subscription callback, but its `useSyncExternalStore` snapshot always reads the latest `chat.messages` array. Because streaming replaces that array for every chunk, an unrelated React render can observe a new snapshot before the throttled callback publishes it. In high-frequency streams this bypasses `experimental_throttle`, causes per-chunk renders, and can contribute to the "Maximum update depth exceeded" failures reported in #6166. This is a branch-native backport of #18525 to `release-v5.0`. ## Summary - Keep a published messages snapshot per `useChat` hook and advance it from that hook's throttled subscription callback. - Publish the latest message snapshot before `ready` or `error` becomes observable, including normal completion and aborts. - Synchronize updates that occur between render and subscription, and ignore delayed callbacks after a hook unsubscribes or changes chat instances. - Add regression coverage for unrelated renders, terminal status/message coherence, aborts, errors, and delayed callbacks after chat replacement. - Turn the existing Next.js throttle route into a deterministic end-to-end reproduction that streams 500 chunks while forcing unrelated renders, reports pass/fail from observed snapshot identities, and verifies the complete message is visible when status becomes `ready`. - Update the reproduction's stale text stream-part shape to the v5 protocol and avoid a render-counter hydration mismatch. - Add a patch changeset for `@ai-sdk/react`. ## Contributor Credit - @brahmveda-arkin reported #6166. - @takumiz19 isolated the snapshot/subscription mismatch and proposed #17893. - @ben-reitz demonstrated the practical value of a conservative UI update cadence in cloudflare/agents#2058. ## Manual Verification Ran `/use-chat-throttle` in `examples/next-openai` in a real browser. The route streamed 500 chunks (1,000 assistant characters) with `experimental_throttle: 50` while a zero-delay timer independently re-rendered the component. The patched v5 run passed with 15 distinct message snapshots in 1,227ms (maximum expected: 29), across 654 total React renders. All 1,000 assistant characters were visible on the first render where status became `ready`, with no Next.js error overlay. Also ran: - `NODE_PATH=packages/rsc/node_modules pnpm --filter @ai-sdk/react test -- use-chat.ui.test.tsx` (53 tests passed; v5's React Vitest config imports the plugin already pinned by the RSC package but does not declare it itself) - `pnpm --filter @ai-sdk/react type-check` - `pnpm --filter @ai-sdk/react... build` - `pnpm check` - `git diff --check` `pnpm type-check:full` remains red in this checkout because unchanged v5 examples resolve incompatible React type versions. It did not report any changed file; the changed React package type-check and declaration build pass, and the changed Next.js route compiled successfully during browser verification. ## Tasks - [x] Tests have been added / updated (for bug fixes / features) - [ ] Documentation has been added / updated (for bug fixes / features) - [x] A _patch_ changeset for relevant packages has been added (for bug fixes / features - run `pnpm changeset` in the project root) - [x] Formatting issues have been fixed (run `pnpm prettier-fix` in the project root) - [x] I have reviewed this pull request (self-review) ## Future Work This backport intentionally leaves the v5 opt-in default unchanged, so applications must continue to set `experimental_throttle` to benefit from paced React publications. For v8, I recommend making a 50ms UI publication cadence the default when `throttle` is omitted, with `throttle: 0` as the explicit unthrottled opt-out. Stream processing, tool handling, and callbacks should remain immediate; only snapshots exposed to React should be paced. The default should guarantee an immediate leading publication and a terminal flush so final messages and `ready` status stay coherent. This would cap the normal rendering rate at about 20 updates per second and protect applications that do not know they need to opt in today. The tradeoff is up to 50ms of additional visible text latency and an explicit opt-out for applications that intentionally need per-chunk rendering, which makes the behavior change appropriate for a major release. ## Related Issues Backport of #18525. Addresses the throttled snapshot bypass discussed in #6166. Reports using the default unthrottled behavior remain outside this PR. Related to cloudflare/agents#2058.
Background
useChatcurrently throttles its messages subscription callback, but itsuseSyncExternalStoresnapshot always reads the latestchat.messagesarray. Because streaming replaces that array for every chunk, any unrelated React render can observe a new snapshot before the throttled callback publishes it. In high-frequency streams this bypassesthrottle, causes per-chunk renders, and can contribute to the "Maximum update depth exceeded" failures reported in #6166.Summary
useChathook and advance it from that hook's throttled subscription callback.readyorerrorbecomes observable, including normal completion and aborts.ready.@ai-sdk/react.Contributor Credit
End-to-End Verification
Ran
/chat/throttleinexamples/ai-e2e-nextin a real browser. The route streams 500 chunks (1,000 assistant characters) withthrottle: 50while a zero-delay timer independently re-renders the component.The patched run rendered all 1,000 assistant characters on the same render where status became
ready, and had no Next.js error overlay or browser error.Checklist
pnpm changesetin the project root)Future Work
This PR intentionally leaves the current opt-in default unchanged, so it does not protect applications that omit
throttlefrom high-frequency unthrottled rendering.For v8, I recommend making a 50ms UI publication cadence the default when
throttleis omitted, withthrottle: 0as the explicit unthrottled opt-out. Stream processing, tool handling, and callbacks should remain immediate; only snapshots exposed to React should be paced. The default should also guarantee an immediate leading publication and a terminal flush so the final messages andreadystatus stay coherent.This would cap the normal rendering rate at about 20 updates per second and protect applications that do not know they need to opt in today. The tradeoff is up to 50ms of additional visible text latency and an explicit opt-out for applications that intentionally need per-chunk rendering, which makes the behavior change appropriate for a major release.
Related Issues
Addresses the throttled snapshot bypass discussed in #6166. Reports using the default unthrottled behavior remain outside this PR.
Closes #17893.
Related to cloudflare/agents#2058.