UNPKG

@reduxjs/toolkit

Version:

The official, opinionated, batteries-included toolset for efficient Redux development

1,003 lines (849 loc) 27.2 kB
import { server } from '@internal/query/tests/mocks/server' import type { InfiniteQueryActionCreatorResult } from '@reduxjs/toolkit/query/react' import { QueryStatus, createApi, fakeBaseQuery, fetchBaseQuery, } from '@reduxjs/toolkit/query/react' import { HttpResponse, delay, http } from 'msw' import { actionsReducer, setupApiStore } from '../../tests/utils/helpers' import type { InfiniteQueryResultFlags } from '../core/buildSelectors' describe('Infinite queries', () => { type Pokemon = { id: string name: string } let counters: Record<string, number> = {} let queryCounter = 0 const pokemonApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), endpoints: (build) => ({ getInfinitePokemon: build.infiniteQuery<Pokemon[], string, number>({ infiniteQueryOptions: { initialPageParam: 0, getNextPageParam: ( lastPage, allPages, lastPageParam, allPageParams, ) => lastPageParam + 1, getPreviousPageParam: ( firstPage, allPages, firstPageParam, allPageParams, ) => { return firstPageParam > 0 ? firstPageParam - 1 : undefined }, }, query({ pageParam }) { return `https://example.com/listItems?page=${pageParam}` }, }), getInfinitePokemonWithMax: build.infiniteQuery<Pokemon[], string, number>( { infiniteQueryOptions: { initialPageParam: 0, maxPages: 3, getNextPageParam: ( lastPage, allPages, lastPageParam, allPageParams, ) => lastPageParam + 1, getPreviousPageParam: ( firstPage, allPages, firstPageParam, allPageParams, ) => { return firstPageParam > 0 ? firstPageParam - 1 : undefined }, }, query({ pageParam }) { return `https://example.com/listItems?page=${pageParam}` }, }, ), counters: build.query<{ id: string; counter: number }, string>({ queryFn: async (arg) => { if (!(arg in counters)) { counters[arg] = 0 } counters[arg]++ return { data: { id: arg, counter: counters[arg] } } }, }), }), }) let hitCounter = 0 type HitCounter = { page: number; hitCounter: number } const countersApi = createApi({ baseQuery: fakeBaseQuery(), tagTypes: ['Counter'], endpoints: (build) => ({ counters: build.infiniteQuery<HitCounter, string, number>({ queryFn({ pageParam }) { hitCounter++ return { data: { page: pageParam, hitCounter } } }, infiniteQueryOptions: { initialPageParam: 0, getNextPageParam: ( lastPage, allPages, lastPageParam, allPageParams, ) => lastPageParam + 1, }, providesTags: ['Counter'], }), mutation: build.mutation<null, void>({ queryFn: async () => { return { data: null } }, invalidatesTags: ['Counter'], }), }), }) let storeRef = setupApiStore( pokemonApi, { ...actionsReducer }, { withoutTestLifecycles: true, }, ) beforeEach(() => { server.use( http.get('https://example.com/listItems', ({ request }) => { const url = new URL(request.url) const pageString = url.searchParams.get('page') const pageNum = parseInt(pageString || '0') queryCounter++ const results: Pokemon[] = [ { id: `${pageNum}`, name: `Pokemon ${pageNum}` }, ] return HttpResponse.json(results) }), ) storeRef = setupApiStore( pokemonApi, { ...actionsReducer }, { withoutTestLifecycles: true, }, ) counters = {} hitCounter = 0 queryCounter = 0 }) type InfiniteQueryResult = Awaited<InfiniteQueryActionCreatorResult<any>> const checkResultData = ( result: InfiniteQueryResult, expectedValues: Pokemon[][], ) => { expect(result.status).toBe(QueryStatus.fulfilled) if (result.status === QueryStatus.fulfilled) { expect(result.data.pages).toEqual(expectedValues) } } const checkResultLength = ( result: InfiniteQueryResult, expectedLength: number, ) => { expect(result.status).toBe(QueryStatus.fulfilled) if (result.status === QueryStatus.fulfilled) { expect(result.data.pages).toHaveLength(expectedLength) } } test('Basic infinite query behavior', async () => { const checkFlags = ( value: unknown, expectedFlags: Partial<InfiniteQueryResultFlags>, ) => { const actualFlags: InfiniteQueryResultFlags = { hasNextPage: false, hasPreviousPage: false, isFetchingNextPage: false, isFetchingPreviousPage: false, isFetchNextPageError: false, isFetchPreviousPageError: false, ...expectedFlags, } expect(value).toMatchObject(actualFlags) } const checkEntryFlags = ( arg: string, expectedFlags: Partial<InfiniteQueryResultFlags>, ) => { const selector = pokemonApi.endpoints.getInfinitePokemon.select(arg) const entry = selector(storeRef.store.getState()) checkFlags(entry, expectedFlags) } const res1 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', {}), ) checkEntryFlags('fire', {}) const entry1InitialLoad = await res1 checkResultData(entry1InitialLoad, [[{ id: '0', name: 'Pokemon 0' }]]) checkFlags(entry1InitialLoad, { hasNextPage: true, }) const res2 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'forward', }), ) checkEntryFlags('fire', { hasNextPage: true, isFetchingNextPage: true, }) const entry1SecondPage = await res2 checkResultData(entry1SecondPage, [ [{ id: '0', name: 'Pokemon 0' }], [{ id: '1', name: 'Pokemon 1' }], ]) checkFlags(entry1SecondPage, { hasNextPage: true, }) const res3 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'backward', }), ) checkEntryFlags('fire', { hasNextPage: true, isFetchingPreviousPage: true, }) const entry1PrevPageMissing = await res3 checkResultData(entry1PrevPageMissing, [ [{ id: '0', name: 'Pokemon 0' }], [{ id: '1', name: 'Pokemon 1' }], ]) checkFlags(entry1PrevPageMissing, { hasNextPage: true, }) const res4 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('water', { initialPageParam: 3, }), ) checkEntryFlags('water', {}) const entry2InitialLoad = await res4 checkResultData(entry2InitialLoad, [[{ id: '3', name: 'Pokemon 3' }]]) checkFlags(entry2InitialLoad, { hasNextPage: true, hasPreviousPage: true, }) const res5 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('water', { direction: 'forward', }), ) checkEntryFlags('water', { hasNextPage: true, hasPreviousPage: true, isFetchingNextPage: true, }) const entry2NextPage = await res5 checkResultData(entry2NextPage, [ [{ id: '3', name: 'Pokemon 3' }], [{ id: '4', name: 'Pokemon 4' }], ]) checkFlags(entry2NextPage, { hasNextPage: true, hasPreviousPage: true, }) const res6 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('water', { direction: 'backward', }), ) checkEntryFlags('water', { hasNextPage: true, hasPreviousPage: true, isFetchingPreviousPage: true, }) const entry2PrevPage = await res6 checkResultData(entry2PrevPage, [ [{ id: '2', name: 'Pokemon 2' }], [{ id: '3', name: 'Pokemon 3' }], [{ id: '4', name: 'Pokemon 4' }], ]) checkFlags(entry2PrevPage, { hasNextPage: true, hasPreviousPage: true, }) }) test('does not have a page limit without maxPages', async () => { for (let i = 1; i <= 10; i++) { const res = await storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'forward', }), ) checkResultLength(res, i) } }) test('applies a page limit with maxPages', async () => { for (let i = 1; i <= 10; i++) { const res = await storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemonWithMax.initiate('fire', { direction: 'forward', }), ) checkResultLength(res, Math.min(i, 3)) } // Should now have entries 7, 8, 9 after the loop const res = await storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemonWithMax.initiate('fire', { direction: 'backward', }), ) checkResultData(res, [ [{ id: '6', name: 'Pokemon 6' }], [{ id: '7', name: 'Pokemon 7' }], [{ id: '8', name: 'Pokemon 8' }], ]) }) test('validates maxPages during createApi call', async () => { vi.stubEnv('NODE_ENV', 'development') const createApiWithMaxPages = ( maxPages: number, getPreviousPageParam: (() => number) | undefined, ) => { createApi({ baseQuery: fakeBaseQuery(), endpoints: (build) => ({ getInfinitePokemon: build.infiniteQuery<Pokemon[], string, number>({ query(pageParam) { return `https://example.com/listItems?page=${pageParam}` }, infiniteQueryOptions: { initialPageParam: 0, maxPages, getNextPageParam: () => 1, getPreviousPageParam, }, }), }), }) } expect(() => createApiWithMaxPages(0, () => 0)).toThrowError( `maxPages for endpoint 'getInfinitePokemon' must be a number greater than 0`, ) expect(() => createApiWithMaxPages(1, undefined)).toThrowError( `getPreviousPageParam for endpoint 'getInfinitePokemon' must be a function if maxPages is used`, ) }) test('refetches all existing pages', async () => { const checkResultData = ( result: InfiniteQueryResult, expectedValues: HitCounter[], ) => { expect(result.status).toBe(QueryStatus.fulfilled) if (result.status === QueryStatus.fulfilled) { expect(result.data.pages).toEqual(expectedValues) } } const storeRef = setupApiStore( countersApi, { ...actionsReducer }, { withoutTestLifecycles: true, }, ) await storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { initialPageParam: 3, }), ) await storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { direction: 'forward', }), ) const thirdPromise = storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { direction: 'forward', }), ) const thirdRes = await thirdPromise checkResultData(thirdRes, [ { page: 3, hitCounter: 1 }, { page: 4, hitCounter: 2 }, { page: 5, hitCounter: 3 }, ]) const fourthRes = await thirdPromise.refetch() checkResultData(fourthRes, [ { page: 3, hitCounter: 4 }, { page: 4, hitCounter: 5 }, { page: 5, hitCounter: 6 }, ]) }) test('Refetches on invalidation', async () => { const checkResultData = ( result: InfiniteQueryResult, expectedValues: HitCounter[], ) => { expect(result.status).toBe(QueryStatus.fulfilled) if (result.status === QueryStatus.fulfilled) { expect(result.data.pages).toEqual(expectedValues) } } const storeRef = setupApiStore( countersApi, { ...actionsReducer }, { withoutTestLifecycles: true, }, ) await storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { initialPageParam: 3, }), ) await storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { direction: 'forward', }), ) const thirdPromise = storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { direction: 'forward', }), ) const thirdRes = await thirdPromise checkResultData(thirdRes, [ { page: 3, hitCounter: 1 }, { page: 4, hitCounter: 2 }, { page: 5, hitCounter: 3 }, ]) await storeRef.store.dispatch(countersApi.endpoints.mutation.initiate()) let entry = countersApi.endpoints.counters.select('item')( storeRef.store.getState(), ) const promise = storeRef.store.dispatch( countersApi.util.getRunningQueryThunk('counters', 'item'), ) const promises = storeRef.store.dispatch( countersApi.util.getRunningQueriesThunk(), ) expect(entry).toMatchObject({ status: 'pending', }) expect(promise).toBeInstanceOf(Promise) expect(promises).toEqual([promise]) const finalRes = await promise checkResultData(finalRes as any, [ { page: 3, hitCounter: 4 }, { page: 4, hitCounter: 5 }, { page: 5, hitCounter: 6 }, ]) }) test('Refetches on polling', async () => { const checkResultData = ( result: InfiniteQueryResult, expectedValues: HitCounter[], ) => { expect(result.status).toBe(QueryStatus.fulfilled) if (result.status === QueryStatus.fulfilled) { expect(result.data.pages).toEqual(expectedValues) } } const storeRef = setupApiStore( countersApi, { ...actionsReducer }, { withoutTestLifecycles: true, }, ) await storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { initialPageParam: 3, }), ) await storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { direction: 'forward', }), ) const thirdPromise = storeRef.store.dispatch( countersApi.endpoints.counters.initiate('item', { direction: 'forward', }), ) const thirdRes = await thirdPromise checkResultData(thirdRes, [ { page: 3, hitCounter: 1 }, { page: 4, hitCounter: 2 }, { page: 5, hitCounter: 3 }, ]) thirdPromise.updateSubscriptionOptions({ pollingInterval: 50, }) await delay(25) let entry = countersApi.endpoints.counters.select('item')( storeRef.store.getState(), ) checkResultData(thirdRes, [ { page: 3, hitCounter: 1 }, { page: 4, hitCounter: 2 }, { page: 5, hitCounter: 3 }, ]) await delay(50) entry = countersApi.endpoints.counters.select('item')( storeRef.store.getState(), ) checkResultData(entry as any, [ { page: 3, hitCounter: 4 }, { page: 4, hitCounter: 5 }, { page: 5, hitCounter: 6 }, ]) }) test('Handles multiple next page fetches at once', async () => { const initialEntry = await storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', {}), ) checkResultData(initialEntry, [[{ id: '0', name: 'Pokemon 0' }]]) expect(queryCounter).toBe(1) const promise1 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'forward', }), ) expect(queryCounter).toBe(1) const promise2 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'forward', }), ) const entry1 = await promise1 const entry2 = await promise2 // The second thunk should have bailed out because the entry was now // pending, so we should only have sent one request. expect(queryCounter).toBe(2) expect(entry1).toEqual(entry2) checkResultData(entry1, [ [{ id: '0', name: 'Pokemon 0' }], [{ id: '1', name: 'Pokemon 1' }], ]) expect(queryCounter).toBe(2) const promise3 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'forward', }), ) expect(queryCounter).toBe(2) // We can abort an existing promise, but due to timing issues, // we have to await the promise first before triggering the next request. promise3.abort() const entry3 = await promise3 const promise4 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { direction: 'forward', }), ) const entry4 = await promise4 expect(queryCounter).toBe(4) checkResultData(entry4, [ [{ id: '0', name: 'Pokemon 0' }], [{ id: '1', name: 'Pokemon 1' }], [{ id: '2', name: 'Pokemon 2' }], ]) }) test('can fetch pages with refetchOnMountOrArgChange active', async () => { const pokemonApiWithRefetch = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), endpoints: (build) => ({ getInfinitePokemon: build.infiniteQuery<Pokemon[], string, number>({ infiniteQueryOptions: { initialPageParam: 0, getNextPageParam: ( lastPage, allPages, // Page param type should be `number` lastPageParam, allPageParams, ) => lastPageParam + 1, getPreviousPageParam: ( firstPage, allPages, firstPageParam, allPageParams, ) => { return firstPageParam > 0 ? firstPageParam - 1 : undefined }, }, query({ pageParam }) { return `https://example.com/listItems?page=${pageParam}` }, }), }), refetchOnMountOrArgChange: true, }) const storeRef = setupApiStore( pokemonApiWithRefetch, { ...actionsReducer }, { withoutTestLifecycles: true, }, ) const res1 = storeRef.store.dispatch( pokemonApiWithRefetch.endpoints.getInfinitePokemon.initiate('fire', {}), ) const entry1InitialLoad = await res1 checkResultData(entry1InitialLoad, [[{ id: '0', name: 'Pokemon 0' }]]) const res2 = storeRef.store.dispatch( pokemonApiWithRefetch.endpoints.getInfinitePokemon.initiate('fire', { direction: 'forward', }), ) const entry1SecondPage = await res2 checkResultData(entry1SecondPage, [ [{ id: '0', name: 'Pokemon 0' }], [{ id: '1', name: 'Pokemon 1' }], ]) expect(queryCounter).toBe(2) const entry2InitialLoad = await storeRef.store.dispatch( pokemonApiWithRefetch.endpoints.getInfinitePokemon.initiate('water', {}), ) checkResultData(entry2InitialLoad, [[{ id: '0', name: 'Pokemon 0' }]]) expect(queryCounter).toBe(3) const entry2SecondPage = await storeRef.store.dispatch( pokemonApiWithRefetch.endpoints.getInfinitePokemon.initiate('water', { direction: 'forward', }), ) checkResultData(entry2SecondPage, [ [{ id: '0', name: 'Pokemon 0' }], [{ id: '1', name: 'Pokemon 1' }], ]) expect(queryCounter).toBe(4) // Should now be able to switch back to the first query. // The hooks dispatch on arg change without a direction. // That should trigger a refetch of the first query, meaning two requests. // It should also _replace_ the existing results, rather than appending // duplicate entries ([0, 1, 0, 1]) const entry1Refetched = await storeRef.store.dispatch( pokemonApiWithRefetch.endpoints.getInfinitePokemon.initiate('fire', {}), ) checkResultData(entry1Refetched, [ [{ id: '0', name: 'Pokemon 0' }], [{ id: '1', name: 'Pokemon 1' }], ]) expect(queryCounter).toBe(6) }) test('Works with cache manipulation utils', async () => { const res1 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('fire', {}), ) const entry1InitialLoad = await res1 checkResultData(entry1InitialLoad, [[{ id: '0', name: 'Pokemon 0' }]]) storeRef.store.dispatch( pokemonApi.util.updateQueryData('getInfinitePokemon', 'fire', (draft) => { draft.pages.push([{ id: '1', name: 'Pokemon 1' }]) draft.pageParams.push(1) }), ) const selectFire = pokemonApi.endpoints.getInfinitePokemon.select('fire') const entry1Updated = selectFire(storeRef.store.getState()) expect(entry1Updated.data).toEqual({ pages: [ [{ id: '0', name: 'Pokemon 0' }], [{ id: '1', name: 'Pokemon 1' }], ], pageParams: [0, 1], }) const res2 = storeRef.store.dispatch( pokemonApi.util.upsertQueryData('getInfinitePokemon', 'water', { pages: [[{ id: '2', name: 'Pokemon 2' }]], pageParams: [2], }), ) const entry2InitialLoad = await res2 const selectWater = pokemonApi.endpoints.getInfinitePokemon.select('water') const entry2Updated = selectWater(storeRef.store.getState()) expect(entry2Updated.data).toEqual({ pages: [[{ id: '2', name: 'Pokemon 2' }]], pageParams: [2], }) storeRef.store.dispatch( pokemonApi.util.upsertQueryEntries([ { endpointName: 'getInfinitePokemon', arg: 'air', value: { pages: [[{ id: '3', name: 'Pokemon 3' }]], pageParams: [3], }, }, ]), ) const selectAir = pokemonApi.endpoints.getInfinitePokemon.select('air') const entry3Initial = selectAir(storeRef.store.getState()) expect(entry3Initial.data).toEqual({ pages: [[{ id: '3', name: 'Pokemon 3' }]], pageParams: [3], }) await storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('air', { direction: 'forward', }), ) const entry3Updated = selectAir(storeRef.store.getState()) expect(entry3Updated.data).toEqual({ pages: [ [{ id: '3', name: 'Pokemon 3' }], [{ id: '4', name: 'Pokemon 4' }], ], pageParams: [3, 4], }) }) test('Cache lifecycle methods are called', async () => { const cacheEntryAddedCallback = vi.fn() const queryStartedCallback = vi.fn() const pokemonApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), endpoints: (build) => ({ getInfinitePokemonWithLifecycles: build.infiniteQuery< Pokemon[], string, number >({ infiniteQueryOptions: { initialPageParam: 0, getNextPageParam: ( lastPage, allPages, // Page param type should be `number` lastPageParam, allPageParams, ) => lastPageParam + 1, getPreviousPageParam: ( firstPage, allPages, firstPageParam, allPageParams, ) => { return firstPageParam > 0 ? firstPageParam - 1 : undefined }, }, query({ pageParam }) { return `https://example.com/listItems?page=${pageParam}` }, async onCacheEntryAdded(arg, api) { const data = await api.cacheDataLoaded cacheEntryAddedCallback(arg, data) }, async onQueryStarted(arg, api) { const data = await api.queryFulfilled queryStartedCallback(arg, data) }, }), }), }) const storeRef = setupApiStore( pokemonApi, { ...actionsReducer }, { withoutTestLifecycles: true, }, ) const res1 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemonWithLifecycles.initiate( 'fire', {}, ), ) const entry1InitialLoad = await res1 checkResultData(entry1InitialLoad, [[{ id: '0', name: 'Pokemon 0' }]]) expect(cacheEntryAddedCallback).toHaveBeenCalledWith('fire', { data: { pages: [[{ id: '0', name: 'Pokemon 0' }]], pageParams: [0], }, meta: expect.objectContaining({ request: expect.anything(), response: expect.anything(), }), }) expect(queryStartedCallback).toHaveBeenCalledWith('fire', { data: { pages: [[{ id: '0', name: 'Pokemon 0' }]], pageParams: [0], }, meta: expect.objectContaining({ request: expect.anything(), response: expect.anything(), }), }) }) test('Can use transformResponse', async () => { type PokemonPage = { items: Pokemon[]; page: number } const pokemonApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), endpoints: (build) => ({ getInfinitePokemonWithTransform: build.infiniteQuery< PokemonPage, string, number >({ infiniteQueryOptions: { initialPageParam: 0, getNextPageParam: ( lastPage, allPages, // Page param type should be `number` lastPageParam, allPageParams, ) => lastPageParam + 1, }, query({ pageParam }) { return `https://example.com/listItems?page=${pageParam}` }, transformResponse(baseQueryReturnValue: Pokemon[], meta, arg) { expect(Array.isArray(baseQueryReturnValue)).toBe(true) return { items: baseQueryReturnValue, page: arg.pageParam, } }, }), }), }) const storeRef = setupApiStore( pokemonApi, { ...actionsReducer }, { withoutTestLifecycles: true, }, ) const checkResultData = ( result: InfiniteQueryResult, expectedValues: PokemonPage[], ) => { expect(result.status).toBe(QueryStatus.fulfilled) if (result.status === QueryStatus.fulfilled) { expect(result.data.pages).toEqual(expectedValues) } } const res1 = storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemonWithTransform.initiate('fire', {}), ) const entry1InitialLoad = await res1 checkResultData(entry1InitialLoad, [ { items: [{ id: '0', name: 'Pokemon 0' }], page: 0 }, ]) const entry1Updated = await storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemonWithTransform.initiate('fire', { direction: 'forward', }), ) checkResultData(entry1Updated, [ { items: [{ id: '0', name: 'Pokemon 0' }], page: 0 }, { items: [{ id: '1', name: 'Pokemon 1' }], page: 1 }, ]) }) })