Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/silent-paws-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"agents": minor
---

Throttle chat UI updates by default in `useAgentChat`

Streaming writes chat state once per chunk, and each write re-renders. When
chunks arrive in a burst — a resumed stream replaying a long turn, for
example — React reaches its 50-render limit and throws "Maximum update depth
exceeded", which the AI SDK reports as a failed turn even though the server
completed it (#1913).

`useAgentChat` now coalesces those updates every 50ms, which removes about 78%
of renders on a fast stream and matches the value the AI SDK documents. The
first chunk of a stream is never delayed. Pass `throttle: false` to render
every chunk as it arrives, or a number to change the interval. The deprecated
`experimental_throttle` is still honoured.
56 changes: 56 additions & 0 deletions packages/agents/src/chat/__tests__/chat-throttle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
chatThrottleOptions,
DEFAULT_CHAT_THROTTLE_MS,
resolveChatThrottleMs
} from "../chat-throttle";

describe("resolveChatThrottleMs", () => {
it("throttles by default, so chat is protected without any configuration", () => {
expect(resolveChatThrottleMs({})).toBe(DEFAULT_CHAT_THROTTLE_MS);
});

it("prefers an explicit throttle", () => {
expect(resolveChatThrottleMs({ throttle: 25 })).toBe(25);
});

it("accepts the deprecated experimental_throttle, which every example passes", () => {
expect(resolveChatThrottleMs({ experimental_throttle: 250 })).toBe(250);
});

it("prefers the current name when both are passed", () => {
expect(
resolveChatThrottleMs({ experimental_throttle: 250, throttle: 25 })
).toBe(25);
});

it("turns throttling off for false", () => {
expect(resolveChatThrottleMs({ throttle: false })).toBeUndefined();
});

it("treats 0 as opting out rather than as unset", () => {
expect(resolveChatThrottleMs({ throttle: 0 })).toBe(0);
expect(resolveChatThrottleMs({ experimental_throttle: 0 })).toBe(0);
});
});

