From 9d36c0432296a37baaf50afe62c7391e458c7adc Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Thu, 5 Mar 2026 12:02:50 +0900 Subject: [PATCH 1/4] fix(vue-query/useBaseQuery): prevent dual error propagation when 'suspense()' and error watcher both handle the same error --- .../src/__tests__/useInfiniteQuery.test.ts | 72 +++++++++++++++++++ .../vue-query/src/__tests__/useQuery.test.ts | 61 ++++++++++++++++ packages/vue-query/src/useBaseQuery.ts | 52 ++++++++------ 3 files changed, 164 insertions(+), 21 deletions(-) diff --git a/packages/vue-query/src/__tests__/useInfiniteQuery.test.ts b/packages/vue-query/src/__tests__/useInfiniteQuery.test.ts index b9eee7547fd..c0685f454aa 100644 --- a/packages/vue-query/src/__tests__/useInfiniteQuery.test.ts +++ b/packages/vue-query/src/__tests__/useInfiniteQuery.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { getCurrentInstance } from 'vue-demi' import { sleep } from '@tanstack/query-test-utils' import { useInfiniteQuery } from '../useInfiniteQuery' import { infiniteQueryOptions } from '../infiniteQueryOptions' +import type { Mock } from 'vitest' vi.mock('../useQueryClient') +vi.mock('../useBaseQuery') describe('useInfiniteQuery', () => { beforeEach(() => { @@ -76,4 +79,73 @@ describe('useInfiniteQuery', () => { }) expect(status.value).toStrictEqual('success') }) + + describe('throwOnError', () => { + test('should throw from error watcher when throwOnError is true and suspense is not used', async () => { + const throwOnErrorFn = vi.fn().mockReturnValue(true) + useInfiniteQuery({ + queryKey: ['infiniteThrowOnErrorWithoutSuspense'], + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Some error'))), + initialPageParam: 0, + getNextPageParam: () => 12, + retry: false, + throwOnError: throwOnErrorFn, + }) + + // Suppress the Unhandled Rejection caused by watcher throw in Vue 3 + const rejectionHandler = () => {} + process.on('unhandledRejection', rejectionHandler) + + await vi.advanceTimersByTimeAsync(10) + + process.off('unhandledRejection', rejectionHandler) + + // throwOnError is evaluated and throw is attempted (not suppressed by suspense) + expect(throwOnErrorFn).toHaveBeenCalledTimes(1) + expect(throwOnErrorFn).toHaveBeenCalledWith( + Error('Some error'), + expect.objectContaining({ + state: expect.objectContaining({ status: 'error' }), + }), + ) + }) + }) + + describe('suspense', () => { + test('should not throw from error watcher when suspense is handling the error with throwOnError: true', async () => { + const getCurrentInstanceSpy = getCurrentInstance as Mock + getCurrentInstanceSpy.mockImplementation(() => ({ suspense: {} })) + + const throwOnErrorFn = vi.fn().mockReturnValue(true) + const query = useInfiniteQuery({ + queryKey: ['infiniteSuspenseThrowOnError'], + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Some error'))), + initialPageParam: 0, + getNextPageParam: () => 12, + retry: false, + throwOnError: throwOnErrorFn, + }) + + let rejectedError: unknown + const promise = query.suspense().catch((error) => { + rejectedError = error + }) + + await vi.advanceTimersByTimeAsync(10) + + await promise + + expect(rejectedError).toBeInstanceOf(Error) + expect((rejectedError as Error).message).toBe('Some error') + // throwOnError is evaluated in both suspense() and the error watcher + expect(throwOnErrorFn).toHaveBeenCalledTimes(2) + // but the error watcher should not throw when suspense is active + expect(query).toMatchObject({ + status: { value: 'error' }, + isError: { value: true }, + }) + }) + }) }) diff --git a/packages/vue-query/src/__tests__/useQuery.test.ts b/packages/vue-query/src/__tests__/useQuery.test.ts index 8a77d149e5d..fec110da40f 100644 --- a/packages/vue-query/src/__tests__/useQuery.test.ts +++ b/packages/vue-query/src/__tests__/useQuery.test.ts @@ -458,6 +458,34 @@ describe('useQuery', () => { }), ) }) + + test('should throw from error watcher when throwOnError is true and suspense is not used', async () => { + const throwOnErrorFn = vi.fn().mockReturnValue(true) + useQuery({ + queryKey: ['throwOnErrorWithoutSuspense'], + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Some error'))), + retry: false, + throwOnError: throwOnErrorFn, + }) + + // Suppress the Unhandled Rejection caused by watcher throw in Vue 3 + const rejectionHandler = () => {} + process.on('unhandledRejection', rejectionHandler) + + await vi.advanceTimersByTimeAsync(10) + + process.off('unhandledRejection', rejectionHandler) + + // throwOnError is evaluated and throw is attempted (not suppressed by suspense) + expect(throwOnErrorFn).toHaveBeenCalledTimes(1) + expect(throwOnErrorFn).toHaveBeenCalledWith( + Error('Some error'), + expect.objectContaining({ + state: expect.objectContaining({ status: 'error' }), + }), + ) + }) }) describe('suspense', () => { @@ -569,5 +597,38 @@ describe('useQuery', () => { }), ) }) + + test('should not throw from error watcher when suspense is handling the error with throwOnError: true', async () => { + const getCurrentInstanceSpy = getCurrentInstance as Mock + getCurrentInstanceSpy.mockImplementation(() => ({ suspense: {} })) + + const throwOnErrorFn = vi.fn().mockReturnValue(true) + const query = useQuery({ + queryKey: ['suspense6'], + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Some error'))), + retry: false, + throwOnError: throwOnErrorFn, + }) + + let rejectedError: unknown + const promise = query.suspense().catch((error) => { + rejectedError = error + }) + + await vi.advanceTimersByTimeAsync(10) + + await promise + + expect(rejectedError).toBeInstanceOf(Error) + expect((rejectedError as Error).message).toBe('Some error') + // throwOnError is evaluated in both suspense() and the error watcher + expect(throwOnErrorFn).toHaveBeenCalledTimes(2) + // but the error watcher should not throw when suspense is active + expect(query).toMatchObject({ + status: { value: 'error' }, + isError: { value: true }, + }) + }) }) }) diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index f5c444b3ae9..42152173c86 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -149,6 +149,8 @@ export function useBaseQuery< return state.refetch(...args) } + let isSuspenseFetching = false + const suspense = () => { return new Promise>( (resolve, reject) => { @@ -164,20 +166,28 @@ export function useBaseQuery< ) if (optimisticResult.isStale) { stopWatch() + isSuspenseFetching = true observer .fetchOptimistic(defaultedOptions.value) - .then(resolve, (error: TError) => { - if ( - shouldThrowError(defaultedOptions.value.throwOnError, [ - error, - observer.getCurrentQuery(), - ]) - ) { - reject(error) - } else { - resolve(observer.getCurrentResult()) - } - }) + .then( + (result) => { + isSuspenseFetching = false + resolve(result) + }, + (error: TError) => { + isSuspenseFetching = false + if ( + shouldThrowError(defaultedOptions.value.throwOnError, [ + error, + observer.getCurrentQuery(), + ]) + ) { + reject(error) + } else { + resolve(observer.getCurrentResult()) + } + }, + ) } else { stopWatch() resolve(optimisticResult) @@ -196,15 +206,15 @@ export function useBaseQuery< watch( () => state.error, (error) => { - if ( - state.isError && - !state.isFetching && - shouldThrowError(defaultedOptions.value.throwOnError, [ - error as TError, - observer.getCurrentQuery(), - ]) - ) { - throw error + if (state.isError && !state.isFetching) { + const shouldThrow = shouldThrowError( + defaultedOptions.value.throwOnError, + [error as TError, observer.getCurrentQuery()], + ) + + if (shouldThrow && !isSuspenseFetching) { + throw error + } } }, ) From 7516d47afd2e786f5389645b5a7454936a9cee8b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:04:16 +0000 Subject: [PATCH 2/4] ci: apply automated fixes --- packages/vue-query/src/useBaseQuery.ts | 40 ++++++++++++-------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index 42152173c86..085d2a64ccb 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -167,27 +167,25 @@ export function useBaseQuery< if (optimisticResult.isStale) { stopWatch() isSuspenseFetching = true - observer - .fetchOptimistic(defaultedOptions.value) - .then( - (result) => { - isSuspenseFetching = false - resolve(result) - }, - (error: TError) => { - isSuspenseFetching = false - if ( - shouldThrowError(defaultedOptions.value.throwOnError, [ - error, - observer.getCurrentQuery(), - ]) - ) { - reject(error) - } else { - resolve(observer.getCurrentResult()) - } - }, - ) + observer.fetchOptimistic(defaultedOptions.value).then( + (result) => { + isSuspenseFetching = false + resolve(result) + }, + (error: TError) => { + isSuspenseFetching = false + if ( + shouldThrowError(defaultedOptions.value.throwOnError, [ + error, + observer.getCurrentQuery(), + ]) + ) { + reject(error) + } else { + resolve(observer.getCurrentResult()) + } + }, + ) } else { stopWatch() resolve(optimisticResult) From 470beb1eea9b5815ad3ab7550fd2299759a6f292 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Thu, 5 Mar 2026 12:04:28 +0900 Subject: [PATCH 3/4] chore(changeset): add changeset for 'useBaseQuery' dual error propagation fix --- .changeset/wide-camels-jog.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/wide-camels-jog.md diff --git a/.changeset/wide-camels-jog.md b/.changeset/wide-camels-jog.md new file mode 100644 index 00000000000..40dfc912749 --- /dev/null +++ b/.changeset/wide-camels-jog.md @@ -0,0 +1,6 @@ +--- +'@tanstack/vue-query': patch +--- + +fix(vue-query/useBaseQuery): prevent dual error propagation when 'suspense()' and error watcher both handle the same error + From 4ecefd75d641cec6c1890da254f25fff0bc83803 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Sat, 7 Mar 2026 16:46:00 +0900 Subject: [PATCH 4/4] refactor(vue-query/useBaseQuery): replace 'isSuspenseFetching' boolean with ref-count to handle overlapping suspense fetches --- packages/vue-query/src/useBaseQuery.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index 085d2a64ccb..e033eba9f0b 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -149,7 +149,7 @@ export function useBaseQuery< return state.refetch(...args) } - let isSuspenseFetching = false + let suspenseFetchCount = 0 const suspense = () => { return new Promise>( @@ -166,14 +166,14 @@ export function useBaseQuery< ) if (optimisticResult.isStale) { stopWatch() - isSuspenseFetching = true + suspenseFetchCount += 1 observer.fetchOptimistic(defaultedOptions.value).then( (result) => { - isSuspenseFetching = false + suspenseFetchCount -= 1 resolve(result) }, (error: TError) => { - isSuspenseFetching = false + suspenseFetchCount -= 1 if ( shouldThrowError(defaultedOptions.value.throwOnError, [ error, @@ -210,7 +210,7 @@ export function useBaseQuery< [error as TError, observer.getCurrentQuery()], ) - if (shouldThrow && !isSuspenseFetching) { + if (shouldThrow && suspenseFetchCount === 0) { throw error } }