Repository navigation
useMutation invokes the next mutation's onSettled with the previous result #11451
Copy link
Copy link
Open
Description
Activity
This diagnosis is spot-on. The bug is caused by reading
this.#mutateOptionsdynamically across synchronous callback execution rather than snapshotting a local reference.Root Cause Walkthrough
In
packages/query-core/src/mutationObserver.tsinside#notify:// Currently in mutationObserver.ts: if (this.#mutateOptions) { if (action.type === 'success') { this.#mutateOptions.onSuccess?.(action.data, action.variables, action.context) this.#mutateOptions.onSettled?.(action.data, null, action.variables, action.context) } else if (action.type === 'error') { this.#mutateOptions.onError?.(action.error, action.variables, action.context) this.#mutateOptions.onSettled?.(undefined, action.error, action.variables, action.context) } }
When mutation 1 completes:
#notifychecksif (this.#mutateOptions)(evaluates to true) and executesthis.#mutateOptions.onSuccess(...).- Inside
onSuccess, user code callsmutation.mutate(2, mutateOptions2). MutationObserver.mutate()synchronously re-assigns the instance field:this.#mutateOptions = mutateOptions // now mutateOptions2 (or undefined if omitted)
- Execution returns from
onSuccessback into#notify. - The very next line evaluates
this.#mutateOptions.onSettled(...):- If the second call passed options,
this.#mutateOptionsnow points tomutateOptions2, somutateOptions2.onSettledis eagerly called with mutation 1'saction.dataandaction.variables. - If the second call omitted options (
mutation.mutate(2)),this.#mutateOptionswas set toundefined, immediately throwing:TypeError: Cannot read properties of undefined (reading 'onSettled')
- If the second call passed options,
Fix
Snapshot
this.#mutateOptionsto a local variable before executing callbacks so re-entrant calls cannot mutate the active dispatch reference:--- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -240,11 +240,12 @@ export class MutationObserver< if (this.#mutateOptions) { + const mutateOptions = this.#mutateOptions if (action.type === 'success') { - this.#mutateOptions.onSuccess?.(action.data, action.variables, action.context) - this.#mutateOptions.onSettled?.(action.data, null, action.variables, action.context) + mutateOptions.onSuccess?.(action.data, action.variables, action.context) + mutateOptions.onSettled?.(action.data, null, action.variables, action.context) } else if (action.type === 'error') { - this.#mutateOptions.onError?.(action.error, action.variables, action.context) - this.#mutateOptions.onSettled?.(undefined, action.error, action.variables, action.context) + mutateOptions.onError?.(action.error, action.variables, action.context) + mutateOptions.onSettled?.(undefined, action.error, action.variables, action.context) } }
Proposed Unit Test
A test for
packages/query-core/src/tests/mutationObserver.test.tsxverifying both re-entrant settling isolation and omitted options:test('re-entrant mutate() inside onSuccess does not leak or trigger next onSettled early', async () => { const queryClient = createQueryClient() const observer = new MutationObserver(queryClient, { mutationFn: async (val: number) => `result-${val}`, }) const calls: Array<{ stage: string; data?: string; val: number }> = [] observer.mutate(1, { onSuccess: () => { observer.mutate(2, { onSettled: (data) => { calls.push({ stage: 'second-settled', data: data as string, val: 2 }) }, }) }, onSettled: (data) => { calls.push({ stage: 'first-settled', data: data as string, val: 1 }) }, }) await waitFor(() => expect(calls).toHaveLength(2)) expect(calls).toEqual([ { stage: 'first-settled', data: 'result-1', val: 1 }, { stage: 'second-settled', data: 'result-2', val: 2 }, ]) })
Metadata
Metadata
Assignees
Labels
No labels
Describe the bug
When a per-call
onSuccessoronErrorcallback starts another mutation using the sameuseMutationinstance, the second mutation'sonSettledcallback receives the first mutation's result and variables. It runs again with its own result when the second mutation finishes.The reproduction logs only the callback registered for the second mutation:
[ { "data": "result-1", "error": null, "variables": 1 }, { "data": "result-2", "error": null, "variables": 2 } ]Your minimal, reproducible example
https://yjdm6m.csb.app/ https://codesandbox.io/s/yjdm6m
Steps to reproduce
1. Start second mutation in onSuccess.result-1/ variables1andresult-2/ variables2.2. Start second mutation in onError. The second mutation's callback similarly receives the first mutation's error and variables before receiving its own successful result.3. Sequential calls outside callbacks. This correctly logs only the second mutation's result once.Expected behavior
The second mutation's
onSettledcallback should run only after that mutation settles, with its own data, error, and variables:[ { "data": "result-2", "error": null, "variables": 2 } ]This is not a request to invoke per-call callbacks for every consecutive mutation. The issue is that the latest mutation's callback receives a different mutation's result.
How often does this bug happen?
Every time
Screenshots or Videos
No response
Platform
Tanstack Query adapter
react-query
TanStack Query version
@tanstack/react-query@5.102.8and@tanstack/query-core@5.102.8TypeScript version
No response
Additional context
In additional local checks, the second mutation was held pending using a manually resolved Promise. Its
onSettledcallback still received the first mutation's result before the second mutation finished.Omitting the second call's optional options argument also produces an internal TypeError, even though the user callback does not throw:
In
MutationObserver.#notify,this.#mutateOptions.onSettledis read after invokingonSuccessoronError. The nestedmutatecall replaces#mutateOptions, while the action's result and variables still belong to the first mutation.The pending-Promise and omitted-options cases were checked separately locally; the shared sandbox contains the three button scenarios described above.
Codex assisted with preparing and verifying the reproduction.