Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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 isSuspenseFetching = false

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) => {
isSuspenseFetching = true
observer.fetchOptimistic(defaultedOptions.value).then(
(result) => {
isSuspenseFetching = false
resolve(result)
},
(error: TError) => {
isSuspenseFetching = false
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 && !isSuspenseFetching) {
throw error
}
}
},
)
Expand Down
Loading