Skip to content

fix(react): throttle published useChat snapshots - #18525

Merged
gr2m merged 2 commits into
mainfrom
codex/fix-use-chat-throttle-snapshot
Aug 6, 2026
Merged

fix(react): throttle published useChat snapshots#18525
gr2m merged 2 commits into
mainfrom
codex/fix-use-chat-throttle-snapshot

Conversation

@gr2m

@gr2m gr2m commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Background

useChat currently throttles its messages subscription callback, but its useSyncExternalStore snapshot always reads the latest chat.messages array. 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 bypasses throttle, causes per-chunk renders, and can contribute to the "Maximum update depth exceeded" failures reported in #6166.

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.
  • Add a patch changeset for @ai-sdk/react.

Contributor Credit

End-to-End Verification

Ran /chat/throttle in examples/ai-e2e-next in a real browser. The route streams 500 chunks (1,000 assistant characters) with throttle: 50 while a zero-delay timer independently re-renders the component.

  • Before: FAIL, 255 distinct message snapshots in 1,475ms (maximum expected: 34), across 946 total React renders.
  • After: PASS, 15 distinct message snapshots in 1,144ms (maximum expected: 27), across 602 total React renders.

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

  • All commits are signed (PRs with unsigned commits cannot be merged)
  • Tests have been added / updated (for bug fixes / features)
  • Documentation has been added / updated (for bug fixes / features)
  • A patch changeset for relevant packages has been added (for bug fixes / features - run pnpm changeset in the project root)
  • I have reviewed this pull request (self-review)

Future Work

This PR intentionally leaves the current opt-in default unchanged, so it does not protect applications that omit throttle from high-frequency unthrottled rendering.

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 also guarantee an immediate leading publication and a terminal flush so the 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

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.

@ai-sdk-factory

ai-sdk-factory Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Bugfix review

Outcome: changes-required

Fixes issue

Status: 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:

  • The linked issue includes failures without UI throttling; this patch changes behavior only when throttle or experimental_throttle is configured.

Side effects

Risk: 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:

  • A focused short-stream probe with a long throttle observed status ready while messages still contained only the user message; the completed assistant message appeared one throttle window later.
  • Terminal message publication should be flushed before or together with ready, error, and abort status publication.

Performance

Risk: low

The implementation retains one additional messages-array reference and allocates a small snapshot wrapper per publication, while substantially reducing avoidable React renders.

Backwards compatibility

Risk: 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:

  • Status-driven persistence or effects may observe stale messages at stream completion.

Breaking changes

Risk: medium

Public types, exports, inputs, and defaults are unchanged, but the observable relationship between status and messages changes incompatibly for throttled consumers.

Concerns:

  • Code that treats ready as indicating that the returned messages contain the completed response can now receive stale data.

Architecture

Risk: 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 scope

Status: 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.

Security

Risk: none

The change adds no new input handling, network destinations, credential flow, or security-sensitive dependency behavior.

Testing

Status: 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:

  • Add a regression test requiring the final message snapshot to be visible when ready or error is published.
  • The delayed-callback guards for unsubscribe and chat-instance changes are substantive new lifecycle behavior but have no focused throttled tests.

Verification

Inspected 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

@gr2m

gr2m commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in 3e8f458:

  • Terminal ready and error notifications now publish the latest hook-local messages snapshot before scheduling the status update. This also covers aborts, which transition back to ready.
  • Added focused throttled tests for normal completion, stream errors, aborts, and a delayed callback after changing chat instances. The React package now passes all 94 tests.
  • Strengthened the browser reproduction so it records the assistant length on the first ready render instead of waiting for a later message update. It passed with 1,000/1,000 characters present at ready, 15 message snapshot changes over 1,144ms, and 602 total React renders.
  • Clarified the PR scope: this fixes configured throttling and no longer claims to close the unthrottled reports in Maximum update depth exceeded #6166. Changing the omitted default remains proposed for v8 because of the rendering-semantics tradeoff.

pnpm check and pnpm type-check:full pass.

Comment on lines 6 to -19
@@ -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' }),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why update this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

500 is sufficient and matches EXPECTED_ASSISTANT_CHARACTERS

@gr2m
gr2m merged commit 10e8db0 into main Aug 6, 2026
46 checks passed
@gr2m
gr2m deleted the codex/fix-use-chat-throttle-snapshot branch August 6, 2026 21:09
gr2m added a commit that referenced this pull request Aug 6, 2026
## 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.
gr2m added a commit that referenced this pull request Aug 6, 2026
## 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants