diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts index 11fc90fe9..7e16342c8 100644 --- a/packages/ai-client/src/devtools.ts +++ b/packages/ai-client/src/devtools.ts @@ -742,23 +742,11 @@ export class ClientDevtoolsBridge { } } -let bridgeIdSequence = 0 - function createBridgeId(hookId: string): string { - const cryptoLike = ( - globalThis as { - crypto?: { - randomUUID?: () => string - } - } - ).crypto - - if (cryptoLike?.randomUUID) { - return `bridge:${hookId}:${cryptoLike.randomUUID()}` - } - - bridgeIdSequence += 1 - return `bridge:${hookId}:${bridgeIdSequence}` + // hookId comes from React's useId and is stable across SSR and hydration. + // Do not generate randomness during render: crypto.randomUUID is unavailable + // or restricted in some server runtimes, and discarded renders must be pure. + return `bridge:${hookId}` } // Owns the chat-client devtools surface so the chat client itself stays a diff --git a/packages/ai-react/src/use-chat.ts b/packages/ai-react/src/use-chat.ts index 96a75eb9c..f637a4934 100644 --- a/packages/ai-react/src/use-chat.ts +++ b/packages/ai-react/src/use-chat.ts @@ -41,10 +41,10 @@ export function useChat< ): UseChatReturn { // The hook's identity is its `threadId` — also the persistence key, so a // reload with the same `threadId` restores the same conversation. `hookId` is - // only a stable fallback for React's client-recreation keying when no - // `threadId` is given (an ephemeral chat), never a persistence key. + // a stable fallback when an empty or missing `threadId` creates an ephemeral + // chat, never a persistence key. const hookId = useId() - const clientId = options.threadId ?? hookId + const clientId = options.threadId || hookId const [messages, setMessages] = useState>>( options.initialMessages || [], @@ -128,9 +128,7 @@ export function useChat< ...transport, initialMessages: messagesToUse, ...(initialOptions.body !== undefined && { body: initialOptions.body }), - ...(initialOptions.threadId !== undefined && { - threadId: initialOptions.threadId, - }), + threadId: clientId, ...(initialOptions.forwardedProps !== undefined && { forwardedProps: initialOptions.forwardedProps, }), diff --git a/packages/ai-react/tests/use-chat.test.ts b/packages/ai-react/tests/use-chat.test.ts index d172ab858..3edfa515c 100644 --- a/packages/ai-react/tests/use-chat.test.ts +++ b/packages/ai-react/tests/use-chat.test.ts @@ -290,6 +290,23 @@ describe('useChat', () => { expect(typeof messageId).toBe('string') }) + it.each(['', undefined])( + 'uses the hook ID instead of generating a random ID for threadId=%s', + (threadId) => { + const chatClientPrototype = ChatClient.prototype as unknown as { + generateUniqueId: () => string + } + const generateUniqueId = vi.spyOn(chatClientPrototype, 'generateUniqueId') + + renderUseChat({ + connection: createMockConnectionAdapter(), + ...(threadId === undefined ? {} : { threadId }), + }) + + expect(generateUniqueId).not.toHaveBeenCalled() + }, + }) + it('should generate id if not provided', async () => { const chunks = createTextChunks('Response') const adapter = createMockConnectionAdapter({ chunks })