Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5ee4a80
refactor: add scope to mutationCache internally
TkDodo Mar 15, 2024
fe5033d
refactor: remove unused defaultOptions on mutation
TkDodo Mar 15, 2024
bc57909
feat: make sure to not run mutations if there is already one running …
TkDodo Mar 15, 2024
a7e015d
feat: make sure mutations in the same scope can run in serial
TkDodo Mar 15, 2024
3e86061
fix: find a _lot_ better way to determine if a mutation can run
TkDodo Mar 15, 2024
0205fd8
test: widen test scenario to include scopes
TkDodo Mar 15, 2024
05d9786
fix: there is a difference between starting and continuing
TkDodo Mar 16, 2024
555689d
refactor: switch to a scope object with `id`
TkDodo Mar 16, 2024
0af0e04
feat: dehydrate and hydrate mutation scope
TkDodo Mar 16, 2024
6d34ee6
fix: initiate the mutationCache with a random number
TkDodo Mar 16, 2024
bf9ce8a
test: hydration
TkDodo Mar 16, 2024
6780817
test: those tests actually fail because resumePausedMutations is stil…
TkDodo Mar 16, 2024
39a4f05
fix: simplify and fix resumePausedMutations
TkDodo Mar 16, 2024
c61b0f9
Merge branch 'main' into feature/scoped-mutations
TkDodo Mar 16, 2024
cec3e2b
test: more tests
TkDodo Mar 18, 2024
492b05f
Merge branch 'main' into feature/scoped-mutations
TkDodo Mar 18, 2024
bae2349
Merge branch 'main' into feature/scoped-mutations
TkDodo Mar 21, 2024
91982c7
Merge branch 'main' into feature/scoped-mutations
TkDodo Apr 21, 2024
b6adf19
refactor: scopeFor doesn't use anything of the mutationCache class
TkDodo Apr 21, 2024
1294869
docs: scoped mutations
TkDodo Apr 21, 2024
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
19 changes: 18 additions & 1 deletion docs/framework/react/guides/mutations.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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',
])
})
})
Loading