Skip to content

Commit 1ef2958

Browse files
authored
fix(core): make sure pausedMutations always execute in serial (#4906)
* fix: make sure pausedMutations always execute in serial when resumePausedMutations is called multiple times, we are starting the second paused mutation even though the first one has not yet finished. This can happen when a focus event and an online event happen in short succession * fix: always return the same Promise when resumePausedMutations is called * fix: make sure every call to resumePausedMutations is evaluated mutation.continue() needs to immediately resolve back if we didn't really continue so that consecutive calls to resumePausedMutations can be appended to the promise chain * fix: types
1 parent 02852dd commit 1ef2958

File tree

5 files changed

+106
-22
lines changed

5 files changed

+106
-22
lines changed

packages/query-core/src/mutation.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,8 @@ export class Mutation<
159159
}
160160
}
161161

162-
continue(): Promise<TData> {
163-
if (this.retryer) {
164-
this.retryer.continue()
165-
return this.retryer.promise
166-
}
167-
return this.execute()
162+
continue(): Promise<unknown> {
163+
return this.retryer?.continue() ?? this.execute()
168164
}
169165

170166
async execute(): Promise<TData> {

packages/query-core/src/mutationCache.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export class MutationCache extends Subscribable<MutationCacheListener> {
7979

8080
private mutations: Mutation<any, any, any, any>[]
8181
private mutationId: number
82+
private resuming: Promise<unknown> | undefined
8283

8384
constructor(config?: MutationCacheConfig) {
8485
super()
@@ -152,14 +153,22 @@ export class MutationCache extends Subscribable<MutationCacheListener> {
152153
})
153154
}
154155

155-
resumePausedMutations(): Promise<void> {
156-
const pausedMutations = this.mutations.filter((x) => x.state.isPaused)
157-
return notifyManager.batch(() =>
158-
pausedMutations.reduce(
159-
(promise, mutation) =>
160-
promise.then(() => mutation.continue().catch(noop)),
161-
Promise.resolve(),
162-
),
163-
)
156+
resumePausedMutations(): Promise<unknown> {
157+
this.resuming = (this.resuming ?? Promise.resolve())
158+
.then(() => {
159+
const pausedMutations = this.mutations.filter((x) => x.state.isPaused)
160+
return notifyManager.batch(() =>
161+
pausedMutations.reduce(
162+
(promise, mutation) =>
163+
promise.then(() => mutation.continue().catch(noop)),
164+
Promise.resolve() as Promise<unknown>,
165+
),
166+
)
167+
})
168+
.then(() => {
169+
this.resuming = undefined
170+
})
171+
172+
return this.resuming
164173
}
165174
}

packages/query-core/src/queryClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,7 @@ export class QueryClient {
594594
.catch(noop)
595595
}
596596

597-
resumePausedMutations(): Promise<void> {
597+
resumePausedMutations(): Promise<unknown> {
598598
return this.mutationCache.resumePausedMutations()
599599
}
600600

packages/query-core/src/retryer.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface RetryerConfig<TData = unknown, TError = unknown> {
2121
export interface Retryer<TData = unknown> {
2222
promise: Promise<TData>
2323
cancel: (cancelOptions?: CancelOptions) => void
24-
continue: () => void
24+
continue: () => Promise<unknown>
2525
cancelRetry: () => void
2626
continueRetry: () => void
2727
}
@@ -69,7 +69,7 @@ export function createRetryer<TData = unknown, TError = unknown>(
6969
let isRetryCancelled = false
7070
let failureCount = 0
7171
let isResolved = false
72-
let continueFn: ((value?: unknown) => void) | undefined
72+
let continueFn: ((value?: unknown) => boolean) | undefined
7373
let promiseResolve: (data: TData) => void
7474
let promiseReject: (error: TError) => void
7575

@@ -118,9 +118,11 @@ export function createRetryer<TData = unknown, TError = unknown>(
118118
const pause = () => {
119119
return new Promise((continueResolve) => {
120120
continueFn = (value) => {
121-
if (isResolved || !shouldPause()) {
122-
return continueResolve(value)
121+
const canContinue = isResolved || !shouldPause()
122+
if (canContinue) {
123+
continueResolve(value)
123124
}
125+
return canContinue
124126
}
125127
config.onPause?.()
126128
}).then(() => {
@@ -208,7 +210,8 @@ export function createRetryer<TData = unknown, TError = unknown>(
208210
promise,
209211
cancel,
210212
continue: () => {
211-
continueFn?.()
213+
const didContinue = continueFn?.()
214+
return didContinue ? promise : Promise.resolve()
212215
},
213216
cancelRetry,
214217
continueRetry,

packages/query-core/src/tests/queryClient.test.tsx

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import type {
88
QueryFunction,
99
QueryObserverOptions,
1010
} from '..'
11-
import { InfiniteQueryObserver, QueryObserver } from '..'
11+
import { InfiniteQueryObserver, MutationObserver, QueryObserver } from '..'
1212
import { focusManager, onlineManager } from '..'
13+
import { noop } from '../utils'
1314

1415
describe('queryClient', () => {
1516
let queryClient: QueryClient
@@ -1468,6 +1469,81 @@ describe('queryClient', () => {
14681469
onlineManager.setOnline(undefined)
14691470
})
14701471

1472+
test('should resume paused mutations when coming online', async () => {
1473+
const consoleMock = jest.spyOn(console, 'error')
1474+
consoleMock.mockImplementation(() => undefined)
1475+
onlineManager.setOnline(false)
1476+
1477+
const observer1 = new MutationObserver(queryClient, {
1478+
mutationFn: async () => 1,
1479+
})
1480+
1481+
const observer2 = new MutationObserver(queryClient, {
1482+
mutationFn: async () => 2,
1483+
})
1484+
void observer1.mutate().catch(noop)
1485+
void observer2.mutate().catch(noop)
1486+
1487+
await waitFor(() => {
1488+
expect(observer1.getCurrentResult().isPaused).toBeTruthy()
1489+
expect(observer2.getCurrentResult().isPaused).toBeTruthy()
1490+
})
1491+
1492+
onlineManager.setOnline(true)
1493+
1494+
await waitFor(() => {
1495+
expect(observer1.getCurrentResult().status).toBe('success')
1496+
expect(observer1.getCurrentResult().status).toBe('success')
1497+
})
1498+
1499+
onlineManager.setOnline(undefined)
1500+
})
1501+
1502+
test('should resume paused mutations one after the other when invoked manually at the same time', async () => {
1503+
const consoleMock = jest.spyOn(console, 'error')
1504+
consoleMock.mockImplementation(() => undefined)
1505+
onlineManager.setOnline(false)
1506+
1507+
const orders: Array<string> = []
1508+
1509+
const observer1 = new MutationObserver(queryClient, {
1510+
mutationFn: async () => {
1511+
orders.push('1start')
1512+
await sleep(50)
1513+
orders.push('1end')
1514+
return 1
1515+
},
1516+
})
1517+
1518+
const observer2 = new MutationObserver(queryClient, {
1519+
mutationFn: async () => {
1520+
orders.push('2start')
1521+
await sleep(20)
1522+
orders.push('2end')
1523+
return 2
1524+
},
1525+
})
1526+
void observer1.mutate().catch(noop)
1527+
void observer2.mutate().catch(noop)
1528+
1529+
await waitFor(() => {
1530+
expect(observer1.getCurrentResult().isPaused).toBeTruthy()
1531+
expect(observer2.getCurrentResult().isPaused).toBeTruthy()
1532+
})
1533+
1534+
onlineManager.setOnline(undefined)
1535+
void queryClient.resumePausedMutations()
1536+
await sleep(5)
1537+
await queryClient.resumePausedMutations()
1538+
1539+
await waitFor(() => {
1540+
expect(observer1.getCurrentResult().status).toBe('success')
1541+
expect(observer2.getCurrentResult().status).toBe('success')
1542+
})
1543+
1544+
expect(orders).toEqual(['1start', '1end', '2start', '2end'])
1545+
})
1546+
14711547
test('should notify queryCache and mutationCache after multiple mounts and single unmount', async () => {
14721548
const testClient = createQueryClient()
14731549
testClient.mount()

0 commit comments

Comments
 (0)