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 764e70ec482f..7cff1dd8a607 100644 --- a/packages/vue/src/use-chat.ts +++ b/packages/vue/src/use-chat.ts @@ -106,6 +106,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( { @@ -164,11 +168,11 @@ export function useChat( () => store[key] ?? fillMessageParts(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 ??= fillMessageParts(initialMessages); @@ -192,7 +196,7 @@ export function useChat( { data, headers, body }: ChatRequestOptions = {}, ) { error.value = undefined; - mutateStatus(() => 'submitted'); + status.value = 'submitted'; const messageCount = messages.value.length; const maxStep = extractMaxToolInvocationStep( @@ -257,7 +261,7 @@ export function useChat( credentials, onResponse, onUpdate({ message, data, replaceLastMessage }) { - mutateStatus(() => 'streaming'); + status.value = 'streaming'; mutate([ ...(replaceLastMessage @@ -283,12 +287,12 @@ export function useChat( lastMessage: recursiveToRaw(chatMessages[chatMessages.length - 1]), }); - 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; } @@ -297,7 +301,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 d31896adf26f..99a8422621d1 100644 --- a/packages/vue/src/use-chat.ui.test.tsx +++ b/packages/vue/src/use-chat.ui.test.tsx @@ -174,6 +174,47 @@ 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('0:"Hello"\n'); + await waitFor(() => + expect(screen.getByTestId('status')).toHaveTextContent('streaming'), + ); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); + document.dispatchEvent(new Event('visibilitychange')); + + controller.write('0:", world."\n'); + 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',