diff --git a/.changeset/release-mutation-settled-retryer.md b/.changeset/release-mutation-settled-retryer.md new file mode 100644 index 0000000000..328e9aa60d --- /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 506f404fbf..22b2df2ab9 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 2483b56336..ffc1b62dca 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) } }