WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/big-buses-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ai-sdk/react': patch
---

Fix subscribeToMessages callback dependency in useChat
6 changes: 2 additions & 4 deletions packages/react/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,12 @@ export function useChat<UI_MESSAGE extends UIMessage = UIMessage>({
chatRef.current = 'chat' in options ? options.chat : new Chat(options);
}

const optionsId = 'id' in options ? options.id : null;

const subscribeToMessages = useCallback(
(update: () => void) =>
chatRef.current['~registerMessagesCallback'](update, throttleWaitMs),
// optionsId is required to trigger re-subscription when the chat ID changes
// `chatRef.current.id` is required to trigger re-subscription when the chat ID changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[throttleWaitMs, optionsId],
[throttleWaitMs, chatRef.current.id],
);

const messages = useSyncExternalStore(
Expand Down
65 changes: 65 additions & 0 deletions packages/react/src/use-chat.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2314,6 +2314,71 @@ describe('chat instance changes', () => {

expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
});

it('should handle streaming correctly when the id changes', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test is largely copied from the test added in #7387:

it('should handle streaming correctly when id changes from undefined to defined', async () => {
const controller = new TestResponseController();
server.urls['/api/chat'].response = {
type: 'controlled-stream',
controller,
};
// First, change the ID from undefined to 'chat-123'
await userEvent.click(screen.getByTestId('change-id'));
// Then send a message
await userEvent.click(screen.getByTestId('send-message'));
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('submitted');
});
controller.write(formatChunk({ type: 'text-start', id: '0' }));
controller.write(
formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
);
// Verify streaming is working - text should appear immediately
await waitFor(() => {
expect(
JSON.parse(screen.getByTestId('messages').textContent ?? ''),
).toContainEqual(
expect.objectContaining({
role: 'assistant',
parts: expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: 'Hello',
}),
]),
}),
);
});
controller.write(formatChunk({ type: 'text-delta', id: '0', delta: ',' }));
controller.write(
formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
);
controller.write(formatChunk({ type: 'text-delta', id: '0', delta: '.' }));
controller.write(formatChunk({ type: 'text-end', id: '0' }));
controller.close();
await waitFor(() => {
expect(
JSON.parse(screen.getByTestId('messages').textContent ?? ''),
).toContainEqual(
expect.objectContaining({
role: 'assistant',
parts: expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: 'Hello, world.',
state: 'done',
}),
]),
}),
);
});
});

const controller = new TestResponseController();
server.urls['/api/chat'].response = {
type: 'controlled-stream',
controller,
};

// First, change the ID
await userEvent.click(screen.getByTestId('do-change-chat'));
Copy link
Collaborator

Choose a reason for hiding this comment

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

is this changing the id on the chat instance in the way you describe in the bug report? ie without your fix would this test fail?

Copy link
Contributor Author

@jeffcarbs jeffcarbs Dec 6, 2025

Choose a reason for hiding this comment

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

Yes, you can see here it resets the chat instance to one with a new ID:

<button
data-testid="do-change-chat"
onClick={() => {
setChat(
new Chat({
id: 'second-id',
generateId: mockId(),
}),
);
}}
/>

The test fails before the change:
Screenshot 2025-12-05 at 10 24 01 AM


// Then send a message
await userEvent.click(screen.getByTestId('do-send'));

await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('submitted');
});

controller.write(formatChunk({ type: 'text-start', id: '0' }));
controller.write(
formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
);

// Verify streaming is working - text should appear immediately
await waitFor(() => {
expect(
JSON.parse(screen.getByTestId('messages').textContent ?? ''),
).toContainEqual(
expect.objectContaining({
role: 'assistant',
parts: expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: 'Hello',
}),
]),
}),
);
});

controller.write(formatChunk({ type: 'text-delta', id: '0', delta: ',' }));
controller.write(
formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
);
controller.write(formatChunk({ type: 'text-delta', id: '0', delta: '.' }));
controller.write(formatChunk({ type: 'text-end', id: '0' }));
controller.close();

await waitFor(() => {
expect(
JSON.parse(screen.getByTestId('messages').textContent ?? ''),
).toContainEqual(
expect.objectContaining({
role: 'assistant',
parts: expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: 'Hello, world.',
state: 'done',
}),
]),
}),
);
});
});
});

describe('streaming with id change from undefined to defined', () => {
Expand Down
Loading