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 + 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..e033eba9f0b 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 suspenseFetchCount = 0 + const suspense = () => { return new Promise>( (resolve, reject) => { @@ -164,9 +166,14 @@ export function useBaseQuery< ) if (optimisticResult.isStale) { stopWatch() - observer - .fetchOptimistic(defaultedOptions.value) - .then(resolve, (error: TError) => { + suspenseFetchCount += 1 + observer.fetchOptimistic(defaultedOptions.value).then( + (result) => { + suspenseFetchCount -= 1 + resolve(result) + }, + (error: TError) => { + suspenseFetchCount -= 1 if ( shouldThrowError(defaultedOptions.value.throwOnError, [ error, @@ -177,7 +184,8 @@ export function useBaseQuery< } else { resolve(observer.getCurrentResult()) } - }) + }, + ) } else { stopWatch() resolve(optimisticResult) @@ -196,15 +204,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 && suspenseFetchCount === 0) { + throw error + } } }, )