From 0ef087e1b1733fd16a0ca61aa1c207c20218aa19 Mon Sep 17 00:00:00 2001 From: rome-xi Date: Wed, 9 Sep 2026 08:25:45 +0000 Subject: [PATCH] fix(query-core): keep nested mutate callbacks scoped to the settling mutation Signed-off-by: rome-xi --- .changeset/nested-mutate-onsettled.md | 5 + .../src/__tests__/mutationObserver.test.tsx | 270 ++++++++++++++++++ packages/query-core/src/mutationObserver.ts | 14 +- .../src/__tests__/useMutation.test.tsx | 190 ++++++++++++ 4 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 .changeset/nested-mutate-onsettled.md diff --git a/.changeset/nested-mutate-onsettled.md b/.changeset/nested-mutate-onsettled.md new file mode 100644 index 00000000000..7dbe4d2e965 --- /dev/null +++ b/.changeset/nested-mutate-onsettled.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Fix per-call mutate `onSettled` receiving another mutation's result when `mutate()` is called from `onSuccess` or `onError`. diff --git a/packages/query-core/src/__tests__/mutationObserver.test.tsx b/packages/query-core/src/__tests__/mutationObserver.test.tsx index 759734a0a6c..f475ae75ee9 100644 --- a/packages/query-core/src/__tests__/mutationObserver.test.tsx +++ b/packages/query-core/src/__tests__/mutationObserver.test.tsx @@ -426,6 +426,276 @@ describe('mutationObserver', () => { unsubscribe() }) + describe('nested mutate from per-call callbacks', () => { + it('should call the outer mutate onSettled with the outer result when nested mutate starts from onSuccess', async () => { + const outerOnSettled = vi.fn() + const innerOnSettled = vi.fn() + + const mutationObserver = new MutationObserver(queryClient, { + mutationFn: (text: string) => Promise.resolve(text), + }) + const unsubscribe = mutationObserver.subscribe(vi.fn()) + + mutationObserver.mutate('first', { + onSuccess: () => { + mutationObserver.mutate('second', { + onSettled: innerOnSettled, + }) + }, + onSettled: outerOnSettled, + }) + + await vi.advanceTimersByTimeAsync(0) + + expect(outerOnSettled).toHaveBeenCalledTimes(1) + expect(outerOnSettled).toHaveBeenCalledWith( + 'first', + null, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(innerOnSettled).toHaveBeenCalledTimes(1) + expect(innerOnSettled).toHaveBeenCalledWith( + 'second', + null, + 'second', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(innerOnSettled).not.toHaveBeenCalledWith( + 'first', + null, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + + unsubscribe() + }) + + it('should not invoke the inner mutate onSettled with the outer result while the inner mutation is still pending', async () => { + let resolveSecond: (value: string) => void + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve + }) + const outerOnSettled = vi.fn() + const innerOnSettled = vi.fn() + + const mutationObserver = new MutationObserver(queryClient, { + mutationFn: (text: string) => + text === 'first' ? Promise.resolve(text) : secondPromise, + }) + const unsubscribe = mutationObserver.subscribe(vi.fn()) + + mutationObserver.mutate('first', { + onSuccess: () => { + mutationObserver.mutate('second', { + onSettled: innerOnSettled, + }) + }, + onSettled: outerOnSettled, + }) + + await vi.advanceTimersByTimeAsync(0) + + expect(outerOnSettled).toHaveBeenCalledTimes(1) + expect(outerOnSettled).toHaveBeenCalledWith( + 'first', + null, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(innerOnSettled).not.toHaveBeenCalled() + + resolveSecond!('second') + await vi.advanceTimersByTimeAsync(0) + + expect(innerOnSettled).toHaveBeenCalledTimes(1) + expect(innerOnSettled).toHaveBeenCalledWith( + 'second', + null, + 'second', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + + unsubscribe() + }) + + it('should call the outer mutate onSettled with the outer error when nested mutate starts from onError', async () => { + const outerError = new Error('fail-first') + const outerOnSettled = vi.fn() + const innerOnSettled = vi.fn() + + const mutationObserver = new MutationObserver(queryClient, { + mutationFn: (text: string) => + text === 'first' ? Promise.reject(outerError) : Promise.resolve(text), + }) + const unsubscribe = mutationObserver.subscribe(vi.fn()) + + mutationObserver + .mutate('first', { + onError: () => { + mutationObserver.mutate('second', { + onSettled: innerOnSettled, + }) + }, + onSettled: outerOnSettled, + }) + .catch(() => {}) + + await vi.advanceTimersByTimeAsync(0) + + expect(outerOnSettled).toHaveBeenCalledTimes(1) + expect(outerOnSettled).toHaveBeenCalledWith( + undefined, + outerError, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(innerOnSettled).toHaveBeenCalledTimes(1) + expect(innerOnSettled).toHaveBeenCalledWith( + 'second', + null, + 'second', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(innerOnSettled).not.toHaveBeenCalledWith( + undefined, + outerError, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + + unsubscribe() + }) + + it('should keep the outer onMutate result on the outer mutate onSettled when nested mutate starts from onSuccess', async () => { + const outerOnSettled = vi.fn() + const innerOnSettled = vi.fn() + + const mutationObserver = new MutationObserver(queryClient, { + mutationFn: (text: string) => Promise.resolve(text), + onMutate: (text: string) => ({ label: text }), + }) + const unsubscribe = mutationObserver.subscribe(vi.fn()) + + mutationObserver.mutate('first', { + onSuccess: () => { + mutationObserver.mutate('second', { + onSettled: innerOnSettled, + }) + }, + onSettled: outerOnSettled, + }) + + await vi.advanceTimersByTimeAsync(0) + + expect(outerOnSettled).toHaveBeenCalledWith( + 'first', + null, + 'first', + { label: 'first' }, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(innerOnSettled).toHaveBeenCalledWith( + 'second', + null, + 'second', + { label: 'second' }, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + + unsubscribe() + }) + + it('should not throw when nested mutate is called without options from onSuccess', async ({ + onTestFinished, + }) => { + const unhandledRejectionFn = vi.fn() + process.on('unhandledRejection', unhandledRejectionFn) + onTestFinished(() => { + process.off('unhandledRejection', unhandledRejectionFn) + }) + + const outerOnSettled = vi.fn() + const mutationObserver = new MutationObserver(queryClient, { + mutationFn: (text: string) => Promise.resolve(text), + }) + const unsubscribe = mutationObserver.subscribe(vi.fn()) + + mutationObserver.mutate('first', { + onSuccess: () => { + mutationObserver.mutate('second') + }, + onSettled: outerOnSettled, + }) + + await vi.advanceTimersByTimeAsync(0) + + expect(unhandledRejectionFn).not.toHaveBeenCalled() + expect(outerOnSettled).toHaveBeenCalledTimes(1) + expect(outerOnSettled).toHaveBeenCalledWith( + 'first', + null, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + + unsubscribe() + }) + }) + describe('erroneous mutation callback', () => { it('onSuccess and onSettled is transferred to different execution context where it is reported', async ({ onTestFinished, diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index b91157abd0a..59ccd3ea1cf 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -240,7 +240,11 @@ export class MutationObserver< #notify(action?: Action): void { notifyManager.batch(() => { // First trigger the mutate callbacks - if (this.#mutateOptions && this.hasListeners()) { + // Snapshot this settlement's per-call options and result fields. A nested + // mutate() from onSuccess/onError overwrites #mutateOptions (and may + // update #currentResult) before onSettled runs. + const mutateOptions = this.#mutateOptions + if (mutateOptions && this.hasListeners()) { const variables = this.#currentResult.variables! const onMutateResult = this.#currentResult.context @@ -252,7 +256,7 @@ export class MutationObserver< if (action?.type === 'success') { try { - this.#mutateOptions.onSuccess?.( + mutateOptions.onSuccess?.( action.data, variables, onMutateResult, @@ -262,7 +266,7 @@ export class MutationObserver< void Promise.reject(e) } try { - this.#mutateOptions.onSettled?.( + mutateOptions.onSettled?.( action.data, null, variables, @@ -274,7 +278,7 @@ export class MutationObserver< } } else if (action?.type === 'error') { try { - this.#mutateOptions.onError?.( + mutateOptions.onError?.( action.error, variables, onMutateResult, @@ -284,7 +288,7 @@ export class MutationObserver< void Promise.reject(e) } try { - this.#mutateOptions.onSettled?.( + mutateOptions.onSettled?.( undefined, action.error, variables, diff --git a/packages/react-query/src/__tests__/useMutation.test.tsx b/packages/react-query/src/__tests__/useMutation.test.tsx index e885b8e244c..16fabf94920 100644 --- a/packages/react-query/src/__tests__/useMutation.test.tsx +++ b/packages/react-query/src/__tests__/useMutation.test.tsx @@ -2576,4 +2576,194 @@ describe('useMutation', () => { expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true) }) + + it('should call the outer mutate onSettled with the outer result when nested mutate starts from onSuccess', async () => { + const outerOnSettled = vi.fn() + const innerOnSettled = vi.fn() + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(10) + + expect(outerOnSettled).toHaveBeenCalledTimes(1) + expect(outerOnSettled).toHaveBeenCalledWith( + 'first', + null, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(innerOnSettled).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(10) + + expect(innerOnSettled).toHaveBeenCalledTimes(1) + expect(innerOnSettled).toHaveBeenCalledWith( + 'second', + null, + 'second', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + }) + + it('should call the outer mutate onSettled with the outer error when nested mutate starts from onError', async () => { + const outerError = new Error('fail-first') + const outerOnSettled = vi.fn() + const innerOnSettled = vi.fn() + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => + sleep(10).then(() => { + if (text === 'first') { + throw outerError + } + return text + }), + retry: false, + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(10) + + expect(outerOnSettled).toHaveBeenCalledTimes(1) + expect(outerOnSettled).toHaveBeenCalledWith( + undefined, + outerError, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + expect(innerOnSettled).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(10) + + expect(innerOnSettled).toHaveBeenCalledTimes(1) + expect(innerOnSettled).toHaveBeenCalledWith( + 'second', + null, + 'second', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + }) + + it('should not throw when nested mutate is called without options from onSuccess', async ({ + onTestFinished, + }) => { + const unhandledRejectionFn = vi.fn() + process.on('unhandledRejection', unhandledRejectionFn) + onTestFinished(() => { + process.off('unhandledRejection', unhandledRejectionFn) + }) + + const outerOnSettled = vi.fn() + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text), + }) + + return ( + + ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + + await vi.advanceTimersByTimeAsync(10) + + expect(unhandledRejectionFn).not.toHaveBeenCalled() + expect(outerOnSettled).toHaveBeenCalledTimes(1) + expect(outerOnSettled).toHaveBeenCalledWith( + 'first', + null, + 'first', + undefined, + { + client: queryClient, + meta: undefined, + mutationKey: undefined, + }, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(unhandledRejectionFn).not.toHaveBeenCalled() + }) })