Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/wide-camels-jog.md
Original file line number Diff line number Diff line change
@@ -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

72 changes: 72 additions & 0 deletions packages/vue-query/src/__tests__/useInfiniteQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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 },
})
})
})
})
61 changes: 61 additions & 0 deletions packages/vue-query/src/__tests__/useQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 },
})
})
})
})
34 changes: 21 additions & 13 deletions packages/vue-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ export function useBaseQuery<
return state.refetch(...args)
}

let suspenseFetchCount = 0

const suspense = () => {
return new Promise<QueryObserverResult<TData, TError>>(
(resolve, reject) => {
Expand All @@ -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,
Expand All @@ -177,7 +184,8 @@ export function useBaseQuery<
} else {
resolve(observer.getCurrentResult())
}
})
},
)
} else {
stopWatch()
resolve(optimisticResult)
Expand All @@ -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
}
}
},
)
Expand Down
Loading