Skip to content

Commit 3545362

Browse files
committed
feat: add hydration support for mutations
1 parent 9a6e59e commit 3545362

File tree

10 files changed

+263
-27
lines changed

10 files changed

+263
-27
lines changed

docs/src/pages/guides/migrating-to-react-query-3.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,10 @@ const mutation = useMutation(addTodo, {
368368
369369
If mutations fail because the device is offline, they will be retried in the same order when the device reconnects.
370370
371+
#### Persist mutations
372+
373+
Mutations can now be persisted to storage and resumed at a later point. More information can be found in the mutations documentation.
374+
371375
#### QueryObserver
372376
373377
A `QueryObserver` can be used to create and/or watch a query:

docs/src/pages/guides/mutations.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,52 @@ const mutation = useMutation(addTodo, {
205205
```
206206

207207
If mutations fail because the device is offline, they will be retried in the same order when the device reconnects.
208+
209+
## Persist mutations
210+
211+
Mutations can be persisted to storage if needed and resumed at a later point. This can be done with the hydration functions:
212+
213+
```js
214+
const queryClient = new QueryClient()
215+
216+
// Define the "addTodo" mutation
217+
queryClient.setMutationDefaults('addTodo', {
218+
mutationFn: addTodo,
219+
onMutate: variables => {
220+
// Cancel current queries for the todos list
221+
await queryClient.cancelQueries('todos')
222+
223+
// Create optimistic todo
224+
const optimisticTodo = { id: uuid(), title: variables.title }
225+
226+
// Add optimistic todo to todos list
227+
queryClient.setQueryData('todos', old => [...old, optimisticTodo])
228+
229+
// Return context with the optimistic todo
230+
return { optimisticTodo }
231+
},
232+
onSuccess: (result, variables, context) => {
233+
// Replace optimistic todo in the todos list with the result
234+
queryClient.setQueryData('todos', old => old.map(todo => todo.id === context.optimisticTodo.id ? result : todo))
235+
},
236+
onError: (error, variables, context) => {
237+
// Remove optimistic todo from the todos list
238+
queryClient.setQueryData('todos', old => old.filter(todo => todo.id !== context.optimisticTodo.id))
239+
},
240+
retry: 3,
241+
})
242+
243+
// Start mutation in some component:
244+
const mutation = useMutation('addTodo')
245+
mutation.mutate({ title: 'title' })
246+
247+
// If the mutation has been paused because the device is for example offline,
248+
// Then the paused mutation can be dehydrated when the application quits:
249+
const state = dehydrate(queryClient)
250+
251+
// The mutation can then be hydrated again when the application is started:
252+
hydrate(queryClient, state)
253+
254+
// Resume the paused mutations:
255+
queryClient.resumePausedMutations()
256+
```

docs/src/pages/reference/hydration/dehydrate.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,22 @@ const dehydratedState = dehydrate(queryClient, {
2020
- The `queryClient` that should be dehydrated
2121
- `options: DehydrateOptions`
2222
- Optional
23+
- `dehydrateMutations: boolean`
24+
- Optional
25+
- Whether or not to dehydrate mutations.
26+
- `dehydrateQueries: boolean`
27+
- Optional
28+
- Whether or not to dehydrate queries.
29+
- `shouldDehydrateMutation: (mutation: Mutation) => boolean`
30+
- Optional
31+
- This function is called for each mutation in the cache
32+
- Return `true` to include this mutation in dehydration, or `false` otherwise
33+
- The default version only includes paused mutations
2334
- `shouldDehydrateQuery: (query: Query) => boolean`
35+
- Optional
2436
- This function is called for each query in the cache
2537
- Return `true` to include this query in dehydration, or `false` otherwise
26-
- Default version only includes successful queries, do `shouldDehydrateQuery: () => true` to include all queries
38+
- The default version only includes successful queries, do `shouldDehydrateQuery: () => true` to include all queries
2739

2840
**Returns**
2941

docs/src/pages/reference/hydration/hydrate.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,7 @@ hydrate(queryClient, dehydratedState, options)
2121
- The state to hydrate into the client
2222
- `options: HydrateOptions`
2323
- Optional
24-
- `defaultOptions: QueryOptions`
25-
- The default query options to use for the hydrated queries.
24+
- `defaultOptions: DefaultOptions`
25+
- Optional
26+
- `mutations: MutationOptions` The default mutation options to use for the hydrated mutations.
27+
- `queries: QueryOptions` The default query options to use for the hydrated queries.

src/core/mutation.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { MutationObserver } from './mutationObserver'
44
import { getLogger } from './logger'
55
import { notifyManager } from './notifyManager'
66
import { Retryer } from './retryer'
7+
import { noop } from './utils'
78

89
// TYPES
910

@@ -15,7 +16,12 @@ interface MutationConfig<TData, TError, TVariables, TContext> {
1516
state?: MutationState<TData, TError, TVariables, TContext>
1617
}
1718

18-
export interface MutationState<TData, TError, TVariables, TContext> {
19+
export interface MutationState<
20+
TData = unknown,
21+
TError = unknown,
22+
TVariables = void,
23+
TContext = unknown
24+
> {
1925
context: TContext | undefined
2026
data: TData | undefined
2127
error: TError | null
@@ -108,6 +114,14 @@ export class Mutation<
108114
this.observers = this.observers.filter(x => x !== observer)
109115
}
110116

117+
cancel(): Promise<void> {
118+
if (this.retryer) {
119+
this.retryer.cancel()
120+
return this.retryer.promise.then(noop).catch(noop)
121+
}
122+
return Promise.resolve()
123+
}
124+
111125
continue(): Promise<TData> {
112126
if (this.retryer) {
113127
this.retryer.continue()

src/core/mutationCache.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export class MutationCache extends Subscribable<MutationCacheListener> {
4848

4949
remove(mutation: Mutation<any, any, any, any>): void {
5050
this.mutations = this.mutations.filter(x => x !== mutation)
51+
mutation.cancel()
5152
this.notify(mutation)
5253
}
5354

@@ -72,14 +73,14 @@ export class MutationCache extends Subscribable<MutationCacheListener> {
7273
}
7374

7475
onFocus(): void {
75-
this.continueMutations()
76+
this.resumePausedMutations()
7677
}
7778

7879
onOnline(): void {
79-
this.continueMutations()
80+
this.resumePausedMutations()
8081
}
8182

82-
continueMutations(): Promise<void> {
83+
resumePausedMutations(): Promise<void> {
8384
const pausedMutations = this.mutations.filter(x => x.state.isPaused)
8485
return notifyManager.batch(() =>
8586
pausedMutations.reduce(

src/core/queryClient.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,17 @@ export class QueryClient {
261261
.catch(noop)
262262
}
263263

264+
cancelMutations(): Promise<void> {
265+
const promises = notifyManager.batch(() =>
266+
this.mutationCache.getAll().map(mutation => mutation.cancel())
267+
)
268+
return Promise.all(promises).then(noop).catch(noop)
269+
}
270+
271+
resumePausedMutations(): Promise<void> {
272+
return this.getMutationCache().resumePausedMutations()
273+
}
274+
264275
executeMutation<
265276
TData = unknown,
266277
TError = unknown,

src/core/tests/mutations.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ describe('mutations', () => {
259259
variables: 'todo',
260260
})
261261

262-
await queryClient.getMutationCache().continueMutations()
262+
await queryClient.resumePausedMutations()
263263

264264
expect(mutation.state).toEqual({
265265
context: 'todo',

src/hydration/hydration.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
11
import type { QueryClient } from '../core/queryClient'
2-
import { Query, QueryState } from '../core/query'
3-
import type { QueryKey, QueryOptions } from '../core/types'
2+
import type { Query, QueryState } from '../core/query'
3+
import type {
4+
MutationKey,
5+
MutationOptions,
6+
QueryKey,
7+
QueryOptions,
8+
} from '../core/types'
9+
import type { Mutation, MutationState } from '../core/mutation'
410

511
// TYPES
612

713
export interface DehydrateOptions {
14+
dehydrateMutations?: boolean
15+
dehydrateQueries?: boolean
16+
shouldDehydrateMutation?: ShouldDehydrateMutationFunction
817
shouldDehydrateQuery?: ShouldDehydrateQueryFunction
918
}
1019

1120
export interface HydrateOptions {
12-
defaultOptions?: QueryOptions
21+
defaultOptions?: {
22+
queries?: QueryOptions
23+
mutations?: MutationOptions
24+
}
25+
}
26+
27+
interface DehydratedMutation {
28+
mutationKey?: MutationKey
29+
state: MutationState
1330
}
1431

1532
interface DehydratedQuery {
@@ -20,11 +37,14 @@ interface DehydratedQuery {
2037
}
2138

2239
export interface DehydratedState {
40+
mutations: DehydratedMutation[]
2341
queries: DehydratedQuery[]
2442
}
2543

2644
export type ShouldDehydrateQueryFunction = (query: Query) => boolean
2745

46+
export type ShouldDehydrateMutationFunction = (mutation: Mutation) => boolean
47+
2848
// FUNCTIONS
2949

3050
function serializePositiveNumber(value: number): number {
@@ -35,6 +55,13 @@ function deserializePositiveNumber(value: number): number {
3555
return value === -1 ? Infinity : value
3656
}
3757

58+
function dehydrateMutation(mutation: Mutation): DehydratedMutation {
59+
return {
60+
mutationKey: mutation.options.mutationKey,
61+
state: mutation.state,
62+
}
63+
}
64+
3865
// Most config is not dehydrated but instead meant to configure again when
3966
// consuming the de/rehydrated data, typically with useQuery on the client.
4067
// Sometimes it might make sense to prefetch data on the server and include
@@ -48,7 +75,11 @@ function dehydrateQuery(query: Query): DehydratedQuery {
4875
}
4976
}
5077

51-
function defaultShouldDehydrate(query: Query) {
78+
function defaultShouldDehydrateMutation(mutation: Mutation) {
79+
return mutation.state.isPaused
80+
}
81+
82+
function defaultShouldDehydrateQuery(query: Query) {
5283
return query.state.status === 'success'
5384
}
5485

@@ -58,21 +89,38 @@ export function dehydrate(
5889
): DehydratedState {
5990
options = options || {}
6091

61-
const shouldDehydrateQuery =
62-
options.shouldDehydrateQuery || defaultShouldDehydrate
63-
92+
const mutations: DehydratedMutation[] = []
6493
const queries: DehydratedQuery[] = []
6594

66-
client
67-
.getQueryCache()
68-
.getAll()
69-
.forEach(query => {
70-
if (shouldDehydrateQuery(query)) {
71-
queries.push(dehydrateQuery(query))
72-
}
73-
})
95+
if (options?.dehydrateMutations !== false) {
96+
const shouldDehydrateMutation =
97+
options.shouldDehydrateMutation || defaultShouldDehydrateMutation
98+
99+
client
100+
.getMutationCache()
101+
.getAll()
102+
.forEach(mutation => {
103+
if (shouldDehydrateMutation(mutation)) {
104+
mutations.push(dehydrateMutation(mutation))
105+
}
106+
})
107+
}
108+
109+
if (options?.dehydrateQueries !== false) {
110+
const shouldDehydrateQuery =
111+
options.shouldDehydrateQuery || defaultShouldDehydrateQuery
112+
113+
client
114+
.getQueryCache()
115+
.getAll()
116+
.forEach(query => {
117+
if (shouldDehydrateQuery(query)) {
118+
queries.push(dehydrateQuery(query))
119+
}
120+
})
121+
}
74122

75-
return { queries }
123+
return { mutations, queries }
76124
}
77125

78126
export function hydrate(
@@ -84,9 +132,23 @@ export function hydrate(
84132
return
85133
}
86134

135+
const mutationCache = client.getMutationCache()
87136
const queryCache = client.getQueryCache()
137+
138+
const mutations = (dehydratedState as DehydratedState).mutations || []
88139
const queries = (dehydratedState as DehydratedState).queries || []
89140

141+
mutations.forEach(dehydratedMutation => {
142+
mutationCache.build(
143+
client,
144+
{
145+
...options?.defaultOptions?.mutations,
146+
mutationKey: dehydratedMutation.mutationKey,
147+
},
148+
dehydratedMutation.state
149+
)
150+
})
151+
90152
queries.forEach(dehydratedQuery => {
91153
const query = queryCache.get(dehydratedQuery.queryHash)
92154

@@ -102,7 +164,7 @@ export function hydrate(
102164
queryCache.build(
103165
client,
104166
{
105-
...options?.defaultOptions,
167+
...options?.defaultOptions?.queries,
106168
queryKey: dehydratedQuery.queryKey,
107169
queryHash: dehydratedQuery.queryHash,
108170
cacheTime: deserializePositiveNumber(dehydratedQuery.cacheTime),

0 commit comments

Comments
 (0)