From 76edb3d5357a43f926ad5535436111e6e8c24ba3 Mon Sep 17 00:00:00 2001 From: S-jooyoung Date: Thu, 3 Sep 2026 14:53:58 +0900 Subject: [PATCH 1/2] fix(vue-query): throw falsy errors from useMutation to the error boundary The error watcher in `useMutation` only evaluated `throwOnError` when the error value was truthy, so a mutation that rejected with a falsy value (e.g. `undefined`) never reached `shouldThrowError` and was not propagated. Check `state.isError` instead, matching the react-query fix in #11311 and the preact-query fix in #11312. --- .changeset/vue-mutation-falsy-error.md | 5 +++++ .../src/__tests__/useMutation.test.ts | 21 +++++++++++++++++++ packages/vue-query/src/useMutation.ts | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .changeset/vue-mutation-falsy-error.md diff --git a/.changeset/vue-mutation-falsy-error.md b/.changeset/vue-mutation-falsy-error.md new file mode 100644 index 0000000000..6bbed21527 --- /dev/null +++ b/.changeset/vue-mutation-falsy-error.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-query': patch +--- + +fix(vue-query): throw falsy errors from `useMutation` to the error boundary diff --git a/packages/vue-query/src/__tests__/useMutation.test.ts b/packages/vue-query/src/__tests__/useMutation.test.ts index 7b1f889eaa..855b2d63d0 100644 --- a/packages/vue-query/src/__tests__/useMutation.test.ts +++ b/packages/vue-query/src/__tests__/useMutation.test.ts @@ -450,6 +450,27 @@ describe('useMutation', () => { expect(throwOnErrorFn).toHaveBeenCalledTimes(1) expect(throwOnErrorFn).toHaveBeenCalledWith(Error('Some error')) }) + + it('should evaluate throwOnError for a falsy error', async () => { + const throwOnErrorFn = vi.fn().mockReturnValue(true) + const { mutate } = useMutation({ + mutationFn: () => sleep(10).then(() => Promise.reject(undefined)), + throwOnError: throwOnErrorFn, + }) + + mutate() + + // 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) + + expect(throwOnErrorFn).toHaveBeenCalledTimes(1) + expect(throwOnErrorFn).toHaveBeenCalledWith(undefined) + }) }) describe('optimistic updates', () => { diff --git a/packages/vue-query/src/useMutation.ts b/packages/vue-query/src/useMutation.ts index 1bd8d30df6..b946b774ca 100644 --- a/packages/vue-query/src/useMutation.ts +++ b/packages/vue-query/src/useMutation.ts @@ -289,7 +289,7 @@ export function useMutation< () => state.error, (error) => { if ( - error && + state.isError && shouldThrowError(defaultedOptions.value.throwOnError, [error as TError]) ) { throw error From f8b63cb79eb8aa2423151a05e428ce4bc3583334 Mon Sep 17 00:00:00 2001 From: S-jooyoung Date: Thu, 3 Sep 2026 15:19:08 +0900 Subject: [PATCH 2/2] fix(vue-query): also watch isError so null rejections evaluate throwOnError A mutation that rejects with `null` leaves `state.error` at its initial `null`, so a watcher on `state.error` alone never fires. Watch `state.isError` together with `state.error`, and cover both `undefined` and `null` rejections in the tests. --- .../src/__tests__/useMutation.test.ts | 28 +++++++++++++------ packages/vue-query/src/useMutation.ts | 19 ++++++------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/vue-query/src/__tests__/useMutation.test.ts b/packages/vue-query/src/__tests__/useMutation.test.ts index 855b2d63d0..f29556bcc7 100644 --- a/packages/vue-query/src/__tests__/useMutation.test.ts +++ b/packages/vue-query/src/__tests__/useMutation.test.ts @@ -451,8 +451,11 @@ describe('useMutation', () => { expect(throwOnErrorFn).toHaveBeenCalledWith(Error('Some error')) }) - it('should evaluate throwOnError for a falsy error', async () => { - const throwOnErrorFn = vi.fn().mockReturnValue(true) + // These tests let throwOnError return false on purpose: under vitest, + // throwing a falsy value from the watcher breaks every watcher created + // later in this file, while the throw path is already covered above. + it('should evaluate throwOnError when the mutation rejects with undefined', async () => { + const throwOnErrorFn = vi.fn().mockReturnValue(false) const { mutate } = useMutation({ mutationFn: () => sleep(10).then(() => Promise.reject(undefined)), throwOnError: throwOnErrorFn, @@ -460,17 +463,26 @@ describe('useMutation', () => { mutate() - // 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) - expect(throwOnErrorFn).toHaveBeenCalledTimes(1) expect(throwOnErrorFn).toHaveBeenCalledWith(undefined) }) + + it('should evaluate throwOnError when the mutation rejects with null', async () => { + const throwOnErrorFn = vi.fn().mockReturnValue(false) + const { mutate } = useMutation({ + mutationFn: () => sleep(10).then(() => Promise.reject(null)), + throwOnError: throwOnErrorFn, + }) + + mutate() + + await vi.advanceTimersByTimeAsync(10) + + expect(throwOnErrorFn).toHaveBeenCalledTimes(1) + expect(throwOnErrorFn).toHaveBeenCalledWith(null) + }) }) describe('optimistic updates', () => { diff --git a/packages/vue-query/src/useMutation.ts b/packages/vue-query/src/useMutation.ts index b946b774ca..b22d7459af 100644 --- a/packages/vue-query/src/useMutation.ts +++ b/packages/vue-query/src/useMutation.ts @@ -285,17 +285,14 @@ export function useMutation< Readonly> > - watch( - () => state.error, - (error) => { - if ( - state.isError && - shouldThrowError(defaultedOptions.value.throwOnError, [error as TError]) - ) { - throw error - } - }, - ) + watch([() => state.isError, () => state.error], ([isError, error]) => { + if ( + isError && + shouldThrowError(defaultedOptions.value.throwOnError, [error as TError]) + ) { + throw error + } + }) return { ...resultRefs,