source: node_modules/@reduxjs/toolkit/src/query/tests/buildHooks.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: 125.0 KB
Line 
1import { noop } from '@internal/listenerMiddleware/utils'
2import type { SubscriptionOptions } from '@internal/query/core/apiState'
3import type { SubscriptionSelectors } from '@internal/query/core/buildMiddleware/types'
4import { server } from '@internal/query/tests/mocks/server'
5import { countObjectKeys } from '@internal/query/utils/countObjectKeys'
6import {
7 actionsReducer,
8 setupApiStore,
9 useRenderCounter,
10 waitForFakeTimer,
11 waitMs,
12 withProvider,
13} from '@internal/tests/utils/helpers'
14import type { UnknownAction } from '@reduxjs/toolkit'
15import {
16 configureStore,
17 createListenerMiddleware,
18 createSlice,
19} from '@reduxjs/toolkit'
20import {
21 QueryStatus,
22 createApi,
23 fetchBaseQuery,
24 skipToken,
25} from '@reduxjs/toolkit/query/react'
26import {
27 act,
28 fireEvent,
29 render,
30 renderHook,
31 screen,
32 waitFor,
33} from '@testing-library/react'
34import { userEvent } from '@testing-library/user-event'
35import type { SyncScreen } from '@testing-library/react-render-stream/pure'
36import { createRenderStream } from '@testing-library/react-render-stream/pure'
37import { HttpResponse, http, delay } from 'msw'
38import { useEffect, useMemo, useRef, useState } from 'react'
39import type { InfiniteQueryResultFlags } from '../core/buildSelectors'
40
41// Just setup a temporary in-memory counter for tests that `getIncrementedAmount`.
42// This can be used to test how many renders happen due to data changes or
43// the refetching behavior of components.
44let amount = 0
45let nextItemId = 0
46let refetchCount = 0
47
48interface Item {
49 id: number
50}
51
52const api = createApi({
53 baseQuery: async (arg: any) => {
54 await waitForFakeTimer(150)
55 if (arg?.body && 'amount' in arg.body) {
56 amount += 1
57 }
58
59 if (arg?.body && 'forceError' in arg.body) {
60 return {
61 error: {
62 status: 500,
63 data: null,
64 },
65 }
66 }
67
68 if (arg?.body && 'listItems' in arg.body) {
69 const items: Item[] = []
70 for (let i = 0; i < 3; i++) {
71 const item = { id: nextItemId++ }
72 items.push(item)
73 }
74 return { data: items }
75 }
76
77 return {
78 data: arg?.body ? { ...arg.body, ...(amount ? { amount } : {}) } : {},
79 }
80 },
81 tagTypes: ['IncrementedAmount'],
82 endpoints: (build) => ({
83 getUser: build.query<{ name: string }, number>({
84 query: () => ({
85 body: { name: 'Timmy' },
86 }),
87 }),
88 getUserAndForceError: build.query<{ name: string }, number>({
89 query: () => ({
90 body: {
91 forceError: true,
92 },
93 }),
94 }),
95 getUserWithRefetchError: build.query<{ name: string }, number>({
96 queryFn: async (id) => {
97 refetchCount += 1
98
99 if (refetchCount > 1) {
100 return { error: true } as any
101 }
102
103 return { data: { name: 'Timmy' } }
104 },
105 }),
106 getIncrementedAmount: build.query<{ amount: number }, void>({
107 query: () => ({
108 url: '',
109 body: {
110 amount,
111 },
112 }),
113 providesTags: ['IncrementedAmount'],
114 }),
115 triggerUpdatedAmount: build.mutation<void, void>({
116 queryFn: async () => {
117 return { data: undefined }
118 },
119 invalidatesTags: ['IncrementedAmount'],
120 }),
121 updateUser: build.mutation<{ name: string }, { name: string }>({
122 query: (update) => ({ body: update }),
123 }),
124 getError: build.query({
125 query: () => '/error',
126 }),
127 listItems: build.query<Item[], { pageNumber: number | bigint }>({
128 serializeQueryArgs: ({ endpointName }) => {
129 return endpointName
130 },
131 query: ({ pageNumber }) => ({
132 url: `items?limit=1&offset=${pageNumber}`,
133 body: {
134 listItems: true,
135 },
136 }),
137 merge: (currentCache, newItems) => {
138 currentCache.push(...newItems)
139 },
140 forceRefetch: () => {
141 return true
142 },
143 }),
144 queryWithDeepArg: build.query<string, { param: { nested: string } }>({
145 query: ({ param: { nested } }) => nested,
146 serializeQueryArgs: ({ queryArgs }) => {
147 return queryArgs.param.nested
148 },
149 }),
150 }),
151})
152
153const listenerMiddleware = createListenerMiddleware()
154
155let actions: UnknownAction[] = []
156
157const storeRef = setupApiStore(
158 api,
159 {},
160 {
161 middleware: {
162 prepend: [listenerMiddleware.middleware],
163 },
164 },
165)
166
167let getSubscriptions: SubscriptionSelectors['getSubscriptions']
168let getSubscriptionCount: SubscriptionSelectors['getSubscriptionCount']
169
170beforeEach(() => {
171 actions = []
172 listenerMiddleware.startListening({
173 predicate: () => true,
174 effect: (action) => {
175 actions.push(action)
176 },
177 })
178 ;({ getSubscriptions, getSubscriptionCount } = storeRef.store.dispatch(
179 api.internalActions.internal_getRTKQSubscriptions(),
180 ) as unknown as SubscriptionSelectors)
181})
182
183afterEach(() => {
184 nextItemId = 0
185 amount = 0
186 listenerMiddleware.clearListeners()
187
188 server.resetHandlers()
189})
190
191let getRenderCount: () => number = () => 0
192
193describe('hooks tests', () => {
194 describe('useQuery', () => {
195 test('useQuery hook basic render count assumptions', async () => {
196 function User() {
197 const { isFetching } = api.endpoints.getUser.useQuery(1)
198 getRenderCount = useRenderCounter()
199
200 return (
201 <div>
202 <div data-testid="isFetching">{String(isFetching)}</div>
203 </div>
204 )
205 }
206
207 render(<User />, { wrapper: storeRef.wrapper })
208 // By the time this runs, the initial render will happen, and the query
209 // will start immediately running by the time we can expect this
210 expect(getRenderCount()).toBe(2)
211
212 await waitFor(() =>
213 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
214 )
215 expect(getRenderCount()).toBe(3)
216 })
217
218 test('useQuery hook sets isFetching=true whenever a request is in flight', async () => {
219 function User() {
220 const [value, setValue] = useState(0)
221
222 const { isFetching } = api.endpoints.getUser.useQuery(1, {
223 skip: value < 1,
224 })
225 getRenderCount = useRenderCounter()
226
227 return (
228 <div>
229 <div data-testid="isFetching">{String(isFetching)}</div>
230 <button onClick={() => setValue((val) => val + 1)}>
231 Increment value
232 </button>
233 </div>
234 )
235 }
236
237 render(<User />, { wrapper: storeRef.wrapper })
238 expect(getRenderCount()).toBe(1)
239
240 await waitFor(() =>
241 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
242 )
243 fireEvent.click(screen.getByText('Increment value')) // setState = 1, perform request = 2
244 await waitFor(() =>
245 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
246 )
247 await waitFor(() =>
248 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
249 )
250 expect(getRenderCount()).toBe(4)
251
252 fireEvent.click(screen.getByText('Increment value'))
253 // Being that nothing has changed in the args, this should never fire.
254 expect(screen.getByTestId('isFetching').textContent).toBe('false')
255 expect(getRenderCount()).toBe(5) // even though there was no request, the button click updates the state so this is an expected render
256 })
257
258 test('useQuery hook sets isLoading=true only on initial request', async () => {
259 let refetch: any, isLoading: boolean, isFetching: boolean
260 function User() {
261 const [value, setValue] = useState(0)
262
263 ;({ isLoading, isFetching, refetch } = api.endpoints.getUser.useQuery(
264 2,
265 {
266 skip: value < 1,
267 },
268 ))
269 return (
270 <div>
271 <div data-testid="isLoading">{String(isLoading)}</div>
272 <div data-testid="isFetching">{String(isFetching)}</div>
273 <button onClick={() => setValue((val) => val + 1)}>
274 Increment value
275 </button>
276 </div>
277 )
278 }
279
280 render(<User />, { wrapper: storeRef.wrapper })
281
282 // Being that we skipped the initial request on mount, this should be false
283 await waitFor(() =>
284 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
285 )
286 fireEvent.click(screen.getByText('Increment value'))
287 // Condition is met, should load
288 await waitFor(() =>
289 expect(screen.getByTestId('isLoading').textContent).toBe('true'),
290 )
291 await waitFor(() =>
292 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
293 ) // Make sure the original loading has completed.
294 fireEvent.click(screen.getByText('Increment value'))
295 // Being that we already have data, isLoading should be false
296 await waitFor(() =>
297 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
298 )
299 // We call a refetch, should still be `false`
300 act(() => void refetch())
301 await waitFor(() =>
302 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
303 )
304 expect(screen.getByTestId('isLoading').textContent).toBe('false')
305 })
306
307 test('useQuery hook sets isLoading and isFetching to the correct states', async () => {
308 let refetchMe: () => void = () => {}
309 function User() {
310 const [value, setValue] = useState(0)
311 getRenderCount = useRenderCounter()
312
313 const { isLoading, isFetching, refetch } =
314 api.endpoints.getUser.useQuery(22, { skip: value < 1 })
315 refetchMe = refetch
316 return (
317 <div>
318 <div data-testid="isFetching">{String(isFetching)}</div>
319 <div data-testid="isLoading">{String(isLoading)}</div>
320 <button onClick={() => setValue((val) => val + 1)}>
321 Increment value
322 </button>
323 </div>
324 )
325 }
326
327 render(<User />, { wrapper: storeRef.wrapper })
328 expect(getRenderCount()).toBe(1)
329
330 expect(screen.getByTestId('isLoading').textContent).toBe('false')
331 expect(screen.getByTestId('isFetching').textContent).toBe('false')
332
333 fireEvent.click(screen.getByText('Increment value')) // renders: set state = 1, perform request = 2
334 // Condition is met, should load
335 await waitFor(() => {
336 expect(screen.getByTestId('isLoading').textContent).toBe('true')
337 expect(screen.getByTestId('isFetching').textContent).toBe('true')
338 })
339
340 // Make sure the request is done for sure.
341 await waitFor(() => {
342 expect(screen.getByTestId('isLoading').textContent).toBe('false')
343 expect(screen.getByTestId('isFetching').textContent).toBe('false')
344 })
345 expect(getRenderCount()).toBe(4)
346
347 fireEvent.click(screen.getByText('Increment value'))
348 // Being that we already have data and changing the value doesn't trigger a new request, only the button click should impact the render
349 await waitFor(() => {
350 expect(screen.getByTestId('isLoading').textContent).toBe('false')
351 expect(screen.getByTestId('isFetching').textContent).toBe('false')
352 })
353 expect(getRenderCount()).toBe(5)
354
355 // We call a refetch, should set `isFetching` to true, then false when complete/errored
356 act(() => void refetchMe())
357 await waitFor(() => {
358 expect(screen.getByTestId('isLoading').textContent).toBe('false')
359 expect(screen.getByTestId('isFetching').textContent).toBe('true')
360 })
361 await waitFor(() => {
362 expect(screen.getByTestId('isLoading').textContent).toBe('false')
363 expect(screen.getByTestId('isFetching').textContent).toBe('false')
364 })
365 expect(getRenderCount()).toBe(7)
366 })
367
368 test('`isLoading` does not jump back to true, while `isFetching` does', async () => {
369 const loadingHist: boolean[] = [],
370 fetchingHist: boolean[] = []
371
372 function User({ id }: { id: number }) {
373 const { isLoading, isFetching, status } =
374 api.endpoints.getUser.useQuery(id)
375
376 useEffect(() => {
377 loadingHist.push(isLoading)
378 }, [isLoading])
379 useEffect(() => {
380 fetchingHist.push(isFetching)
381 }, [isFetching])
382 return (
383 <div data-testid="status">
384 {status === QueryStatus.fulfilled && id}
385 </div>
386 )
387 }
388
389 let { rerender } = render(<User id={1} />, { wrapper: storeRef.wrapper })
390
391 await waitFor(() =>
392 expect(screen.getByTestId('status').textContent).toBe('1'),
393 )
394 rerender(<User id={2} />)
395
396 await waitFor(() =>
397 expect(screen.getByTestId('status').textContent).toBe('2'),
398 )
399
400 expect(loadingHist).toEqual([true, false])
401 expect(fetchingHist).toEqual([true, false, true, false])
402 })
403
404 test('`isSuccess` does not jump back false on subsequent queries', async () => {
405 type LoadingState = {
406 id: number
407 isFetching: boolean
408 isSuccess: boolean
409 }
410 const loadingHistory: LoadingState[] = []
411
412 function User({ id }: { id: number }) {
413 const queryRes = api.endpoints.getUser.useQuery(id)
414
415 useEffect(() => {
416 const { isFetching, isSuccess } = queryRes
417 loadingHistory.push({ id, isFetching, isSuccess })
418 }, [id, queryRes])
419 return (
420 <div data-testid="status">
421 {queryRes.status === QueryStatus.fulfilled && id}
422 </div>
423 )
424 }
425
426 let { rerender } = render(<User id={1} />, { wrapper: storeRef.wrapper })
427
428 await waitFor(() =>
429 expect(screen.getByTestId('status').textContent).toBe('1'),
430 )
431 rerender(<User id={2} />)
432
433 await waitFor(() =>
434 expect(screen.getByTestId('status').textContent).toBe('2'),
435 )
436
437 expect(loadingHistory).toEqual([
438 // Initial render(s)
439 { id: 1, isFetching: true, isSuccess: false },
440 { id: 1, isFetching: true, isSuccess: false },
441 // Data returned
442 { id: 1, isFetching: false, isSuccess: true },
443 // ID changed, there's an uninitialized cache entry.
444 // IMPORTANT: `isSuccess` should not be false here.
445 // We have valid data already for the old item.
446 { id: 2, isFetching: true, isSuccess: true },
447 { id: 2, isFetching: true, isSuccess: true },
448 { id: 2, isFetching: false, isSuccess: true },
449 ])
450 })
451
452 test('isSuccess stays consistent if there is an error while refetching', async () => {
453 type LoadingState = {
454 id: number
455 isFetching: boolean
456 isSuccess: boolean
457 isError: boolean
458 }
459 const loadingHistory: LoadingState[] = []
460
461 function Component({ id = 1 }) {
462 const queryRes = api.endpoints.getUserWithRefetchError.useQuery(id)
463 const { refetch, data, status } = queryRes
464
465 useEffect(() => {
466 const { isFetching, isSuccess, isError } = queryRes
467 loadingHistory.push({ id, isFetching, isSuccess, isError })
468 }, [id, queryRes])
469
470 return (
471 <div>
472 <button
473 onClick={() => {
474 refetch()
475 }}
476 >
477 refetch
478 </button>
479 <div data-testid="name">{data?.name}</div>
480 <div data-testid="status">{status}</div>
481 </div>
482 )
483 }
484
485 render(<Component />, { wrapper: storeRef.wrapper })
486
487 await waitFor(() =>
488 expect(screen.getByTestId('name').textContent).toBe('Timmy'),
489 )
490
491 fireEvent.click(screen.getByText('refetch'))
492
493 await waitFor(() =>
494 expect(screen.getByTestId('status').textContent).toBe('pending'),
495 )
496
497 await waitFor(() =>
498 expect(screen.getByTestId('status').textContent).toBe('rejected'),
499 )
500
501 fireEvent.click(screen.getByText('refetch'))
502
503 await waitFor(() =>
504 expect(screen.getByTestId('status').textContent).toBe('pending'),
505 )
506
507 await waitFor(() =>
508 expect(screen.getByTestId('status').textContent).toBe('rejected'),
509 )
510
511 expect(loadingHistory).toEqual([
512 // Initial renders
513 { id: 1, isFetching: true, isSuccess: false, isError: false },
514 { id: 1, isFetching: true, isSuccess: false, isError: false },
515 // Data is returned
516 { id: 1, isFetching: false, isSuccess: true, isError: false },
517 // Started first refetch
518 { id: 1, isFetching: true, isSuccess: true, isError: false },
519 // First refetch errored
520 { id: 1, isFetching: false, isSuccess: false, isError: true },
521 // Started second refetch
522 // IMPORTANT We expect `isSuccess` to still be false,
523 // despite having started the refetch again.
524 { id: 1, isFetching: true, isSuccess: false, isError: false },
525 // Second refetch errored
526 { id: 1, isFetching: false, isSuccess: false, isError: true },
527 ])
528 })
529
530 test('useQuery hook respects refetchOnMountOrArgChange: true', async () => {
531 let data, isLoading, isFetching
532 function User() {
533 ;({ data, isLoading, isFetching } =
534 api.endpoints.getIncrementedAmount.useQuery(undefined, {
535 refetchOnMountOrArgChange: true,
536 }))
537 return (
538 <div>
539 <div data-testid="isLoading">{String(isLoading)}</div>
540 <div data-testid="isFetching">{String(isFetching)}</div>
541 <div data-testid="amount">{String(data?.amount)}</div>
542 </div>
543 )
544 }
545
546 const { unmount } = render(<User />, { wrapper: storeRef.wrapper })
547
548 await waitFor(() =>
549 expect(screen.getByTestId('isLoading').textContent).toBe('true'),
550 )
551 await waitFor(() =>
552 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
553 )
554
555 await waitFor(() =>
556 expect(screen.getByTestId('amount').textContent).toBe('1'),
557 )
558
559 unmount()
560
561 render(<User />, { wrapper: storeRef.wrapper })
562 // Let's make sure we actually fetch, and we increment
563 expect(screen.getByTestId('isLoading').textContent).toBe('false')
564 await waitFor(() =>
565 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
566 )
567 await waitFor(() =>
568 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
569 )
570
571 await waitFor(() =>
572 expect(screen.getByTestId('amount').textContent).toBe('2'),
573 )
574 })
575
576 test('useQuery does not refetch when refetchOnMountOrArgChange: NUMBER condition is not met', async () => {
577 let data, isLoading, isFetching
578 function User() {
579 ;({ data, isLoading, isFetching } =
580 api.endpoints.getIncrementedAmount.useQuery(undefined, {
581 refetchOnMountOrArgChange: 10,
582 }))
583 return (
584 <div>
585 <div data-testid="isLoading">{String(isLoading)}</div>
586 <div data-testid="isFetching">{String(isFetching)}</div>
587 <div data-testid="amount">{String(data?.amount)}</div>
588 </div>
589 )
590 }
591
592 const { unmount } = render(<User />, { wrapper: storeRef.wrapper })
593
594 await waitFor(() =>
595 expect(screen.getByTestId('isLoading').textContent).toBe('true'),
596 )
597 await waitFor(() =>
598 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
599 )
600
601 await waitFor(() =>
602 expect(screen.getByTestId('amount').textContent).toBe('1'),
603 )
604
605 unmount()
606
607 render(<User />, { wrapper: storeRef.wrapper })
608 // Let's make sure we actually fetch, and we increment. Should be false because we do this immediately
609 // and the condition is set to 10 seconds
610 expect(screen.getByTestId('isFetching').textContent).toBe('false')
611 await waitFor(() =>
612 expect(screen.getByTestId('amount').textContent).toBe('1'),
613 )
614 })
615
616 test('useQuery refetches when refetchOnMountOrArgChange: NUMBER condition is met', async () => {
617 let data, isLoading, isFetching
618 function User() {
619 ;({ data, isLoading, isFetching } =
620 api.endpoints.getIncrementedAmount.useQuery(undefined, {
621 refetchOnMountOrArgChange: 0.5,
622 }))
623 return (
624 <div>
625 <div data-testid="isLoading">{String(isLoading)}</div>
626 <div data-testid="isFetching">{String(isFetching)}</div>
627 <div data-testid="amount">{String(data?.amount)}</div>
628 </div>
629 )
630 }
631
632 const { unmount } = render(<User />, { wrapper: storeRef.wrapper })
633
634 await waitFor(() =>
635 expect(screen.getByTestId('isLoading').textContent).toBe('true'),
636 )
637 await waitFor(() =>
638 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
639 )
640
641 await waitFor(() =>
642 expect(screen.getByTestId('amount').textContent).toBe('1'),
643 )
644
645 unmount()
646
647 // Wait to make sure we've passed the `refetchOnMountOrArgChange` value
648 await waitMs(510)
649
650 render(<User />, { wrapper: storeRef.wrapper })
651 // Let's make sure we actually fetch, and we increment
652 await waitFor(() =>
653 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
654 )
655 await waitFor(() =>
656 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
657 )
658
659 await waitFor(() =>
660 expect(screen.getByTestId('amount').textContent).toBe('2'),
661 )
662 })
663
664 test('refetchOnMountOrArgChange works as expected when changing skip from false->true', async () => {
665 let data, isLoading, isFetching
666 function User() {
667 const [skip, setSkip] = useState(true)
668 ;({ data, isLoading, isFetching } =
669 api.endpoints.getIncrementedAmount.useQuery(undefined, {
670 refetchOnMountOrArgChange: 0.5,
671 skip,
672 }))
673
674 return (
675 <div>
676 <div data-testid="isLoading">{String(isLoading)}</div>
677 <div data-testid="isFetching">{String(isFetching)}</div>
678 <div data-testid="amount">{String(data?.amount)}</div>
679 <button onClick={() => setSkip((prev) => !prev)}>
680 change skip
681 </button>
682 ;
683 </div>
684 )
685 }
686
687 render(<User />, { wrapper: storeRef.wrapper })
688
689 expect(screen.getByTestId('isLoading').textContent).toBe('false')
690 expect(screen.getByTestId('amount').textContent).toBe('undefined')
691
692 fireEvent.click(screen.getByText('change skip'))
693
694 await waitFor(() =>
695 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
696 )
697 await waitFor(() =>
698 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
699 )
700
701 await waitFor(() =>
702 expect(screen.getByTestId('amount').textContent).toBe('1'),
703 )
704 })
705
706 test('refetchOnMountOrArgChange works as expected when changing skip from false->true with a cached query', async () => {
707 // 1. we need to mount a skipped query, then toggle skip to generate a cached result
708 // 2. we need to mount a skipped component after that, then toggle skip as well. should pull from the cache.
709 // 3. we need to mount another skipped component, then toggle skip after the specified duration and expect the time condition to be satisfied
710
711 let data, isLoading, isFetching
712 function User() {
713 const [skip, setSkip] = useState(true)
714 ;({ data, isLoading, isFetching } =
715 api.endpoints.getIncrementedAmount.useQuery(undefined, {
716 skip,
717 refetchOnMountOrArgChange: 0.5,
718 }))
719
720 return (
721 <div>
722 <div data-testid="isLoading">{String(isLoading)}</div>
723 <div data-testid="isFetching">{String(isFetching)}</div>
724 <div data-testid="amount">{String(data?.amount)}</div>
725 <button onClick={() => setSkip((prev) => !prev)}>
726 change skip
727 </button>
728 ;
729 </div>
730 )
731 }
732
733 let { unmount } = render(<User />, { wrapper: storeRef.wrapper })
734
735 expect(screen.getByTestId('isFetching').textContent).toBe('false')
736
737 // skipped queries do nothing by default, so we need to toggle that to get a cached result
738 fireEvent.click(screen.getByText('change skip'))
739
740 await waitFor(() =>
741 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
742 )
743
744 await waitFor(() => {
745 expect(screen.getByTestId('amount').textContent).toBe('1')
746 expect(screen.getByTestId('isFetching').textContent).toBe('false')
747 })
748
749 unmount()
750
751 await waitMs(100)
752
753 // This will pull from the cache as the time criteria is not met.
754 ;({ unmount } = render(<User />, {
755 wrapper: storeRef.wrapper,
756 }))
757
758 // skipped queries return nothing
759 expect(screen.getByTestId('isFetching').textContent).toBe('false')
760 expect(screen.getByTestId('amount').textContent).toBe('undefined')
761
762 // toggle skip -> true... won't refetch as the time critera is not met, and just loads the cached values
763 fireEvent.click(screen.getByText('change skip'))
764 expect(screen.getByTestId('isFetching').textContent).toBe('false')
765 expect(screen.getByTestId('amount').textContent).toBe('1')
766
767 unmount()
768
769 await waitMs(500)
770 ;({ unmount } = render(<User />, {
771 wrapper: storeRef.wrapper,
772 }))
773
774 // toggle skip -> true... will cause a refetch as the time criteria is now satisfied
775 fireEvent.click(screen.getByText('change skip'))
776
777 await waitFor(() =>
778 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
779 )
780 await waitFor(() =>
781 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
782 )
783
784 await waitFor(() =>
785 expect(screen.getByTestId('amount').textContent).toBe('2'),
786 )
787 })
788
789 test(`useQuery refetches when query args object changes even if serialized args don't change`, async () => {
790 const user = userEvent.setup()
791
792 function ItemList() {
793 const [pageNumber, setPageNumber] = useState(0)
794 const { data = [] } = api.useListItemsQuery({
795 pageNumber,
796 })
797
798 const renderedItems = data.map((item) => (
799 <li key={item.id}>ID: {item.id}</li>
800 ))
801 return (
802 <div>
803 <button onClick={() => setPageNumber(pageNumber + 1)}>
804 Next Page
805 </button>
806 <ul>{renderedItems}</ul>
807 </div>
808 )
809 }
810
811 render(<ItemList />, { wrapper: storeRef.wrapper })
812
813 await screen.findByText('ID: 0')
814
815 await user.click(screen.getByText('Next Page'))
816
817 await screen.findByText('ID: 3')
818 })
819
820 test(`useQuery shouldn't call args serialization if request skipped`, async () => {
821 expect(() =>
822 renderHook(() => api.endpoints.queryWithDeepArg.useQuery(skipToken), {
823 wrapper: storeRef.wrapper,
824 }),
825 ).not.toThrow()
826 })
827
828 test(`useQuery gracefully handles bigint types`, async () => {
829 const user = userEvent.setup()
830
831 function ItemList() {
832 const [pageNumber, setPageNumber] = useState(0)
833 const { data = [] } = api.useListItemsQuery({
834 pageNumber: BigInt(pageNumber),
835 })
836
837 const renderedItems = data.map((item) => (
838 <li key={item.id}>ID: {item.id}</li>
839 ))
840 return (
841 <div>
842 <button onClick={() => setPageNumber(pageNumber + 1)}>
843 Next Page
844 </button>
845 <ul>{renderedItems}</ul>
846 </div>
847 )
848 }
849
850 render(<ItemList />, { wrapper: storeRef.wrapper })
851
852 await screen.findByText('ID: 0')
853
854 await user.click(screen.getByText('Next Page'))
855
856 await screen.findByText('ID: 3')
857 })
858
859 describe('api.util.resetApiState resets hook', () => {
860 test('without `selectFromResult`', async () => {
861 const { result } = renderHook(() => api.endpoints.getUser.useQuery(5), {
862 wrapper: storeRef.wrapper,
863 })
864
865 await waitFor(() => expect(result.current.isSuccess).toBe(true))
866
867 act(() => void storeRef.store.dispatch(api.util.resetApiState()))
868
869 expect(result.current).toEqual(
870 expect.objectContaining({
871 isError: false,
872 isFetching: true,
873 isLoading: true,
874 isSuccess: false,
875 isUninitialized: false,
876 refetch: expect.any(Function),
877 status: 'pending',
878 }),
879 )
880 })
881 test('with `selectFromResult`', async () => {
882 const selectFromResult = vi.fn((x) => x)
883 const { result } = renderHook(
884 () => api.endpoints.getUser.useQuery(5, { selectFromResult }),
885 {
886 wrapper: storeRef.wrapper,
887 },
888 )
889
890 await waitFor(() => expect(result.current.isSuccess).toBe(true))
891 selectFromResult.mockClear()
892 act(() => {
893 storeRef.store.dispatch(api.util.resetApiState())
894 })
895
896 expect(selectFromResult).toHaveBeenNthCalledWith(1, {
897 isError: false,
898 isFetching: false,
899 isLoading: false,
900 isSuccess: false,
901 isUninitialized: true,
902 status: 'uninitialized',
903 })
904 })
905
906 test('hook should not be stuck loading post resetApiState after re-render', async () => {
907 const user = userEvent.setup()
908
909 function QueryComponent() {
910 const { isLoading, data } = api.endpoints.getUser.useQuery(1)
911
912 if (isLoading) {
913 return <p>Loading...</p>
914 }
915
916 return <p>{data?.name}</p>
917 }
918
919 function Wrapper() {
920 const [open, setOpen] = useState(true)
921
922 const handleRerender = () => {
923 setOpen(false)
924 setTimeout(() => {
925 setOpen(true)
926 }, 250)
927 }
928
929 const handleReset = () => {
930 storeRef.store.dispatch(api.util.resetApiState())
931 }
932
933 return (
934 <>
935 <button onClick={handleRerender} aria-label="Rerender component">
936 Rerender
937 </button>
938 {open ? (
939 <div>
940 <button onClick={handleReset} aria-label="Reset API state">
941 Reset
942 </button>
943
944 <QueryComponent />
945 </div>
946 ) : null}
947 </>
948 )
949 }
950
951 render(<Wrapper />, { wrapper: storeRef.wrapper })
952
953 await user.click(
954 screen.getByRole('button', { name: /Rerender component/i }),
955 )
956 await waitFor(() => {
957 expect(screen.getByText('Timmy')).toBeTruthy()
958 })
959
960 await user.click(
961 screen.getByRole('button', { name: /reset api state/i }),
962 )
963 await waitFor(() => {
964 expect(screen.queryByText('Loading...')).toBeNull()
965 })
966 await waitFor(() => {
967 expect(screen.getByText('Timmy')).toBeTruthy()
968 })
969 })
970 })
971
972 test('useQuery refetch method returns a promise that resolves with the result', async () => {
973 const { result } = renderHook(
974 () => api.endpoints.getIncrementedAmount.useQuery(),
975 {
976 wrapper: storeRef.wrapper,
977 },
978 )
979
980 await waitFor(() => expect(result.current.isSuccess).toBe(true))
981 const originalAmount = result.current.data!.amount
982
983 const { refetch } = result.current
984
985 let resPromise: ReturnType<typeof refetch> = null as any
986 await act(async () => {
987 resPromise = refetch()
988 })
989 expect(resPromise).toBeInstanceOf(Promise)
990 const res = await act(() => resPromise)
991 expect(res.data!.amount).toBeGreaterThan(originalAmount)
992 })
993
994 // See https://github.com/reduxjs/redux-toolkit/issues/4267 - Memory leak in useQuery rapid query arg changes
995 test('Hook subscriptions are properly cleaned up when query is fulfilled/rejected', async () => {
996 // This is imported already, but it seems to be causing issues with the test on certain matrixes
997
998 const pokemonApi = createApi({
999 baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
1000 endpoints: (builder) => ({
1001 getTest: builder.query<string, number>({
1002 async queryFn() {
1003 await new Promise((resolve) => setTimeout(resolve, 1000))
1004 return { data: 'data!' }
1005 },
1006 keepUnusedDataFor: 0,
1007 }),
1008 }),
1009 })
1010
1011 const storeRef = setupApiStore(pokemonApi, undefined, {
1012 withoutTestLifecycles: true,
1013 })
1014
1015 const checkNumQueries = (count: number) => {
1016 const cacheEntries = Object.keys(storeRef.store.getState().api.queries)
1017 const queries = cacheEntries.length
1018
1019 expect(queries).toBe(count)
1020 }
1021
1022 let i = 0
1023
1024 function User() {
1025 const [fetchTest, { isFetching, isUninitialized }] =
1026 pokemonApi.endpoints.getTest.useLazyQuery()
1027
1028 return (
1029 <div>
1030 <div data-testid="isUninitialized">{String(isUninitialized)}</div>
1031 <div data-testid="isFetching">{String(isFetching)}</div>
1032 <button data-testid="fetchButton" onClick={() => fetchTest(i++)}>
1033 fetchUser
1034 </button>
1035 </div>
1036 )
1037 }
1038
1039 render(<User />, { wrapper: storeRef.wrapper })
1040 fireEvent.click(screen.getByTestId('fetchButton'))
1041 fireEvent.click(screen.getByTestId('fetchButton'))
1042 fireEvent.click(screen.getByTestId('fetchButton'))
1043 checkNumQueries(3)
1044
1045 await act(async () => {
1046 await delay(1500)
1047 })
1048
1049 // There should only be one stored query once they have had time to resolve
1050 checkNumQueries(1)
1051 })
1052
1053 // See https://github.com/reduxjs/redux-toolkit/issues/3182
1054 test('Hook subscriptions are properly cleaned up when changing skip back and forth', async () => {
1055 const pokemonApi = createApi({
1056 baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
1057 endpoints: (builder) => ({
1058 getPokemonByName: builder.query({
1059 queryFn: (name: string) => ({ data: null }),
1060 keepUnusedDataFor: 1,
1061 }),
1062 }),
1063 })
1064
1065 const storeRef = setupApiStore(pokemonApi, undefined, {
1066 withoutTestLifecycles: true,
1067 })
1068
1069 const checkNumSubscriptions = (arg: string, count: number) => {
1070 const subscriptions = getSubscriptions()
1071 const cacheKeyEntry = subscriptions.get(arg)
1072
1073 if (cacheKeyEntry) {
1074 const subscriptionCount = Object.keys(cacheKeyEntry) //getSubscriptionCount(arg)
1075 expect(subscriptionCount).toBe(count)
1076 }
1077 }
1078
1079 // 1) Initial state: an active subscription
1080 const { rerender, unmount } = renderHook(
1081 ([arg, options]: Parameters<
1082 typeof pokemonApi.useGetPokemonByNameQuery
1083 >) => pokemonApi.useGetPokemonByNameQuery(arg, options),
1084 {
1085 wrapper: storeRef.wrapper,
1086 initialProps: ['a'],
1087 },
1088 )
1089
1090 await act(async () => {
1091 await waitMs(1)
1092 })
1093
1094 // 2) Set the current subscription to `{skip: true}
1095 rerender(['a', { skip: true }])
1096
1097 // 3) Change _both_ the cache key _and_ `{skip: false}` at the same time.
1098 // This causes the `subscriptionRemoved` check to be `true`.
1099 rerender(['b'])
1100
1101 // There should only be one active subscription after changing the arg
1102 checkNumSubscriptions('b', 1)
1103
1104 // 4) Re-render with the same arg.
1105 // This causes the `subscriptionRemoved` check to be `false`.
1106 // Correct behavior is this does _not_ clear the promise ref,
1107 // so
1108 rerender(['b'])
1109
1110 // There should only be one active subscription after changing the arg
1111 checkNumSubscriptions('b', 1)
1112
1113 await act(async () => {
1114 await waitMs(1)
1115 })
1116
1117 unmount()
1118
1119 await act(async () => {
1120 await waitMs(1)
1121 })
1122
1123 // There should be no subscription entries left over after changing
1124 // cache key args and swapping `skip` on and off
1125 checkNumSubscriptions('b', 0)
1126
1127 const finalSubscriptions = getSubscriptions()
1128
1129 for (const cacheKeyEntry of Object.values(finalSubscriptions)) {
1130 expect(Object.values(cacheKeyEntry!).length).toBe(0)
1131 }
1132 })
1133
1134 test('Hook subscription failures do not reset isLoading state', async () => {
1135 const states: boolean[] = []
1136
1137 function Parent() {
1138 const { isLoading } = api.endpoints.getUserAndForceError.useQuery(1)
1139
1140 // Collect loading states to verify that it does not revert back to true.
1141 states.push(isLoading)
1142
1143 // Parent conditionally renders child when loading.
1144 if (isLoading) return null
1145
1146 return <Child />
1147 }
1148
1149 function Child() {
1150 // Using the same args as the parent
1151 api.endpoints.getUserAndForceError.useQuery(1)
1152
1153 return null
1154 }
1155
1156 render(<Parent />, { wrapper: storeRef.wrapper })
1157
1158 expect(states).toHaveLength(2)
1159
1160 // Allow at least three state effects to hit.
1161 // Trying to see if any [true, false, true] occurs.
1162 await act(async () => {
1163 await waitForFakeTimer(150)
1164 })
1165
1166 expect(states).toHaveLength(4)
1167
1168 await act(async () => {
1169 await waitForFakeTimer(150)
1170 })
1171
1172 expect(states).toHaveLength(5)
1173
1174 await act(async () => {
1175 await waitForFakeTimer(150)
1176 })
1177
1178 expect(states).toHaveLength(5)
1179
1180 // Find if at any time the isLoading state has reverted
1181 // E.G.: `[..., true, false, ..., true]`
1182 // ^^^^ ^^^^^ ^^^^
1183 const firstTrue = states.indexOf(true)
1184 const firstFalse = states.slice(firstTrue).indexOf(false)
1185 const revertedState = states.slice(firstFalse).indexOf(true)
1186
1187 expect(
1188 revertedState,
1189 `Expected isLoading state to never revert back to true but did after ${revertedState} renders...`,
1190 ).toBe(-1)
1191 })
1192
1193 test('query thunk should be aborted when component unmounts and cache entry is removed', async () => {
1194 let abortSignalFromQueryFn: AbortSignal | undefined
1195
1196 const pokemonApi = createApi({
1197 baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
1198 endpoints: (builder) => ({
1199 getTest: builder.query<string, number>({
1200 async queryFn(arg, { signal }) {
1201 abortSignalFromQueryFn = signal
1202
1203 // Simulate a long-running request that should be aborted
1204 await new Promise((resolve, reject) => {
1205 const timeout = setTimeout(resolve, 5000)
1206
1207 signal.addEventListener('abort', () => {
1208 clearTimeout(timeout)
1209 reject(new Error('Aborted'))
1210 })
1211 })
1212
1213 return { data: 'data!' }
1214 },
1215 keepUnusedDataFor: 0.01, // Very short timeout (10ms)
1216 }),
1217 }),
1218 })
1219
1220 const storeRef = setupApiStore(pokemonApi, undefined, {
1221 withoutTestLifecycles: true,
1222 })
1223
1224 function TestComponent() {
1225 const { data, isFetching } = pokemonApi.endpoints.getTest.useQuery(1)
1226
1227 return (
1228 <div>
1229 <div data-testid="isFetching">{String(isFetching)}</div>
1230 <div data-testid="data">{data || 'no data'}</div>
1231 </div>
1232 )
1233 }
1234
1235 function App() {
1236 const [showComponent, setShowComponent] = useState(true)
1237
1238 return (
1239 <div>
1240 {showComponent && <TestComponent />}
1241 <button
1242 data-testid="unmount"
1243 onClick={() => setShowComponent(false)}
1244 >
1245 Unmount Component
1246 </button>
1247 </div>
1248 )
1249 }
1250
1251 render(<App />, { wrapper: storeRef.wrapper })
1252
1253 // Wait for the query to start
1254 await waitFor(() =>
1255 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1256 )
1257
1258 // Verify we have an abort signal
1259 expect(abortSignalFromQueryFn).toBeDefined()
1260 expect(abortSignalFromQueryFn!.aborted).toBe(false)
1261
1262 // Unmount the component
1263 fireEvent.click(screen.getByTestId('unmount'))
1264
1265 // Wait for the cache entry to be removed (keepUnusedDataFor: 0.01s = 10ms)
1266 await act(async () => {
1267 await delay(100)
1268 })
1269
1270 // The abort signal should now be aborted
1271 expect(abortSignalFromQueryFn!.aborted).toBe(true)
1272 })
1273
1274 describe('Hook middleware requirements', () => {
1275 const consoleErrorSpy = vi
1276 .spyOn(console, 'error')
1277 .mockImplementation(noop)
1278
1279 afterEach(() => {
1280 consoleErrorSpy.mockClear()
1281 })
1282
1283 afterAll(() => {
1284 consoleErrorSpy.mockRestore()
1285 })
1286
1287 test('Throws error if middleware is not added to the store', async () => {
1288 const store = configureStore({
1289 reducer: {
1290 [api.reducerPath]: api.reducer,
1291 },
1292 })
1293
1294 const doRender = () => {
1295 renderHook(() => api.endpoints.getIncrementedAmount.useQuery(), {
1296 wrapper: withProvider(store),
1297 })
1298 }
1299
1300 expect(doRender).toThrowError(
1301 /Warning: Middleware for RTK-Query API at reducerPath "api" has not been added to the store/,
1302 )
1303 })
1304 })
1305 })
1306
1307 describe('useLazyQuery', () => {
1308 let data: any
1309
1310 afterEach(() => {
1311 data = undefined
1312 })
1313
1314 let getRenderCount: () => number = () => 0
1315 test('useLazyQuery does not automatically fetch when mounted and has undefined data', async () => {
1316 function User() {
1317 const [fetchUser, { data: hookData, isFetching, isUninitialized }] =
1318 api.endpoints.getUser.useLazyQuery()
1319 getRenderCount = useRenderCounter()
1320
1321 data = hookData
1322
1323 return (
1324 <div>
1325 <div data-testid="isUninitialized">{String(isUninitialized)}</div>
1326 <div data-testid="isFetching">{String(isFetching)}</div>
1327 <button data-testid="fetchButton" onClick={() => fetchUser(1)}>
1328 fetchUser
1329 </button>
1330 </div>
1331 )
1332 }
1333
1334 render(<User />, { wrapper: storeRef.wrapper })
1335 expect(getRenderCount()).toBe(1)
1336
1337 await waitFor(() =>
1338 expect(screen.getByTestId('isUninitialized').textContent).toBe('true'),
1339 )
1340 await waitFor(() => expect(data).toBeUndefined())
1341
1342 fireEvent.click(screen.getByTestId('fetchButton'))
1343 expect(getRenderCount()).toBe(2)
1344
1345 await waitFor(() =>
1346 expect(screen.getByTestId('isUninitialized').textContent).toBe('false'),
1347 )
1348 await waitFor(() =>
1349 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
1350 )
1351 expect(getRenderCount()).toBe(3)
1352
1353 fireEvent.click(screen.getByTestId('fetchButton'))
1354 await waitFor(() =>
1355 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1356 )
1357 await waitFor(() =>
1358 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
1359 )
1360 expect(getRenderCount()).toBe(5)
1361 })
1362
1363 test('useLazyQuery accepts updated subscription options and only dispatches updateSubscriptionOptions when values are updated', async () => {
1364 let interval = 1000
1365 function User() {
1366 const [options, setOptions] = useState<SubscriptionOptions>()
1367 const [fetchUser, { data: hookData, isFetching, isUninitialized }] =
1368 api.endpoints.getUser.useLazyQuery(options)
1369 getRenderCount = useRenderCounter()
1370
1371 data = hookData
1372
1373 return (
1374 <div>
1375 <div data-testid="isUninitialized">{String(isUninitialized)}</div>
1376 <div data-testid="isFetching">{String(isFetching)}</div>
1377
1378 <button data-testid="fetchButton" onClick={() => fetchUser(1)}>
1379 fetchUser
1380 </button>
1381 <button
1382 data-testid="updateOptions"
1383 onClick={() =>
1384 setOptions({
1385 pollingInterval: interval,
1386 })
1387 }
1388 >
1389 updateOptions
1390 </button>
1391 </div>
1392 )
1393 }
1394
1395 render(<User />, { wrapper: storeRef.wrapper })
1396 expect(getRenderCount()).toBe(1) // hook mount
1397
1398 await waitFor(() =>
1399 expect(screen.getByTestId('isUninitialized').textContent).toBe('true'),
1400 )
1401 await waitFor(() => expect(data).toBeUndefined())
1402
1403 fireEvent.click(screen.getByTestId('fetchButton'))
1404 expect(getRenderCount()).toBe(2)
1405
1406 await waitFor(() =>
1407 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1408 )
1409 await waitFor(() =>
1410 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
1411 )
1412 expect(getRenderCount()).toBe(3)
1413
1414 fireEvent.click(screen.getByTestId('updateOptions')) // setState = 1
1415 expect(getRenderCount()).toBe(4)
1416
1417 fireEvent.click(screen.getByTestId('fetchButton')) // perform new request = 2
1418 await waitFor(() =>
1419 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1420 )
1421 await waitFor(() =>
1422 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
1423 )
1424 expect(getRenderCount()).toBe(6)
1425
1426 interval = 1000
1427
1428 fireEvent.click(screen.getByTestId('updateOptions')) // setState = 1
1429 expect(getRenderCount()).toBe(7)
1430
1431 fireEvent.click(screen.getByTestId('fetchButton'))
1432 await waitFor(() =>
1433 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1434 )
1435 await waitFor(() =>
1436 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
1437 )
1438 expect(getRenderCount()).toBe(9)
1439
1440 expect(
1441 actions.filter(api.internalActions.updateSubscriptionOptions.match),
1442 ).toHaveLength(1)
1443 })
1444
1445 test('useLazyQuery accepts updated args and unsubscribes the original query', async () => {
1446 function User() {
1447 const [fetchUser, { data: hookData, isFetching, isUninitialized }] =
1448 api.endpoints.getUser.useLazyQuery()
1449
1450 data = hookData
1451
1452 return (
1453 <div>
1454 <div data-testid="isUninitialized">{String(isUninitialized)}</div>
1455 <div data-testid="isFetching">{String(isFetching)}</div>
1456
1457 <button data-testid="fetchUser1" onClick={() => fetchUser(1)}>
1458 fetchUser1
1459 </button>
1460 <button data-testid="fetchUser2" onClick={() => fetchUser(2)}>
1461 fetchUser2
1462 </button>
1463 </div>
1464 )
1465 }
1466
1467 const { unmount } = render(<User />, { wrapper: storeRef.wrapper })
1468
1469 await waitFor(() =>
1470 expect(screen.getByTestId('isUninitialized').textContent).toBe('true'),
1471 )
1472 await waitFor(() => expect(data).toBeUndefined())
1473
1474 fireEvent.click(screen.getByTestId('fetchUser1'))
1475
1476 await waitFor(() =>
1477 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1478 )
1479 await waitFor(() =>
1480 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
1481 )
1482
1483 // Being that there is only the initial query, no unsubscribe should be dispatched
1484 expect(
1485 actions.filter(api.internalActions.unsubscribeQueryResult.match),
1486 ).toHaveLength(0)
1487
1488 fireEvent.click(screen.getByTestId('fetchUser2'))
1489
1490 await waitFor(() =>
1491 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1492 )
1493 await waitFor(() =>
1494 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
1495 )
1496
1497 expect(
1498 actions.filter(api.internalActions.unsubscribeQueryResult.match),
1499 ).toHaveLength(1)
1500
1501 fireEvent.click(screen.getByTestId('fetchUser1'))
1502
1503 expect(
1504 actions.filter(api.internalActions.unsubscribeQueryResult.match),
1505 ).toHaveLength(2)
1506
1507 // we always unsubscribe the original promise and create a new one
1508 fireEvent.click(screen.getByTestId('fetchUser1'))
1509 expect(
1510 actions.filter(api.internalActions.unsubscribeQueryResult.match),
1511 ).toHaveLength(3)
1512
1513 unmount()
1514
1515 // We unsubscribe after the component unmounts
1516 expect(
1517 actions.filter(api.internalActions.unsubscribeQueryResult.match),
1518 ).toHaveLength(4)
1519 })
1520
1521 test('useLazyQuery hook callback returns various properties to handle the result', async () => {
1522 const user = userEvent.setup()
1523
1524 function User() {
1525 const [getUser] = api.endpoints.getUser.useLazyQuery()
1526 const [{ successMsg, errMsg, isAborted }, setValues] = useState({
1527 successMsg: '',
1528 errMsg: '',
1529 isAborted: false,
1530 })
1531
1532 const handleClick = (abort: boolean) => async () => {
1533 const res = getUser(1)
1534
1535 // abort the query immediately to force an error
1536 if (abort) res.abort()
1537 res
1538 .unwrap()
1539 .then((result) => {
1540 setValues({
1541 successMsg: `Successfully fetched user ${result.name}`,
1542 errMsg: '',
1543 isAborted: false,
1544 })
1545 })
1546 .catch((err) => {
1547 setValues({
1548 successMsg: '',
1549 errMsg: `An error has occurred fetching userId: ${res.arg}`,
1550 isAborted: err.name === 'AbortError',
1551 })
1552 })
1553 }
1554
1555 return (
1556 <div>
1557 <button onClick={handleClick(false)}>
1558 Fetch User successfully
1559 </button>
1560 <button onClick={handleClick(true)}>Fetch User and abort</button>
1561 <div>{successMsg}</div>
1562 <div>{errMsg}</div>
1563 <div>{isAborted ? 'Request was aborted' : ''}</div>
1564 </div>
1565 )
1566 }
1567
1568 render(<User />, { wrapper: storeRef.wrapper })
1569 expect(screen.queryByText(/An error has occurred/i)).toBeNull()
1570 expect(screen.queryByText(/Successfully fetched user/i)).toBeNull()
1571 expect(screen.queryByText('Request was aborted')).toBeNull()
1572
1573 fireEvent.click(
1574 screen.getByRole('button', { name: 'Fetch User and abort' }),
1575 )
1576 await screen.findByText('An error has occurred fetching userId: 1')
1577 expect(screen.queryByText(/Successfully fetched user/i)).toBeNull()
1578 screen.getByText('Request was aborted')
1579
1580 await user.click(
1581 screen.getByRole('button', { name: 'Fetch User successfully' }),
1582 )
1583
1584 await screen.findByText('Successfully fetched user Timmy')
1585 expect(screen.queryByText(/An error has occurred/i)).toBeNull()
1586 expect(screen.queryByText('Request was aborted')).toBeNull()
1587 })
1588
1589 // Based on issue #5079, which I couldn't reproduce but we might as well capture
1590 test('useLazyQuery calling abort() multiple times does not throw an error', async () => {
1591 const user = userEvent.setup()
1592
1593 // Create a fresh API instance with fetchBaseQuery and timeout, matching the user's example
1594 const timeoutApi = createApi({
1595 baseQuery: fetchBaseQuery({
1596 baseUrl: 'https://example.com',
1597 timeout: 5000,
1598 }),
1599 endpoints: (builder) => ({
1600 getData: builder.query<string, void>({
1601 query: () => ({ url: '/data/' }),
1602 }),
1603 }),
1604 })
1605
1606 const timeoutStoreRef = setupApiStore(timeoutApi, undefined, {
1607 withoutTestLifecycles: true,
1608 })
1609
1610 // Set up a mock handler for the endpoint
1611 server.use(
1612 http.get('https://example.com/data/', async () => {
1613 await delay(100)
1614 return HttpResponse.json('test data')
1615 }),
1616 )
1617
1618 function Component() {
1619 const [trigger] = timeoutApi.endpoints.getData.useLazyQuery()
1620 const abortRef = useRef<(() => void) | undefined>(undefined)
1621 const [errorMsg, setErrorMsg] = useState('')
1622
1623 const handleChange = () => {
1624 // Abort any previous request
1625 abortRef.current?.()
1626
1627 // Trigger new request
1628 const result = trigger()
1629
1630 // Store abort function for next call
1631 abortRef.current = () => {
1632 try {
1633 result.abort()
1634 } catch (err: any) {
1635 setErrorMsg(err.message)
1636 }
1637 }
1638 }
1639
1640 return (
1641 <div>
1642 <input data-testid="input" onChange={handleChange} />
1643 <div data-testid="error">{errorMsg}</div>
1644 </div>
1645 )
1646 }
1647
1648 render(<Component />, { wrapper: timeoutStoreRef.wrapper })
1649
1650 const input = screen.getByTestId('input')
1651
1652 // Trigger multiple rapid changes that will call abort() multiple times
1653 await user.type(input, 'abc')
1654
1655 // Wait a bit to ensure any errors would have been caught
1656 await waitMs(200)
1657
1658 // Should not have any error messages
1659 expect(screen.getByTestId('error').textContent).toBe('')
1660 })
1661
1662 test('unwrapping the useLazyQuery trigger result does not throw on ConditionError and instead returns the aggregate error', async () => {
1663 function User() {
1664 const [getUser, { data, error }] =
1665 api.endpoints.getUserAndForceError.useLazyQuery()
1666
1667 const [unwrappedError, setUnwrappedError] = useState<any>()
1668
1669 const handleClick = async () => {
1670 const res = getUser(1)
1671
1672 try {
1673 await res.unwrap()
1674 } catch (error) {
1675 setUnwrappedError(error)
1676 }
1677 }
1678
1679 return (
1680 <div>
1681 <button onClick={handleClick}>Fetch User</button>
1682 <div data-testid="result">{JSON.stringify(data)}</div>
1683 <div data-testid="error">{JSON.stringify(error)}</div>
1684 <div data-testid="unwrappedError">
1685 {JSON.stringify(unwrappedError)}
1686 </div>
1687 </div>
1688 )
1689 }
1690
1691 render(<User />, { wrapper: storeRef.wrapper })
1692
1693 const fetchButton = screen.getByRole('button', { name: 'Fetch User' })
1694 fireEvent.click(fetchButton)
1695 fireEvent.click(fetchButton) // This technically dispatches a ConditionError, but we don't want to see that here. We want the real error to resolve.
1696
1697 await waitFor(() => {
1698 const errorResult = screen.getByTestId('error')?.textContent
1699 const unwrappedErrorResult =
1700 screen.getByTestId('unwrappedError')?.textContent
1701
1702 if (errorResult && unwrappedErrorResult) {
1703 expect(JSON.parse(errorResult)).toMatchObject({
1704 status: 500,
1705 data: null,
1706 })
1707 expect(JSON.parse(unwrappedErrorResult)).toMatchObject(
1708 JSON.parse(errorResult),
1709 )
1710 }
1711 })
1712
1713 expect(screen.getByTestId('result').textContent).toBe('')
1714 })
1715
1716 test('useLazyQuery does not throw on ConditionError and instead returns the aggregate result', async () => {
1717 function User() {
1718 const [getUser, { data, error }] = api.endpoints.getUser.useLazyQuery()
1719
1720 const [unwrappedResult, setUnwrappedResult] = useState<
1721 undefined | { name: string }
1722 >()
1723
1724 const handleClick = async () => {
1725 const res = getUser(1)
1726
1727 const result = await res.unwrap()
1728 setUnwrappedResult(result)
1729 }
1730
1731 return (
1732 <div>
1733 <button onClick={handleClick}>Fetch User</button>
1734 <div data-testid="result">{JSON.stringify(data)}</div>
1735 <div data-testid="error">{JSON.stringify(error)}</div>
1736 <div data-testid="unwrappedResult">
1737 {JSON.stringify(unwrappedResult)}
1738 </div>
1739 </div>
1740 )
1741 }
1742
1743 render(<User />, { wrapper: storeRef.wrapper })
1744
1745 const fetchButton = screen.getByRole('button', { name: 'Fetch User' })
1746 fireEvent.click(fetchButton)
1747 fireEvent.click(fetchButton) // This technically dispatches a ConditionError, but we don't want to see that here. We want the real result to resolve and ignore the error.
1748
1749 await waitFor(() => {
1750 const dataResult = screen.getByTestId('error')?.textContent
1751 const unwrappedDataResult =
1752 screen.getByTestId('unwrappedResult')?.textContent
1753
1754 if (dataResult && unwrappedDataResult) {
1755 expect(JSON.parse(dataResult)).toMatchObject({
1756 name: 'Timmy',
1757 })
1758 expect(JSON.parse(unwrappedDataResult)).toMatchObject(
1759 JSON.parse(dataResult),
1760 )
1761 }
1762 })
1763
1764 expect(screen.getByTestId('error').textContent).toBe('')
1765 })
1766
1767 test('useLazyQuery trigger promise returns the correctly updated data', async () => {
1768 const user = userEvent.setup()
1769
1770 const LazyUnwrapUseEffect = () => {
1771 const [triggerGetIncrementedAmount, { isFetching, isSuccess, data }] =
1772 api.endpoints.getIncrementedAmount.useLazyQuery()
1773
1774 type AmountData = { amount: number } | undefined
1775
1776 const [triggerUpdate] = api.endpoints.triggerUpdatedAmount.useMutation()
1777
1778 const [dataFromQuery, setDataFromQuery] =
1779 useState<AmountData>(undefined)
1780 const [dataFromTrigger, setDataFromTrigger] =
1781 useState<AmountData>(undefined)
1782
1783 const handleLoad = async () => {
1784 try {
1785 const res = await triggerGetIncrementedAmount().unwrap()
1786
1787 setDataFromTrigger(res) // adding client side state here will cause stale data
1788 } catch (error) {
1789 console.error('Error handling increment trigger', error)
1790 }
1791 }
1792
1793 const handleMutate = async () => {
1794 try {
1795 await triggerUpdate()
1796 // Force the lazy trigger to refetch
1797 await handleLoad()
1798 } catch (error) {
1799 console.error('Error handling mutate trigger', error)
1800 }
1801 }
1802
1803 useEffect(() => {
1804 // Intentionally copy to local state for comparison purposes
1805 setDataFromQuery(data)
1806 }, [data])
1807
1808 let content: React.ReactNode | null = null
1809
1810 if (isFetching) {
1811 content = <div className="loading">Loading</div>
1812 } else if (isSuccess) {
1813 content = (
1814 <div className="wrapper">
1815 <div>
1816 useEffect data: {dataFromQuery?.amount ?? 'No query amount'}
1817 </div>
1818 <div>
1819 Unwrap data: {dataFromTrigger?.amount ?? 'No trigger amount'}
1820 </div>
1821 </div>
1822 )
1823 }
1824
1825 return (
1826 <div className="outer">
1827 <button onClick={() => handleLoad()}>Load Data</button>
1828 <button onClick={() => handleMutate()}>Update Data</button>
1829 {content}
1830 </div>
1831 )
1832 }
1833
1834 render(<LazyUnwrapUseEffect />, { wrapper: storeRef.wrapper })
1835
1836 // Kick off the initial fetch via lazy query trigger
1837 await user.click(screen.getByText('Load Data'))
1838
1839 // We get back initial data, which should get copied into local state,
1840 // and also should come back as valid via the lazy trigger promise
1841 await waitFor(() => {
1842 expect(screen.getByText('useEffect data: 1')).toBeTruthy()
1843 expect(screen.getByText('Unwrap data: 1')).toBeTruthy()
1844 })
1845
1846 // If we mutate and then re-run the lazy trigger afterwards...
1847 await user.click(screen.getByText('Update Data'))
1848
1849 // We should see both sets of data agree (ie, the lazy trigger promise
1850 // should not return stale data or be out of sync with the hook).
1851 // Prior to PR #4651, this would fail because the trigger never updated properly.
1852 await waitFor(() => {
1853 expect(screen.getByText('useEffect data: 2')).toBeTruthy()
1854 expect(screen.getByText('Unwrap data: 2')).toBeTruthy()
1855 })
1856 })
1857
1858 test('`reset` sets state back to original state', async () => {
1859 const user = userEvent.setup()
1860
1861 function User() {
1862 const [getUser, { isSuccess, isUninitialized, reset }, _lastInfo] =
1863 api.endpoints.getUser.useLazyQuery()
1864
1865 const handleFetchClick = async () => {
1866 await getUser(1).unwrap()
1867 }
1868
1869 return (
1870 <div>
1871 <span>
1872 {isUninitialized
1873 ? 'isUninitialized'
1874 : isSuccess
1875 ? 'isSuccess'
1876 : 'other'}
1877 </span>
1878 <button onClick={handleFetchClick}>Fetch User</button>
1879 <button onClick={reset}>Reset</button>
1880 </div>
1881 )
1882 }
1883
1884 render(<User />, { wrapper: storeRef.wrapper })
1885
1886 await screen.findByText(/isUninitialized/i)
1887 expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(0)
1888
1889 await user.click(screen.getByRole('button', { name: 'Fetch User' }))
1890
1891 await screen.findByText(/isSuccess/i)
1892 expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(1)
1893
1894 await user.click(
1895 screen.getByRole('button', {
1896 name: 'Reset',
1897 }),
1898 )
1899
1900 await screen.findByText(/isUninitialized/i)
1901 expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(0)
1902 })
1903 })
1904
1905 describe('useInfiniteQuery', () => {
1906 type Pokemon = {
1907 id: string
1908 name: string
1909 }
1910
1911 const pokemonApi = createApi({
1912 baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
1913 endpoints: (builder) => ({
1914 getInfinitePokemon: builder.infiniteQuery<Pokemon, string, number>({
1915 infiniteQueryOptions: {
1916 initialPageParam: 0,
1917 getNextPageParam: (
1918 lastPage,
1919 allPages,
1920 lastPageParam,
1921 allPageParams,
1922 ) => lastPageParam + 1,
1923 getPreviousPageParam: (
1924 firstPage,
1925 allPages,
1926 firstPageParam,
1927 allPageParams,
1928 ) => {
1929 return firstPageParam > 0 ? firstPageParam - 1 : undefined
1930 },
1931 },
1932 query({ pageParam }) {
1933 return `https://example.com/listItems?page=${pageParam}`
1934 },
1935 }),
1936 }),
1937 })
1938
1939 const pokemonApiWithRefetch = createApi({
1940 baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
1941 endpoints: (builder) => ({
1942 getInfinitePokemon: builder.infiniteQuery<Pokemon, string, number>({
1943 infiniteQueryOptions: {
1944 initialPageParam: 0,
1945 getNextPageParam: (
1946 lastPage,
1947 allPages,
1948 lastPageParam,
1949 allPageParams,
1950 ) => lastPageParam + 1,
1951 getPreviousPageParam: (
1952 firstPage,
1953 allPages,
1954 firstPageParam,
1955 allPageParams,
1956 ) => {
1957 return firstPageParam > 0 ? firstPageParam - 1 : undefined
1958 },
1959 },
1960 query({ pageParam }) {
1961 return `https://example.com/listItems?page=${pageParam}`
1962 },
1963 }),
1964 }),
1965 refetchOnMountOrArgChange: true,
1966 })
1967
1968 function PokemonList({
1969 api,
1970 arg = 'fire',
1971 initialPageParam = 0,
1972 }: {
1973 api: typeof pokemonApi
1974 arg?: string
1975 initialPageParam?: number
1976 }) {
1977 const {
1978 data,
1979 isFetching,
1980 isUninitialized,
1981 fetchNextPage,
1982 fetchPreviousPage,
1983 refetch,
1984 } = api.useGetInfinitePokemonInfiniteQuery(arg, {
1985 initialPageParam,
1986 })
1987
1988 const handlePreviousPage = async () => {
1989 const res = await fetchPreviousPage()
1990 }
1991
1992 const handleNextPage = async () => {
1993 const res = await fetchNextPage()
1994 }
1995
1996 const handleRefetch = async () => {
1997 const res = await refetch()
1998 }
1999
2000 return (
2001 <div>
2002 <div data-testid="isUninitialized">{String(isUninitialized)}</div>
2003 <div data-testid="isFetching">{String(isFetching)}</div>
2004 <div>Type: {arg}</div>
2005 <div data-testid="data">
2006 {data?.pages.map((page, i: number | null | undefined) => (
2007 <div key={i}>{page.name}</div>
2008 ))}
2009 </div>
2010 <button data-testid="prevPage" onClick={() => handlePreviousPage()}>
2011 previousPage
2012 </button>
2013 <button data-testid="nextPage" onClick={() => handleNextPage()}>
2014 nextPage
2015 </button>
2016 <button data-testid="refetch" onClick={() => handleRefetch()}>
2017 refetch
2018 </button>
2019 </div>
2020 )
2021 }
2022
2023 beforeEach(() => {
2024 server.use(
2025 http.get('https://example.com/listItems', ({ request }) => {
2026 const url = new URL(request.url)
2027 const pageString = url.searchParams.get('page')
2028 const pageNum = parseInt(pageString || '0')
2029
2030 const results: Pokemon = {
2031 id: `${pageNum}`,
2032 name: `Pokemon ${pageNum}`,
2033 }
2034
2035 return HttpResponse.json(results)
2036 }),
2037 )
2038 })
2039
2040 test.each([
2041 ['no refetch', pokemonApi],
2042 ['with refetch', pokemonApiWithRefetch],
2043 ])(`useInfiniteQuery %s`, async (_, pokemonApi) => {
2044 const storeRef = setupApiStore(pokemonApi, undefined, {
2045 withoutTestLifecycles: true,
2046 })
2047
2048 const { takeRender, render, getCurrentRender } = createRenderStream({
2049 snapshotDOM: true,
2050 })
2051
2052 const checkNumQueries = (count: number) => {
2053 const cacheEntries = Object.keys(storeRef.store.getState().api.queries)
2054 const queries = cacheEntries.length
2055
2056 expect(queries).toBe(count)
2057 }
2058
2059 const checkEntryFlags = (
2060 arg: string,
2061 expectedFlags: Partial<InfiniteQueryResultFlags>,
2062 ) => {
2063 const selector = pokemonApi.endpoints.getInfinitePokemon.select(arg)
2064 const entry = selector(storeRef.store.getState())
2065
2066 const actualFlags: InfiniteQueryResultFlags = {
2067 hasNextPage: false,
2068 hasPreviousPage: false,
2069 isFetchingNextPage: false,
2070 isFetchingPreviousPage: false,
2071 isFetchNextPageError: false,
2072 isFetchPreviousPageError: false,
2073 ...expectedFlags,
2074 }
2075
2076 expect(entry).toMatchObject(actualFlags)
2077 }
2078
2079 const checkPageRows = (
2080 withinDOM: () => SyncScreen,
2081 type: string,
2082 ids: number[],
2083 ) => {
2084 expect(withinDOM().getByText(`Type: ${type}`)).toBeTruthy()
2085 for (const id of ids) {
2086 expect(withinDOM().getByText(`Pokemon ${id}`)).toBeTruthy()
2087 }
2088 }
2089
2090 async function waitForFetch(handleExtraMiddleRender = false) {
2091 {
2092 const { withinDOM } = await takeRender()
2093 expect(withinDOM().getByTestId('isFetching').textContent).toBe('true')
2094 }
2095
2096 // We seem to do an extra render when fetching an uninitialized entry
2097 if (handleExtraMiddleRender) {
2098 {
2099 const { withinDOM } = await takeRender()
2100 expect(withinDOM().getByTestId('isFetching').textContent).toBe(
2101 'true',
2102 )
2103 }
2104 }
2105
2106 {
2107 // Second fetch complete
2108 const { withinDOM } = await takeRender()
2109 expect(withinDOM().getByTestId('isFetching').textContent).toBe(
2110 'false',
2111 )
2112 }
2113 }
2114
2115 const utils = render(<PokemonList api={pokemonApi} />, {
2116 wrapper: storeRef.wrapper,
2117 })
2118 checkNumQueries(1)
2119 checkEntryFlags('fire', {})
2120 await waitForFetch(true)
2121 checkNumQueries(1)
2122 checkPageRows(getCurrentRender().withinDOM, 'fire', [0])
2123 checkEntryFlags('fire', {
2124 hasNextPage: true,
2125 })
2126
2127 fireEvent.click(screen.getByTestId('nextPage'), {})
2128 checkEntryFlags('fire', {
2129 hasNextPage: true,
2130 isFetchingNextPage: true,
2131 })
2132 await waitForFetch()
2133 checkPageRows(getCurrentRender().withinDOM, 'fire', [0, 1])
2134 checkEntryFlags('fire', {
2135 hasNextPage: true,
2136 })
2137
2138 fireEvent.click(screen.getByTestId('nextPage'))
2139 await waitForFetch()
2140 checkPageRows(getCurrentRender().withinDOM, 'fire', [0, 1, 2])
2141
2142 utils.rerender(
2143 <PokemonList api={pokemonApi} arg="water" initialPageParam={3} />,
2144 )
2145 checkEntryFlags('water', {})
2146 await waitForFetch(true)
2147 checkNumQueries(2)
2148 checkPageRows(getCurrentRender().withinDOM, 'water', [3])
2149 checkEntryFlags('water', {
2150 hasNextPage: true,
2151 hasPreviousPage: true,
2152 })
2153
2154 fireEvent.click(screen.getByTestId('nextPage'))
2155 checkEntryFlags('water', {
2156 hasNextPage: true,
2157 hasPreviousPage: true,
2158 isFetchingNextPage: true,
2159 })
2160 await waitForFetch()
2161 checkPageRows(getCurrentRender().withinDOM, 'water', [3, 4])
2162 checkEntryFlags('water', {
2163 hasNextPage: true,
2164 hasPreviousPage: true,
2165 })
2166
2167 fireEvent.click(screen.getByTestId('prevPage'))
2168 checkEntryFlags('water', {
2169 hasNextPage: true,
2170 hasPreviousPage: true,
2171 isFetchingPreviousPage: true,
2172 })
2173 await waitForFetch()
2174 checkPageRows(getCurrentRender().withinDOM, 'water', [2, 3, 4])
2175 checkEntryFlags('water', {
2176 hasNextPage: true,
2177 hasPreviousPage: true,
2178 })
2179
2180 fireEvent.click(screen.getByTestId('refetch'))
2181 checkEntryFlags('water', {
2182 hasNextPage: true,
2183 hasPreviousPage: true,
2184 })
2185 await waitForFetch()
2186 checkPageRows(getCurrentRender().withinDOM, 'water', [2, 3, 4])
2187 checkEntryFlags('water', {
2188 hasNextPage: true,
2189 hasPreviousPage: true,
2190 })
2191 })
2192
2193 test('Object page params does not keep forcing refetching', async () => {
2194 type Project = {
2195 id: number
2196 createdAt: string
2197 }
2198
2199 type ProjectsResponse = {
2200 projects: Project[]
2201 numFound: number
2202 serverTime: string
2203 }
2204
2205 interface ProjectsInitialPageParam {
2206 offset: number
2207 limit: number
2208 }
2209
2210 const apiWithInfiniteScroll = createApi({
2211 baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/' }),
2212 endpoints: (builder) => ({
2213 projectsLimitOffset: builder.infiniteQuery<
2214 ProjectsResponse,
2215 void,
2216 ProjectsInitialPageParam
2217 >({
2218 infiniteQueryOptions: {
2219 initialPageParam: {
2220 offset: 0,
2221 limit: 20,
2222 },
2223 getNextPageParam: (
2224 lastPage,
2225 allPages,
2226 lastPageParam,
2227 allPageParams,
2228 ) => {
2229 const nextOffset = lastPageParam.offset + lastPageParam.limit
2230 const remainingItems = lastPage?.numFound - nextOffset
2231
2232 if (remainingItems <= 0) {
2233 return undefined
2234 }
2235
2236 return {
2237 ...lastPageParam,
2238 offset: nextOffset,
2239 }
2240 },
2241 getPreviousPageParam: (
2242 firstPage,
2243 allPages,
2244 firstPageParam,
2245 allPageParams,
2246 ) => {
2247 const prevOffset = firstPageParam.offset - firstPageParam.limit
2248 if (prevOffset < 0) return undefined
2249
2250 return {
2251 ...firstPageParam,
2252 offset: firstPageParam.offset - firstPageParam.limit,
2253 }
2254 },
2255 },
2256 query: ({ pageParam }) => {
2257 const { offset, limit } = pageParam
2258 return {
2259 url: `https://example.com/api/projectsLimitOffset?offset=${offset}&limit=${limit}`,
2260 method: 'GET',
2261 }
2262 },
2263 }),
2264 }),
2265 })
2266
2267 const projects = Array.from({ length: 50 }, (_, i) => {
2268 return {
2269 id: i,
2270 createdAt: Date.now() + i * 1000,
2271 }
2272 })
2273
2274 let numRequests = 0
2275
2276 server.use(
2277 http.get(
2278 'https://example.com/api/projectsLimitOffset',
2279 async ({ request }) => {
2280 const url = new URL(request.url)
2281 const limit = parseInt(url.searchParams.get('limit') ?? '5', 10)
2282 let offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
2283
2284 numRequests++
2285
2286 if (isNaN(offset) || offset < 0) {
2287 offset = 0
2288 }
2289 if (isNaN(limit) || limit <= 0) {
2290 return HttpResponse.json(
2291 {
2292 message:
2293 "Invalid 'limit' parameter. It must be a positive integer.",
2294 } as any,
2295 { status: 400 },
2296 )
2297 }
2298
2299 const result = projects.slice(offset, offset + limit)
2300
2301 await delay(10)
2302 return HttpResponse.json({
2303 projects: result,
2304 serverTime: Date.now(),
2305 numFound: projects.length,
2306 })
2307 },
2308 ),
2309 )
2310
2311 function LimitOffsetExample() {
2312 const {
2313 data,
2314 hasPreviousPage,
2315 hasNextPage,
2316 error,
2317 isFetching,
2318 isLoading,
2319 isError,
2320 fetchNextPage,
2321 fetchPreviousPage,
2322 isFetchingNextPage,
2323 isFetchingPreviousPage,
2324 status,
2325 } = apiWithInfiniteScroll.useProjectsLimitOffsetInfiniteQuery(
2326 undefined,
2327 {
2328 initialPageParam: {
2329 offset: 10,
2330 limit: 10,
2331 },
2332 },
2333 )
2334
2335 const [counter, setCounter] = useState(0)
2336
2337 const combinedData = useMemo(() => {
2338 return data?.pages?.map((item) => item?.projects)?.flat()
2339 }, [data])
2340
2341 return (
2342 <div>
2343 <h2>Limit and Offset Infinite Scroll</h2>
2344 <button onClick={() => setCounter((c) => c + 1)}>Increment</button>
2345 <div>Counter: {counter}</div>
2346 {isLoading ? (
2347 <p>Loading...</p>
2348 ) : isError ? (
2349 <span>Error: {error.message}</span>
2350 ) : null}
2351
2352 <>
2353 <div>
2354 <button
2355 onClick={() => fetchPreviousPage()}
2356 disabled={!hasPreviousPage || isFetchingPreviousPage}
2357 >
2358 {isFetchingPreviousPage
2359 ? 'Loading more...'
2360 : hasPreviousPage
2361 ? 'Load Older'
2362 : 'Nothing more to load'}
2363 </button>
2364 </div>
2365 <div data-testid="projects">
2366 {combinedData?.map((project, index, arr) => {
2367 return (
2368 <div key={project.id}>
2369 <div data-testid="project">
2370 <div>{`Project ${project.id} (created at: ${project.createdAt})`}</div>
2371 </div>
2372 </div>
2373 )
2374 })}
2375 </div>
2376 <div>
2377 <button
2378 onClick={() => fetchNextPage()}
2379 disabled={!hasNextPage || isFetchingNextPage}
2380 >
2381 {isFetchingNextPage
2382 ? 'Loading more...'
2383 : hasNextPage
2384 ? 'Load Newer'
2385 : 'Nothing more to load'}
2386 </button>
2387 </div>
2388 <div>
2389 {isFetching && !isFetchingPreviousPage && !isFetchingNextPage
2390 ? 'Background Updating...'
2391 : null}
2392 </div>
2393 </>
2394 </div>
2395 )
2396 }
2397
2398 const storeRef = setupApiStore(
2399 apiWithInfiniteScroll,
2400 { ...actionsReducer },
2401 {
2402 withoutTestLifecycles: true,
2403 },
2404 )
2405
2406 const { takeRender, render, totalRenderCount } = createRenderStream({
2407 snapshotDOM: true,
2408 })
2409
2410 render(<LimitOffsetExample />, {
2411 wrapper: storeRef.wrapper,
2412 })
2413
2414 {
2415 const { withinDOM } = await takeRender()
2416 withinDOM().getByText('Counter: 0')
2417 withinDOM().getByText('Loading...')
2418 }
2419
2420 {
2421 const { withinDOM } = await takeRender()
2422 withinDOM().getByText('Counter: 0')
2423 withinDOM().getByText('Loading...')
2424 }
2425
2426 {
2427 const { withinDOM } = await takeRender()
2428 withinDOM().getByText('Counter: 0')
2429
2430 expect(withinDOM().getAllByTestId('project').length).toBe(10)
2431 expect(withinDOM().queryByTestId('Loading...')).toBeNull()
2432 }
2433
2434 expect(totalRenderCount()).toBe(3)
2435 expect(numRequests).toBe(1)
2436 })
2437
2438 test.each([
2439 ['skip token', true],
2440 ['skip option', false],
2441 ])(
2442 'useInfiniteQuery hook does not fetch when skipped via %s',
2443 async (_, useSkipToken) => {
2444 function Pokemon() {
2445 const [value, setValue] = useState(0)
2446
2447 const shouldFetch = value > 0
2448
2449 const arg = shouldFetch || !useSkipToken ? 'fire' : skipToken
2450 const skip = useSkipToken ? undefined : shouldFetch ? undefined : true
2451
2452 const { isFetching } = pokemonApi.useGetInfinitePokemonInfiniteQuery(
2453 arg,
2454 {
2455 skip,
2456 },
2457 )
2458 getRenderCount = useRenderCounter()
2459
2460 return (
2461 <div>
2462 <div data-testid="isFetching">{String(isFetching)}</div>
2463 <button onClick={() => setValue((val) => val + 1)}>
2464 Increment value
2465 </button>
2466 </div>
2467 )
2468 }
2469
2470 render(<Pokemon />, { wrapper: storeRef.wrapper })
2471 expect(getRenderCount()).toBe(1)
2472
2473 await waitFor(() =>
2474 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
2475 )
2476 fireEvent.click(screen.getByText('Increment value'))
2477 await waitFor(() =>
2478 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
2479 )
2480 expect(getRenderCount()).toBe(2)
2481 },
2482 )
2483
2484 test('useInfiniteQuery hook option refetchCachedPages: false only refetches first page', async () => {
2485 const storeRef = setupApiStore(pokemonApi, undefined, {
2486 withoutTestLifecycles: true,
2487 })
2488
2489 function PokemonList() {
2490 const { data, fetchNextPage, refetch } =
2491 pokemonApi.useGetInfinitePokemonInfiniteQuery('fire', {
2492 refetchCachedPages: false,
2493 })
2494
2495 return (
2496 <div>
2497 <div data-testid="data">
2498 {data?.pages.map((page, i) => (
2499 <div key={i} data-testid={`page-${i}`}>
2500 {page.name}
2501 </div>
2502 ))}
2503 </div>
2504 <button data-testid="nextPage" onClick={() => fetchNextPage()}>
2505 Next Page
2506 </button>
2507 <button data-testid="refetch" onClick={() => refetch()}>
2508 Refetch
2509 </button>
2510 </div>
2511 )
2512 }
2513
2514 render(<PokemonList />, { wrapper: storeRef.wrapper })
2515
2516 // Wait for initial page to load
2517 await waitFor(() => {
2518 expect(screen.getByTestId('page-0').textContent).toBe('Pokemon 0')
2519 })
2520
2521 // Fetch second page
2522 fireEvent.click(screen.getByTestId('nextPage'))
2523 await waitFor(() => {
2524 expect(screen.getByTestId('page-1').textContent).toBe('Pokemon 1')
2525 })
2526
2527 // Fetch third page
2528 fireEvent.click(screen.getByTestId('nextPage'))
2529 await waitFor(() => {
2530 expect(screen.getByTestId('page-2').textContent).toBe('Pokemon 2')
2531 })
2532
2533 // Now we have 3 pages. Refetch with refetchCachedPages: false should only refetch page 0
2534 fireEvent.click(screen.getByTestId('refetch'))
2535
2536 await waitFor(
2537 () => {
2538 // Should only have 1 page
2539 expect(screen.queryByTestId('page-0')).toBeTruthy()
2540 expect(screen.queryByTestId('page-1')).toBeNull()
2541 expect(screen.queryByTestId('page-2')).toBeNull()
2542 },
2543 { timeout: 1000 },
2544 )
2545
2546 // Verify we only have 1 page (not refetched all)
2547 const pages = screen.getAllByTestId(/^page-/)
2548 expect(pages).toHaveLength(1)
2549 })
2550
2551 test('useInfiniteQuery refetch() method option refetchCachedPages: false only refetches first page', async () => {
2552 const storeRef = setupApiStore(pokemonApi, undefined, {
2553 withoutTestLifecycles: true,
2554 })
2555
2556 function PokemonList() {
2557 const { data, fetchNextPage, refetch } =
2558 pokemonApi.useGetInfinitePokemonInfiniteQuery('fire')
2559
2560 return (
2561 <div>
2562 <div data-testid="data">
2563 {data?.pages.map((page, i) => (
2564 <div key={i} data-testid={`page-${i}`}>
2565 {page.name}
2566 </div>
2567 ))}
2568 </div>
2569 <button data-testid="nextPage" onClick={() => fetchNextPage()}>
2570 Next Page
2571 </button>
2572 <button
2573 data-testid="refetch"
2574 onClick={() => refetch({ refetchCachedPages: false })}
2575 >
2576 Refetch
2577 </button>
2578 </div>
2579 )
2580 }
2581
2582 render(<PokemonList />, { wrapper: storeRef.wrapper })
2583
2584 // Wait for initial page to load
2585 await waitFor(() => {
2586 expect(screen.getByTestId('page-0').textContent).toBe('Pokemon 0')
2587 })
2588
2589 // Fetch second page
2590 fireEvent.click(screen.getByTestId('nextPage'))
2591 await waitFor(() => {
2592 expect(screen.getByTestId('page-1').textContent).toBe('Pokemon 1')
2593 })
2594
2595 // Fetch third page
2596 fireEvent.click(screen.getByTestId('nextPage'))
2597 await waitFor(() => {
2598 expect(screen.getByTestId('page-2').textContent).toBe('Pokemon 2')
2599 })
2600
2601 // Now we have 3 pages. Refetch with refetchCachedPages: false should only refetch page 0
2602 fireEvent.click(screen.getByTestId('refetch'))
2603
2604 await waitFor(() => {
2605 // Should only have 1 page
2606 expect(screen.queryByTestId('page-0')).toBeTruthy()
2607 expect(screen.queryByTestId('page-1')).toBeNull()
2608 expect(screen.queryByTestId('page-2')).toBeNull()
2609 })
2610
2611 // Verify we only have 1 page (not refetched all)
2612 const pages = screen.getAllByTestId(/^page-/)
2613 expect(pages).toHaveLength(1)
2614 })
2615 })
2616
2617 describe('useMutation', () => {
2618 test('useMutation hook sets and unsets the isLoading flag when running', async () => {
2619 function User() {
2620 const [updateUser, { isLoading }] =
2621 api.endpoints.updateUser.useMutation()
2622
2623 return (
2624 <div>
2625 <div data-testid="isLoading">{String(isLoading)}</div>
2626 <button onClick={() => updateUser({ name: 'Banana' })}>
2627 Update User
2628 </button>
2629 </div>
2630 )
2631 }
2632
2633 render(<User />, { wrapper: storeRef.wrapper })
2634
2635 await waitFor(() =>
2636 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
2637 )
2638 fireEvent.click(screen.getByText('Update User'))
2639 await waitFor(() =>
2640 expect(screen.getByTestId('isLoading').textContent).toBe('true'),
2641 )
2642 await waitFor(() =>
2643 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
2644 )
2645 })
2646
2647 test('useMutation hook sets data to the resolved response on success', async () => {
2648 const result = { name: 'Banana' }
2649
2650 function User() {
2651 const [updateUser, { data }] = api.endpoints.updateUser.useMutation()
2652
2653 return (
2654 <div>
2655 <div data-testid="result">{JSON.stringify(data)}</div>
2656 <button onClick={() => updateUser({ name: 'Banana' })}>
2657 Update User
2658 </button>
2659 </div>
2660 )
2661 }
2662
2663 render(<User />, { wrapper: storeRef.wrapper })
2664
2665 fireEvent.click(screen.getByText('Update User'))
2666 await waitFor(() =>
2667 expect(screen.getByTestId('result').textContent).toBe(
2668 JSON.stringify(result),
2669 ),
2670 )
2671 })
2672
2673 test('useMutation hook callback returns various properties to handle the result', async () => {
2674 const user = userEvent.setup()
2675
2676 function User() {
2677 const [updateUser] = api.endpoints.updateUser.useMutation()
2678 const [successMsg, setSuccessMsg] = useState('')
2679 const [errMsg, setErrMsg] = useState('')
2680 const [isAborted, setIsAborted] = useState(false)
2681
2682 const handleClick = async () => {
2683 const res = updateUser({ name: 'Banana' })
2684
2685 // abort the mutation immediately to force an error
2686 res.abort()
2687 res
2688 .unwrap()
2689 .then((result) => {
2690 setSuccessMsg(`Successfully updated user ${result.name}`)
2691 })
2692 .catch((err) => {
2693 setErrMsg(
2694 `An error has occurred updating user ${res.arg.originalArgs.name}`,
2695 )
2696 if (err.name === 'AbortError') {
2697 setIsAborted(true)
2698 }
2699 })
2700 }
2701
2702 return (
2703 <div>
2704 <button onClick={handleClick}>Update User and abort</button>
2705 <div>{successMsg}</div>
2706 <div>{errMsg}</div>
2707 <div>{isAborted ? 'Request was aborted' : ''}</div>
2708 </div>
2709 )
2710 }
2711
2712 render(<User />, { wrapper: storeRef.wrapper })
2713 expect(screen.queryByText(/An error has occurred/i)).toBeNull()
2714 expect(screen.queryByText(/Successfully updated user/i)).toBeNull()
2715 expect(screen.queryByText('Request was aborted')).toBeNull()
2716
2717 await user.click(
2718 screen.getByRole('button', { name: 'Update User and abort' }),
2719 )
2720 await screen.findByText('An error has occurred updating user Banana')
2721 expect(screen.queryByText(/Successfully updated user/i)).toBeNull()
2722 screen.getByText('Request was aborted')
2723 })
2724
2725 test('useMutation return value contains originalArgs', async () => {
2726 const { result } = renderHook(
2727 () => api.endpoints.updateUser.useMutation(),
2728 {
2729 wrapper: storeRef.wrapper,
2730 },
2731 )
2732 const arg = { name: 'Foo' }
2733
2734 const firstRenderResult = result.current
2735 expect(firstRenderResult[1].originalArgs).toBe(undefined)
2736 await act(async () => {
2737 await firstRenderResult[0](arg)
2738 })
2739 const secondRenderResult = result.current
2740 expect(firstRenderResult[1].originalArgs).toBe(undefined)
2741 expect(secondRenderResult[1].originalArgs).toBe(arg)
2742 })
2743
2744 test('`reset` sets state back to original state', async () => {
2745 const user = userEvent.setup()
2746
2747 function User() {
2748 const [updateUser, result] = api.endpoints.updateUser.useMutation()
2749 return (
2750 <>
2751 <span>
2752 {result.isUninitialized
2753 ? 'isUninitialized'
2754 : result.isSuccess
2755 ? 'isSuccess'
2756 : 'other'}
2757 </span>
2758 <span>{result.originalArgs?.name}</span>
2759 <button onClick={() => updateUser({ name: 'Yay' })}>trigger</button>
2760 <button onClick={result.reset}>reset</button>
2761 </>
2762 )
2763 }
2764 render(<User />, { wrapper: storeRef.wrapper })
2765
2766 await screen.findByText(/isUninitialized/i)
2767 expect(screen.queryByText('Yay')).toBeNull()
2768 expect(countObjectKeys(storeRef.store.getState().api.mutations)).toBe(0)
2769
2770 await user.click(screen.getByRole('button', { name: 'trigger' }))
2771
2772 await screen.findByText(/isSuccess/i)
2773 expect(screen.queryByText('Yay')).not.toBeNull()
2774 expect(countObjectKeys(storeRef.store.getState().api.mutations)).toBe(1)
2775
2776 await user.click(screen.getByRole('button', { name: 'reset' }))
2777
2778 await screen.findByText(/isUninitialized/i)
2779 expect(screen.queryByText('Yay')).toBeNull()
2780 expect(countObjectKeys(storeRef.store.getState().api.mutations)).toBe(0)
2781 })
2782 })
2783
2784 describe('usePrefetch', () => {
2785 test('usePrefetch respects force arg', async () => {
2786 const user = userEvent.setup()
2787
2788 const { usePrefetch } = api
2789 const USER_ID = 4
2790 function User() {
2791 const { isFetching } = api.endpoints.getUser.useQuery(USER_ID)
2792 const prefetchUser = usePrefetch('getUser', { force: true })
2793
2794 return (
2795 <div>
2796 <div data-testid="isFetching">{String(isFetching)}</div>
2797 <button
2798 onMouseEnter={() => prefetchUser(USER_ID, { force: true })}
2799 data-testid="highPriority"
2800 >
2801 High priority action intent
2802 </button>
2803 </div>
2804 )
2805 }
2806
2807 render(<User />, { wrapper: storeRef.wrapper })
2808
2809 // Resolve initial query
2810 await waitFor(() =>
2811 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
2812 )
2813
2814 await user.hover(screen.getByTestId('highPriority'))
2815
2816 expect(
2817 api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any),
2818 ).toEqual({
2819 data: { name: 'Timmy' },
2820 endpointName: 'getUser',
2821 error: undefined,
2822 fulfilledTimeStamp: expect.any(Number),
2823 isError: false,
2824 isLoading: true,
2825 isSuccess: false,
2826 isUninitialized: false,
2827 originalArgs: USER_ID,
2828 requestId: expect.any(String),
2829 startedTimeStamp: expect.any(Number),
2830 status: QueryStatus.pending,
2831 })
2832
2833 await waitFor(() =>
2834 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
2835 )
2836
2837 expect(
2838 api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any),
2839 ).toEqual({
2840 data: { name: 'Timmy' },
2841 endpointName: 'getUser',
2842 fulfilledTimeStamp: expect.any(Number),
2843 isError: false,
2844 isLoading: false,
2845 isSuccess: true,
2846 isUninitialized: false,
2847 originalArgs: USER_ID,
2848 requestId: expect.any(String),
2849 startedTimeStamp: expect.any(Number),
2850 status: QueryStatus.fulfilled,
2851 })
2852 })
2853
2854 test('usePrefetch does not make an additional request if already in the cache and force=false', async () => {
2855 const user = userEvent.setup()
2856
2857 const { usePrefetch } = api
2858 const USER_ID = 2
2859
2860 function User() {
2861 // Load the initial query
2862 const { isFetching } = api.endpoints.getUser.useQuery(USER_ID)
2863 const prefetchUser = usePrefetch('getUser', { force: false })
2864
2865 return (
2866 <div>
2867 <div data-testid="isFetching">{String(isFetching)}</div>
2868 <button
2869 onMouseEnter={() => prefetchUser(USER_ID)}
2870 data-testid="lowPriority"
2871 >
2872 Low priority user action intent
2873 </button>
2874 </div>
2875 )
2876 }
2877
2878 render(<User />, { wrapper: storeRef.wrapper })
2879
2880 // Let the initial query resolve
2881 await waitFor(() =>
2882 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
2883 )
2884 // Try to prefetch what we just loaded
2885 await user.hover(screen.getByTestId('lowPriority'))
2886
2887 expect(
2888 api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any),
2889 ).toEqual({
2890 data: { name: 'Timmy' },
2891 endpointName: 'getUser',
2892 fulfilledTimeStamp: expect.any(Number),
2893 isError: false,
2894 isLoading: false,
2895 isSuccess: true,
2896 isUninitialized: false,
2897 originalArgs: USER_ID,
2898 requestId: expect.any(String),
2899 startedTimeStamp: expect.any(Number),
2900 status: QueryStatus.fulfilled,
2901 })
2902
2903 await waitMs()
2904
2905 expect(
2906 api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any),
2907 ).toEqual({
2908 data: { name: 'Timmy' },
2909 endpointName: 'getUser',
2910 fulfilledTimeStamp: expect.any(Number),
2911 isError: false,
2912 isLoading: false,
2913 isSuccess: true,
2914 isUninitialized: false,
2915 originalArgs: USER_ID,
2916 requestId: expect.any(String),
2917 startedTimeStamp: expect.any(Number),
2918 status: QueryStatus.fulfilled,
2919 })
2920 })
2921
2922 test('usePrefetch respects ifOlderThan when it evaluates to true', async () => {
2923 const user = userEvent.setup()
2924
2925 const { usePrefetch } = api
2926 const USER_ID = 47
2927
2928 function User() {
2929 // Load the initial query
2930 const { isFetching } = api.endpoints.getUser.useQuery(USER_ID)
2931 const prefetchUser = usePrefetch('getUser', { ifOlderThan: 0.2 })
2932
2933 return (
2934 <div>
2935 <div data-testid="isFetching">{String(isFetching)}</div>
2936 <button
2937 onMouseEnter={() => prefetchUser(USER_ID)}
2938 data-testid="lowPriority"
2939 >
2940 Low priority user action intent
2941 </button>
2942 </div>
2943 )
2944 }
2945
2946 render(<User />, { wrapper: storeRef.wrapper })
2947
2948 await waitFor(() =>
2949 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
2950 )
2951
2952 // Wait 400ms, making it respect ifOlderThan
2953 await waitMs(400)
2954
2955 // This should run the query being that we're past the threshold
2956 await user.hover(screen.getByTestId('lowPriority'))
2957
2958 expect(
2959 api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any),
2960 ).toEqual({
2961 data: { name: 'Timmy' },
2962 endpointName: 'getUser',
2963 fulfilledTimeStamp: expect.any(Number),
2964 isError: false,
2965 isLoading: true,
2966 isSuccess: false,
2967 isUninitialized: false,
2968 originalArgs: USER_ID,
2969 requestId: expect.any(String),
2970 startedTimeStamp: expect.any(Number),
2971 status: QueryStatus.pending,
2972 })
2973
2974 await waitFor(() =>
2975 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
2976 )
2977
2978 expect(
2979 api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any),
2980 ).toEqual({
2981 data: { name: 'Timmy' },
2982 endpointName: 'getUser',
2983 fulfilledTimeStamp: expect.any(Number),
2984 isError: false,
2985 isLoading: false,
2986 isSuccess: true,
2987 isUninitialized: false,
2988 originalArgs: USER_ID,
2989 requestId: expect.any(String),
2990 startedTimeStamp: expect.any(Number),
2991 status: QueryStatus.fulfilled,
2992 })
2993 })
2994
2995 test('usePrefetch returns the last success result when ifOlderThan evaluates to false', async () => {
2996 const user = userEvent.setup()
2997
2998 const { usePrefetch } = api
2999 const USER_ID = 2
3000
3001 function User() {
3002 // Load the initial query
3003 const { isFetching } = api.endpoints.getUser.useQuery(USER_ID)
3004 const prefetchUser = usePrefetch('getUser', { ifOlderThan: 10 })
3005
3006 return (
3007 <div>
3008 <div data-testid="isFetching">{String(isFetching)}</div>
3009 <button
3010 onMouseEnter={() => prefetchUser(USER_ID)}
3011 data-testid="lowPriority"
3012 >
3013 Low priority user action intent
3014 </button>
3015 </div>
3016 )
3017 }
3018
3019 render(<User />, { wrapper: storeRef.wrapper })
3020
3021 await waitFor(() =>
3022 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
3023 )
3024 await waitMs()
3025
3026 // Get a snapshot of the last result
3027 const latestQueryData = api.endpoints.getUser.select(USER_ID)(
3028 storeRef.store.getState() as any,
3029 )
3030
3031 await user.hover(screen.getByTestId('lowPriority'))
3032
3033 // Serve up the result from the cache being that the condition wasn't met
3034 expect(
3035 api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any),
3036 ).toEqual(latestQueryData)
3037 })
3038
3039 test('usePrefetch executes a query even if conditions fail when the cache is empty', async () => {
3040 const user = userEvent.setup()
3041
3042 const { usePrefetch } = api
3043 const USER_ID = 2
3044
3045 function User() {
3046 const prefetchUser = usePrefetch('getUser', { ifOlderThan: 10 })
3047
3048 return (
3049 <div>
3050 <button
3051 onMouseEnter={() => prefetchUser(USER_ID)}
3052 data-testid="lowPriority"
3053 >
3054 Low priority user action intent
3055 </button>
3056 </div>
3057 )
3058 }
3059
3060 render(<User />, { wrapper: storeRef.wrapper })
3061
3062 await user.hover(screen.getByTestId('lowPriority'))
3063
3064 expect(
3065 api.endpoints.getUser.select(USER_ID)(storeRef.store.getState()),
3066 ).toEqual({
3067 endpointName: 'getUser',
3068 isError: false,
3069 isLoading: true,
3070 isSuccess: false,
3071 isUninitialized: false,
3072 originalArgs: USER_ID,
3073 requestId: expect.any(String),
3074 startedTimeStamp: expect.any(Number),
3075 status: 'pending',
3076 })
3077 })
3078
3079 it('should create subscription when hook mounts after prefetch', async () => {
3080 const api = createApi({
3081 baseQuery: async () => ({ data: 'test data' }),
3082 endpoints: (build) => ({
3083 getTest: build.query<string, void>({
3084 query: () => '',
3085 }),
3086 }),
3087 })
3088 const storeRef = setupApiStore(api, undefined, { withoutListeners: true })
3089
3090 // 1. Prefetch data (no subscription)
3091 await storeRef.store.dispatch(api.util.prefetch('getTest', undefined))
3092
3093 // Verify data is cached
3094 await waitFor(() => {
3095 let state = storeRef.store.getState()
3096 expect(state.api.queries['getTest(undefined)']?.data).toBe('test data')
3097 })
3098
3099 // Verify no subscription exists
3100 const subscriptions = storeRef.store.dispatch(
3101 api.internalActions.internal_getRTKQSubscriptions(),
3102 ) as any
3103 expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(0)
3104
3105 // 2. Mount component with useQuery hook
3106 function TestComponent() {
3107 const result = api.endpoints.getTest.useQuery()
3108 return <div>{result.data}</div>
3109 }
3110
3111 const { unmount } = render(<TestComponent />, {
3112 wrapper: storeRef.wrapper,
3113 })
3114
3115 // Wait for hook to initialize
3116 await waitFor(() => {
3117 // EXPECTED: Subscription should be created
3118 expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(1)
3119 })
3120
3121 // 3. Verify data is still available
3122 let state = storeRef.store.getState()
3123 expect(state.api.queries['getTest(undefined)']?.data).toBe('test data')
3124
3125 // 4. Unmount and verify subscription is removed
3126 unmount()
3127
3128 await waitFor(() => {
3129 expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(0)
3130 })
3131 })
3132 })
3133
3134 describe('useQuery and useMutation invalidation behavior', () => {
3135 const api = createApi({
3136 baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }),
3137 tagTypes: ['User'],
3138 endpoints: (build) => ({
3139 checkSession: build.query<any, void>({
3140 query: () => '/me',
3141 providesTags: ['User'],
3142 }),
3143 login: build.mutation<any, any>({
3144 query: () => ({ url: '/login', method: 'POST' }),
3145 invalidatesTags: ['User'],
3146 }),
3147 }),
3148 })
3149
3150 const storeRef = setupApiStore(api, { ...actionsReducer })
3151 test('initially failed useQueries that provide an tag will refetch after a mutation invalidates it', async () => {
3152 const checkSessionData = { name: 'matt' }
3153 server.use(
3154 http.get(
3155 'https://example.com/me',
3156 () => {
3157 return HttpResponse.json(null, { status: 500 })
3158 },
3159 { once: true },
3160 ),
3161 http.get('https://example.com/me', () => {
3162 return HttpResponse.json(checkSessionData)
3163 }),
3164 http.post('https://example.com/login', () => {
3165 return HttpResponse.json(null, { status: 200 })
3166 }),
3167 )
3168 let data, isLoading, isError
3169 function User() {
3170 ;({ data, isError, isLoading } = api.endpoints.checkSession.useQuery())
3171 const [login, { isLoading: loginLoading }] =
3172 api.endpoints.login.useMutation()
3173
3174 return (
3175 <div>
3176 <div data-testid="isLoading">{String(isLoading)}</div>
3177 <div data-testid="isError">{String(isError)}</div>
3178 <div data-testid="user">{JSON.stringify(data)}</div>
3179 <div data-testid="loginLoading">{String(loginLoading)}</div>
3180 <button onClick={() => login(null)}>Login</button>
3181 </div>
3182 )
3183 }
3184
3185 render(<User />, { wrapper: storeRef.wrapper })
3186 await waitFor(() =>
3187 expect(screen.getByTestId('isLoading').textContent).toBe('true'),
3188 )
3189 await waitFor(() =>
3190 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
3191 )
3192 await waitFor(() =>
3193 expect(screen.getByTestId('isError').textContent).toBe('true'),
3194 )
3195 await waitFor(() =>
3196 expect(screen.getByTestId('user').textContent).toBe(''),
3197 )
3198
3199 fireEvent.click(screen.getByRole('button', { name: /Login/i }))
3200
3201 await waitFor(() =>
3202 expect(screen.getByTestId('loginLoading').textContent).toBe('true'),
3203 )
3204 await waitFor(() =>
3205 expect(screen.getByTestId('loginLoading').textContent).toBe('false'),
3206 )
3207 // login mutation will cause the original errored out query to refire, clearing the error and setting the user
3208 await waitFor(() =>
3209 expect(screen.getByTestId('isError').textContent).toBe('false'),
3210 )
3211 await waitFor(() =>
3212 expect(screen.getByTestId('user').textContent).toBe(
3213 JSON.stringify(checkSessionData),
3214 ),
3215 )
3216
3217 const { checkSession, login } = api.endpoints
3218 expect(storeRef.store.getState().actions).toMatchSequence(
3219 api.internalActions.middlewareRegistered.match,
3220 checkSession.matchPending,
3221 checkSession.matchRejected,
3222 login.matchPending,
3223 login.matchFulfilled,
3224 checkSession.matchPending,
3225 checkSession.matchFulfilled,
3226 )
3227 })
3228 })
3229})
3230
3231describe('hooks with createApi defaults set', () => {
3232 const defaultApi = createApi({
3233 baseQuery: async (arg: any) => {
3234 await waitMs()
3235 if ('amount' in arg?.body) {
3236 amount += 1
3237 }
3238 return {
3239 data: arg?.body
3240 ? { ...arg.body, ...(amount ? { amount } : {}) }
3241 : undefined,
3242 }
3243 },
3244 endpoints: (build) => ({
3245 getIncrementedAmount: build.query<any, void>({
3246 query: () => ({
3247 url: '',
3248 body: {
3249 amount,
3250 },
3251 }),
3252 }),
3253 }),
3254 refetchOnMountOrArgChange: true,
3255 })
3256
3257 const storeRef = setupApiStore(defaultApi)
3258 test('useQuery hook respects refetchOnMountOrArgChange: true when set in createApi options', async () => {
3259 let data, isLoading, isFetching
3260
3261 function User() {
3262 ;({ data, isLoading } =
3263 defaultApi.endpoints.getIncrementedAmount.useQuery())
3264 return (
3265 <div>
3266 <div data-testid="isLoading">{String(isLoading)}</div>
3267 <div data-testid="amount">{String(data?.amount)}</div>
3268 </div>
3269 )
3270 }
3271
3272 const { unmount } = render(<User />, { wrapper: storeRef.wrapper })
3273
3274 await waitFor(() =>
3275 expect(screen.getByTestId('isLoading').textContent).toBe('true'),
3276 )
3277 await waitFor(() =>
3278 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
3279 )
3280
3281 await waitFor(() =>
3282 expect(screen.getByTestId('amount').textContent).toBe('1'),
3283 )
3284
3285 unmount()
3286
3287 function OtherUser() {
3288 ;({ data, isFetching } =
3289 defaultApi.endpoints.getIncrementedAmount.useQuery(undefined, {
3290 refetchOnMountOrArgChange: true,
3291 }))
3292 return (
3293 <div>
3294 <div data-testid="isFetching">{String(isFetching)}</div>
3295 <div data-testid="amount">{String(data?.amount)}</div>
3296 </div>
3297 )
3298 }
3299
3300 render(<OtherUser />, { wrapper: storeRef.wrapper })
3301 // Let's make sure we actually fetch, and we increment
3302 await waitFor(() =>
3303 expect(screen.getByTestId('isFetching').textContent).toBe('true'),
3304 )
3305 await waitFor(() =>
3306 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
3307 )
3308
3309 await waitFor(() =>
3310 expect(screen.getByTestId('amount').textContent).toBe('2'),
3311 )
3312 })
3313
3314 test('useQuery hook overrides default refetchOnMountOrArgChange: false that was set by createApi', async () => {
3315 let data, isLoading, isFetching
3316
3317 function User() {
3318 ;({ data, isLoading } =
3319 defaultApi.endpoints.getIncrementedAmount.useQuery())
3320 return (
3321 <div>
3322 <div data-testid="isLoading">{String(isLoading)}</div>
3323 <div data-testid="amount">{String(data?.amount)}</div>
3324 </div>
3325 )
3326 }
3327
3328 let { unmount } = render(<User />, { wrapper: storeRef.wrapper })
3329
3330 await waitFor(() =>
3331 expect(screen.getByTestId('isLoading').textContent).toBe('true'),
3332 )
3333 await waitFor(() =>
3334 expect(screen.getByTestId('isLoading').textContent).toBe('false'),
3335 )
3336
3337 await waitFor(() =>
3338 expect(screen.getByTestId('amount').textContent).toBe('1'),
3339 )
3340
3341 unmount()
3342
3343 function OtherUser() {
3344 ;({ data, isFetching } =
3345 defaultApi.endpoints.getIncrementedAmount.useQuery(undefined, {
3346 refetchOnMountOrArgChange: false,
3347 }))
3348 return (
3349 <div>
3350 <div data-testid="isFetching">{String(isFetching)}</div>
3351 <div data-testid="amount">{String(data?.amount)}</div>
3352 </div>
3353 )
3354 }
3355
3356 render(<OtherUser />, { wrapper: storeRef.wrapper })
3357
3358 await waitFor(() =>
3359 expect(screen.getByTestId('isFetching').textContent).toBe('false'),
3360 )
3361 await waitFor(() =>
3362 expect(screen.getByTestId('amount').textContent).toBe('1'),
3363 )
3364 })
3365
3366 describe('selectFromResult (query) behaviors', () => {
3367 let startingId = 3
3368 const initialPosts = [
3369 { id: 1, name: 'A sample post', fetched_at: new Date().toUTCString() },
3370 {
3371 id: 2,
3372 name: 'A post about rtk-query',
3373 fetched_at: new Date().toUTCString(),
3374 },
3375 ]
3376 let posts = [] as typeof initialPosts
3377
3378 beforeEach(() => {
3379 startingId = 3
3380 posts = [...initialPosts]
3381
3382 const handlers = [
3383 http.get('https://example.com/posts', () => {
3384 return HttpResponse.json(posts)
3385 }),
3386 http.put<{ id: string }, Partial<Post>>(
3387 'https://example.com/post/:id',
3388 async ({ request, params }) => {
3389 const body = await request.json()
3390 const id = Number(params.id)
3391 const idx = posts.findIndex((post) => post.id === id)
3392
3393 const newPosts = posts.map((post, index) =>
3394 index !== idx
3395 ? post
3396 : {
3397 ...body,
3398 id,
3399 name: body?.name || post.name,
3400 fetched_at: new Date().toUTCString(),
3401 },
3402 )
3403 posts = [...newPosts]
3404
3405 return HttpResponse.json(posts)
3406 },
3407 ),
3408 http.post<any, Omit<Post, 'id'>>(
3409 'https://example.com/post',
3410 async ({ request }) => {
3411 const body = await request.json()
3412 const post = body
3413 startingId += 1
3414 posts.concat({
3415 ...post,
3416 fetched_at: new Date().toISOString(),
3417 id: startingId,
3418 })
3419 return HttpResponse.json(posts)
3420 },
3421 ),
3422 ]
3423
3424 server.use(...handlers)
3425 })
3426
3427 interface Post {
3428 id: number
3429 name: string
3430 fetched_at: string
3431 }
3432
3433 type PostsResponse = Post[]
3434
3435 const api = createApi({
3436 baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/' }),
3437 tagTypes: ['Posts'],
3438 endpoints: (build) => ({
3439 getPosts: build.query<PostsResponse, void>({
3440 query: () => ({ url: 'posts' }),
3441 providesTags: (result) =>
3442 result ? result.map(({ id }) => ({ type: 'Posts', id })) : [],
3443 }),
3444 updatePost: build.mutation<Post, Partial<Post>>({
3445 query: ({ id, ...body }) => ({
3446 url: `post/${id}`,
3447 method: 'PUT',
3448 body,
3449 }),
3450 invalidatesTags: (result, error, { id }) => [{ type: 'Posts', id }],
3451 }),
3452 addPost: build.mutation<Post, Partial<Post>>({
3453 query: (body) => ({
3454 url: `post`,
3455 method: 'POST',
3456 body,
3457 }),
3458 invalidatesTags: ['Posts'],
3459 }),
3460 }),
3461 })
3462
3463 const counterSlice = createSlice({
3464 name: 'counter',
3465 initialState: { count: 0 },
3466 reducers: {
3467 increment(state) {
3468 state.count++
3469 },
3470 },
3471 })
3472
3473 const storeRef = setupApiStore(api, {
3474 counter: counterSlice.reducer,
3475 })
3476
3477 test('useQueryState serves a deeply memoized value and does not rerender unnecessarily', async () => {
3478 function Posts() {
3479 const { data: posts } = api.endpoints.getPosts.useQuery()
3480 const [addPost] = api.endpoints.addPost.useMutation()
3481 return (
3482 <div>
3483 <button
3484 data-testid="addPost"
3485 onClick={() => addPost({ name: `some text ${posts?.length}` })}
3486 >
3487 Add random post
3488 </button>
3489 </div>
3490 )
3491 }
3492
3493 function SelectedPost() {
3494 const { post } = api.endpoints.getPosts.useQueryState(undefined, {
3495 selectFromResult: ({ data }) => ({
3496 post: data?.find((post) => post.id === 1),
3497 }),
3498 })
3499 getRenderCount = useRenderCounter()
3500
3501 /**
3502 * Notes on the renderCount behavior
3503 *
3504 * We initialize at 0, and the first render will bump that 1 while post is `undefined`.
3505 * Once the request resolves, it will be at 2. What we're looking for is to make sure that
3506 * any requests that don't directly change the value of the selected item will have no impact
3507 * on rendering.
3508 */
3509
3510 return <div />
3511 }
3512
3513 render(
3514 <div>
3515 <Posts />
3516 <SelectedPost />
3517 </div>,
3518 { wrapper: storeRef.wrapper },
3519 )
3520
3521 expect(getRenderCount()).toBe(1)
3522
3523 const addBtn = screen.getByTestId('addPost')
3524
3525 await waitFor(() => expect(getRenderCount()).toBe(2))
3526
3527 fireEvent.click(addBtn)
3528 await waitFor(() => expect(getRenderCount()).toBe(2))
3529 // We fire off a few requests that would typically cause a rerender as JSON.parse() on a request would always be a new object.
3530 fireEvent.click(addBtn)
3531 fireEvent.click(addBtn)
3532 await waitFor(() => expect(getRenderCount()).toBe(2))
3533 // Being that it didn't rerender, we can be assured that the behavior is correct
3534 })
3535
3536 /**
3537 * This test shows that even though a user can select a specific post, the fetching/loading flags
3538 * will still cause rerenders for the query. This should show that if you're using selectFromResult,
3539 * the 'performance' value comes with selecting _only_ the data.
3540 */
3541 test('useQuery with selectFromResult with all flags destructured rerenders like the default useQuery behavior', async () => {
3542 function Posts() {
3543 const { data: posts } = api.endpoints.getPosts.useQuery()
3544 const [addPost] = api.endpoints.addPost.useMutation()
3545 getRenderCount = useRenderCounter()
3546 return (
3547 <div>
3548 <button
3549 data-testid="addPost"
3550 onClick={() =>
3551 addPost({
3552 name: `some text ${posts?.length}`,
3553 fetched_at: new Date().toISOString(),
3554 })
3555 }
3556 >
3557 Add random post
3558 </button>
3559 </div>
3560 )
3561 }
3562
3563 function SelectedPost() {
3564 getRenderCount = useRenderCounter()
3565
3566 const { post } = api.endpoints.getPosts.useQuery(undefined, {
3567 selectFromResult: ({
3568 data,
3569 isUninitialized,
3570 isLoading,
3571 isFetching,
3572 isSuccess,
3573 isError,
3574 }) => ({
3575 post: data?.find((post) => post.id === 1),
3576 isUninitialized,
3577 isLoading,
3578 isFetching,
3579 isSuccess,
3580 isError,
3581 }),
3582 })
3583
3584 return <div />
3585 }
3586
3587 render(
3588 <div>
3589 <Posts />
3590 <SelectedPost />
3591 </div>,
3592 { wrapper: storeRef.wrapper },
3593 )
3594 expect(getRenderCount()).toBe(2)
3595
3596 const addBtn = screen.getByTestId('addPost')
3597
3598 await waitFor(() => expect(getRenderCount()).toBe(3))
3599
3600 fireEvent.click(addBtn)
3601 await waitFor(() => expect(getRenderCount()).toBe(5))
3602 fireEvent.click(addBtn)
3603 fireEvent.click(addBtn)
3604 await waitFor(() => expect(getRenderCount()).toBe(7))
3605 })
3606
3607 test('useQuery with selectFromResult option serves a deeply memoized value and does not rerender unnecessarily', async () => {
3608 function Posts() {
3609 const { data: posts } = api.endpoints.getPosts.useQuery()
3610 const [addPost] = api.endpoints.addPost.useMutation()
3611 return (
3612 <div>
3613 <button
3614 data-testid="addPost"
3615 onClick={() =>
3616 addPost({
3617 name: `some text ${posts?.length}`,
3618 fetched_at: new Date().toISOString(),
3619 })
3620 }
3621 >
3622 Add random post
3623 </button>
3624 </div>
3625 )
3626 }
3627
3628 function SelectedPost() {
3629 getRenderCount = useRenderCounter()
3630 const { post } = api.endpoints.getPosts.useQuery(undefined, {
3631 selectFromResult: ({ data }) => ({
3632 post: data?.find((post) => post.id === 1),
3633 }),
3634 })
3635
3636 return <div />
3637 }
3638
3639 render(
3640 <div>
3641 <Posts />
3642 <SelectedPost />
3643 </div>,
3644 { wrapper: storeRef.wrapper },
3645 )
3646 expect(getRenderCount()).toBe(1)
3647
3648 const addBtn = screen.getByTestId('addPost')
3649
3650 await waitFor(() => expect(getRenderCount()).toBe(2))
3651
3652 fireEvent.click(addBtn)
3653 await waitFor(() => expect(getRenderCount()).toBe(2))
3654 fireEvent.click(addBtn)
3655 fireEvent.click(addBtn)
3656 await waitFor(() => expect(getRenderCount()).toBe(2))
3657 })
3658
3659 test('useQuery with selectFromResult option serves a deeply memoized value, then ONLY updates when the underlying data changes', async () => {
3660 let expectablePost: Post | undefined
3661 function Posts() {
3662 const { data: posts } = api.endpoints.getPosts.useQuery()
3663 const [addPost] = api.endpoints.addPost.useMutation()
3664 const [updatePost] = api.endpoints.updatePost.useMutation()
3665
3666 return (
3667 <div>
3668 <button
3669 data-testid="addPost"
3670 onClick={() =>
3671 addPost({
3672 name: `some text ${posts?.length}`,
3673 fetched_at: new Date().toISOString(),
3674 })
3675 }
3676 >
3677 Add random post
3678 </button>
3679 <button
3680 data-testid="updatePost"
3681 onClick={() => updatePost({ id: 1, name: 'supercoooll!' })}
3682 >
3683 Update post
3684 </button>
3685 </div>
3686 )
3687 }
3688
3689 function SelectedPost() {
3690 const { post } = api.endpoints.getPosts.useQuery(undefined, {
3691 selectFromResult: ({ data }) => ({
3692 post: data?.find((post) => post.id === 1),
3693 }),
3694 })
3695 getRenderCount = useRenderCounter()
3696
3697 useEffect(() => {
3698 expectablePost = post
3699 }, [post])
3700
3701 return (
3702 <div>
3703 <div data-testid="postName">{post?.name}</div>
3704 </div>
3705 )
3706 }
3707
3708 render(
3709 <div>
3710 <Posts />
3711 <SelectedPost />
3712 </div>,
3713 { wrapper: storeRef.wrapper },
3714 )
3715 expect(getRenderCount()).toBe(1)
3716
3717 const addBtn = screen.getByTestId('addPost')
3718 const updateBtn = screen.getByTestId('updatePost')
3719
3720 fireEvent.click(addBtn)
3721 await waitFor(() => expect(getRenderCount()).toBe(2))
3722 fireEvent.click(addBtn)
3723 fireEvent.click(addBtn)
3724 await waitFor(() => expect(getRenderCount()).toBe(2))
3725
3726 fireEvent.click(updateBtn)
3727 await waitFor(() => expect(getRenderCount()).toBe(3))
3728 expect(expectablePost?.name).toBe('supercoooll!')
3729
3730 fireEvent.click(addBtn)
3731 await waitFor(() => expect(getRenderCount()).toBe(3))
3732 })
3733
3734 test('useQuery with selectFromResult option does not update when unrelated data in the store changes', async () => {
3735 function Posts() {
3736 const { posts } = api.endpoints.getPosts.useQuery(undefined, {
3737 selectFromResult: ({ data }) => ({
3738 // Intentionally use an unstable reference to force a rerender
3739 posts: data?.filter((post) => post.name.includes('post')),
3740 }),
3741 })
3742
3743 getRenderCount = useRenderCounter()
3744
3745 return (
3746 <div>
3747 {posts?.map((post) => <div key={post.id}>{post.name}</div>)}
3748 </div>
3749 )
3750 }
3751
3752 function CounterButton() {
3753 return (
3754 <div
3755 data-testid="incrementButton"
3756 onClick={() =>
3757 storeRef.store.dispatch(counterSlice.actions.increment())
3758 }
3759 >
3760 Increment Count
3761 </div>
3762 )
3763 }
3764
3765 render(
3766 <div>
3767 <Posts />
3768 <CounterButton />
3769 </div>,
3770 { wrapper: storeRef.wrapper },
3771 )
3772
3773 await waitFor(() => expect(getRenderCount()).toBe(2))
3774
3775 const incrementBtn = screen.getByTestId('incrementButton')
3776 fireEvent.click(incrementBtn)
3777 expect(getRenderCount()).toBe(2)
3778 })
3779
3780 test('useQuery with selectFromResult option has a type error if the result is not an object', async () => {
3781 function SelectedPost() {
3782 const res2 = api.endpoints.getPosts.useQuery(undefined, {
3783 // selectFromResult must always return an object
3784 selectFromResult: ({ data }) => ({ size: data?.length ?? 0 }),
3785 })
3786
3787 return (
3788 <div>
3789 <div data-testid="size2">{res2.size}</div>
3790 </div>
3791 )
3792 }
3793
3794 render(
3795 <div>
3796 <SelectedPost />
3797 </div>,
3798 { wrapper: storeRef.wrapper },
3799 )
3800
3801 expect(screen.getByTestId('size2').textContent).toBe('0')
3802 })
3803 })
3804
3805 describe('selectFromResult (mutation) behavior', () => {
3806 const api = createApi({
3807 baseQuery: async (arg: any) => {
3808 await waitMs()
3809 if ('amount' in arg?.body) {
3810 amount += 1
3811 }
3812 return {
3813 data: arg?.body
3814 ? { ...arg.body, ...(amount ? { amount } : {}) }
3815 : undefined,
3816 }
3817 },
3818 endpoints: (build) => ({
3819 increment: build.mutation<{ amount: number }, number>({
3820 query: (amount) => ({
3821 url: '',
3822 method: 'POST',
3823 body: {
3824 amount,
3825 },
3826 }),
3827 }),
3828 }),
3829 })
3830
3831 const storeRef = setupApiStore(api, {
3832 ...actionsReducer,
3833 })
3834
3835 it('causes no more than one rerender when using selectFromResult with an empty object', async () => {
3836 function Counter() {
3837 const [increment] = api.endpoints.increment.useMutation({
3838 selectFromResult: () => ({}),
3839 })
3840 getRenderCount = useRenderCounter()
3841
3842 return (
3843 <div>
3844 <button
3845 data-testid="incrementButton"
3846 onClick={() => increment(1)}
3847 ></button>
3848 </div>
3849 )
3850 }
3851
3852 render(<Counter />, { wrapper: storeRef.wrapper })
3853
3854 expect(getRenderCount()).toBe(1)
3855
3856 fireEvent.click(screen.getByTestId('incrementButton'))
3857 await waitMs(200) // give our baseQuery a chance to return
3858 expect(getRenderCount()).toBe(2)
3859
3860 fireEvent.click(screen.getByTestId('incrementButton'))
3861 await waitMs(200)
3862 expect(getRenderCount()).toBe(3)
3863
3864 const { increment } = api.endpoints
3865
3866 expect(storeRef.store.getState().actions).toMatchSequence(
3867 api.internalActions.middlewareRegistered.match,
3868 increment.matchPending,
3869 increment.matchFulfilled,
3870 increment.matchPending,
3871 api.internalActions.removeMutationResult.match,
3872 increment.matchFulfilled,
3873 )
3874 })
3875
3876 it('causes rerenders when only selected data changes', async () => {
3877 function Counter() {
3878 const [increment, { data }] = api.endpoints.increment.useMutation({
3879 selectFromResult: ({ data }) => ({ data }),
3880 })
3881 getRenderCount = useRenderCounter()
3882
3883 return (
3884 <div>
3885 <button
3886 data-testid="incrementButton"
3887 onClick={() => increment(1)}
3888 ></button>
3889 <div data-testid="data">{JSON.stringify(data)}</div>
3890 </div>
3891 )
3892 }
3893
3894 render(<Counter />, { wrapper: storeRef.wrapper })
3895
3896 expect(getRenderCount()).toBe(1)
3897
3898 fireEvent.click(screen.getByTestId('incrementButton'))
3899 await waitFor(() =>
3900 expect(screen.getByTestId('data').textContent).toBe(
3901 JSON.stringify({ amount: 1 }),
3902 ),
3903 )
3904 expect(getRenderCount()).toBe(3)
3905
3906 fireEvent.click(screen.getByTestId('incrementButton'))
3907 await waitFor(() =>
3908 expect(screen.getByTestId('data').textContent).toBe(
3909 JSON.stringify({ amount: 2 }),
3910 ),
3911 )
3912 expect(getRenderCount()).toBe(5)
3913 })
3914
3915 it('causes the expected # of rerenders when NOT using selectFromResult', async () => {
3916 function Counter() {
3917 const [increment, data] = api.endpoints.increment.useMutation()
3918 getRenderCount = useRenderCounter()
3919
3920 return (
3921 <div>
3922 <button
3923 data-testid="incrementButton"
3924 onClick={() => increment(1)}
3925 ></button>
3926 <div data-testid="status">{String(data.status)}</div>
3927 </div>
3928 )
3929 }
3930
3931 render(<Counter />, { wrapper: storeRef.wrapper })
3932
3933 expect(getRenderCount()).toBe(1) // mount, uninitialized status in substate
3934
3935 fireEvent.click(screen.getByTestId('incrementButton'))
3936
3937 expect(getRenderCount()).toBe(2) // will be pending, isLoading: true,
3938 await waitFor(() =>
3939 expect(screen.getByTestId('status').textContent).toBe('pending'),
3940 )
3941 await waitFor(() =>
3942 expect(screen.getByTestId('status').textContent).toBe('fulfilled'),
3943 )
3944 expect(getRenderCount()).toBe(3)
3945
3946 fireEvent.click(screen.getByTestId('incrementButton'))
3947 await waitFor(() =>
3948 expect(screen.getByTestId('status').textContent).toBe('pending'),
3949 )
3950 await waitFor(() =>
3951 expect(screen.getByTestId('status').textContent).toBe('fulfilled'),
3952 )
3953 expect(getRenderCount()).toBe(5)
3954 })
3955
3956 it('useMutation with selectFromResult option has a type error if the result is not an object', async () => {
3957 function Counter() {
3958 const [increment] = api.endpoints.increment.useMutation({
3959 // selectFromResult must always return an object
3960 // @ts-expect-error
3961 selectFromResult: () => 42,
3962 })
3963
3964 return (
3965 <div>
3966 <button
3967 data-testid="incrementButton"
3968 onClick={() => increment(1)}
3969 ></button>
3970 </div>
3971 )
3972 }
3973
3974 render(<Counter />, { wrapper: storeRef.wrapper })
3975 })
3976 })
3977})
3978
3979describe('skip behavior', () => {
3980 const uninitialized = {
3981 status: QueryStatus.uninitialized,
3982 refetch: expect.any(Function),
3983 data: undefined,
3984 isError: false,
3985 isFetching: false,
3986 isLoading: false,
3987 isSuccess: false,
3988 isUninitialized: true,
3989 }
3990
3991 test('normal skip', async () => {
3992 const { result, rerender } = renderHook(
3993 ([arg, options]: Parameters<typeof api.endpoints.getUser.useQuery>) =>
3994 api.endpoints.getUser.useQuery(arg, options),
3995 {
3996 wrapper: storeRef.wrapper,
3997 initialProps: [1, { skip: true }],
3998 },
3999 )
4000
4001 expect(result.current).toEqual(uninitialized)
4002 await waitMs(1)
4003 expect(getSubscriptionCount('getUser(1)')).toBe(0)
4004
4005 rerender([1])
4006
4007 await act(async () => {
4008 await waitForFakeTimer(150)
4009 })
4010
4011 expect(result.current).toMatchObject({ status: QueryStatus.fulfilled })
4012 await waitMs(1)
4013 expect(getSubscriptionCount('getUser(1)')).toBe(1)
4014
4015 rerender([1, { skip: true }])
4016
4017 expect(result.current).toEqual({
4018 ...uninitialized,
4019 isSuccess: true,
4020 currentData: undefined,
4021 data: { name: 'Timmy' },
4022 })
4023 await waitMs(1)
4024 expect(getSubscriptionCount('getUser(1)')).toBe(0)
4025 })
4026
4027 test('skipToken', async () => {
4028 const { result, rerender } = renderHook(
4029 ([arg, options]: Parameters<typeof api.endpoints.getUser.useQuery>) =>
4030 api.endpoints.getUser.useQuery(arg, options),
4031 {
4032 wrapper: storeRef.wrapper,
4033 initialProps: [skipToken],
4034 },
4035 )
4036
4037 expect(result.current).toEqual(uninitialized)
4038 await waitMs(1)
4039
4040 expect(getSubscriptionCount('getUser(1)')).toBe(0)
4041 // also no subscription on `getUser(skipToken)` or similar:
4042 expect(getSubscriptions().size).toBe(0)
4043
4044 rerender([1])
4045
4046 await act(async () => {
4047 await waitForFakeTimer(150)
4048 })
4049
4050 expect(result.current).toMatchObject({ status: QueryStatus.fulfilled })
4051 await waitMs(1)
4052 expect(getSubscriptionCount('getUser(1)')).toBe(1)
4053 expect(getSubscriptions().size).toBe(1)
4054
4055 rerender([skipToken])
4056
4057 expect(result.current).toEqual({
4058 ...uninitialized,
4059 isSuccess: true,
4060 currentData: undefined,
4061 data: { name: 'Timmy' },
4062 })
4063 await waitMs(1)
4064 expect(getSubscriptionCount('getUser(1)')).toBe(0)
4065 })
4066
4067 test('skipToken does not break serializeQueryArgs', async () => {
4068 const { result, rerender } = renderHook(
4069 ([arg, options]: Parameters<
4070 typeof api.endpoints.queryWithDeepArg.useQuery
4071 >) => api.endpoints.queryWithDeepArg.useQuery(arg, options),
4072 {
4073 wrapper: storeRef.wrapper,
4074 initialProps: [skipToken],
4075 },
4076 )
4077
4078 expect(result.current).toEqual(uninitialized)
4079 await waitMs(1)
4080
4081 expect(getSubscriptionCount('nestedValue')).toBe(0)
4082 // also no subscription on `getUser(skipToken)` or similar:
4083 expect(getSubscriptions().size).toBe(0)
4084
4085 rerender([{ param: { nested: 'nestedValue' } }])
4086
4087 await act(async () => {
4088 await waitForFakeTimer(150)
4089 })
4090
4091 expect(result.current).toMatchObject({ status: QueryStatus.fulfilled })
4092 await waitMs(1)
4093
4094 expect(getSubscriptionCount('nestedValue')).toBe(1)
4095 expect(getSubscriptions().size).toBe(1)
4096
4097 rerender([skipToken])
4098
4099 expect(result.current).toEqual({
4100 ...uninitialized,
4101 isSuccess: true,
4102 currentData: undefined,
4103 data: {},
4104 })
4105 await waitMs(1)
4106 expect(getSubscriptionCount('nestedValue')).toBe(0)
4107 })
4108
4109 test('skipping a previously fetched query retains the existing value as `data`, but clears `currentData`', async () => {
4110 const { result, rerender } = renderHook(
4111 ([arg, options]: Parameters<typeof api.endpoints.getUser.useQuery>) =>
4112 api.endpoints.getUser.useQuery(arg, options),
4113 {
4114 wrapper: storeRef.wrapper,
4115 initialProps: [1],
4116 },
4117 )
4118
4119 await act(async () => {
4120 await waitForFakeTimer(150)
4121 })
4122
4123 // Normal fulfilled result, with both `data` and `currentData`
4124 expect(result.current).toMatchObject({
4125 status: QueryStatus.fulfilled,
4126 isSuccess: true,
4127 data: { name: 'Timmy' },
4128 currentData: { name: 'Timmy' },
4129 })
4130
4131 rerender([1, { skip: true }])
4132
4133 // After skipping, the query is "uninitialized", but still retains the last fetched `data`
4134 // even though it's skipped. `currentData` is undefined, since that matches the current arg.
4135 expect(result.current).toMatchObject({
4136 status: QueryStatus.uninitialized,
4137 isSuccess: true,
4138 data: { name: 'Timmy' },
4139 currentData: undefined,
4140 })
4141 })
4142})
Note: See TracBrowser for help on using the repository browser.