Skip to content

Commit 5981771

Browse files
fix(query-core): release the retryer once a mutation settles (#11218)
* fix(query-core): release the retryer once a mutation settles Mutation.execute() never cleared #retryer after settling, so the settled retryer's promise kept that mutation's result, variables and context reachable for as long as the MutationCache retained the Mutation. Mirror the treatment Query.fetch() received in #11163, guarded by an identity check so a mutation re-executed from a cache callback keeps its own retryer. Releasing the retryer alone would change continue(): a settled mutation no longer has a retryer to continue, so it would fall through to execute() and run the mutationFn a second time. Both internal callers filter on state.isPaused and never reach a settled mutation, but continue() is reachable directly, so the fallback is now gated on the mutation still being pending -- the same condition execute() already uses to detect a restored mutation. Fixes #11216 * test(query-core): assert the settled retryer is actually released The previous tests only guarded the continue() gating and passed on main too, so they did not demonstrate the fix. continue() is the only reader of #retryer outside execute(): while a settled retryer is still held it hands back that retryer's promise, which resolves with the raw result it closed over (or rejects with its error). Both new tests fail on main and pass with the retryer released. --------- Co-authored-by: Dominik Dorfmeister 🔮 <office@dorfmeister.cc>
1 parent 1f631b3 commit 5981771

3 files changed

Lines changed: 96 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/query-core': patch
3+
---
4+
5+
Release a mutation's retryer once its execution settles, so the settled promise no longer keeps that mutation's result, variables and context in memory for as long as the mutation cache retains it.

packages/query-core/src/__tests__/mutations.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,4 +1191,81 @@ describe('mutations', () => {
11911191

11921192
expect(queryClient.getMutationCache().getAll()).toHaveLength(1)
11931193
})
1194+
1195+
it('should release the retryer once a mutation settles', async () => {
1196+
const observer = new MutationObserver(queryClient, {
1197+
mutationFn: (text: string) => sleep(10).then(() => text),
1198+
})
1199+
1200+
observer.mutate('data')
1201+
await vi.advanceTimersByTimeAsync(10)
1202+
1203+
const mutation = queryClient.getMutationCache().getAll()[0]!
1204+
expect(mutation.state.status).toBe('success')
1205+
1206+
// continue() is the only reader of the retryer outside execute(): while a
1207+
// settled retryer is still referenced it hands back that retryer's promise,
1208+
// which resolves with the raw result it closed over
1209+
await expect(mutation.continue()).resolves.toBeUndefined()
1210+
})
1211+
1212+
it('should release the retryer of a mutation that settled with an error', async () => {
1213+
const observer = new MutationObserver(queryClient, {
1214+
mutationFn: () => sleep(10).then(() => Promise.reject(new Error('oops'))),
1215+
})
1216+
1217+
observer.mutate(undefined).catch(() => undefined)
1218+
await vi.advanceTimersByTimeAsync(10)
1219+
1220+
const mutation = queryClient.getMutationCache().getAll()[0]!
1221+
expect(mutation.state.status).toBe('error')
1222+
1223+
// a retained retryer would hand back its rejected promise here
1224+
await expect(mutation.continue()).resolves.toBeUndefined()
1225+
})
1226+
1227+
it('should not re-execute a settled mutation when it is continued', async () => {
1228+
const mutationFn = vi.fn(() => sleep(10).then(() => 'data'))
1229+
const observer = new MutationObserver(queryClient, { mutationFn })
1230+
1231+
observer.mutate()
1232+
await vi.advanceTimersByTimeAsync(10)
1233+
1234+
const mutation = queryClient.getMutationCache().getAll()[0]!
1235+
expect(mutation.state.status).toBe('success')
1236+
expect(mutationFn).toHaveBeenCalledTimes(1)
1237+
1238+
await mutation.continue()
1239+
await vi.advanceTimersByTimeAsync(10)
1240+
1241+
expect(mutationFn).toHaveBeenCalledTimes(1)
1242+
expect(mutation.state.status).toBe('success')
1243+
})
1244+
1245+
it('should still continue a restored paused mutation that has no retryer', async () => {
1246+
const mutationFn = vi.fn(() => sleep(10).then(() => 'data'))
1247+
// a mutation restored from a dehydrated pending state has no retryer yet
1248+
const mutation = queryClient.getMutationCache().build(
1249+
queryClient,
1250+
{ mutationFn },
1251+
{
1252+
context: undefined,
1253+
data: undefined,
1254+
error: null,
1255+
failureCount: 0,
1256+
failureReason: null,
1257+
isPaused: true,
1258+
status: 'pending',
1259+
variables: undefined,
1260+
submittedAt: Date.now(),
1261+
},
1262+
)
1263+
1264+
const continued = mutation.continue()
1265+
await vi.advanceTimersByTimeAsync(10)
1266+
await continued
1267+
1268+
expect(mutationFn).toHaveBeenCalledTimes(1)
1269+
expect(mutation.state.status).toBe('success')
1270+
})
11941271
})

packages/query-core/src/mutation.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,11 @@ export class Mutation<
165165
continue(): Promise<unknown> {
166166
return (
167167
this.#retryer?.continue() ??
168-
// continuing a mutation assumes that variables are set, mutation must have been dehydrated before
169-
this.execute(this.state.variables!)
168+
// continuing a mutation assumes that variables are set, mutation must have been dehydrated before.
169+
// a settled mutation has no retryer to continue and must not run again
170+
(this.state.status === 'pending'
171+
? this.execute(this.state.variables!)
172+
: Promise.resolve())
170173
)
171174
}
172175

@@ -181,7 +184,7 @@ export class Mutation<
181184
mutationKey: this.options.mutationKey,
182185
} satisfies MutationFunctionContext
183186

184-
this.#retryer = createRetryer({
187+
const retryer = (this.#retryer = createRetryer({
185188
fn: () => {
186189
if (!this.options.mutationFn) {
187190
return Promise.reject(new Error('No mutationFn found'))
@@ -200,10 +203,10 @@ export class Mutation<
200203
retryDelay: this.options.retryDelay,
201204
networkMode: this.options.networkMode,
202205
canRun: () => this.#mutationCache.canRun(this),
203-
})
206+
}))
204207

205208
const restored = this.state.status === 'pending'
206-
const isPaused = !this.#retryer.canStart()
209+
const isPaused = !retryer.canStart()
207210

208211
try {
209212
if (restored) {
@@ -232,7 +235,7 @@ export class Mutation<
232235
})
233236
}
234237
}
235-
const data = await this.#retryer.start()
238+
const data = await retryer.start()
236239

237240
// Notify cache callback
238241
await this.#mutationCache.config.onSuccess?.(
@@ -324,6 +327,11 @@ export class Mutation<
324327
this.#dispatch({ type: 'error', error: error as TError })
325328
throw error
326329
} finally {
330+
// The settled retryer's promise would otherwise pin this mutation's
331+
// result, variables and context for as long as the cache keeps it
332+
if (this.#retryer === retryer) {
333+
this.#retryer = undefined
334+
}
327335
this.#mutationCache.runNext(this)
328336
}
329337
}

0 commit comments

Comments
 (0)