Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/release-mutation-retryer.md
Original file line numberDiff line numberDiff line change
@@ -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.
46 changes: 46 additions & 0 deletions packages/query-core/src/__tests__/mutations.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<unknown> | 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()
})
})
21 changes: 17 additions & 4 deletions packages/query-core/src/mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<TData> | undefined {
return this.#retryer?.promise
}

get meta(): MutationMeta | undefined {
return this.options.meta
}
Expand DownExpand Up@@ -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'))
Expand All@@ -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) {
Expand DownExpand Up@@ -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?.(
Expand DownExpand Up@@ -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)
}
}
Expand Down