Skip to content

Commit

Permalink
feat: scoped mutations (#7312)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
TkDodo committed Apr 22, 2024
1 parent 7368bd0 commit 24f1d45
Show file tree
Hide file tree
Showing 12 changed files with 473 additions and 97 deletions.
19 changes: 18 additions & 1 deletion docs/framework/react/guides/mutations.md
Expand Up @@ -267,7 +267,7 @@ try {

## Retry

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

[//]: # 'Example9'

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

[//]: # 'Materials'

## Mutation Scopes

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.

[//]: # 'ExampleScopes'

```tsx
const mutation = useMutation({
mutationFn: addTodo,
scope: {
id: 'todo',
},
})
```

[//]: # 'ExampleScopes'

## Further reading

For more information about mutations, have a look at [#12: Mastering Mutations in React Query](../tkdodos-blog#12-mastering-mutations-in-react-query) from
Expand Down
7 changes: 6 additions & 1 deletion docs/framework/react/reference/useMutation.md
Expand Up @@ -23,6 +23,7 @@ const {
} = useMutation({
mutationFn,
gcTime,
meta,
mutationKey,
networkMode,
onError,
Expand All @@ -31,8 +32,8 @@ const {
onSuccess,
retry,
retryDelay,
scope,
throwOnError,
meta,
})

mutate(variables, {
Expand Down Expand Up @@ -85,6 +86,10 @@ mutate(variables, {
- This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds.
- A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff.
- A function like `attempt => attempt * 1000` applies linear backoff.
- `scope: { id: string }`
- Optional
- Defaults to a unique id (so that all mutations run in parallel)
- Mutations with the same scope id will run in serial
- `throwOnError: undefined | boolean | (error: TError) => boolean`
- Defaults to the global query config's `throwOnError` value, which is `undefined`
- Set this to `true` if you want mutation errors to be thrown in the render phase and propagate to the nearest error boundary
Expand Down
34 changes: 34 additions & 0 deletions packages/query-core/src/__tests__/hydration.test.tsx
Expand Up @@ -704,4 +704,38 @@ describe('dehydration and rehydration', () => {
hydrationCache.find({ queryKey: ['string'] })?.state.fetchStatus,
).toBe('idle')
})

test('should dehydrate and hydrate mutation scopes', async () => {
const queryClient = createQueryClient()
const onlineMock = mockOnlineManagerIsOnline(false)

void executeMutation(
queryClient,
{
mutationKey: ['mutation'],
mutationFn: async () => {
return 'mutation'
},
scope: {
id: 'scope',
},
},
'vars',
)

const dehydrated = dehydrate(queryClient)
expect(dehydrated.mutations[0]?.scope?.id).toBe('scope')
const stringified = JSON.stringify(dehydrated)

// ---
const parsed = JSON.parse(stringified)
const hydrationCache = new MutationCache()
const hydrationClient = createQueryClient({ mutationCache: hydrationCache })

hydrate(hydrationClient, parsed)

expect(dehydrated.mutations[0]?.scope?.id).toBe('scope')

onlineMock.mockRestore()
})
})
191 changes: 191 additions & 0 deletions packages/query-core/src/__tests__/mutations.test.tsx
Expand Up @@ -409,4 +409,195 @@ describe('mutations', () => {

expect(onSuccess).toHaveBeenCalledWith(2)
})

describe('scoped mutations', () => {
test('mutations in the same scope should run in serial', async () => {
const key1 = queryKey()
const key2 = queryKey()

const results: Array<string> = []

const execute1 = executeMutation(
queryClient,
{
mutationKey: key1,
scope: {
id: 'scope',
},
mutationFn: async () => {
results.push('start-A')
await sleep(10)
results.push('finish-A')
return 'a'
},
},
'vars1',
)

expect(
queryClient.getMutationCache().find({ mutationKey: key1 })?.state,
).toMatchObject({
status: 'pending',
isPaused: false,
})

const execute2 = executeMutation(
queryClient,
{
mutationKey: key2,
scope: {
id: 'scope',
},
mutationFn: async () => {
results.push('start-B')
await sleep(10)
results.push('finish-B')
return 'b'
},
},
'vars2',
)

expect(
queryClient.getMutationCache().find({ mutationKey: key2 })?.state,
).toMatchObject({
status: 'pending',
isPaused: true,
})

await Promise.all([execute1, execute2])

expect(results).toStrictEqual([
'start-A',
'finish-A',
'start-B',
'finish-B',
])
})
})

test('mutations without scope should run in parallel', async () => {
const key1 = queryKey()
const key2 = queryKey()

const results: Array<string> = []

const execute1 = executeMutation(
queryClient,
{
mutationKey: key1,
mutationFn: async () => {
results.push('start-A')
await sleep(10)
results.push('finish-A')
return 'a'
},
},
'vars1',
)

const execute2 = executeMutation(
queryClient,
{
mutationKey: key2,
mutationFn: async () => {
results.push('start-B')
await sleep(10)
results.push('finish-B')
return 'b'
},
},
'vars2',
)

await Promise.all([execute1, execute2])

expect(results).toStrictEqual([
'start-A',
'start-B',
'finish-A',
'finish-B',
])
})

test('each scope should run should run in parallel, serial within scope', async () => {
const results: Array<string> = []

const execute1 = executeMutation(
queryClient,
{
scope: {
id: '1',
},
mutationFn: async () => {
results.push('start-A1')
await sleep(10)
results.push('finish-A1')
return 'a'
},
},
'vars1',
)

const execute2 = executeMutation(
queryClient,
{
scope: {
id: '1',
},
mutationFn: async () => {
results.push('start-B1')
await sleep(10)
results.push('finish-B1')
return 'b'
},
},
'vars2',
)

const execute3 = executeMutation(
queryClient,
{
scope: {
id: '2',
},
mutationFn: async () => {
results.push('start-A2')
await sleep(10)
results.push('finish-A2')
return 'a'
},
},
'vars1',
)

const execute4 = executeMutation(
queryClient,
{
scope: {
id: '2',
},
mutationFn: async () => {
results.push('start-B2')
await sleep(10)
results.push('finish-B2')
return 'b'
},
},
'vars2',
)

await Promise.all([execute1, execute2, execute3, execute4])

expect(results).toStrictEqual([
'start-A1',
'start-A2',
'finish-A1',
'start-B1',
'finish-A2',
'start-B2',
'finish-B1',
'finish-B2',
])
})
})

0 comments on commit 24f1d45

Please sign in to comment.