Skip to content

Commit 98e83ab

Browse files
nwalters512claudegr2m
authored
fix(ai): avoid status flash to 'submitted' during stream resumption with no active stream (#12102)
## Background When `useChat` is configured with `resume: true`, the status briefly flashes to `submitted` on page load even when there is no active stream to resume. This happens because `makeRequest()` unconditionally sets status to `submitted` at the start, before checking whether `reconnectToStream` returns a stream or `null` (204 No Content). This is observable in React as a brief `ready → submitted → ready` transition on mount, which can cause unintended side effects in code that watches the `status` value. ## Summary - Move the `reconnectToStream` call for the `resume-stream` trigger to **before** `setStatus({ status: 'submitted' })`, so we can bail out early without changing status when there is no active stream - Store the reconnect result and reuse it later, avoiding a second network request - Wrap the early `reconnectToStream` call in its own try-catch so errors (e.g. network failures, server errors) are properly surfaced via `onError` and `setStatus({ status: 'error' })` - Add regression tests for both the no-stream (204) and error (500) cases, verifying status history never includes `submitted` **Note:** This changes `onFinish` behavior for the resume-with-no-active-stream case. Previously, `onFinish` was called with a default empty message (because the early return was inside the `try` block, so the `finally` block ran). Now it is not called at all when there is nothing to resume, which is arguably more correct — there is no "finished" message to report. ## Manual Verification Verified by running the new tests both before and after the fix: - **Before**: no-stream test fails with status history `ready,submitted,ready` - **After**: both tests pass — no-stream stays `ready`, error goes to `ready,error` Also confirmed all existing resume tests continue to pass. ## Checklist - [x] Tests have been added / updated (for bug fixes / features) - [x] 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) ## Additional Context This PR was authored with the assistance of AI (Claude Opus 4.5). --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
1 parent d0d4ecd commit 98e83ab

File tree

3 files changed

+164
-13
lines changed

3 files changed

+164
-13
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
Fix `useChat` status briefly flashing to `submitted` on page load when `resume: true` is set and there is no active stream to resume. The `reconnectToStream` check is now performed before setting status to `submitted`, so status stays `ready` when the server responds with 204 (no active stream).

packages/ai/src/ui/chat.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,33 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
582582
trigger: 'submit-message' | 'resume-stream' | 'regenerate-message';
583583
messageId?: string;
584584
} & ChatRequestOptions) {
585+
// For resume-stream, check if there's an active stream before
586+
// changing status. This avoids a brief flash of 'submitted' status
587+
// when there is no stream to resume (e.g. on page load).
588+
let resumeStream: ReadableStream<UIMessageChunk> | undefined;
589+
if (trigger === 'resume-stream') {
590+
try {
591+
const reconnect = await this.transport.reconnectToStream({
592+
chatId: this.id,
593+
metadata,
594+
headers,
595+
body,
596+
});
597+
598+
if (reconnect == null) {
599+
return; // no active stream found, so we do not resume
600+
}
601+
602+
resumeStream = reconnect;
603+
} catch (err) {
604+
if (this.onError && err instanceof Error) {
605+
this.onError(err);
606+
}
607+
this.setStatus({ status: 'error', error: err as Error });
608+
return;
609+
}
610+
}
611+
585612
this.setStatus({ status: 'submitted', error: undefined });
586613

587614
const lastMessage = this.lastMessage;
@@ -608,19 +635,7 @@ export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
608635
let stream: ReadableStream<UIMessageChunk>;
609636

610637
if (trigger === 'resume-stream') {
611-
const reconnect = await this.transport.reconnectToStream({
612-
chatId: this.id,
613-
metadata,
614-
headers,
615-
body,
616-
});
617-
618-
if (reconnect == null) {
619-
this.setStatus({ status: 'ready' });
620-
return; // no active stream found, so we do not resume
621-
}
622-
623-
stream = reconnect;
638+
stream = resumeStream!;
624639
} else {
625640
stream = await this.transport.sendMessages({
626641
chatId: this.id,

packages/react/src/use-chat.ui.test.tsx

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2027,6 +2027,137 @@ describe('resume ongoing stream and return assistant message', () => {
20272027
});
20282028
});
20292029

2030+
describe('resume with no active stream should not flash submitted status', () => {
2031+
setupTestComponent(
2032+
() => {
2033+
const { messages, status } = useChat({
2034+
id: '123',
2035+
messages: [
2036+
{
2037+
id: 'msg_123',
2038+
role: 'user',
2039+
parts: [{ type: 'text', text: 'hi' }],
2040+
},
2041+
],
2042+
generateId: mockId(),
2043+
resume: true,
2044+
});
2045+
2046+
const statusHistoryRef = useRef<string[]>([]);
2047+
if (statusHistoryRef.current.at(-1) !== status) {
2048+
statusHistoryRef.current.push(status);
2049+
}
2050+
2051+
return (
2052+
<div>
2053+
{messages.map((m, idx) => (
2054+
<div data-testid={`message-${idx}`} key={m.id}>
2055+
{m.role === 'user' ? 'User: ' : 'AI: '}
2056+
{m.parts
2057+
.map(part => (part.type === 'text' ? part.text : ''))
2058+
.join('')}
2059+
</div>
2060+
))}
2061+
2062+
<div data-testid="status">{status}</div>
2063+
<div data-testid="status-history">
2064+
{statusHistoryRef.current.join(',')}
2065+
</div>
2066+
</div>
2067+
);
2068+
},
2069+
{
2070+
init: TestComponent => {
2071+
server.urls['/api/chat/123/stream'].response = {
2072+
type: 'empty',
2073+
status: 204,
2074+
};
2075+
2076+
return <TestComponent />;
2077+
},
2078+
},
2079+
);
2080+
2081+
it('should not transition to submitted when no active stream exists', async () => {
2082+
await waitFor(() => {
2083+
expect(server.calls.length).toBe(1);
2084+
});
2085+
2086+
expect(screen.getByTestId('status')).toHaveTextContent('ready');
2087+
expect(screen.getByTestId('status-history')).not.toHaveTextContent(
2088+
'submitted',
2089+
);
2090+
});
2091+
});
2092+
2093+
describe('resume with server error should set error status without flashing submitted', () => {
2094+
const onErrorCalls: Error[] = [];
2095+
2096+
setupTestComponent(
2097+
() => {
2098+
const { status, error } = useChat({
2099+
id: '123',
2100+
messages: [
2101+
{
2102+
id: 'msg_123',
2103+
role: 'user',
2104+
parts: [{ type: 'text', text: 'hi' }],
2105+
},
2106+
],
2107+
generateId: mockId(),
2108+
resume: true,
2109+
onError(err) {
2110+
onErrorCalls.push(err);
2111+
},
2112+
});
2113+
2114+
const statusHistoryRef = useRef<string[]>([]);
2115+
if (statusHistoryRef.current.at(-1) !== status) {
2116+
statusHistoryRef.current.push(status);
2117+
}
2118+
2119+
return (
2120+
<div>
2121+
<div data-testid="status">{status}</div>
2122+
<div data-testid="status-history">
2123+
{statusHistoryRef.current.join(',')}
2124+
</div>
2125+
{error && <div data-testid="error">{error.toString()}</div>}
2126+
</div>
2127+
);
2128+
},
2129+
{
2130+
init: TestComponent => {
2131+
server.urls['/api/chat/123/stream'].response = {
2132+
type: 'error',
2133+
status: 500,
2134+
body: 'Internal server error',
2135+
};
2136+
2137+
return <TestComponent />;
2138+
},
2139+
},
2140+
);
2141+
2142+
beforeEach(() => {
2143+
onErrorCalls.length = 0;
2144+
});
2145+
2146+
it('should set error status and call onError', async () => {
2147+
await waitFor(() => {
2148+
expect(screen.getByTestId('status')).toHaveTextContent('error');
2149+
});
2150+
2151+
expect(screen.getByTestId('error')).toHaveTextContent(
2152+
'Internal server error',
2153+
);
2154+
expect(screen.getByTestId('status-history')).not.toHaveTextContent(
2155+
'submitted',
2156+
);
2157+
expect(onErrorCalls).toHaveLength(1);
2158+
});
2159+
});
2160+
20302161
describe('stop', () => {
20312162
setupTestComponent(() => {
20322163
const { messages, sendMessage, stop, status } = useChat({

0 commit comments

Comments
 (0)