@tanstack/react-query 4.43.0 → 4.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/react-query",
3
- "version": "4.43.0",
3
+ "version": "4.44.0",
4
4
  "description": "Hooks for managing, caching and syncing asynchronous and remote data in React",
5
5
  "author": "tannerlinsley",
6
6
  "license": "MIT",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "use-sync-external-store": "^1.6.0",
56
- "@tanstack/query-core": "4.43.0"
56
+ "@tanstack/query-core": "4.44.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
@@ -0,0 +1,140 @@
1
+ import * as React from 'react'
2
+ import { QueryClient } from '@tanstack/query-core'
3
+ import { fireEvent, waitFor } from '@testing-library/react'
4
+ import { mutationOptions } from '../mutationOptions'
5
+ import { useIsMutating, useMutation } from '..'
6
+ import { renderWithClient, sleep } from './utils'
7
+ import type { UseMutationOptions } from '../types'
8
+
9
+ describe('mutationOptions', () => {
10
+ it('should return the object received as a parameter without any modification (with mutationKey)', () => {
11
+ const object: UseMutationOptions = {
12
+ mutationKey: ['key'],
13
+ mutationFn: () => Promise.resolve(5),
14
+ } as const
15
+
16
+ expect(mutationOptions(object)).toBe(object)
17
+ })
18
+
19
+ it('should return the object received as a parameter without any modification (without mutationKey)', () => {
20
+ const object: UseMutationOptions = {
21
+ mutationFn: () => Promise.resolve(5),
22
+ } as const
23
+
24
+ expect(mutationOptions(object)).toBe(object)
25
+ })
26
+
27
+ it('should work with useMutation (with mutationKey)', async () => {
28
+ const queryClient = new QueryClient()
29
+ const mutationOpts = mutationOptions({
30
+ mutationKey: ['key'],
31
+ mutationFn: () => sleep(10).then(() => 'data'),
32
+ })
33
+
34
+ function Page() {
35
+ const mutation = useMutation(mutationOpts)
36
+
37
+ return (
38
+ <div>
39
+ <button onClick={() => mutation.mutate()}>mutate</button>
40
+ <span>{mutation.data ?? 'empty'}</span>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ const rendered = renderWithClient(queryClient, <Page />)
46
+
47
+ expect(rendered.getByText('empty')).toBeTruthy()
48
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
49
+ await waitFor(() => rendered.getByText('data'))
50
+ })
51
+
52
+ it('should work with useMutation (without mutationKey)', async () => {
53
+ const queryClient = new QueryClient()
54
+ const mutationOpts = mutationOptions({
55
+ mutationFn: () => sleep(10).then(() => 'data'),
56
+ })
57
+
58
+ function Page() {
59
+ const mutation = useMutation(mutationOpts)
60
+
61
+ return (
62
+ <div>
63
+ <button onClick={() => mutation.mutate()}>mutate</button>
64
+ <span>{mutation.data ?? 'empty'}</span>
65
+ </div>
66
+ )
67
+ }
68
+
69
+ const rendered = renderWithClient(queryClient, <Page />)
70
+
71
+ expect(rendered.getByText('empty')).toBeTruthy()
72
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
73
+ await waitFor(() => rendered.getByText('data'))
74
+ })
75
+
76
+ it('should work with useIsMutating filtering by mutationKey', async () => {
77
+ const queryClient = new QueryClient()
78
+ const mutationOpts1 = mutationOptions({
79
+ mutationKey: ['key1'],
80
+ mutationFn: () => sleep(50).then(() => 'data1'),
81
+ })
82
+ const mutationOpts2 = mutationOptions({
83
+ mutationKey: ['key2'],
84
+ mutationFn: () => sleep(50).then(() => 'data2'),
85
+ })
86
+
87
+ function Page() {
88
+ const isMutating = useIsMutating({
89
+ mutationKey: mutationOpts1.mutationKey,
90
+ })
91
+ const { mutate: mutate1 } = useMutation(mutationOpts1)
92
+ const { mutate: mutate2 } = useMutation(mutationOpts2)
93
+
94
+ return (
95
+ <div>
96
+ <span>isMutating: {isMutating}</span>
97
+ <button onClick={() => mutate1()}>mutate1</button>
98
+ <button onClick={() => mutate2()}>mutate2</button>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ const rendered = renderWithClient(queryClient, <Page />)
104
+
105
+ rendered.getByText('isMutating: 0')
106
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
107
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
108
+ await waitFor(() => rendered.getByText('isMutating: 1'))
109
+ await waitFor(() => rendered.getByText('isMutating: 0'))
110
+ })
111
+
112
+ it('should work with queryClient.isMutating', async () => {
113
+ const queryClient = new QueryClient()
114
+ const mutationOpts = mutationOptions({
115
+ mutationKey: ['mutation'],
116
+ mutationFn: () => sleep(10).then(() => 'data'),
117
+ })
118
+
119
+ function Page() {
120
+ const isMutating = queryClient.isMutating({
121
+ mutationKey: mutationOpts.mutationKey,
122
+ })
123
+ const { mutate } = useMutation(mutationOpts)
124
+
125
+ return (
126
+ <div>
127
+ <span>isMutating: {isMutating}</span>
128
+ <button onClick={() => mutate()}>mutate</button>
129
+ </div>
130
+ )
131
+ }
132
+
133
+ const rendered = renderWithClient(queryClient, <Page />)
134
+
135
+ rendered.getByText('isMutating: 0')
136
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
137
+ await waitFor(() => rendered.getByText('isMutating: 1'))
138
+ await waitFor(() => rendered.getByText('isMutating: 0'))
139
+ })
140
+ })
@@ -0,0 +1,438 @@
1
+ import { expectTypeOf } from 'expect-type'
2
+ import { QueryClient } from '@tanstack/query-core'
3
+ import {
4
+ type UseMutationOptions,
5
+ type UseMutationResult,
6
+ mutationOptions,
7
+ useIsMutating,
8
+ useMutation,
9
+ useQueryClient,
10
+ } from '..'
11
+ import { doNotExecute } from './utils'
12
+ import type { MutationKey, OmitKeyof, WithRequired } from '@tanstack/query-core'
13
+
14
+ const mutationKey = ['key'] as const
15
+ const mutationFn = (_input: { id: string }) =>
16
+ Promise.resolve({ field: 'success' })
17
+
18
+ describe('mutationOptions', () => {
19
+ it('should not allow excess properties', () => {
20
+ doNotExecute(() => {
21
+ // @ts-expect-error this is a good error, because onMutates does not exist!
22
+ mutationOptions({
23
+ mutationFn: () => Promise.resolve(5),
24
+ mutationKey: ['key'],
25
+ onMutates: 1000,
26
+ onSuccess: (data) => {
27
+ expectTypeOf(data).toEqualTypeOf<number>()
28
+ },
29
+ })
30
+ })
31
+ })
32
+
33
+ it('should infer types for callbacks', () => {
34
+ doNotExecute(() => {
35
+ mutationOptions({
36
+ mutationFn: () => Promise.resolve(5),
37
+ mutationKey: ['key'],
38
+ onSuccess: (data) => {
39
+ expectTypeOf(data).toEqualTypeOf<number>()
40
+ },
41
+ })
42
+ })
43
+ })
44
+
45
+ it('should infer types for onError callback', () => {
46
+ doNotExecute(() => {
47
+ mutationOptions({
48
+ mutationFn: () => {
49
+ throw new Error('fail')
50
+ },
51
+ mutationKey: ['key'],
52
+ onError: (error) => {
53
+ expectTypeOf(error).toEqualTypeOf<unknown>()
54
+ },
55
+ })
56
+ })
57
+ })
58
+
59
+ it('should infer types for variables', () => {
60
+ doNotExecute(() => {
61
+ mutationOptions<number, unknown, { id: string }>({
62
+ mutationFn: (vars) => {
63
+ expectTypeOf(vars).toEqualTypeOf<{ id: string }>()
64
+ return Promise.resolve(5)
65
+ },
66
+ mutationKey: ['with-vars'],
67
+ })
68
+ })
69
+ })
70
+
71
+ it('should infer context type correctly', () => {
72
+ doNotExecute(() => {
73
+ mutationOptions<number, unknown, void, { name: string }>({
74
+ mutationFn: () => Promise.resolve(5),
75
+ mutationKey: ['key'],
76
+ onMutate: () => {
77
+ return { name: 'context' }
78
+ },
79
+ onSuccess: (_data, _variables, context) => {
80
+ expectTypeOf(context).toEqualTypeOf<{ name: string } | undefined>()
81
+ },
82
+ })
83
+ })
84
+ })
85
+
86
+ it('should error if mutationFn return type mismatches TData', () => {
87
+ doNotExecute(() => {
88
+ mutationOptions<number>({
89
+ // @ts-expect-error this is a good error, because return type is string, not number
90
+ mutationFn: async () => Promise.resolve('wrong return'),
91
+ })
92
+ })
93
+ })
94
+
95
+ it('should allow mutationKey to be omitted', () => {
96
+ doNotExecute(() => {
97
+ mutationOptions({
98
+ mutationFn: () => Promise.resolve(123),
99
+ onSuccess: (data) => {
100
+ expectTypeOf(data).toEqualTypeOf<number>()
101
+ },
102
+ })
103
+ })
104
+ })
105
+
106
+ it('should infer all types when not explicitly provided', () => {
107
+ doNotExecute(() => {
108
+ expectTypeOf(
109
+ mutationOptions({
110
+ mutationFn: (id: string) => Promise.resolve(id.length),
111
+ mutationKey: ['key'],
112
+ onSuccess: (data) => {
113
+ expectTypeOf(data).toEqualTypeOf<number>()
114
+ },
115
+ }),
116
+ ).toEqualTypeOf<
117
+ WithRequired<
118
+ UseMutationOptions<number, unknown, string, unknown>,
119
+ 'mutationKey'
120
+ >
121
+ >()
122
+ expectTypeOf(
123
+ mutationOptions({
124
+ mutationFn: (id: string) => Promise.resolve(id.length),
125
+ onSuccess: (data) => {
126
+ expectTypeOf(data).toEqualTypeOf<number>()
127
+ },
128
+ }),
129
+ ).toEqualTypeOf<
130
+ OmitKeyof<
131
+ UseMutationOptions<number, unknown, string, unknown>,
132
+ 'mutationKey'
133
+ >
134
+ >()
135
+ })
136
+ })
137
+
138
+ it('should infer types when used with useMutation', () => {
139
+ doNotExecute(() => {
140
+ const mutation = useMutation(
141
+ mutationOptions({
142
+ mutationKey: ['key'],
143
+ mutationFn: () => Promise.resolve('data'),
144
+ onSuccess: (data) => {
145
+ expectTypeOf(data).toEqualTypeOf<string>()
146
+ },
147
+ }),
148
+ )
149
+ expectTypeOf(mutation).toEqualTypeOf<
150
+ UseMutationResult<string, unknown, void, unknown>
151
+ >()
152
+
153
+ // should allow when used with useMutation without mutationKey
154
+ useMutation(
155
+ mutationOptions({
156
+ mutationFn: () => Promise.resolve('data'),
157
+ onSuccess: (data) => {
158
+ expectTypeOf(data).toEqualTypeOf<string>()
159
+ },
160
+ }),
161
+ )
162
+ })
163
+ })
164
+
165
+ it('should be used with useMutation and spread with additional options', () => {
166
+ doNotExecute(() => {
167
+ const result = useMutation({
168
+ ...mutationOptions({
169
+ mutationKey,
170
+ mutationFn,
171
+ }),
172
+ retry: 3,
173
+ })
174
+
175
+ expectTypeOf(result).toEqualTypeOf<
176
+ UseMutationResult<{ field: string }, unknown, { id: string }, unknown>
177
+ >()
178
+ })
179
+ })
180
+
181
+ it('should preserve mutationKey for use with useIsMutating/queryClient', () => {
182
+ doNotExecute(() => {
183
+ const options = mutationOptions({
184
+ mutationKey: ['todos', 'create'] as const,
185
+ mutationFn: (input: { title: string }) =>
186
+ Promise.resolve({ id: 1, title: input.title }),
187
+ })
188
+
189
+ // mutationKey is MutationKey, usable with filters
190
+ expectTypeOf(options.mutationKey).toMatchTypeOf<MutationKey>()
191
+ })
192
+ })
193
+
194
+ it('should work with void variables (no arguments to mutationFn)', () => {
195
+ doNotExecute(() => {
196
+ const options = mutationOptions({
197
+ mutationKey,
198
+ mutationFn: () => Promise.resolve('done'),
199
+ })
200
+
201
+ const result = useMutation(options)
202
+
203
+ // mutate should be callable without arguments
204
+ result.mutate()
205
+ })
206
+ })
207
+
208
+ it('should infer TContext from onMutate when explicitly typed', () => {
209
+ doNotExecute(() => {
210
+ mutationOptions<
211
+ { success: boolean },
212
+ unknown,
213
+ string,
214
+ { previousData: string }
215
+ >({
216
+ mutationKey,
217
+ mutationFn: (_id: string) => Promise.resolve({ success: true }),
218
+ onMutate: (variables) => {
219
+ expectTypeOf(variables).toEqualTypeOf<string>()
220
+ return { previousData: 'backup' }
221
+ },
222
+ onError: (_error, variables, context) => {
223
+ expectTypeOf(variables).toEqualTypeOf<string>()
224
+ expectTypeOf(context).toEqualTypeOf<
225
+ { previousData: string } | undefined
226
+ >()
227
+ },
228
+ onSuccess: (data, variables, context) => {
229
+ expectTypeOf(data).toEqualTypeOf<{ success: boolean }>()
230
+ expectTypeOf(variables).toEqualTypeOf<string>()
231
+ expectTypeOf(context).toEqualTypeOf<
232
+ { previousData: string } | undefined
233
+ >()
234
+ },
235
+ onSettled: (data, _error, variables, context) => {
236
+ expectTypeOf(data).toEqualTypeOf<{ success: boolean } | undefined>()
237
+ expectTypeOf(variables).toEqualTypeOf<string>()
238
+ expectTypeOf(context).toEqualTypeOf<
239
+ { previousData: string } | undefined
240
+ >()
241
+ },
242
+ })
243
+ })
244
+ })
245
+
246
+ it('should work with complex generic types', () => {
247
+ doNotExecute(() => {
248
+ interface CreateUserInput {
249
+ name: string
250
+ email: string
251
+ roles: Array<'admin' | 'user'>
252
+ }
253
+
254
+ interface User {
255
+ id: number
256
+ name: string
257
+ email: string
258
+ roles: Array<'admin' | 'user'>
259
+ createdAt: Date
260
+ }
261
+
262
+ interface OptimisticContext {
263
+ previousUsers: Array<User>
264
+ tempId: number
265
+ }
266
+
267
+ const options = mutationOptions<
268
+ User,
269
+ unknown,
270
+ CreateUserInput,
271
+ OptimisticContext
272
+ >({
273
+ mutationKey: ['users', 'create'] as const,
274
+ mutationFn: (input: CreateUserInput) =>
275
+ Promise.resolve({
276
+ id: 1,
277
+ ...input,
278
+ createdAt: new Date(),
279
+ } as User),
280
+ onMutate: (variables) => {
281
+ expectTypeOf(variables).toEqualTypeOf<CreateUserInput>()
282
+ return { previousUsers: [], tempId: Date.now() }
283
+ },
284
+ onError: (_error, _variables, context) => {
285
+ expectTypeOf(context).toEqualTypeOf<OptimisticContext | undefined>()
286
+ },
287
+ onSuccess: (data, variables, context) => {
288
+ expectTypeOf(data).toEqualTypeOf<User>()
289
+ expectTypeOf(variables).toEqualTypeOf<CreateUserInput>()
290
+ expectTypeOf(context).toEqualTypeOf<OptimisticContext | undefined>()
291
+ },
292
+ })
293
+
294
+ const result = useMutation(options)
295
+ expectTypeOf(result.data).toEqualTypeOf<User | undefined>()
296
+ })
297
+ })
298
+
299
+ it('should be usable in a factory pattern', () => {
300
+ doNotExecute(() => {
301
+ const mutations = {
302
+ create: () =>
303
+ mutationOptions({
304
+ mutationKey: ['items', 'create'] as const,
305
+ mutationFn: (input: { name: string }) =>
306
+ Promise.resolve({ id: 1, name: input.name }),
307
+ }),
308
+ delete: () =>
309
+ mutationOptions({
310
+ mutationKey: ['items', 'delete'] as const,
311
+ mutationFn: (_id: number) => Promise.resolve(undefined),
312
+ }),
313
+ }
314
+
315
+ const createResult = useMutation(mutations.create())
316
+ expectTypeOf(createResult.data).toEqualTypeOf<
317
+ { id: number; name: string } | undefined
318
+ >()
319
+
320
+ const deleteResult = useMutation(mutations.delete())
321
+ expectTypeOf(deleteResult.data).toEqualTypeOf<undefined>()
322
+ })
323
+ })
324
+
325
+ it('should work with queryClient mutation cache filters', () => {
326
+ doNotExecute(async () => {
327
+ const queryClient = useQueryClient()
328
+ const options = mutationOptions({
329
+ mutationKey: ['key'] as const,
330
+ mutationFn: () => Promise.resolve('data'),
331
+ })
332
+
333
+ queryClient.getMutationCache().findAll({
334
+ mutationKey: options.mutationKey,
335
+ })
336
+ })
337
+ })
338
+
339
+ it('should infer types when used with queryClient.isMutating', () => {
340
+ doNotExecute(() => {
341
+ const queryClient = new QueryClient()
342
+
343
+ const isMutating = queryClient.isMutating({
344
+ mutationKey: mutationOptions({
345
+ mutationKey: ['key'],
346
+ mutationFn: () => Promise.resolve(5),
347
+ }).mutationKey,
348
+ })
349
+ expectTypeOf(isMutating).toEqualTypeOf<number>()
350
+ })
351
+ })
352
+
353
+ it('should handle union type variables', () => {
354
+ doNotExecute(() => {
355
+ type Action =
356
+ | { type: 'create'; payload: { name: string } }
357
+ | { type: 'delete'; payload: { id: number } }
358
+
359
+ const options = mutationOptions({
360
+ mutationKey,
361
+ mutationFn: (_action: Action) => Promise.resolve('done'),
362
+ })
363
+
364
+ const result = useMutation(options)
365
+ result.mutate({ type: 'create', payload: { name: 'test' } })
366
+ result.mutate({ type: 'delete', payload: { id: 1 } })
367
+ })
368
+ })
369
+
370
+ it('should properly narrow mutationKey presence based on overload', () => {
371
+ doNotExecute(() => {
372
+ // With mutationKey: mutationKey is required in the return type
373
+ const withKey = mutationOptions({
374
+ mutationKey: ['key'] as const,
375
+ mutationFn: () => Promise.resolve(1),
376
+ })
377
+ expectTypeOf(withKey.mutationKey).toMatchTypeOf<MutationKey>()
378
+
379
+ // Without mutationKey: mutationKey should not be accessible
380
+ const withoutKey = mutationOptions({
381
+ mutationFn: () => Promise.resolve(1),
382
+ })
383
+ // @ts-expect-error mutationKey should not exist
384
+ withoutKey.mutationKey
385
+ })
386
+ })
387
+
388
+ it('should allow mutationKey to be used as MutationKey', () => {
389
+ doNotExecute(() => {
390
+ const options = mutationOptions({
391
+ mutationKey: ['todos', { status: 'active' }] as const,
392
+ mutationFn: () => Promise.resolve(true),
393
+ })
394
+
395
+ const key: MutationKey = options.mutationKey
396
+ expectTypeOf(key).toMatchTypeOf<MutationKey>()
397
+ })
398
+ })
399
+
400
+ it('should infer types when used with useIsMutating via mutationKey filter', () => {
401
+ doNotExecute(() => {
402
+ const options = mutationOptions({
403
+ mutationKey: ['key'] as const,
404
+ mutationFn: () => Promise.resolve(5),
405
+ })
406
+
407
+ // mutationKey from mutationOptions can be used in MutationFilters
408
+ const isMutating = useIsMutating({
409
+ mutationKey: options.mutationKey,
410
+ })
411
+ expectTypeOf(isMutating).toEqualTypeOf<number>()
412
+ })
413
+ })
414
+
415
+ it('should infer types when used with useIsMutating passing mutationKey directly', () => {
416
+ doNotExecute(() => {
417
+ const options = mutationOptions({
418
+ mutationKey: ['key'] as const,
419
+ mutationFn: () => Promise.resolve(5),
420
+ })
421
+
422
+ // v4 useIsMutating accepts MutationKey as first arg
423
+ const isMutating = useIsMutating(options.mutationKey)
424
+ expectTypeOf(isMutating).toEqualTypeOf<number>()
425
+ })
426
+ })
427
+
428
+ it('should not allow passing mutationOptions without mutationKey to useIsMutating filter', () => {
429
+ doNotExecute(() => {
430
+ const options = mutationOptions({
431
+ mutationFn: () => Promise.resolve(5),
432
+ })
433
+
434
+ // @ts-expect-error mutationKey does not exist on options without mutationKey
435
+ useIsMutating({ mutationKey: options.mutationKey })
436
+ })
437
+ })
438
+ })
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  } from './QueryErrorResetBoundary'
44
44
  export { useIsFetching } from './useIsFetching'
45
45
  export { useIsMutating } from './useIsMutating'
46
+ export { mutationOptions } from './mutationOptions'
46
47
  export { useMutation } from './useMutation'
47
48
  export { useInfiniteQuery } from './useInfiniteQuery'
48
49
  export { useIsRestoring, IsRestoringProvider } from './isRestoring'
@@ -0,0 +1,34 @@
1
+ import type { OmitKeyof, WithRequired } from '@tanstack/query-core'
2
+ import type { UseMutationOptions } from './types'
3
+
4
+ export function mutationOptions<
5
+ TData = unknown,
6
+ TError = unknown,
7
+ TVariables = void,
8
+ TContext = unknown,
9
+ >(
10
+ options: WithRequired<
11
+ UseMutationOptions<TData, TError, TVariables, TContext>,
12
+ 'mutationKey'
13
+ >,
14
+ ): WithRequired<
15
+ UseMutationOptions<TData, TError, TVariables, TContext>,
16
+ 'mutationKey'
17
+ >
18
+ export function mutationOptions<
19
+ TData = unknown,
20
+ TError = unknown,
21
+ TVariables = void,
22
+ TContext = unknown,
23
+ >(
24
+ options: OmitKeyof<
25
+ UseMutationOptions<TData, TError, TVariables, TContext>,
26
+ 'mutationKey'
27
+ >,
28
+ ): OmitKeyof<
29
+ UseMutationOptions<TData, TError, TVariables, TContext>,
30
+ 'mutationKey'
31
+ >
32
+ export function mutationOptions(options: unknown) {
33
+ return options
34
+ }