-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
fix(vue-query/useBaseQuery): prevent dual error propagation when 'suspense()' and error watcher both handle the same error #10234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9d36c04
7516d47
470beb1
4ecefd7
0b074af
e104dfa
9e2924c
af17464
cf3705e
a22c838
d74e8d2
228f736
c98fbe9
89bdaf9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,12 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { getCurrentInstance } from 'vue-demi' | ||
| import { queryKey, 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(() => { | ||
|
|
@@ -78,4 +81,98 @@ describe('useInfiniteQuery', () => { | |
| }) | ||
| expect(status.value).toStrictEqual('success') | ||
| }) | ||
|
|
||
| describe('throwOnError', () => { | ||
| it('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, | ||
| }) | ||
|
|
||
| // The watcher rethrows, which Vue surfaces differently across versions: | ||
| // through console.error on Vue 2.x and as an unhandled rejection on Vue 3. | ||
| // Capture both channels so the assertion holds on every supported version. | ||
| const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) | ||
| const rejectionHandler = vi.fn() | ||
| process.on('unhandledRejection', rejectionHandler) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(10) | ||
|
|
||
| process.off('unhandledRejection', rejectionHandler) | ||
|
|
||
| const reportedError = [ | ||
| ...errorSpy.mock.calls, | ||
| ...rejectionHandler.mock.calls, | ||
| ].some((args) => | ||
| args.some( | ||
| (arg) => arg instanceof Error && arg.message === 'Some error', | ||
| ), | ||
| ) | ||
|
|
||
| errorSpy.mockRestore() | ||
|
|
||
| // 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' }), | ||
| }), | ||
| ) | ||
| // The watcher rethrows, so Vue surfaces the error (console.error or an | ||
| // unhandled rejection depending on the version). | ||
| expect(reportedError).toBe(true) | ||
| }) | ||
| }) | ||
|
|
||
| describe('suspense', () => { | ||
| it('should not throw from error watcher when suspense is handling the error with throwOnError: true', async () => { | ||
| const getCurrentInstanceSpy = getCurrentInstance as Mock | ||
| getCurrentInstanceSpy.mockImplementation(() => ({ suspense: {} })) | ||
|
|
||
| // Spy on unhandled rejections so we can assert the watcher does not rethrow. | ||
| const rejectionHandler = vi.fn() | ||
| process.on('unhandledRejection', rejectionHandler) | ||
|
|
||
| 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 | ||
|
|
||
| process.off('unhandledRejection', rejectionHandler) | ||
|
|
||
| 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) | ||
| // The error watcher must not rethrow when suspense is active, so no | ||
| // unhandled rejection should be observed. | ||
| expect(rejectionHandler).not.toHaveBeenCalled() | ||
| expect(query).toMatchObject({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This actually does not assert, what comment suggests. Should there be a spy on
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @DamianOsipiuk You're right β the previous assertion did not match the comment's intent. In 9e2924c I added a |
||
| status: { value: 'error' }, | ||
| isError: { value: true }, | ||
| }) | ||
| }) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π― Functional Correctness | π‘ Minor | β‘ Quick win
Capture
console.errorin the suspense test.On Vue 2.x, a watcher rethrow is reported through
console.error, notunhandledRejection. This test only checksunhandledRejection, so a Vue 2.x regression can still pass. Captureconsole.errorand assert that neither channel receivesSome error.π€ Prompt for AI Agents