Skip to content
Closed
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
20 changes: 4 additions & 16 deletions packages/ai-client/src/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,23 +742,11 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
}
}

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
Expand Down
10 changes: 4 additions & 6 deletions packages/ai-react/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ export function useChat<
): UseChatReturn<TTools, TSchema> {
// 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<Array<UIMessage<TTools>>>(
options.initialMessages || [],
Expand Down Expand Up @@ -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,
}),
Expand Down
17 changes: 17 additions & 0 deletions packages/ai-react/tests/use-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down