From 38cfb5b4d36c90f11cc24f1b59c551b75eed23fc Mon Sep 17 00:00:00 2001 From: okxint Date: Tue, 18 Aug 2026 14:27:43 +0530 Subject: [PATCH] fix(query-core): release mutation retryer after execute() settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutation.execute() left #retryer set after the mutation settled. The retryer's resolved promise kept the raw mutation result in memory for the mutation's entire lifetime — a second copy alongside the structurally-shared state.data. The same bug existed in Query.fetch() and was fixed in #11163. The fix mirrors that change exactly: - Capture the retryer in a local variable before the try block - In the finally, null #retryer when it still points to this retryer (the identity check is safe against a re-entrant execute() call installing a fresh retryer from a cache onSuccess callback) - Expose `get promise()` on Mutation to match Query, enabling a parallel regression test Co-Authored-By: Claude Sonnet 4.6 --- .changeset/release-mutation-retryer.md | 5 ++ .../src/__tests__/mutations.test.tsx | 46 +++++++++++++++++++ packages/query-core/src/mutation.ts | 21 +++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 .changeset/release-mutation-retryer.md diff --git a/.changeset/release-mutation-retryer.md b/.changeset/release-mutation-retryer.md new file mode 100644 index 00000000000..e083c0f734b --- /dev/null +++ b/.changeset/release-mutation-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 the raw mutation result in memory alongside `state.data` for the mutation's lifetime. Mirrors the same fix applied to `Query.fetch()` in #11163. diff --git a/packages/query-core/src/__tests__/mutations.test.tsx b/packages/query-core/src/__tests__/mutations.test.tsx index 506f404fbf2..96c32235c6e 100644 --- a/packages/query-core/src/__tests__/mutations.test.tsx +++ b/packages/query-core/src/__tests__/mutations.test.tsx @@ -1191,4 +1191,50 @@ describe('mutations', () => { expect(queryClient.getMutationCache().getAll()).toHaveLength(1) }) + + it('should release the retryer once execute() has settled', async () => { + // Regression: Mutation.execute() left #retryer set after settling, so the + // retryer's resolved promise kept the raw mutation result in memory for the + // mutation's lifetime — a second copy alongside state.data. Mirrors the fix + // applied to Query.fetch() in #11163. + let secondExecute: Promise | undefined + const testCache = new MutationCache({ + onSuccess: (_data, _variables, _context, mutation) => { + // On the first success, kick off a second execute(). + // The identity guard (this.#retryer === retryer) must keep the NEW + // retryer alive while clearing the OLD one. + secondExecute ??= mutation.execute(undefined as any) + }, + }) + const testClient = new QueryClient({ mutationCache: testCache }) + + const observer = new MutationObserver(testClient, { + mutationFn: () => sleep(10).then(() => 'data'), + }) + + const firstExecutePromise = observer.mutate() + const mutation = testCache.getAll()[0]! + + // While the first execute is in-flight the promise must be defined. + expect(mutation.promise).toBeDefined() + const firstPromise = mutation.promise + + // Let the first execute settle and the cache onSuccess callback fire, + // which starts the second execute synchronously. + await vi.advanceTimersByTimeAsync(10) + await firstExecutePromise.catch(() => undefined) + + // A new retryer was installed by the second execute; its promise is a + // different object from the first one. + expect(mutation.promise).toBeDefined() + expect(mutation.promise).not.toBe(firstPromise) + + // Let the second execute settle. + await vi.advanceTimersByTimeAsync(10) + await secondExecute + + // After everything has settled the retryer must be released. + expect(mutation.state.data).toBe('data') + expect(mutation.promise).toBeUndefined() + }) }) diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index 2483b563366..dc3acc0ead4 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -121,6 +121,11 @@ export class Mutation< this.updateGcTime(this.options.gcTime) } + /** The active retryer's promise, or `undefined` once `execute()` has settled. */ + get promise(): Promise | undefined { + return this.#retryer?.promise + } + get meta(): MutationMeta | undefined { return this.options.meta } @@ -181,7 +186,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 +205,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 +237,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 +329,14 @@ 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 raw + // result for the mutation's lifetime. Clear it once execute() settles, + // mirroring the same fix applied to Query.fetch() in #11163. + // The identity check guards against a re-entrant execute() call (e.g. from + // a cache onSuccess callback) having already installed a new retryer. + if (this.#retryer === retryer) { + this.#retryer = undefined + } this.#mutationCache.runNext(this) } }