diff --git a/.changeset/heavy-ligers-lay.md b/.changeset/heavy-ligers-lay.md new file mode 100644 index 000000000000..b87f18bc7826 --- /dev/null +++ b/.changeset/heavy-ligers-lay.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/vue': patch +--- + +fix (ai-sdk/vue): fix status reactivity diff --git a/packages/vue/src/use-chat.ts b/packages/vue/src/use-chat.ts index 875c63615b64..269de7b32ee2 100644 --- a/packages/vue/src/use-chat.ts +++ b/packages/vue/src/use-chat.ts @@ -107,6 +107,10 @@ export type UseChatHelpers = { // @ts-expect-error - some issues with the default export of useSWRV const useSWRV = (swrv.default as (typeof import('swrv'))['default']) || swrv; const store: Record = {}; +const statusStore: Record< + string, + Ref<'submitted' | 'streaming' | 'ready' | 'error'> +> = {}; export function useChat( { @@ -165,11 +169,11 @@ export function useChat( () => store[key] ?? initialMessages, ); - const { data: status, mutate: mutateStatus } = useSWRV< - 'submitted' | 'streaming' | 'ready' | 'error' - >(`${chatId}-status`, null); - - status.value ??= 'ready'; + const status = + statusStore[chatId] ?? + (statusStore[chatId] = ref<'submitted' | 'streaming' | 'ready' | 'error'>( + 'ready', + )); // Force the `data` to be `initialMessages` if it's `undefined`. messagesData.value ??= initialMessages; @@ -193,7 +197,7 @@ export function useChat( { data, headers, body }: ChatRequestOptions = {}, ) { error.value = undefined; - mutateStatus(() => 'submitted'); + status.value = 'submitted'; const messageCount = messages.value.length; const lastMessage = messages.value.at(-1); @@ -233,7 +237,7 @@ export function useChat( credentials, onResponse, onUpdate({ message, data, replaceLastMessage }) { - mutateStatus(() => 'streaming'); + status.value = 'streaming'; mutate([ ...(replaceLastMessage @@ -256,12 +260,12 @@ export function useChat( getCurrentDate, }); - mutateStatus(() => 'ready'); + status.value = 'ready'; } catch (err) { // Ignore abort errors as they are expected. if ((err as any).name === 'AbortError') { abortController = null; - mutateStatus(() => 'ready'); + status.value = 'ready'; return null; } @@ -270,7 +274,7 @@ export function useChat( } error.value = err as Error; - mutateStatus(() => 'error'); + status.value = 'error'; } finally { abortController = null; } diff --git a/packages/vue/src/use-chat.ui.test.tsx b/packages/vue/src/use-chat.ui.test.tsx index 1816d4f006ce..71121ebd04ae 100644 --- a/packages/vue/src/use-chat.ui.test.tsx +++ b/packages/vue/src/use-chat.ui.test.tsx @@ -193,6 +193,52 @@ describe('data protocol stream', () => { }); }); + it('should update status when the tab is hidden', async () => { + const controller = new TestResponseController(); + server.urls['/api/chat'].response = { + type: 'controlled-stream', + controller, + }; + + const originalVisibilityState = document.visibilityState; + + try { + await userEvent.click(screen.getByTestId('do-append')); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('submitted'), + ); + + controller.write( + formatDataStreamPart({ type: 'text', value: 'Hello' }), + ); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('streaming'), + ); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); + document.dispatchEvent(new Event('visibilitychange')); + + controller.write( + formatDataStreamPart({ type: 'text', value: ' world.' }), + ); + controller.close(); + + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('ready'), + ); + } finally { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => originalVisibilityState, + }); + document.dispatchEvent(new Event('visibilitychange')); + } + }); + it('should set status to error when there is a server error', async () => { server.urls['/api/chat'].response = { type: 'error',