From cd7697e933441e427fddcb15e9402bf1ba32bffe Mon Sep 17 00:00:00 2001 From: gujishh Date: Tue, 18 Aug 2026 15:40:35 +0900 Subject: [PATCH] fix(query-core): release the mutation retryer once execute settles Mirror the Query.fetch() cleanup from #11163 so a settled Mutation no longer retains its Retryer (and closed-over variables/result) for the MutationCache lifetime. Fixes #11216 --- .../release-mutation-settled-retryer.md | 5 ++++ .../src/__tests__/mutations.test.tsx | 24 +++++++++++++++++++ packages/query-core/src/mutation.ts | 13 ++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .changeset/release-mutation-settled-retryer.md diff --git a/.changeset/release-mutation-settled-retryer.md b/.changeset/release-mutation-settled-retryer.md new file mode 100644 index 00000000000..328e9aa60d7 --- /dev/null +++ b/.changeset/release-mutation-settled-retryer.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Release a mutation's retryer once execute settles, so the settled promise no longer keeps that mutation's variables and result reachable for the MutationCache lifetime. diff --git a/packages/query-core/src/__tests__/mutations.test.tsx b/packages/query-core/src/__tests__/mutations.test.tsx index 506f404fbf2..22b2df2ab95 100644 --- a/packages/query-core/src/__tests__/mutations.test.tsx +++ b/packages/query-core/src/__tests__/mutations.test.tsx @@ -1166,6 +1166,30 @@ describe('mutations', () => { }) }) + it('should release the retryer once its mutation has settled', async () => { + let count = 0 + const observer = new MutationObserver(queryClient, { + mutationFn: () => { + count += 1 + return sleep(10).then(() => 'data') + }, + }) + + const mutatePromise = observer.mutate() + await vi.advanceTimersByTimeAsync(10) + await expect(mutatePromise).resolves.toBe('data') + expect(count).toBe(1) + + const mutation = queryClient.getMutationCache().getAll()[0]! + // With the retryer cleared, continue() falls through to a fresh execute(). + // If the settled retryer were retained, continue() would return it and skip + // the mutation function. + const continued = mutation.continue() + await vi.advanceTimersByTimeAsync(10) + await expect(continued).resolves.toBe('data') + expect(count).toBe(2) + }) + it('should not remove mutation when one observer is removed but another still exists', async () => { const observer1 = new MutationObserver(queryClient, { gcTime: 10, diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index 2483b563366..ffc1b62dca4 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -181,7 +181,7 @@ export class Mutation< mutationKey: this.options.mutationKey, } satisfies MutationFunctionContext - this.#retryer = createRetryer({ + const retryer = (this.#retryer = createRetryer({ fn: () => { if (!this.options.mutationFn) { return Promise.reject(new Error('No mutationFn found')) @@ -200,10 +200,10 @@ export class Mutation< retryDelay: this.options.retryDelay, networkMode: this.options.networkMode, canRun: () => this.#mutationCache.canRun(this), - }) + })) const restored = this.state.status === 'pending' - const isPaused = !this.#retryer.canStart() + const isPaused = !retryer.canStart() try { if (restored) { @@ -232,7 +232,7 @@ export class Mutation< }) } } - const data = await this.#retryer.start() + const data = await retryer.start() // Notify cache callback await this.#mutationCache.config.onSuccess?.( @@ -324,6 +324,11 @@ export class Mutation< this.#dispatch({ type: 'error', error: error as TError }) throw error } finally { + // The settled retryer's promise would otherwise pin this mutation's + // variables/result for as long as MutationCache retains the instance. + if (this.#retryer === retryer) { + this.#retryer = undefined + } this.#mutationCache.runNext(this) } }