describe("chatThrottleOptions", () => {
// @ai-sdk/react v3 reads `experimental_throttle`; v4 reads `throttle`. Both
// majors are in our peer range, so both names have to carry the value.
it("spells the throttle under both option names", () => {
expect(chatThrottleOptions({})).toEqual({
experimental_throttle: DEFAULT_CHAT_THROTTLE_MS,
throttle: DEFAULT_CHAT_THROTTLE_MS
});
expect(chatThrottleOptions({ throttle: 0 })).toEqual({
experimental_throttle: 0,
throttle: 0
});
});

// Both majors decide with `waitMs != null`, so omitting the option is the
// only spelling that reaches the SDK's own unthrottled path.
it("omits both names when throttling is off", () => {
expect(chatThrottleOptions({ throttle: false })).toEqual({});
});
});
85 changes: 85 additions & 0 deletions packages/agents/src/chat/chat-throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* How often chat state is allowed to re-render the UI.
*
* The AI SDK writes chat state once per streamed chunk, and each write is a
* React render. Without a throttle a burst of chunks becomes a burst of
* renders, and past 50 in an unbroken row React throws "Maximum update depth
* exceeded" (#1913). A throttle collapses those renders no matter how many
* chunks arrive, which is why it protects cases chunk merging cannot: a replay
* of many tool steps, or any other backlog delivered in one go.
*
* Any value above zero prevents that crash, because the update then arrives
* from a timer rather than from the current task. The size of the value is a
* cost decision instead, measured over a 200-chunk turn at ~100 chunks/sec:
*
* off 404 commits / 138ms 50ms 90 commits / 37ms
* 16ms 261 commits / 89ms 100ms 48 commits / 21ms
*
* 50ms removes 78% of the renders. Going higher saves progressively less and
* makes the text lag further behind the stream, so this sits at the knee of
* that curve. It is also the value the AI SDK's own documentation uses.
*
* This does not delay the first chunk. The SDK throttles with `throttleit`,
* which runs the first call of an idle window immediately, so only mid-stream
* updates are coalesced.
*
* It is a mitigation rather than a guarantee. `useChat` throttles the store
* subscription, but its `getSnapshot` returns a new messages array on every
* chunk, and React forces a synchronous re-render whenever that identity moved
* during a render — a path the throttle never sees (vercel/ai#6166, fix open in
* vercel/ai#17893). Only writing state less often bounds it, which is why the
* transport also merges replayed chunks before they reach the SDK.
*/
export const DEFAULT_CHAT_THROTTLE_MS = 50;

export type ChatThrottleOptions = {
/**
* Milliseconds to coalesce chat updates, or `false` to render every chunk.
*/
throttle?: number | false;
/** @deprecated Use `throttle`. */
experimental_throttle?: number;
};

/** What gets forwarded to `useChat`. Omitted keys mean "do not throttle". */
type ForwardedThrottleOptions = {
throttle?: number;
experimental_throttle?: number;
};

/**
* Picks the throttle from an explicit caller value, the deprecated alias, or
* the default — in that order. `false` turns throttling off.
*/
export function resolveChatThrottleMs(
options: ChatThrottleOptions
): number | undefined {
if (options.throttle === false) return undefined;
return (
options.throttle ??
options.experimental_throttle ??
DEFAULT_CHAT_THROTTLE_MS
);
}

/**
* The throttle spelled under both option names, or neither name when it is off.
*
* The two names are not interchangeable across the peer range. `@ai-sdk/react`
* v3 only reads `experimental_throttle`; v4 renamed it to `throttle` and reads
* `throttle ?? experimental_throttle`. Our peer range allows both majors, so
* sending one name would silently do nothing on the other. Unknown option keys
* are ignored by both, so sending both names is safe.
*
* Turning throttling off omits both names rather than sending `0`. Both majors
* decide with `waitMs != null`, so an omitted option is the only spelling that
* takes the SDK's own unthrottled path; `0` still wraps the callback and only
* behaves like "off" as a side effect of how the delay is computed.
*/
export function chatThrottleOptions(
options: ChatThrottleOptions
): ForwardedThrottleOptions {
const ms = resolveChatThrottleMs(options);
if (ms === undefined) return {};
return { experimental_throttle: ms, throttle: ms };
}
22 changes: 21 additions & 1 deletion packages/agents/src/chat/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from "ai";
import { nanoid } from "nanoid";
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { chatThrottleOptions } from "./chat-throttle";
import type { OutgoingMessage } from "./wire-types";
import { STREAM_RESUME_NONE_REASONS } from "./protocol";
import { MessageType } from "./wire-types";
Expand Down Expand Up @@ -383,7 +384,12 @@ export type UseAgentChatOptions<
// oxlint-disable-next-line no-unused-vars -- kept for backward compat
State = unknown,
ChatMessage extends UIMessage = UIMessage
> = Omit<UseChatParams<ChatMessage>, "fetch" | "onToolCall"> & {
> = Omit<
UseChatParams<ChatMessage>,
// Both throttle names are redeclared below: ours accepts `false`, and
// intersecting with the SDK's `throttle?: number` would drop that.
"fetch" | "onToolCall" | "throttle" | "experimental_throttle"
> & {
/** Agent connection from useAgent (accepts both typed and untyped agents) */
agent: AgentConnection & {
agent: string;
Expand All @@ -400,6 +406,17 @@ export type UseAgentChatOptions<
credentials?: RequestCredentials;
/** Request headers */
headers?: HeadersInit;
/**
* Milliseconds to coalesce chat state updates before re-rendering,
* defaulting to 50.
*
* Streaming writes chat state once per chunk, so without a throttle a fast
* burst of chunks renders once per chunk. The first chunk is never delayed.
* Pass `false` to render every chunk as it arrives.
*/
throttle?: number | false;
/** @deprecated Use `throttle`. */
experimental_throttle?: number;
/**
* Callback for handling client-side tool execution.
* Called when a tool without server-side `execute` is invoked by the LLM.
Expand Down Expand Up @@ -690,6 +707,8 @@ export function useAgentChat<
syncMessagesToServer = true,
body: bodyOption,
prepareSendMessagesRequest,
throttle,
experimental_throttle,
...rest
} = options;

Expand Down Expand Up @@ -1017,6 +1036,7 @@ export function useAgentChat<
// the Chat stable across socket recreations.
const useChatHelpers = useChat<ChatMessage>({
...rest,
...chatThrottleOptions({ experimental_throttle, throttle }),
onData,
messages: initialMessages,
transport: customTransport,
Expand Down
Loading
Loading