Skip to content

Commit 24f1d45

Browse files
authored
feat: scoped mutations (#7312)
* refactor: add scope to mutationCache internally * refactor: remove unused defaultOptions on mutation this private field is a leftover from v4 * feat: make sure to not run mutations if there is already one running in the scope of this mutation * feat: make sure mutations in the same scope can run in serial * fix: find a _lot_ better way to determine if a mutation can run * test: widen test scenario to include scopes * fix: there is a difference between starting and continuing when starting, we need to check the networkMode differently than when continuing, because of how offlineFirst works (can start, but can't continue) * refactor: switch to a scope object with `id` * feat: dehydrate and hydrate mutation scope * fix: initiate the mutationCache with a random number since we use the mutationId to create the default scope, and the mutationId is merely incremented, we risk colliding scopes when hydrating mutations into an existing cache. That's because the mutationId itself is never dehydrated. When a mutation gets hydrated, it gets re-built, thus getting a new id. At this point, its id and the scope can differ. That per se isn't a problem. But if a mutation was dehydrated with scope:1, it would put into the same scope with another mutation from the new cache that might also have the scope:1. To avoid that, we can initialize the mutationId with Date.now(). It will make sure (or at least very likely) that there is no collision In the future, we should just be able to use `Crypto.randomUUID()` to generate a unique scope, but our promised compatibility doesn't allow for using this function * test: hydration * test: those tests actually fail because resumePausedMutations is still wrongly implemented * fix: simplify and fix resumePausedMutations we can fire off all mutations at the same time - only the first one in each scope will actually fire, the others have to stay paused until their time has come. mutation.continue handles this internally. but, we get back all the retryer promises, so resumePausedMutations will wait until the whole chain is done * test: more tests * refactor: scopeFor doesn't use anything of the mutationCache class * docs: scoped mutations
1 parent 7368bd0 commit 24f1d45

File tree

12 files changed

+473
-97
lines changed

12 files changed

+473
-97
lines changed

docs/framework/react/guides/mutations.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ try {
267267

268268
## Retry
269269

270-
By default TanStack Query will not retry a mutation on error, but it is possible with the `retry` option:
270+
By default, TanStack Query will not retry a mutation on error, but it is possible with the `retry` option:
271271

272272
[//]: # 'Example9'
273273

@@ -390,6 +390,23 @@ We also have an extensive [offline example](../examples/offline) that covers bot
390390

391391
[//]: # 'Materials'
392392

393+
## Mutation Scopes
394+
395+
Per default, all mutations run in parallel - even if you invoke `.mutate()` of the same mutation multiple times. Mutations can be given a `scope` with an `id` to avoid that. All mutations with the same `scope.id` will run in serial, which means when they are triggered, they will start in `isPaused: true` state if there is already a mutation for that scope in progress. They will be put into a queue and will automatically resume once their time in the queue has come.
396+
397+
[//]: # 'ExampleScopes'
398+
399+
```tsx
400+
const mutation = useMutation({
401+
mutationFn: addTodo,
402+
scope: {
403+
id: 'todo',
404+
},
405+
})
406+
```
407+
408+
[//]: # 'ExampleScopes'
409+
393410
## Further reading
394411

395412
For more information about mutations, have a look at [#12: Mastering Mutations in React Query](../tkdodos-blog#12-mastering-mutations-in-react-query) from

docs/framework/react/reference/useMutation.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const {
2323
} = useMutation({
2424
mutationFn,
2525
gcTime,
26+
meta,
2627
mutationKey,
2728
networkMode,
2829
onError,
@@ -31,8 +32,8 @@ const {
3132
onSuccess,
3233
retry,
3334
retryDelay,
35+
scope,
3436
throwOnError,
35-
meta,
3637
})
3738

3839
mutate(variables, {
@@ -85,6 +86,10 @@ mutate(variables, {
8586
- This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds.
8687
- A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff.
8788
- A function like `attempt => attempt * 1000` applies linear backoff.
89+
- `scope: { id: string }`
90+
- Optional
91+
- Defaults to a unique id (so that all mutations run in parallel)
92+
- Mutations with the same scope id will run in serial
8893
- `throwOnError: undefined | boolean | (error: TError) => boolean`
8994
- Defaults to the global query config's `throwOnError` value, which is `undefined`
9095
- Set this to `true` if you want mutation errors to be thrown in the render phase and propagate to the nearest error boundary

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,4 +704,38 @@ describe('dehydration and rehydration', () => {
704704
hydrationCache.find({ queryKey: ['string'] })?.state.fetchStatus,
705705
).toBe('idle')
706706
})
707+
708+
test('should dehydrate and hydrate mutation scopes', async () => {
709+
const queryClient = createQueryClient()
710+
const onlineMock = mockOnlineManagerIsOnline(false)
711+
712+
void executeMutation(
713+
queryClient,
714+
{
715+
mutationKey: ['mutation'],
716+
mutationFn: async () => {
717+
return 'mutation'
718+
},
719+
scope: {
720+
id: 'scope',
721+
},
722+
},
723+
'vars',
724+
)
725+
726+
const dehydrated = dehydrate(queryClient)
727+
expect(dehydrated.mutations[0]?.scope?.id).toBe('scope')
728+
const stringified = JSON.stringify(dehydrated)
729+
730+
// ---
731+
const parsed = JSON.parse(stringified)
732+
const hydrationCache = new MutationCache()
733+
const hydrationClient = createQueryClient({ mutationCache: hydrationCache })
734+
735+
hydrate(hydrationClient, parsed)
736+
737+
expect(dehydrated.mutations[0]?.scope?.id).toBe('scope')
738+
739+
onlineMock.mockRestore()
740+
})
707741
})

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

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,4 +409,195 @@ describe('mutations', () => {
409409

410410
expect(onSuccess).toHaveBeenCalledWith(2)
411411
})
412+
413+
describe('scoped mutations', () => {
414+
test('mutations in the same scope should run in serial', async () => {
415+
const key1 = queryKey()
416+
const key2 = queryKey()
417+
418+
const results: Array<string> = []
419+
420+
const execute1 = executeMutation(
421+
queryClient,
422+
{
423+
mutationKey: key1,
424+
scope: {
425+
id: 'scope',
426+
},
427+
mutationFn: async () => {
428+
results.push('start-A')
429+
await sleep(10)
430+
results.push('finish-A')
431+
return 'a'
432+
},
433+
},
434+
'vars1',
435+
)
436+
437+
expect(
438+
queryClient.getMutationCache().find({ mutationKey: key1 })?.state,
439+
).toMatchObject({
440+
status: 'pending',
441+
isPaused: false,
442+
})
443+
444+
const execute2 = executeMutation(
445+
queryClient,
446+
{
447+
mutationKey: key2,
448+
scope: {
449+
id: 'scope',
450+
},
451+
mutationFn: async () => {
452+
results.push('start-B')
453+
await sleep(10)
454+
results.push('finish-B')
455+
return 'b'
456+
},
457+
},
458+
'vars2',
459+
)
460+
461+
expect(
462+
queryClient.getMutationCache().find({ mutationKey: key2 })?.state,
463+
).toMatchObject({
464+
status: 'pending',
465+
isPaused: true,
466+
})
467+
468+
await Promise.all([execute1, execute2])
469+
470+
expect(results).toStrictEqual([
471+
'start-A',
472+
'finish-A',
473+
'start-B',
474+
'finish-B',
475+
])
476+
})
477+
})
478+
479+
test('mutations without scope should run in parallel', async () => {
480+
const key1 = queryKey()
481+
const key2 = queryKey()
482+
483+
const results: Array<string> = []
484+
485+
const execute1 = executeMutation(
486+
queryClient,
487+
{
488+
mutationKey: key1,
489+
mutationFn: async () => {
490+
results.push('start-A')
491+
await sleep(10)
492+
results.push('finish-A')
493+
return 'a'
494+
},
495+
},
496+
'vars1',
497+
)
498+
499+
const execute2 = executeMutation(
500+
queryClient,
501+
{
502+
mutationKey: key2,
503+
mutationFn: async () => {
504+
results.push('start-B')
505+
await sleep(10)
506+
results.push('finish-B')
507+
return 'b'
508+
},
509+
},
510+
'vars2',
511+
)
512+
513+
await Promise.all([execute1, execute2])
514+
515+
expect(results).toStrictEqual([
516+
'start-A',
517+
'start-B',
518+
'finish-A',
519+
'finish-B',
520+
])
521+
})
522+
523+
test('each scope should run should run in parallel, serial within scope', async () => {
524+
const results: Array<string> = []
525+
526+
const execute1 = executeMutation(
527+
queryClient,
528+
{
529+
scope: {
530+
id: '1',
531+
},
532+
mutationFn: async () => {
533+
results.push('start-A1')
534+
await sleep(10)
535+
results.push('finish-A1')
536+
return 'a'
537+
},
538+
},
539+
'vars1',
540+
)
541+
542+
const execute2 = executeMutation(
543+
queryClient,
544+
{
545+
scope: {
546+
id: '1',
547+
},
548+
mutationFn: async () => {
549+
results.push('start-B1')
550+
await sleep(10)
551+
results.push('finish-B1')
552+
return 'b'
553+
},
554+
},
555+
'vars2',
556+
)
557+
558+
const execute3 = executeMutation(
559+
queryClient,
560+
{
561+
scope: {
562+
id: '2',
563+
},
564+
mutationFn: async () => {
565+
results.push('start-A2')
566+
await sleep(10)
567+
results.push('finish-A2')
568+
return 'a'
569+
},
570+
},
571+
'vars1',
572+
)
573+
574+
const execute4 = executeMutation(
575+
queryClient,
576+
{
577+
scope: {
578+
id: '2',
579+
},
580+
mutationFn: async () => {
581+
results.push('start-B2')
582+
await sleep(10)
583+
results.push('finish-B2')
584+
return 'b'
585+
},
586+
},
587+
'vars2',
588+
)
589+
590+
await Promise.all([execute1, execute2, execute3, execute4])
591+
592+
expect(results).toStrictEqual([
593+
'start-A1',
594+
'start-A2',
595+
'finish-A1',
596+
'start-B1',
597+
'finish-A2',
598+
'start-B2',
599+
'finish-B1',
600+
'finish-B2',
601+
])
602+
})
412603
})

0 commit comments

Comments
 (0)