source: node_modules/@reduxjs/toolkit/src/query/tests/optimisticUpserts.test.tsx@ a762898

Last change on this file since a762898 was a762898, checked in by istevanoska <ilinastevanoska@…>, 5 months ago

Added visualizations

  • Property mode set to 100644
File size: 18.4 KB
Line 
1import { createApi } from '@reduxjs/toolkit/query/react'
2import { createAction } from '@reduxjs/toolkit'
3import {
4 actionsReducer,
5 hookWaitFor,
6 setupApiStore,
7} from '../../tests/utils/helpers'
8import {
9 render,
10 renderHook,
11 act,
12 waitFor,
13 screen,
14} from '@testing-library/react'
15import { delay } from 'msw'
16
17interface Post {
18 id: string
19 title: string
20 contents: string
21}
22
23interface FolderT {
24 id: number
25 children: FolderT[]
26}
27
28const baseQuery = vi.fn()
29beforeEach(() => baseQuery.mockReset())
30
31const postAddedAction = createAction<string>('postAdded')
32
33const api = createApi({
34 baseQuery: (...args: any[]) => {
35 const result = baseQuery(...args)
36 if (typeof result === 'object' && 'then' in result)
37 return result
38 .then((data: any) => ({ data, meta: 'meta' }))
39 .catch((e: any) => ({ error: e }))
40 return { data: result, meta: 'meta' }
41 },
42 tagTypes: ['Post', 'Folder'],
43 endpoints: (build) => ({
44 getPosts: build.query<Post[], void>({ query: () => '/posts' }),
45 post: build.query<Post, string>({
46 query: (id) => `post/${id}`,
47 providesTags: ['Post'],
48 }),
49 updatePost: build.mutation<void, Pick<Post, 'id'> & Partial<Post>>({
50 query: ({ id, ...patch }) => ({
51 url: `post/${id}`,
52 method: 'PATCH',
53 body: patch,
54 }),
55 async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
56 const currentItem = api.endpoints.post.select(arg.id)(getState())
57 if (currentItem?.data) {
58 dispatch(
59 api.util.upsertQueryData('post', arg.id, {
60 ...currentItem.data,
61 ...arg,
62 }),
63 )
64 }
65 },
66 invalidatesTags: (result) => (result ? ['Post'] : []),
67 }),
68 post2: build.query<Post, string>({
69 queryFn: async (id) => {
70 await delay(20)
71 return { data: { id, title: 'All about cheese.', contents: 'TODO' } }
72 },
73 }),
74 postWithSideEffect: build.query<Post, string>({
75 query: (id) => `post/${id}`,
76 providesTags: (result) => {
77 if (result) {
78 return [{ type: 'Post', id: result.id } as const]
79 }
80 return []
81 },
82 async onCacheEntryAdded(arg, api) {
83 // Verify that lifecycle promise resolution works
84 const res = await api.cacheDataLoaded
85
86 // and leave a side effect we can check in the test
87 api.dispatch(postAddedAction(res.data.id))
88 },
89 keepUnusedDataFor: 0.1,
90 }),
91 getFolder: build.query<FolderT, number>({
92 queryFn: async (args) => {
93 return {
94 data: {
95 id: args,
96 // Folder contains children that are as well folders
97 children: [{ id: 2, children: [] }],
98 },
99 }
100 },
101 providesTags: (result, err, args) => [{ type: 'Folder', id: args }],
102 onQueryStarted: async (args, queryApi) => {
103 const { data } = await queryApi.queryFulfilled
104
105 // Upsert getFolder endpoint with children from response data
106 const upsertData = data.children.map((child) => ({
107 arg: child.id,
108 endpointName: 'getFolder' as const,
109 value: child,
110 }))
111
112 queryApi.dispatch(api.util.upsertQueryEntries(upsertData))
113 },
114 }),
115 }),
116})
117
118const storeRef = setupApiStore(api, { ...actionsReducer })
119
120describe('basic lifecycle', () => {
121 let onStart = vi.fn(),
122 onError = vi.fn(),
123 onSuccess = vi.fn()
124
125 const extendedApi = api.injectEndpoints({
126 endpoints: (build) => ({
127 test: build.mutation({
128 query: (x) => x,
129 async onQueryStarted(arg, api) {
130 onStart(arg)
131 try {
132 const result = await api.queryFulfilled
133 onSuccess(result)
134 } catch (e) {
135 onError(e)
136 }
137 },
138 }),
139 }),
140 overrideExisting: true,
141 })
142
143 beforeEach(() => {
144 onStart.mockReset()
145 onError.mockReset()
146 onSuccess.mockReset()
147 })
148
149 test('Does basic inserts and upserts', async () => {
150 const newPost: Post = {
151 id: '3',
152 contents: 'Inserted content',
153 title: 'Inserted title',
154 }
155 const insertPromise = storeRef.store.dispatch(
156 api.util.upsertQueryData('post', newPost.id, newPost),
157 )
158
159 await insertPromise
160
161 const selectPost3 = api.endpoints.post.select(newPost.id)
162 const insertedPostEntry = selectPost3(storeRef.store.getState())
163 expect(insertedPostEntry.isSuccess).toBe(true)
164 expect(insertedPostEntry.data).toEqual(newPost)
165
166 const updatedPost: Post = {
167 id: '3',
168 contents: 'Updated content',
169 title: 'Updated title',
170 }
171
172 const updatePromise = storeRef.store.dispatch(
173 api.util.upsertQueryData('post', updatedPost.id, updatedPost),
174 )
175
176 await updatePromise
177
178 const updatedPostEntry = selectPost3(storeRef.store.getState())
179
180 expect(updatedPostEntry.isSuccess).toBe(true)
181 expect(updatedPostEntry.data).toEqual(updatedPost)
182 })
183
184 test('success', async () => {
185 const { result } = renderHook(
186 () => extendedApi.endpoints.test.useMutation(),
187 { wrapper: storeRef.wrapper },
188 )
189
190 baseQuery.mockResolvedValue('success')
191
192 expect(onStart).not.toHaveBeenCalled()
193 expect(baseQuery).not.toHaveBeenCalled()
194 act(() => void result.current[0]('arg'))
195 expect(onStart).toHaveBeenCalledWith('arg')
196 expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined)
197
198 expect(onError).not.toHaveBeenCalled()
199 expect(onSuccess).not.toHaveBeenCalled()
200 await act(() => delay(5))
201 expect(onError).not.toHaveBeenCalled()
202 expect(onSuccess).toHaveBeenCalledWith({ data: 'success', meta: 'meta' })
203 })
204
205 test('error', async () => {
206 const { result } = renderHook(
207 () => extendedApi.endpoints.test.useMutation(),
208 { wrapper: storeRef.wrapper },
209 )
210
211 baseQuery.mockRejectedValueOnce('error')
212
213 expect(onStart).not.toHaveBeenCalled()
214 expect(baseQuery).not.toHaveBeenCalled()
215 act(() => void result.current[0]('arg'))
216 expect(onStart).toHaveBeenCalledWith('arg')
217 expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined)
218
219 expect(onError).not.toHaveBeenCalled()
220 expect(onSuccess).not.toHaveBeenCalled()
221 await act(() => delay(5))
222 expect(onError).toHaveBeenCalledWith({
223 error: 'error',
224 isUnhandledError: false,
225 meta: undefined,
226 })
227 expect(onSuccess).not.toHaveBeenCalled()
228 })
229})
230
231describe('upsertQueryData', () => {
232 test('inserts cache entry', async () => {
233 baseQuery
234 .mockResolvedValueOnce({
235 id: '3',
236 title: 'All about cheese.',
237 contents: 'TODO',
238 })
239 // TODO I have no idea why the query is getting called multiple times,
240 // but passing an additional mocked value (_any_ value)
241 // seems to silence some annoying "got an undefined result" logging
242 .mockResolvedValueOnce(42)
243 const { result } = renderHook(() => api.endpoints.post.useQuery('3'), {
244 wrapper: storeRef.wrapper,
245 })
246 await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
247
248 const dataBefore = result.current.data
249 expect(dataBefore).toEqual({
250 id: '3',
251 title: 'All about cheese.',
252 contents: 'TODO',
253 })
254
255 await act(async () => {
256 storeRef.store.dispatch(
257 api.util.upsertQueryData('post', '3', {
258 id: '3',
259 title: 'All about cheese.',
260 contents: 'I love cheese!',
261 }),
262 )
263 })
264
265 expect(result.current.data).not.toBe(dataBefore)
266 expect(result.current.data).toEqual({
267 id: '3',
268 title: 'All about cheese.',
269 contents: 'I love cheese!',
270 })
271 })
272
273 test('does update non-existing values', async () => {
274 baseQuery
275 // throw an error to make sure there is no cached data
276 .mockImplementationOnce(async () => {
277 throw new Error('failed to load')
278 })
279 .mockResolvedValueOnce(42)
280
281 // a subscriber is needed to have the data stay in the cache
282 // Not sure if this is the wanted behavior, I would have liked
283 // it to stay in the cache for the x amount of time the cache
284 // is preserved normally after the last subscriber was unmounted
285 const { result, rerender } = renderHook(
286 () => api.endpoints.post.useQuery('4'),
287 { wrapper: storeRef.wrapper },
288 )
289 await hookWaitFor(() => expect(result.current.isError).toBeTruthy())
290
291 // upsert the data
292 act(() => {
293 storeRef.store.dispatch(
294 api.util.upsertQueryData('post', '4', {
295 id: '4',
296 title: 'All about cheese',
297 contents: 'I love cheese!',
298 }),
299 )
300 })
301
302 // rerender the hook
303 rerender()
304 // wait until everything has settled
305 await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
306
307 // the cached data is returned as the result
308 expect(result.current.data).toStrictEqual({
309 id: '4',
310 title: 'All about cheese',
311 contents: 'I love cheese!',
312 })
313 })
314
315 test('upsert while a normal query is running (success)', async () => {
316 const fetchedData = {
317 id: '3',
318 title: 'All about cheese.',
319 contents: 'Yummy',
320 }
321 baseQuery.mockImplementation(() => delay(20).then(() => fetchedData))
322 const upsertedData = {
323 id: '3',
324 title: 'Data from a SSR Render',
325 contents: 'This is just some random data',
326 }
327
328 const selector = api.endpoints.post.select('3')
329 const fetchRes = storeRef.store.dispatch(api.endpoints.post.initiate('3'))
330 const upsertRes = storeRef.store.dispatch(
331 api.util.upsertQueryData('post', '3', upsertedData),
332 )
333
334 await upsertRes
335 let state = selector(storeRef.store.getState())
336 expect(state.data).toEqual(upsertedData)
337
338 await fetchRes
339 state = selector(storeRef.store.getState())
340 expect(state.data).toEqual(fetchedData)
341 })
342 test('upsert while a normal query is running (rejected)', async () => {
343 baseQuery.mockImplementationOnce(async () => {
344 await delay(20)
345 // eslint-disable-next-line no-throw-literal
346 throw 'Error!'
347 })
348 const upsertedData = {
349 id: '3',
350 title: 'Data from a SSR Render',
351 contents: 'This is just some random data',
352 }
353
354 const selector = api.endpoints.post.select('3')
355 const fetchRes = storeRef.store.dispatch(api.endpoints.post.initiate('3'))
356 const upsertRes = storeRef.store.dispatch(
357 api.util.upsertQueryData('post', '3', upsertedData),
358 )
359
360 await upsertRes
361 let state = selector(storeRef.store.getState())
362 expect(state.data).toEqual(upsertedData)
363 expect(state.isSuccess).toBeTruthy()
364
365 await fetchRes
366 state = selector(storeRef.store.getState())
367 expect(state.data).toEqual(upsertedData)
368 expect(state.isError).toBeTruthy()
369 })
370})
371
372describe('upsertQueryEntries', () => {
373 const posts: Post[] = [
374 { id: '1', contents: 'A', title: 'A' },
375 { id: '2', contents: 'B', title: 'B' },
376 { id: '3', contents: 'C', title: 'C' },
377 ]
378
379 const entriesAction = api.util.upsertQueryEntries([
380 { endpointName: 'getPosts', arg: undefined, value: posts },
381 ...posts.map((post) => ({
382 endpointName: 'postWithSideEffect' as const,
383 arg: post.id,
384 value: post,
385 })),
386 ])
387
388 test('Upserts many entries at once', async () => {
389 storeRef.store.dispatch(entriesAction)
390
391 const state = storeRef.store.getState()
392
393 expect(api.endpoints.getPosts.select()(state).data).toBe(posts)
394
395 for (const post of posts) {
396 expect(api.endpoints.postWithSideEffect.select(post.id)(state).data).toBe(
397 post,
398 )
399
400 // Should have added tags
401 expect(state.api.provided.tags.Post[post.id]).toEqual([
402 `postWithSideEffect("${post.id}")`,
403 ])
404 }
405 })
406
407 test('Triggers cache lifecycles and side effects', async () => {
408 storeRef.store.dispatch(entriesAction)
409
410 // Tricky timing. The cache data promises will be resolved
411 // in microtasks. We need to wait for them. Best to do this
412 // in a loop just to avoid a hardcoded delay, but also this
413 // needs to complete before `keepUnusedDataFor` expires them.
414 await waitFor(
415 () => {
416 const state = storeRef.store.getState()
417
418 // onCacheEntryAdded should have run for each post,
419 // including cache data being resolved
420 for (const post of posts) {
421 const matchingSideEffectAction = state.actions.find(
422 (action) =>
423 postAddedAction.match(action) && action.payload === post.id,
424 )
425 expect(matchingSideEffectAction).toBeTruthy()
426 }
427
428 const selectedData =
429 api.endpoints.postWithSideEffect.select('1')(state).data
430
431 expect(selectedData).toBe(posts[0])
432 },
433 { timeout: 150, interval: 5 },
434 )
435
436 // The cache data should be removed after the keepUnusedDataFor time,
437 // so wait longer than that
438 await delay(300)
439
440 const stateAfter = storeRef.store.getState()
441
442 expect(api.endpoints.postWithSideEffect.select('1')(stateAfter).data).toBe(
443 undefined,
444 )
445 })
446
447 test('Handles repeated upserts and async lifecycles', async () => {
448 const StateForUpsertFolder = ({ folderId }: { folderId: number }) => {
449 const { status } = api.useGetFolderQuery(folderId)
450
451 return (
452 <>
453 <div>
454 Status getFolder with ID (
455 {folderId === 1 ? 'original request' : 'upserted'}) {folderId}:{' '}
456 <span data-testid={`status-${folderId}`}>{status}</span>
457 </div>
458 </>
459 )
460 }
461
462 const Folder = () => {
463 const { data, isLoading, isError } = api.useGetFolderQuery(1)
464
465 return (
466 <div>
467 <h1>Folders</h1>
468
469 {isLoading && <div>Loading...</div>}
470
471 {isError && <div>Error...</div>}
472
473 <StateForUpsertFolder key={`state-${1}`} folderId={1} />
474 <StateForUpsertFolder key={`state-${2}`} folderId={2} />
475 </div>
476 )
477 }
478
479 render(<Folder />, { wrapper: storeRef.wrapper })
480
481 await waitFor(() => {
482 const { actions } = storeRef.store.getState()
483 // Inspection:
484 // - 2 inits
485 // - 2 pendings, 2 fulfilleds for the hook queries
486 // - 2 upserts
487 expect(actions.length).toBe(8)
488 expect(
489 actions.filter((a) => api.util.upsertQueryEntries.match(a)).length,
490 ).toBe(2)
491 })
492 expect(screen.getByTestId('status-2').textContent).toBe('fulfilled')
493 })
494})
495
496describe('full integration', () => {
497 test('success case', async () => {
498 baseQuery
499 .mockResolvedValueOnce({
500 id: '3',
501 title: 'All about cheese.',
502 contents: 'TODO',
503 })
504 .mockResolvedValueOnce({
505 id: '3',
506 title: 'Meanwhile, this changed server-side.',
507 contents: 'Delicious cheese!',
508 })
509 .mockResolvedValueOnce({
510 id: '3',
511 title: 'Meanwhile, this changed server-side.',
512 contents: 'Delicious cheese!',
513 })
514 .mockResolvedValueOnce(42)
515 const { result } = renderHook(
516 () => ({
517 query: api.endpoints.post.useQuery('3'),
518 mutation: api.endpoints.updatePost.useMutation(),
519 }),
520 { wrapper: storeRef.wrapper },
521 )
522 await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy())
523
524 expect(result.current.query.data).toEqual({
525 id: '3',
526 title: 'All about cheese.',
527 contents: 'TODO',
528 })
529
530 await act(async () => {
531 await result.current.mutation[0]({
532 id: '3',
533 contents: 'Delicious cheese!',
534 })
535 })
536
537 expect(result.current.query.data).toEqual({
538 id: '3',
539 title: 'Meanwhile, this changed server-side.',
540 contents: 'Delicious cheese!',
541 })
542
543 await hookWaitFor(() =>
544 expect(result.current.query.data).toEqual({
545 id: '3',
546 title: 'Meanwhile, this changed server-side.',
547 contents: 'Delicious cheese!',
548 }),
549 )
550 })
551
552 test('error case', async () => {
553 baseQuery
554 .mockResolvedValueOnce({
555 id: '3',
556 title: 'All about cheese.',
557 contents: 'TODO',
558 })
559 .mockRejectedValueOnce('some error!')
560 .mockResolvedValueOnce({
561 id: '3',
562 title: 'Meanwhile, this changed server-side.',
563 contents: 'TODO',
564 })
565 .mockResolvedValueOnce(42)
566
567 const { result } = renderHook(
568 () => ({
569 query: api.endpoints.post.useQuery('3'),
570 mutation: api.endpoints.updatePost.useMutation(),
571 }),
572 { wrapper: storeRef.wrapper },
573 )
574 await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy())
575
576 expect(result.current.query.data).toEqual({
577 id: '3',
578 title: 'All about cheese.',
579 contents: 'TODO',
580 })
581
582 await act(async () => {
583 await result.current.mutation[0]({
584 id: '3',
585 contents: 'Delicious cheese!',
586 })
587 })
588
589 // optimistic update
590 expect(result.current.query.data).toEqual({
591 id: '3',
592 title: 'All about cheese.',
593 contents: 'Delicious cheese!',
594 })
595
596 // mutation failed - will not invalidate query and not refetch data from the server
597 await expect(() =>
598 hookWaitFor(
599 () =>
600 expect(result.current.query.data).toEqual({
601 id: '3',
602 title: 'Meanwhile, this changed server-side.',
603 contents: 'TODO',
604 }),
605 50,
606 ),
607 ).rejects.toBeTruthy()
608
609 act(() => void result.current.query.refetch())
610
611 // manually refetching gives up-to-date data
612 await hookWaitFor(
613 () =>
614 expect(result.current.query.data).toEqual({
615 id: '3',
616 title: 'Meanwhile, this changed server-side.',
617 contents: 'TODO',
618 }),
619 50,
620 )
621 })
622
623 test('Interop with in-flight requests', async () => {
624 await act(async () => {
625 const fetchRes = storeRef.store.dispatch(
626 api.endpoints.post2.initiate('3'),
627 )
628
629 const upsertRes = storeRef.store.dispatch(
630 api.util.upsertQueryData('post2', '3', {
631 id: '3',
632 title: 'Upserted title',
633 contents: 'Upserted contents',
634 }),
635 )
636
637 const selectEntry = api.endpoints.post2.select('3')
638 await waitFor(
639 () => {
640 const entry1 = selectEntry(storeRef.store.getState())
641 expect(entry1.data).toEqual({
642 id: '3',
643 title: 'Upserted title',
644 contents: 'Upserted contents',
645 })
646 },
647 { interval: 1, timeout: 15 },
648 )
649 await waitFor(
650 () => {
651 const entry2 = selectEntry(storeRef.store.getState())
652 expect(entry2.data).toEqual({
653 id: '3',
654 title: 'All about cheese.',
655 contents: 'TODO',
656 })
657 },
658 { interval: 1 },
659 )
660 })
661 })
662})
Note: See TracBrowser for help on using the repository browser.