diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index 36dea32cc4..fa6b5e3832 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -46,6 +46,7 @@ export type GetPreviousPageParamFunction = ( export type InfiniteQueryConfigOptions = { initialPageParam: TPageParam + maxPages?: number /** * This function can be set to automatically get the previous cursor for infinite queries. * The result will also be used to determine the value of `hasPreviousPage`. diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index 7b45aa105b..a7bae821d8 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -431,6 +431,7 @@ export function buildThunks< const fetchPage = async ( data: InfiniteData, param: unknown, + maxPages: number, previous?: boolean, ): Promise => { // This should handle cases where there is no `getPrevPageParam`, @@ -442,7 +443,6 @@ export function buildThunks< const pageResponse = await executeRequest(param) // TODO Get maxPages from endpoint config - const maxPages = 20 const addTo = previous ? addToStart : addToEnd return { @@ -535,6 +535,10 @@ export function buildThunks< 'infiniteQueryOptions' in endpointDefinition ) { // This is an infinite query endpoint + const { infiniteQueryOptions } = endpointDefinition + + // Runtime checks should guarantee this is a positive number if provided + const { maxPages = Infinity } = infiniteQueryOptions let result: QueryReturnValue @@ -551,24 +555,20 @@ export function buildThunks< if ('direction' in arg && arg.direction && existingData.pages.length) { const previous = arg.direction === 'backward' const pageParamFn = previous ? getPreviousPageParam : getNextPageParam - const param = pageParamFn( - endpointDefinition.infiniteQueryOptions, - existingData, - ) + const param = pageParamFn(infiniteQueryOptions, existingData) - result = await fetchPage(existingData, param, previous) + result = await fetchPage(existingData, param, maxPages, previous) } else { // Otherwise, fetch the first page and then any remaining pages - const { - initialPageParam = endpointDefinition.infiniteQueryOptions - .initialPageParam, - } = arg as InfiniteQueryThunkArg + const { initialPageParam = infiniteQueryOptions.initialPageParam } = + arg as InfiniteQueryThunkArg // Fetch first page result = await fetchPage( existingData, existingData.pageParams[0] ?? initialPageParam, + maxPages, ) //original @@ -587,6 +587,7 @@ export function buildThunks< result = await fetchPage( result.data as InfiniteData, param, + maxPages, ) } } diff --git a/packages/toolkit/src/query/createApi.ts b/packages/toolkit/src/query/createApi.ts index 06d1d66ec9..fa7a5ebcbc 100644 --- a/packages/toolkit/src/query/createApi.ts +++ b/packages/toolkit/src/query/createApi.ts @@ -7,7 +7,11 @@ import type { EndpointBuilder, EndpointDefinitions, } from './endpointDefinitions' -import { DefinitionType, isQueryDefinition } from './endpointDefinitions' +import { + DefinitionType, + isInfiniteQueryDefinition, + isQueryDefinition, +} from './endpointDefinitions' import { nanoid } from './core/rtkImports' import type { UnknownAction } from '@reduxjs/toolkit' import type { NoInfer } from './tsHelpers' @@ -347,7 +351,8 @@ export function buildCreateApi, ...Module[]]>( const evaluatedEndpoints = inject.endpoints({ query: (x) => ({ ...x, type: DefinitionType.query }) as any, mutation: (x) => ({ ...x, type: DefinitionType.mutation }) as any, - infiniteQuery: (x) => ({ ...x, type: DefinitionType.infinitequery } as any), + infiniteQuery: (x) => + ({ ...x, type: DefinitionType.infinitequery }) as any, }) for (const [endpointName, definition] of Object.entries( @@ -373,6 +378,30 @@ export function buildCreateApi, ...Module[]]>( continue } + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'development' + ) { + if (isInfiniteQueryDefinition(definition)) { + const { infiniteQueryOptions } = definition + const { maxPages, getPreviousPageParam } = infiniteQueryOptions + + if (typeof maxPages === 'number') { + if (maxPages < 1) { + throw new Error( + `maxPages for endpoint '${endpointName}' must be a number greater than 0`, + ) + } + + if (typeof getPreviousPageParam !== 'function') { + throw new Error( + `getPreviousPageParam for endpoint '${endpointName}' must be a function if maxPages is used`, + ) + } + } + } + } + context.endpointDefinitions[endpointName] = definition for (const m of initializedModules) { m.injectEndpoint(endpointName, definition) diff --git a/packages/toolkit/src/query/tests/infiniteQueries.test.ts b/packages/toolkit/src/query/tests/infiniteQueries.test.ts index 692a43e21c..6a893f6c50 100644 --- a/packages/toolkit/src/query/tests/infiniteQueries.test.ts +++ b/packages/toolkit/src/query/tests/infiniteQueries.test.ts @@ -14,6 +14,7 @@ import { QueryStatus, createApi, fetchBaseQuery, + fakeBaseQuery, skipToken, } from '@reduxjs/toolkit/query/react' import { @@ -46,8 +47,6 @@ describe('Infinite queries', () => { const pokemonApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), endpoints: (builder) => ({ - // GOAL: Specify both the query arg (for cache key serialization) - // and the page param type (for feeding into the query URL) getInfinitePokemon: builder.infiniteQuery({ infiniteQueryOptions: { initialPageParam: 0, @@ -67,23 +66,67 @@ describe('Infinite queries', () => { return firstPageParam > 0 ? firstPageParam - 1 : undefined }, }, - - // Actual query arg type should be `number` query(pageParam) { return `https://example.com/listItems?page=${pageParam}` }, }), + getInfinitePokemonWithMax: builder.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}` + }, + }), + counter: builder.query({ + queryFn: async () => { + return { data: 0 } + }, + }), }), }) - let storeRef = setupApiStore(pokemonApi, undefined, { - withoutTestLifecycles: true, - }) + let storeRef = setupApiStore( + pokemonApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) beforeEach(() => { - storeRef = setupApiStore(pokemonApi, undefined, { - withoutTestLifecycles: true, - }) + storeRef = setupApiStore( + pokemonApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + process.env.NODE_ENV = 'development' + }) + + afterEach(() => { + process.env.NODE_ENV = 'test' }) test('Basic infinite query behavior', async () => { @@ -110,7 +153,6 @@ describe('Infinite queries', () => { ) expect(entry1SecondPage.status).toBe(QueryStatus.fulfilled) - // console.log('Value: ', util.inspect(entry1SecondPage, { depth: Infinity })) if (entry1SecondPage.status === QueryStatus.fulfilled) { expect(entry1SecondPage.data.pages).toEqual([ // two pages, one entry each @@ -126,7 +168,6 @@ describe('Infinite queries', () => { ) if (entry1PrevPageMissing.status === QueryStatus.fulfilled) { - // There is no p expect(entry1PrevPageMissing.data.pages).toEqual([ // two pages, one entry each [{ id: '0', name: 'Pokemon 0' }], @@ -134,11 +175,6 @@ describe('Infinite queries', () => { ]) } - // console.log( - // 'API state: ', - // util.inspect(storeRef.store.getState().api, { depth: Infinity }), - // ) - const entry2InitialLoad = await storeRef.store.dispatch( pokemonApi.endpoints.getInfinitePokemon.initiate('water', { initialPageParam: 3, @@ -181,4 +217,81 @@ describe('Infinite queries', () => { ]) } }) + + 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', + }), + ) + + if (res.status === QueryStatus.fulfilled) { + expect(res.data.pages).toHaveLength(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', + }), + ) + if (res.status === QueryStatus.fulfilled) { + // Should have 1, 2, 3 (repeating) pages + expect(res.data.pages).toHaveLength(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', + }), + ) + + if (res.status === QueryStatus.fulfilled) { + // When we go back 1, we now have 6, 7, 8 + expect(res.data.pages).toEqual([ + [{ id: '6', name: 'Pokemon 6' }], + [{ id: '7', name: 'Pokemon 7' }], + [{ id: '8', name: 'Pokemon 8' }], + ]) + } + }) + + test('validates maxPages during createApi call', async () => { + const createApiWithMaxPages = ( + maxPages: number, + getPreviousPageParam: (() => number) | undefined, + ) => { + createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + getInfinitePokemon: build.infiniteQuery({ + 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`, + ) + }) })