diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index 4a2a664ab4..26647ce41f 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -7,6 +7,8 @@ import type { BaseEndpointDefinition, ResultTypeFrom, QueryArgFrom, + InfiniteQueryDefinition, + PageParamFrom, } from '../endpointDefinitions' import type { Id, WithRequiredProp } from '../tsHelpers' @@ -28,6 +30,34 @@ export type RefetchConfigOptions = { refetchOnFocus: boolean } +export type GetNextPageParamFunction = ( + lastPage: TQueryFnData, + allPages: Array, + lastPageParam: TPageParam, + allPageParams: Array, +) => TPageParam | undefined | null + +export type GetPreviousPageParamFunction = ( + firstPage: TQueryFnData, + allPages: Array, + firstPageParam: TPageParam, + allPageParams: Array, +) => TPageParam | undefined | null + +export type InfiniteQueryConfigOptions = { + /** + * 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`. + */ + getPreviousPageParam?: GetPreviousPageParamFunction + getNextPageParam: GetNextPageParamFunction +} + +export interface InfiniteData { + pages: Array + pageParams: Array +} + /** * Strings describing the query state at any given time. */ @@ -133,7 +163,10 @@ export type MutationKeys = { : never }[keyof Definitions] -type BaseQuerySubState> = { +type BaseQuerySubState< + D extends BaseEndpointDefinition, + DataType = ResultTypeFrom, +> = { /** * The argument originally passed into the hook or `initiate` action call */ @@ -145,7 +178,7 @@ type BaseQuerySubState> = { /** * The received data from the query */ - data?: ResultTypeFrom + data?: DataType /** * The received error if applicable */ @@ -166,21 +199,31 @@ type BaseQuerySubState> = { * Time that the latest query was fulfilled */ fulfilledTimeStamp?: number + /** + * Infinite Query Specific substate properties + */ + hasNextPage?: boolean + hasPreviousPage?: boolean + direction?: 'forward' | 'backwards' + param?: QueryArgFrom } -export type QuerySubState> = Id< +export type QuerySubState< + D extends BaseEndpointDefinition, + DataType = ResultTypeFrom, +> = Id< | ({ status: QueryStatus.fulfilled } & WithRequiredProp< - BaseQuerySubState, + BaseQuerySubState, 'data' | 'fulfilledTimeStamp' > & { error: undefined }) | ({ status: QueryStatus.pending - } & BaseQuerySubState) + } & BaseQuerySubState) | ({ status: QueryStatus.rejected - } & WithRequiredProp, 'error'>) + } & WithRequiredProp, 'error'>) | { status: QueryStatus.uninitialized originalArgs?: undefined @@ -193,6 +236,21 @@ export type QuerySubState> = Id< } > +export type InfiniteQuerySubState< + D extends BaseEndpointDefinition, +> = + D extends InfiniteQueryDefinition + ? QuerySubState, PageParamFrom>> & { + // TODO: These shouldn't be optional + hasNextPage?: boolean + hasPreviousPage?: boolean + isFetchingNextPage?: boolean + isFetchingPreviousPage?: boolean + param?: PageParamFrom + direction?: 'forward' | 'backwards' + } + : never + type BaseMutationSubState> = { requestId: string data?: ResultTypeFrom @@ -249,7 +307,10 @@ export type InvalidationState = { } export type QueryState = { - [queryCacheKey: string]: QuerySubState | undefined + [queryCacheKey: string]: + | QuerySubState + | InfiniteQuerySubState + | undefined } export type SubscriptionState = { diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index ff2ab45456..9cae065730 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -1,6 +1,7 @@ import type { SerializedError, ThunkAction, + ThunkDispatch, UnknownAction, } from '@reduxjs/toolkit' import type { Dispatch } from 'redux' @@ -11,16 +12,30 @@ import type { BaseQueryError, QueryReturnValue } from '../baseQueryTypes' import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' import type { EndpointDefinitions, + InfiniteQueryDefinition, MutationDefinition, QueryArgFrom, QueryDefinition, ResultTypeFrom, } from '../endpointDefinitions' import { countObjectKeys, isNotNullish } from '../utils' -import type { SubscriptionOptions } from './apiState' -import type { QueryResultSelectorResult } from './buildSelectors' -import type { MutationThunk, QueryThunk, QueryThunkArg } from './buildThunks' -import type { ApiEndpointQuery } from './module' +import type { + SubscriptionOptions, + RootState, + InfiniteQueryConfigOptions, + InfiniteData, +} from './apiState' +import type { + InfiniteQueryResultSelectorResult, + QueryResultSelectorResult, +} from './buildSelectors' +import type { + InfiniteQueryThunk, + MutationThunk, + QueryThunk, + QueryThunkArg, +} from './buildThunks' +import type { ApiEndpointInfiniteQuery, ApiEndpointQuery } from './module' export type BuildInitiateApiEndpointQuery< Definition extends QueryDefinition, @@ -28,6 +43,12 @@ export type BuildInitiateApiEndpointQuery< initiate: StartQueryActionCreator } +export type BuildInitiateApiEndpointInfiniteQuery< + Definition extends InfiniteQueryDefinition, +> = { + initiate: StartInfiniteQueryActionCreator +} + export type BuildInitiateApiEndpointMutation< Definition extends MutationDefinition, > = { @@ -45,6 +66,18 @@ export type StartQueryActionCreatorOptions = { [forceQueryFnSymbol]?: () => QueryReturnValue } +export type StartInfiniteQueryActionCreatorOptions = { + subscribe?: boolean + forceRefetch?: boolean | number + subscriptionOptions?: SubscriptionOptions + infiniteQueryOptions?: InfiniteQueryConfigOptions + direction?: 'forward' | 'backwards' + [forceQueryFnSymbol]?: () => QueryReturnValue + data?: InfiniteData + param?: unknown + previous?: boolean +} + type StartQueryActionCreator< D extends QueryDefinition, > = ( @@ -52,6 +85,18 @@ type StartQueryActionCreator< options?: StartQueryActionCreatorOptions, ) => ThunkAction, any, any, UnknownAction> +// placeholder type which +// may attempt to derive the list of args to query in pagination +type StartInfiniteQueryActionCreator< + D extends InfiniteQueryDefinition, +> = ( + arg: QueryArgFrom, + options?: StartInfiniteQueryActionCreatorOptions, +) => ( + dispatch: ThunkDispatch, + getState: () => any, +) => InfiniteQueryActionCreatorResult + export type QueryActionCreatorResult< D extends QueryDefinition, > = SafePromise> & { @@ -66,6 +111,20 @@ export type QueryActionCreatorResult< queryCacheKey: string } +export type InfiniteQueryActionCreatorResult< + D extends InfiniteQueryDefinition, +> = Promise> & { + arg: QueryArgFrom + requestId: string + subscriptionOptions: SubscriptionOptions | undefined + abort(): void + unwrap(): Promise> + unsubscribe(): void + refetch(): InfiniteQueryActionCreatorResult + updateSubscriptionOptions(options: SubscriptionOptions): void + queryCacheKey: string +} + type StartMutationActionCreator< D extends MutationDefinition, > = ( @@ -189,19 +248,26 @@ export type MutationActionCreatorResult< export function buildInitiate({ serializeQueryArgs, queryThunk, + infiniteQueryThunk, mutationThunk, api, context, }: { serializeQueryArgs: InternalSerializeQueryArgs queryThunk: QueryThunk + infiniteQueryThunk: InfiniteQueryThunk mutationThunk: MutationThunk api: Api context: ApiContext }) { const runningQueries: Map< Dispatch, - Record | undefined> + Record< + string, + | QueryActionCreatorResult + | InfiniteQueryActionCreatorResult + | undefined + > > = new Map() const runningMutations: Map< Dispatch, @@ -215,6 +281,7 @@ export function buildInitiate({ } = api.internalActions return { buildInitiateQuery, + buildInitiateInfiniteQuery, buildInitiateMutation, getRunningQueryThunk, getRunningMutationThunk, @@ -232,6 +299,7 @@ export function buildInitiate({ }) return runningQueries.get(dispatch)?.[queryCacheKey] as | QueryActionCreatorResult + | InfiniteQueryActionCreatorResult | undefined } } @@ -408,6 +476,143 @@ You must add the middleware for RTK-Query to function correctly!`, return queryAction } + // Concept for the pagination thunk which queries for each page + + function buildInitiateInfiniteQuery( + endpointName: string, + endpointDefinition: InfiniteQueryDefinition, + pages?: number, + ) { + const infiniteQueryAction: StartInfiniteQueryActionCreator = + ( + arg, + { + subscribe = true, + forceRefetch, + subscriptionOptions, + infiniteQueryOptions, + [forceQueryFnSymbol]: forceQueryFn, + direction, + data = { pages: [], pageParams: [] }, + param = arg, + previous, + } = {}, + ) => + (dispatch, getState) => { + const queryCacheKey = serializeQueryArgs({ + queryArgs: param, + endpointDefinition, + endpointName, + }) + + const thunk = infiniteQueryThunk({ + type: 'query', + subscribe, + forceRefetch: forceRefetch, + subscriptionOptions, + endpointName, + originalArgs: arg, + queryCacheKey, + [forceQueryFnSymbol]: forceQueryFn, + data, + param, + previous, + direction, + }) + const selector = ( + api.endpoints[endpointName] as ApiEndpointInfiniteQuery + ).select(arg) + + const thunkResult = dispatch(thunk) + const stateAfter = selector(getState()) + + middlewareWarning(dispatch) + + const { requestId, abort } = thunkResult + + const skippedSynchronously = stateAfter.requestId !== requestId + + const runningQuery = runningQueries.get(dispatch)?.[queryCacheKey] + const selectFromState = () => selector(getState()) + + const statePromise: InfiniteQueryActionCreatorResult = + Object.assign( + (forceQueryFn + ? // a query has been forced (upsertQueryData) + // -> we want to resolve it once data has been written with the data that will be written + thunkResult.then(selectFromState) + : skippedSynchronously && !runningQuery + ? // a query has been skipped due to a condition and we do not have any currently running query + // -> we want to resolve it immediately with the current data + Promise.resolve(stateAfter) + : // query just started or one is already in flight + // -> wait for the running query, then resolve with data from after that + Promise.all([runningQuery, thunkResult]).then( + selectFromState, + )) as SafePromise, + { + arg, + requestId, + subscriptionOptions, + infiniteQueryOptions, + queryCacheKey, + abort, + async unwrap() { + const result = await statePromise + + if (result.isError) { + throw result.error + } + + return result.data + }, + refetch: () => + dispatch( + infiniteQueryAction(arg, { + subscribe: false, + forceRefetch: true, + }), + ), + unsubscribe() { + if (subscribe) + dispatch( + unsubscribeQueryResult({ + queryCacheKey, + requestId, + }), + ) + }, + updateSubscriptionOptions(options: SubscriptionOptions) { + statePromise.subscriptionOptions = options + dispatch( + updateSubscriptionOptions({ + endpointName, + requestId, + queryCacheKey, + options, + }), + ) + }, + }, + ) + + if (!runningQuery && !skippedSynchronously && !forceQueryFn) { + const running = runningQueries.get(dispatch) || {} + running[queryCacheKey] = statePromise + runningQueries.set(dispatch, running) + + statePromise.then(() => { + delete running[queryCacheKey] + if (!countObjectKeys(running)) { + runningQueries.delete(dispatch) + } + }) + } + return statePromise + } + return infiniteQueryAction + } + function buildInitiateMutation( endpointName: string, ): StartMutationActionCreator { diff --git a/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts b/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts index 72f84a0c54..f683dd4f69 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts @@ -142,6 +142,19 @@ export type CacheLifecycleQueryExtraOptions< ): Promise | void } +// copying QueryDefinition to get past initial build +export type CacheLifecycleInfiniteQueryExtraOptions< + ResultType, + QueryArg, + BaseQuery extends BaseQueryFn, + ReducerPath extends string = string, +> = { + onCacheEntryAdded?( + arg: QueryArg, + api: QueryCacheLifecycleApi, + ): Promise | void +} + export type CacheLifecycleMutationExtraOptions< ResultType, QueryArg, diff --git a/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts b/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts index 8b899d9202..df2e72a023 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts @@ -116,6 +116,19 @@ export type QueryLifecycleQueryExtraOptions< ): Promise | void } +// temporarily cloned QueryOptions again to just get the definition to build for now +export type QueryLifecycleInfiniteQueryExtraOptions< + ResultType, + QueryArg, + BaseQuery extends BaseQueryFn, + ReducerPath extends string = string, +> = { + onQueryStarted?( + arg: QueryArg, + api: QueryLifecycleApi, + ): Promise | void +} + export type QueryLifecycleMutationExtraOptions< ResultType, QueryArg, diff --git a/packages/toolkit/src/query/core/buildMiddleware/types.ts b/packages/toolkit/src/query/core/buildMiddleware/types.ts index e24612748e..9cd0bcdd0c 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/types.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/types.ts @@ -19,10 +19,11 @@ import type { SubscriptionState, } from '../apiState' import type { + InfiniteQueryThunk, MutationThunk, QueryThunk, QueryThunkArg, - ThunkResult, + ThunkResult } from '../buildThunks' import type { QueryActionCreatorResult } from '../buildInitiate' @@ -48,6 +49,7 @@ export interface BuildMiddlewareInput< context: ApiContext queryThunk: QueryThunk mutationThunk: MutationThunk + infiniteQueryThunk: InfiniteQueryThunk api: Api assertTagType: AssertTagTypes } diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index 83fa9c0103..2aefb5d2a2 100644 --- a/packages/toolkit/src/query/core/buildSelectors.ts +++ b/packages/toolkit/src/query/core/buildSelectors.ts @@ -1,6 +1,7 @@ import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' import type { EndpointDefinitions, + InfiniteQueryDefinition, MutationDefinition, QueryArgFrom, QueryDefinition, @@ -11,6 +12,7 @@ import type { import { expandTagDescription } from '../endpointDefinitions' import { flatten } from '../utils' import type { + InfiniteQuerySubState, MutationSubState, QueryCacheKey, QueryKeys, @@ -63,6 +65,20 @@ export type BuildSelectorsApiEndpointQuery< > } +export type BuildSelectorsApiEndpointInfiniteQuery< + Definition extends InfiniteQueryDefinition, + Definitions extends EndpointDefinitions, +> = { + select: InfiniteQueryResultSelectorFactory< + Definition, + _RootState< + Definitions, + TagTypesFrom, + ReducerPathFrom + > + > +} + export type BuildSelectorsApiEndpointMutation< Definition extends MutationDefinition, Definitions extends EndpointDefinitions, @@ -88,6 +104,17 @@ export type QueryResultSelectorResult< Definition extends QueryDefinition, > = QuerySubState & RequestStatusFlags +type InfiniteQueryResultSelectorFactory< + Definition extends InfiniteQueryDefinition, + RootState, +> = ( + queryArg: QueryArgFrom | SkipToken, +) => (state: RootState) => InfiniteQueryResultSelectorResult + +export type InfiniteQueryResultSelectorResult< + Definition extends InfiniteQueryDefinition, +> = InfiniteQuerySubState & RequestStatusFlags + type MutationResultSelectorFactory< Definition extends MutationDefinition, RootState, @@ -135,6 +162,7 @@ export function buildSelectors< return { buildQuerySelector, + buildInfiniteQuerySelector, buildMutationSelector, selectInvalidatedBy, selectCachedArgsForQuery, @@ -183,6 +211,28 @@ export function buildSelectors< }) as QueryResultSelectorFactory } + // Selector will merge all existing entries in the cache and return the result + // selector currently is just a clone of Query though + function buildInfiniteQuerySelector( + endpointName: string, + endpointDefinition: InfiniteQueryDefinition, + ) { + return ((queryArgs: any) => { + const serializedArgs = serializeQueryArgs({ + queryArgs, + endpointDefinition, + endpointName, + }) + const selectQuerySubstate = (state: RootState) => + selectInternalState(state)?.queries?.[serializedArgs] ?? + defaultQuerySubState + const finalSelectQuerySubState = + queryArgs === skipToken ? selectSkippedQuery : selectQuerySubstate + + return createSelector(finalSelectQuerySubState, withRequestFlags) + }) as InfiniteQueryResultSelectorFactory + } + function buildMutationSelector() { return ((id) => { let mutationId: string | typeof skipToken diff --git a/packages/toolkit/src/query/core/buildSlice.ts b/packages/toolkit/src/query/core/buildSlice.ts index 11e0c863c5..a84a3b0e76 100644 --- a/packages/toolkit/src/query/core/buildSlice.ts +++ b/packages/toolkit/src/query/core/buildSlice.ts @@ -24,9 +24,11 @@ import type { SubscriptionState, ConfigState, QueryKeys, + InfiniteQuerySubState, } from './apiState' import { QueryStatus } from './apiState' import type { + InfiniteQueryThunk, MutationThunk, QueryThunk, QueryThunkArg, @@ -100,7 +102,7 @@ export type UpsertEntries = < function updateQuerySubstateIfExists( state: QueryState, queryCacheKey: QueryCacheKey, - update: (substate: QuerySubState) => void, + update: (substate: QuerySubState | InfiniteQuerySubState) => void, ) { const substate = state[queryCacheKey] if (substate) { @@ -158,6 +160,7 @@ export function buildSlice({ }: { reducerPath: string queryThunk: QueryThunk + infiniteQueryThunk: InfiniteQueryThunk mutationThunk: MutationThunk serializeQueryArgs: InternalSerializeQueryArgs context: ApiContext @@ -197,6 +200,17 @@ export function buildSlice({ substate.originalArgs = arg.originalArgs } substate.startedTimeStamp = meta.startedTimeStamp + + // TODO: Awful - fix this most likely by just moving it to its own slice that only works on InfQuery's + if ('param' in substate && 'direction' in substate) { + if ('param' in arg && 'direction' in arg) { + substate.param = arg.param + substate.direction = arg.direction as + | 'forward' + | 'backwards' + | undefined + } + } }) } @@ -562,6 +576,77 @@ export function buildSlice({ }, }) + const infiniteQuerySlice = createSlice({ + name: `${reducerPath}/infinitequeries`, + initialState: initialState as QueryState, + reducers: { + fetchNextPage( + d, + a: PayloadAction< + { + endpointName: string + requestId: string + options: Subscribers[number] + } & QuerySubstateIdentifier + >, + ) { + // Dummy + }, + unsubscribeQueryResult( + d, + a: PayloadAction<{ requestId: string } & QuerySubstateIdentifier>, + ) { + // Dummy + }, + internal_getRTKQSubscriptions() {}, + }, + // extraReducers(builder) { + // builder + // .addCase(queryThunk.fulfilled, (draft, { meta, payload }) => { + // updateQuerySubstateIfExists( + // draft, + // meta.arg.queryCacheKey, + // (substate) => { + // const { infiniteQueryOptions } = definitions[ + // meta.arg.endpointName + // ] as InfiniteQueryDefinition + // substate.status = QueryStatus.fulfilled + // if(!infiniteQueryOptions) return + // + // if (substate.data !== undefined) { + // const { fulfilledTimeStamp, arg, baseQueryMeta, requestId } = + // meta + // // There's existing cache data. Let the user merge it in themselves. + // // We're already inside an Immer-powered reducer, and the user could just mutate `substate.data` + // // themselves inside of `merge()`. But, they might also want to return a new value. + // // Try to let Immer figure that part out, save the result, and assign it to `substate.data`. + // substate.data = payload + // } else { + // // Presumably a fresh request. Just cache the response data. + // substate.data = payload + // } + // } else { + // // Assign or safely update the cache data. + // substate.data = + // definitions[meta.arg.endpointName].structuralSharing ?? true + // ? copyWithStructuralSharing( + // isDraft(substate.data) + // ? original(substate.data) + // : substate.data, + // payload, + // ) + // : payload + // } + // + // delete substate.error + // substate.fulfilledTimeStamp = meta.fulfilledTimeStamp + // }, + // ) + // }) + + // }, + }) + // Dummy slice to generate actions const subscriptionSlice = createSlice({ name: `${reducerPath}/subscriptions`, diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index 9e79c3b6a7..7cd8ae7ea6 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -27,10 +27,18 @@ import type { import { calculateProvidedBy, isQueryDefinition } from '../endpointDefinitions' import { HandledError } from '../HandledError' import type { UnwrapPromise } from '../tsHelpers' -import type { QueryKeys, QuerySubstateIdentifier, RootState } from './apiState' +import type { InfiniteQueryDefinition } from '@internal/query/endpointDefinitions' +import type { + RootState, + QueryKeys, + QuerySubstateIdentifier, + InfiniteData, + InfiniteQueryConfigOptions, +} from './apiState' import { QueryStatus } from './apiState' import type { QueryActionCreatorResult, + StartInfiniteQueryActionCreatorOptions, StartQueryActionCreatorOptions, } from './buildInitiate' import { forceQueryFnSymbol, isUpsertQuery } from './buildInitiate' @@ -49,6 +57,10 @@ export type BuildThunksApiEndpointQuery< Definition extends QueryDefinition, > = Matchers +export type BuildThunksApiEndpointInfiniteQuery< + Definition extends InfiniteQueryDefinition, +> = Matchers + export type BuildThunksApiEndpointMutation< Definition extends MutationDefinition, > = Matchers @@ -105,6 +117,17 @@ export type QueryThunkArg = QuerySubstateIdentifier & endpointName: string } +export type InfiniteQueryThunkArg = QuerySubstateIdentifier & + StartInfiniteQueryActionCreatorOptions & { + type: `query` + originalArgs: unknown + endpointName: string + data: InfiniteData + param: unknown + previous?: boolean + direction?: 'forward' | 'backwards' + } + type MutationThunkArg = { type: 'mutation' originalArgs: unknown @@ -135,6 +158,11 @@ export type QueryThunk = AsyncThunk< QueryThunkArg, ThunkApiMetaConfig > +export type InfiniteQueryThunk = AsyncThunk< + ThunkResult, + InfiniteQueryThunkArg, + ThunkApiMetaConfig +> export type MutationThunk = AsyncThunk< ThunkResult, MutationThunkArg, @@ -265,6 +293,16 @@ export function buildThunks< ) } + function addToStart(items: Array, item: T, max = 0): Array { + const newItems = [item, ...items] + return max && newItems.length > max ? newItems.slice(0, -1) : newItems + } + + function addToEnd(items: Array, item: T, max = 0): Array { + const newItems = [...items, item] + return max && newItems.length > max ? newItems.slice(1) : newItems + } + const updateQueryData: UpdateQueryDataThunk = (endpointName, arg, updateRecipe, updateProvided = true) => (dispatch, getState) => { @@ -343,7 +381,7 @@ export function buildThunks< const executeEndpoint: AsyncThunkPayloadCreator< ThunkResult, - QueryThunkArg | MutationThunkArg, + QueryThunkArg | MutationThunkArg | InfiniteQueryThunkArg, ThunkApiMetaConfig & { state: RootState } > = async ( arg, @@ -384,14 +422,82 @@ export function buildThunks< if (forceQueryFn) { result = forceQueryFn() } else if (endpointDefinition.query) { - result = await baseQuery( - endpointDefinition.query(arg.originalArgs), - baseQueryApi, - endpointDefinition.extraOptions as any, - ) + const oldPages: any[] = [] + const oldPageParams: any[] = [] + + const fetchPage = async ( + data: InfiniteData, + param: unknown, + previous?: boolean, + ): Promise => { + if (param == null && data.pages.length) { + return Promise.resolve({ data }) + } + + const page = await baseQuery( + endpointDefinition.query(param), + baseQueryApi, + endpointDefinition.extraOptions as any, + ) + + const maxPages = 20 + const addTo = previous ? addToStart : addToEnd - if (endpointDefinition.transformResponse) { - transformResponse = endpointDefinition.transformResponse + return { + data: { + pages: addTo(data.pages, page.data, maxPages), + pageParams: addTo(data.pageParams, param, maxPages), + }, + } + } + + if ('infiniteQueryOptions' in endpointDefinition) { + if ('direction' in arg && arg.direction && arg.data.pages.length) { + const previous = arg.direction === 'backwards' + const pageParamFn = previous + ? getPreviousPageParam + : getNextPageParam + const oldData = arg.data + const param = pageParamFn( + endpointDefinition.infiniteQueryOptions, + oldData, + ) + + result = await fetchPage(oldData, param, previous) + } else { + // Fetch first page + result = await fetchPage( + { pages: [], pageParams: [] }, + oldPageParams[0] ?? arg.originalArgs, + ) + + //original + // const remainingPages = pages ?? oldPages.length + const remainingPages = oldPages.length + + // Fetch remaining pages + for (let i = 1; i < remainingPages; i++) { + // @ts-ignore + const param = getNextPageParam( + endpointDefinition.infiniteQueryOptions, + result.data as InfiniteData, + ) + result = await fetchPage( + result.data as InfiniteData, + param, + ) + } + } + } else { + result = await baseQuery( + endpointDefinition.query(arg.originalArgs), + baseQueryApi, + endpointDefinition.extraOptions as any, + ) + + if (endpointDefinition.transformResponse) { + transformResponse = endpointDefinition.transformResponse + } } } else { result = await endpointDefinition.queryFn( @@ -406,6 +512,7 @@ export function buildThunks< ), ) } + if ( typeof process !== 'undefined' && process.env.NODE_ENV === 'development' @@ -493,6 +600,31 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` } } + function getNextPageParam( + options: InfiniteQueryConfigOptions, + { pages, pageParams }: InfiniteData, + ): unknown | undefined { + const lastIndex = pages.length - 1 + return options.getNextPageParam( + pages[lastIndex], + pages, + pageParams[lastIndex], + pageParams, + ) + } + + function getPreviousPageParam( + options: InfiniteQueryConfigOptions, + { pages, pageParams }: InfiniteData, + ): unknown | undefined { + return options.getPreviousPageParam?.( + pages[0], + pages, + pageParams[0], + pageParams, + ) + } + function isForcedQuery( arg: QueryThunkArg, state: RootState, @@ -574,6 +706,71 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` dispatchConditionRejection: true, }) + const infiniteQueryThunk = createAsyncThunk< + ThunkResult, + InfiniteQueryThunkArg, + ThunkApiMetaConfig & { state: RootState } + >(`${reducerPath}/executeQuery`, executeEndpoint, { + getPendingMeta(queryThunkArgs) { + return { + startedTimeStamp: Date.now(), + [SHOULD_AUTOBATCH]: true, + direction: queryThunkArgs.arg.direction, + data: queryThunkArgs.arg.data, + } + }, + condition(queryThunkArgs, { getState }) { + const state = getState() + + const requestState = + state[reducerPath]?.queries?.[queryThunkArgs.queryCacheKey] + const fulfilledVal = requestState?.fulfilledTimeStamp + const currentArg = queryThunkArgs.originalArgs + const previousArg = requestState?.originalArgs + const endpointDefinition = + endpointDefinitions[queryThunkArgs.endpointName] + const direction = queryThunkArgs.direction + + // Order of these checks matters. + // In order for `upsertQueryData` to successfully run while an existing request is in flight, + /// we have to check for that first, otherwise `queryThunk` will bail out and not run at all. + // if (isUpsertQuery(queryThunkArgs)) { + // return true + // } + + // Don't retry a request that's currently in-flight + if (requestState?.status === 'pending') { + return false + } + + // if this is forced, continue + // if (isForcedQuery(queryThunkArgs, state)) { + // return true + // } + + if ( + isQueryDefinition(endpointDefinition) && + endpointDefinition?.forceRefetch?.({ + currentArg, + previousArg, + endpointState: requestState, + state, + }) + ) { + return true + } + + // Pull from the cache unless we explicitly force refetch or qualify based on time + if (fulfilledVal && !direction) { + // Value is cached and we didn't specify to refresh, skip it. + return false + } + + return true + }, + dispatchConditionRejection: true, + }) + const mutationThunk = createAsyncThunk< ThunkResult, MutationThunkArg, @@ -653,6 +850,7 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` return { queryThunk, mutationThunk, + infiniteQueryThunk, prefetch, updateQueryData, upsertQueryData, diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index 074aa08eb8..444a872030 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -16,12 +16,17 @@ import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' import type { AssertTagTypes, EndpointDefinitions, + InfiniteQueryDefinition, MutationDefinition, QueryArgFrom, QueryDefinition, TagDescription, } from '../endpointDefinitions' -import { isMutationDefinition, isQueryDefinition } from '../endpointDefinitions' +import { + isInfiniteQueryDefinition, + isMutationDefinition, + isQueryDefinition, +} from '../endpointDefinitions' import { assertCast, safeAssign } from '../tsHelpers' import type { CombinedState, @@ -34,6 +39,8 @@ import type { BuildInitiateApiEndpointQuery, MutationActionCreatorResult, QueryActionCreatorResult, + InfiniteQueryActionCreatorResult, + BuildInitiateApiEndpointInfiniteQuery, } from './buildInitiate' import { buildInitiate } from './buildInitiate' import type { @@ -43,6 +50,7 @@ import type { } from './buildMiddleware' import { buildMiddleware } from './buildMiddleware' import type { + BuildSelectorsApiEndpointInfiniteQuery, BuildSelectorsApiEndpointMutation, BuildSelectorsApiEndpointQuery, } from './buildSelectors' @@ -50,6 +58,7 @@ import { buildSelectors } from './buildSelectors' import type { SliceActions, UpsertEntries } from './buildSlice' import { buildSlice } from './buildSlice' import type { + BuildThunksApiEndpointInfiniteQuery, BuildThunksApiEndpointMutation, BuildThunksApiEndpointQuery, PatchQueryDataThunk, @@ -165,6 +174,9 @@ export interface ApiModules< | QueryActionCreatorResult< Definitions[EndpointName] & { type: 'query' } > + | InfiniteQueryActionCreatorResult< + Definitions[EndpointName] & { type: 'infinitequery' } + > | undefined > @@ -197,7 +209,9 @@ export interface ApiModules< * See https://redux-toolkit.js.org/rtk-query/usage/server-side-rendering for details. */ getRunningQueriesThunk(): ThunkWithReturnValue< - Array> + Array< + QueryActionCreatorResult | InfiniteQueryActionCreatorResult + > > /** @@ -392,7 +406,15 @@ export interface ApiModules< ? ApiEndpointQuery : Definitions[K] extends MutationDefinition ? ApiEndpointMutation - : never + : Definitions[K] extends InfiniteQueryDefinition< + any, + any, + any, + any, + any + > + ? ApiEndpointInfiniteQuery + : never } } } @@ -412,6 +434,21 @@ export interface ApiEndpointQuery< Types: NonNullable } +export interface ApiEndpointInfiniteQuery< + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Definition extends InfiniteQueryDefinition, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Definitions extends EndpointDefinitions, +> extends BuildThunksApiEndpointInfiniteQuery, + BuildInitiateApiEndpointInfiniteQuery, + BuildSelectorsApiEndpointInfiniteQuery { + name: string + /** + * All of these are `undefined` at runtime, purely to be used in TypeScript declarations! + */ + Types: NonNullable +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface ApiEndpointMutation< // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -511,6 +548,7 @@ export const coreModule = ({ const { queryThunk, + infiniteQueryThunk, mutationThunk, patchQueryData, updateQueryData, @@ -529,6 +567,7 @@ export const coreModule = ({ const { reducer, actions: sliceActions } = buildSlice({ context, queryThunk, + infiniteQueryThunk, mutationThunk, serializeQueryArgs, reducerPath, @@ -558,6 +597,7 @@ export const coreModule = ({ context, queryThunk, mutationThunk, + infiniteQueryThunk, api, assertTagType, }) @@ -567,6 +607,7 @@ export const coreModule = ({ const { buildQuerySelector, + buildInfiniteQuerySelector, buildMutationSelector, selectInvalidatedBy, selectCachedArgsForQuery, @@ -580,6 +621,7 @@ export const coreModule = ({ const { buildInitiateQuery, + buildInitiateInfiniteQuery, buildInitiateMutation, getRunningMutationThunk, getRunningMutationsThunk, @@ -588,6 +630,7 @@ export const coreModule = ({ } = buildInitiate({ queryThunk, mutationThunk, + infiniteQueryThunk, api, serializeQueryArgs: serializeQueryArgs as any, context, @@ -621,7 +664,8 @@ export const coreModule = ({ }, buildMatchThunkActions(queryThunk, endpointName), ) - } else if (isMutationDefinition(definition)) { + } + if (isMutationDefinition(definition)) { safeAssign( anyApi.endpoints[endpointName], { @@ -632,6 +676,17 @@ export const coreModule = ({ buildMatchThunkActions(mutationThunk, endpointName), ) } + if (isInfiniteQueryDefinition(definition)) { + safeAssign( + anyApi.endpoints[endpointName], + { + name: endpointName, + select: buildInfiniteQuerySelector(endpointName, definition), + initiate: buildInitiateInfiniteQuery(endpointName, definition), + }, + buildMatchThunkActions(queryThunk, endpointName), + ) + } }, } }, diff --git a/packages/toolkit/src/query/createApi.ts b/packages/toolkit/src/query/createApi.ts index d360ededb0..06d1d66ec9 100644 --- a/packages/toolkit/src/query/createApi.ts +++ b/packages/toolkit/src/query/createApi.ts @@ -347,6 +347,7 @@ 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), }) for (const [endpointName, definition] of Object.entries( diff --git a/packages/toolkit/src/query/endpointDefinitions.ts b/packages/toolkit/src/query/endpointDefinitions.ts index 38e5343475..980db16d1a 100644 --- a/packages/toolkit/src/query/endpointDefinitions.ts +++ b/packages/toolkit/src/query/endpointDefinitions.ts @@ -1,4 +1,9 @@ import type { Api } from '@reduxjs/toolkit/query' +import type { + InfiniteQueryConfigOptions, + QuerySubState, + RootState, +} from './core/apiState' import type { BaseQueryApi, BaseQueryArg, @@ -9,13 +14,14 @@ import type { BaseQueryResult, QueryReturnValue, } from './baseQueryTypes' -import type { QuerySubState, RootState } from './core' import type { CacheCollectionQueryExtraOptions } from './core/buildMiddleware/cacheCollection' import type { + CacheLifecycleInfiniteQueryExtraOptions, CacheLifecycleMutationExtraOptions, CacheLifecycleQueryExtraOptions, } from './core/buildMiddleware/cacheLifecycle' import type { + QueryLifecycleInfiniteQueryExtraOptions, QueryLifecycleMutationExtraOptions, QueryLifecycleQueryExtraOptions, } from './core/buildMiddleware/queryLifecycle' @@ -29,6 +35,7 @@ import type { OmitFromUnion, UnwrapPromise, } from './tsHelpers' +import { AnyARecord } from 'dns' const resultType = /* @__PURE__ */ Symbol() const baseQuery = /* @__PURE__ */ Symbol() @@ -188,13 +195,59 @@ type BaseEndpointTypes = { ResultType: ResultType } +// GOAL: if `PageParam` is supplied, then we should +// pass it through so that it becomes the argument type +// for `query` and `queryFn`. +// Otherwise, we stick with `QueryArg`. +// However, `initiate` should always receive`QueryArg`. + +// ❌ Failing change 1: use `FinalQueryArg`: +// - ❌ `initiate(arg: number) +// - ✅`query(pageParam: number) +/* + ? never + : EndpointDefinitionWithQuery) +| EndpointDefinitionWithQueryFn +*/ + +// ❌ Failing change 2: one nested `PageParam` checks: +// - ❌ `initiate(arg: string | number) +// - ✅ `query(pageParam: number)` +/* + ? never +: [PageParam] extends [never] + ? EndpointDefinitionWithQuery + : EndpointDefinitionWithQuery) + */ + +// ❌ Failing change 3: both nested `PageParam` checks: +// - ❌ `initiate(arg: unknown) +// - ❌ `query(pageParam: any)`, field is `undefined` +/* + ? never +: [PageParam] extends [never] + ? EndpointDefinitionWithQuery + : EndpointDefinitionWithQuery) +| [PageParam] extends [never] +? EndpointDefinitionWithQueryFn +: EndpointDefinitionWithQueryFn +*/ + export type BaseEndpointDefinition< QueryArg, BaseQuery extends BaseQueryFn, ResultType, + PageParam = never, + // Tried using this instead of the extra nested check below, + // but that ends up with `initiate(arg: number)` + // instead of `initiate(arg: string)` + FinalQueryArg = [PageParam] extends [never] ? QueryArg : PageParam, > = ( | ([CastAny, {}>] extends [NEVER] - ? never + ? // ❌Existing logic: + // - ✅ `initiate(arg: string) + // - ❌ `query(pageParam: string) + never : EndpointDefinitionWithQuery) | EndpointDefinitionWithQueryFn ) & { @@ -211,6 +264,8 @@ export type BaseEndpointDefinition< export enum DefinitionType { query = 'query', mutation = 'mutation', + // hijacking query temporarily to get the definition to build + infinitequery = 'infinitequery', } export type GetResultDescriptionFn< @@ -535,6 +590,94 @@ export type QueryDefinition< > = BaseEndpointDefinition & QueryExtraOptions +// cloning Query Endpoint Definition with an extra option to begin with +export interface InfiniteQueryTypes< + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, + TagTypes extends string, + ResultType, + ReducerPath extends string = string, +> extends BaseEndpointTypes { + /** + * The endpoint definition type. To be used with some internal generic types. + * @example + * ```ts + * const useMyWrappedHook: UseQuery = ... + * ``` + */ + InfiniteQueryDefinition: InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + TagTypes, + ResultType, + ReducerPath + > + TagTypes: TagTypes + ReducerPath: ReducerPath +} + +export interface InfiniteQueryExtraOptions< + TagTypes extends string, + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, + ReducerPath extends string = string, +> extends CacheLifecycleInfiniteQueryExtraOptions< + ResultType, + QueryArg, + BaseQuery, + ReducerPath + >, + QueryLifecycleInfiniteQueryExtraOptions< + ResultType, + QueryArg, + BaseQuery, + ReducerPath + >, + CacheCollectionQueryExtraOptions { + type: DefinitionType.infinitequery + + providesTags?: never + /** + * Not to be used. A query should not invalidate tags in the cache. + */ + invalidatesTags?: never + + infiniteQueryOptions: InfiniteQueryConfigOptions + + /** + * All of these are `undefined` at runtime, purely to be used in TypeScript declarations! + */ + Types?: InfiniteQueryTypes< + QueryArg, + PageParam, + BaseQuery, + TagTypes, + ResultType, + ReducerPath + > +} + +export type InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, + TagTypes extends string, + ResultType, + ReducerPath extends string = string, +> = BaseEndpointDefinition & + InfiniteQueryExtraOptions< + TagTypes, + ResultType, + QueryArg, + PageParam, + BaseQuery, + ReducerPath + > + type MutationTypes< QueryArg, BaseQuery extends BaseQueryFn, @@ -661,9 +804,18 @@ export type EndpointDefinition< TagTypes extends string, ResultType, ReducerPath extends string = string, + PageParam = any, > = | QueryDefinition | MutationDefinition + | InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + TagTypes, + ResultType, + ReducerPath + > export type EndpointDefinitions = Record< string, @@ -682,6 +834,12 @@ export function isMutationDefinition( return e.type === DefinitionType.mutation } +export function isInfiniteQueryDefinition( + e: EndpointDefinition, +): e is InfiniteQueryDefinition { + return e.type === DefinitionType.infinitequery +} + export type EndpointBuilder< BaseQuery extends BaseQueryFn, TagTypes extends string, @@ -757,6 +915,27 @@ export type EndpointBuilder< 'type' >, ): MutationDefinition + + infiniteQuery( + definition: OmitFromUnion< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + TagTypes, + ResultType, + ReducerPath + >, + 'type' + >, + ): InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + TagTypes, + ResultType, + ReducerPath + > } export type AssertTagTypes = >(t: T) => T @@ -809,6 +988,11 @@ export type ReducerPathFrom< export type TagTypesFrom> = D extends EndpointDefinition ? RP : unknown +export type PageParamFrom< + D extends InfiniteQueryDefinition, +> = + D extends InfiniteQueryDefinition ? PP : unknown + export type TagTypesFromApi = T extends Api ? TagTypes : never @@ -850,7 +1034,23 @@ export type OverrideResultType = NewResultType, ReducerPath > - : never + : Definition extends InfiniteQueryDefinition< + infer QueryArg, + infer PageParam, + infer BaseQuery, + infer TagTypes, + any, + infer ReducerPath + > + ? InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + TagTypes, + NewResultType, + ReducerPath + > + : never export type UpdateDefinitions< Definitions extends EndpointDefinitions, @@ -885,5 +1085,21 @@ export type UpdateDefinitions< TransformedResponse, ReducerPath > - : never + : Definitions[K] extends InfiniteQueryDefinition< + infer QueryArg, + infer PageParam, + infer BaseQuery, + any, + infer ResultType, + infer ReducerPath + > + ? InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + NewTagTypes, + TransformedResponse, + ReducerPath + > + : never } diff --git a/packages/toolkit/src/query/index.ts b/packages/toolkit/src/query/index.ts index 630b0afe65..1b66861359 100644 --- a/packages/toolkit/src/query/index.ts +++ b/packages/toolkit/src/query/index.ts @@ -30,6 +30,8 @@ export type { EndpointDefinition, EndpointBuilder, QueryDefinition, + InfiniteQueryDefinition, + InfiniteQueryExtraOptions, MutationDefinition, MutationExtraOptions, TagDescription, @@ -72,6 +74,7 @@ export { createApi, coreModule, coreModuleName } from './core' export type { ApiEndpointMutation, ApiEndpointQuery, + ApiEndpointInfiniteQuery, ApiModules, CoreModule, PrefetchOptions, diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 20b659b18b..20d33b7ef3 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -8,10 +8,12 @@ import type { Api, ApiContext, ApiEndpointMutation, + ApiEndpointInfiniteQuery, ApiEndpointQuery, BaseQueryFn, CoreModule, EndpointDefinitions, + InfiniteQueryDefinition, MutationActionCreatorResult, MutationDefinition, MutationResultSelectorResult, @@ -51,6 +53,14 @@ import type { ReactHooksModuleOptions } from './module' import { useStableQueryArgs } from './useSerializedStableValue' import { useShallowStableValue } from './useShallowStableValue' +import type { InfiniteQueryActionCreatorResult } from '@internal/query/core/buildInitiate' +import type { + InfiniteData, + InfiniteQueryConfigOptions, +} from '@internal/query/core/apiState' +import type { InfiniteQueryResultSelectorResult } from '../core/buildSelectors' +import type { PageParamFrom } from '../endpointDefinitions' + // Copy-pasted from React-Redux const canUseDOM = () => !!( @@ -84,6 +94,14 @@ export type QueryHooks< useQueryState: UseQueryState } +export type InfiniteQueryHooks< + Definition extends InfiniteQueryDefinition, +> = { + useInfiniteQuery: UseInfiniteQuery + useInfiniteQuerySubscription: UseInfiniteQuerySubscription + useInfiniteQueryState: UseInfiniteQueryState +} + export type MutationHooks< Definition extends MutationDefinition, > = { @@ -709,6 +727,338 @@ type UseQueryStateDefaultResult> = status: QueryStatus } +export type LazyInfiniteQueryTrigger< + D extends InfiniteQueryDefinition, +> = { + /** + * Triggers a lazy query. + * + * By default, this will start a new request even if there is already a value in the cache. + * If you want to use the cache value and only start a request if there is no cache value, set the second argument to `true`. + * + * @remarks + * If you need to access the error or success payload immediately after a lazy query, you can chain .unwrap(). + * + * @example + * ```ts + * // codeblock-meta title="Using .unwrap with async await" + * try { + * const payload = await getUserById(1).unwrap(); + * console.log('fulfilled', payload) + * } catch (error) { + * console.error('rejected', error); + * } + * ``` + */ + ( + arg: QueryArgFrom, + data: InfiniteData, + direction: 'forward' | 'backwards', + ): InfiniteQueryActionCreatorResult +} + +interface UseInfiniteQuerySubscriptionOptions extends SubscriptionOptions { + /** + * Prevents a query from automatically running. + * + * @remarks + * When `skip` is true (or `skipToken` is passed in as `arg`): + * + * - **If the query has cached data:** + * * The cached data **will not be used** on the initial load, and will ignore updates from any identical query until the `skip` condition is removed + * * The query will have a status of `uninitialized` + * * If `skip: false` is set after the initial load, the cached result will be used + * - **If the query does not have cached data:** + * * The query will have a status of `uninitialized` + * * The query will not exist in the state when viewed with the dev tools + * * The query will not automatically fetch on mount + * * The query will not automatically run when additional components with the same query are added that do run + * + * @example + * ```tsx + * // codeblock-meta no-transpile title="Skip example" + * const Pokemon = ({ name, skip }: { name: string; skip: boolean }) => { + * const { data, error, status } = useGetPokemonByNameQuery(name, { + * skip, + * }); + * + * return ( + *
+ * {name} - {status} + *
+ * ); + * }; + * ``` + */ + skip?: boolean + /** + * Defaults to `false`. This setting allows you to control whether if a cached result is already available, RTK Query will only serve a cached result, or if it should `refetch` when set to `true` or if an adequate amount of time has passed since the last successful query result. + * - `false` - Will not cause a query to be performed _unless_ it does not exist yet. + * - `true` - Will always refetch when a new subscriber to a query is added. Behaves the same as calling the `refetch` callback or passing `forceRefetch: true` in the action creator. + * - `number` - **Value is in seconds**. If a number is provided and there is an existing query in the cache, it will compare the current time vs the last fulfilled timestamp, and only refetch if enough time has elapsed. + * + * If you specify this option alongside `skip: true`, this **will not be evaluated** until `skip` is false. + */ + refetchOnMountOrArgChange?: boolean | number +} + +export type TypedUseInfiniteQuerySubscription< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, +> = UseInfiniteQuerySubscription< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > +> + +export type UseInfiniteQuerySubscriptionResult< + D extends InfiniteQueryDefinition, +> = LazyInfiniteQueryTrigger + +/** + * Helper type to manually type the result + * of the `useQuerySubscription` hook in userland code. + */ +export type TypedUseInfiniteQuerySubscriptionResult< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, +> = UseInfiniteQuerySubscriptionResult< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > +> + +export type InfiniteQueryStateSelector< + R extends Record, + D extends InfiniteQueryDefinition, +> = (state: UseInfiniteQueryStateDefaultResult) => R + +export type UseInfiniteQuery< + D extends InfiniteQueryDefinition, +> = = UseInfiniteQueryStateDefaultResult>( + arg: QueryArgFrom | SkipToken, + options?: UseInfiniteQuerySubscriptionOptions & + UseInfiniteQueryStateOptions & + InfiniteQueryConfigOptions, PageParamFrom>, +) => UseInfiniteQueryHookResult + +export type UseInfiniteQueryState< + D extends InfiniteQueryDefinition, +> = = UseInfiniteQueryStateDefaultResult>( + arg: QueryArgFrom | SkipToken, + InfiniteQueryConfigOptions: InfiniteQueryConfigOptions< + ResultTypeFrom, + PageParamFrom + >, + options?: UseInfiniteQueryStateOptions, +) => UseInfiniteQueryStateResult + +export type TypedUseInfiniteQueryState< + ResultType, + QueryArg, + PageParam, + BaseQuery extends BaseQueryFn, +> = UseInfiniteQueryState< + InfiniteQueryDefinition< + QueryArg, + PageParam, + BaseQuery, + string, + ResultType, + string + > +> + +export type UseInfiniteQuerySubscription< + D extends InfiniteQueryDefinition, +> = ( + arg: QueryArgFrom | SkipToken, + options?: UseInfiniteQuerySubscriptionOptions, +) => readonly [ + LazyInfiniteQueryTrigger, + QueryArgFrom | UninitializedValue, +] + +export type UseInfiniteQueryHookResult< + D extends InfiniteQueryDefinition, + R = UseInfiniteQueryStateDefaultResult, +> = UseInfiniteQueryStateResult + +export type UseInfiniteQueryStateOptions< + D extends InfiniteQueryDefinition, + R extends Record, +> = { + /** + * Prevents a query from automatically running. + * + * @remarks + * When skip is true: + * + * - **If the query has cached data:** + * * The cached data **will not be used** on the initial load, and will ignore updates from any identical query until the `skip` condition is removed + * * The query will have a status of `uninitialized` + * * If `skip: false` is set after skipping the initial load, the cached result will be used + * - **If the query does not have cached data:** + * * The query will have a status of `uninitialized` + * * The query will not exist in the state when viewed with the dev tools + * * The query will not automatically fetch on mount + * * The query will not automatically run when additional components with the same query are added that do run + * + * @example + * ```ts + * // codeblock-meta title="Skip example" + * const Pokemon = ({ name, skip }: { name: string; skip: boolean }) => { + * const { data, error, status } = useGetPokemonByNameQuery(name, { + * skip, + * }); + * + * return ( + *
+ * {name} - {status} + *
+ * ); + * }; + * ``` + */ + skip?: boolean + /** + * `selectFromResult` allows you to get a specific segment from a query result in a performant manner. + * When using this feature, the component will not rerender unless the underlying data of the selected item has changed. + * If the selected item is one element in a larger collection, it will disregard changes to elements in the same collection. + * + * @example + * ```ts + * // codeblock-meta title="Using selectFromResult to extract a single result" + * function PostsList() { + * const { data: posts } = api.useGetPostsQuery(); + * + * return ( + *
    + * {posts?.data?.map((post) => ( + * + * ))} + *
+ * ); + * } + * + * function PostById({ id }: { id: number }) { + * // Will select the post with the given id, and will only rerender if the given posts data changes + * const { post } = api.useGetPostsQuery(undefined, { + * selectFromResult: ({ data }) => ({ post: data?.find((post) => post.id === id) }), + * }); + * + * return
  • {post?.name}
  • ; + * } + * ``` + */ + selectFromResult?: InfiniteQueryStateSelector + // TODO: This shouldn't be any + trigger?: any +} + +export type UseInfiniteQueryStateResult< + _ extends InfiniteQueryDefinition, + R, +> = TSHelpersNoInfer + +type UseInfiniteQueryStateBaseResult< + D extends InfiniteQueryDefinition, +> = QuerySubState & { + /** + * Where `data` tries to hold data as much as possible, also re-using + * data from the last arguments passed into the hook, this property + * will always contain the received data from the query, for the current query arguments. + */ + currentData?: ResultTypeFrom + /** + * Query has not started yet. + */ + isUninitialized: false + /** + * Query is currently loading for the first time. No data yet. + */ + isLoading: false + /** + * Query is currently fetching, but might have data from an earlier request. + */ + isFetching: false + /** + * Query has data from a successful load. + */ + isSuccess: false + /** + * Query is currently in "error" state. + */ + isError: false + hasNextPage: false + hasPreviousPage: false + isFetchingNextPage: false + isFetchingPreviousPage: false + + fetchNextPage: () => Promise> + fetchPreviousPage: () => Promise> +} + +type UseInfiniteQueryStateDefaultResult< + D extends InfiniteQueryDefinition, +> = TSHelpersId< + | TSHelpersOverride< + Extract< + UseInfiniteQueryStateBaseResult, + { status: QueryStatus.uninitialized } + >, + { isUninitialized: true } + > + | TSHelpersOverride< + UseInfiniteQueryStateBaseResult, + | { isLoading: true; isFetching: boolean; data: undefined } + | ({ + isSuccess: true + isFetching: true + error: undefined + } & Required< + Pick< + UseInfiniteQueryStateBaseResult, + 'data' | 'fulfilledTimeStamp' + > + >) + | ({ + isSuccess: true + isFetching: false + error: undefined + } & Required< + Pick< + UseInfiniteQueryStateBaseResult, + 'data' | 'fulfilledTimeStamp' | 'currentData' + > + >) + | ({ isError: true } & Required< + Pick, 'error'> + >) + > +> & { + /** + * @deprecated Included for completeness, but discouraged. + * Please use the `isLoading`, `isFetching`, `isSuccess`, `isError` + * and `isUninitialized` flags instead + */ + status: QueryStatus +} + export type MutationStateSelector< R extends Record, D extends MutationDefinition, @@ -859,7 +1209,12 @@ export function buildHooks({ deps?: DependencyList, ) => void = unstable__sideEffectsInRender ? (cb) => cb() : useEffect - return { buildQueryHooks, buildMutationHook, usePrefetch } + return { + buildQueryHooks, + buildInfiniteQueryHooks, + buildMutationHook, + usePrefetch, + } function queryStatePreSelector( currentState: QueryResultSelectorResult, @@ -913,6 +1268,63 @@ export function buildHooks({ } as UseQueryStateDefaultResult } + function infiniteQueryStatePreSelector( + currentState: InfiniteQueryResultSelectorResult, + lastResult: UseInfiniteQueryStateDefaultResult | undefined, + queryArgs: any, + ): UseInfiniteQueryStateDefaultResult { + // if we had a last result and the current result is uninitialized, + // we might have called `api.util.resetApiState` + // in this case, reset the hook + if (lastResult?.endpointName && currentState.isUninitialized) { + const { endpointName } = lastResult + const endpointDefinition = context.endpointDefinitions[endpointName] + if ( + serializeQueryArgs({ + queryArgs: lastResult.originalArgs, + endpointDefinition, + endpointName, + }) === + serializeQueryArgs({ + queryArgs, + endpointDefinition, + endpointName, + }) + ) + lastResult = undefined + } + + // data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args + let data = currentState.isSuccess ? currentState.data : lastResult?.data + if (data === undefined) data = currentState.data + + const hasData = data !== undefined + + // isFetching = true any time a request is in flight + const isFetching = currentState.isLoading + // isLoading = true only when loading while no data is present yet (initial load with no data in the cache) + const isLoading = !hasData && isFetching + // isSuccess = true when data is present + const isSuccess = currentState.isSuccess || (isFetching && hasData) + + const isFetchingNextPage = + isFetching && currentState.direction === 'forward' + + const isFetchingPreviousPage = + isFetching && currentState.direction === 'backwards' + + return { + ...currentState, + data, + currentData: currentState.data, + isFetching, + isLoading, + isSuccess, + isFetchingNextPage, + isFetchingPreviousPage, + } as UseInfiniteQueryStateDefaultResult + } + function usePrefetch>( endpointName: EndpointName, defaultOptions?: PrefetchOptions, @@ -1283,6 +1695,306 @@ export function buildHooks({ } } + function buildInfiniteQueryHooks(name: string): InfiniteQueryHooks { + const useInfiniteQuerySubscription: UseInfiniteQuerySubscription = ( + arg: any, + { + refetchOnReconnect, + refetchOnFocus, + refetchOnMountOrArgChange, + skip = false, + pollingInterval = 0, + skipPollingIfUnfocused = false, + } = {}, + ) => { + const { initiate } = api.endpoints[ + name + ] as unknown as ApiEndpointInfiniteQuery< + InfiniteQueryDefinition, + Definitions + > + const dispatch = useDispatch>() + const subscriptionSelectorsRef = useRef() + if (!subscriptionSelectorsRef.current) { + const returnedValue = dispatch( + api.internalActions.internal_getRTKQSubscriptions(), + ) + + if (process.env.NODE_ENV !== 'production') { + if ( + typeof returnedValue !== 'object' || + typeof returnedValue?.type === 'string' + ) { + throw new Error( + `Warning: Middleware for RTK-Query API at reducerPath "${api.reducerPath}" has not been added to the store. + You must add the middleware for RTK-Query to function correctly!`, + ) + } + } + + subscriptionSelectorsRef.current = + returnedValue as unknown as SubscriptionSelectors + } + const stableArg = useStableQueryArgs( + skip ? skipToken : arg, + // Even if the user provided a per-endpoint `serializeQueryArgs` with + // a consistent return value, _here_ we want to use the default behavior + // so we can tell if _anything_ actually changed. Otherwise, we can end up + // with a case where the query args did change but the serialization doesn't, + // and then we never try to initiate a refetch. + defaultSerializeQueryArgs, + context.endpointDefinitions[name], + name, + ) + const stableSubscriptionOptions = useShallowStableValue({ + refetchOnReconnect, + refetchOnFocus, + pollingInterval, + skipPollingIfUnfocused, + }) + + const lastRenderHadSubscription = useRef(false) + + const promiseRef = useRef>() + + let { queryCacheKey, requestId } = promiseRef.current || {} + + // HACK We've saved the middleware subscription lookup callbacks into a ref, + // so we can directly check here if the subscription exists for this query. + let currentRenderHasSubscription = false + if (queryCacheKey && requestId) { + currentRenderHasSubscription = + subscriptionSelectorsRef.current.isRequestSubscribed( + queryCacheKey, + requestId, + ) + } + + const subscriptionRemoved = + !currentRenderHasSubscription && lastRenderHadSubscription.current + + usePossiblyImmediateEffect(() => { + lastRenderHadSubscription.current = currentRenderHasSubscription + }) + + usePossiblyImmediateEffect((): void | undefined => { + if (subscriptionRemoved) { + promiseRef.current = undefined + } + }, [subscriptionRemoved]) + + usePossiblyImmediateEffect((): void | undefined => { + const lastPromise = promiseRef.current + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'removeMeOnCompilation' + ) { + // this is only present to enforce the rule of hooks to keep `isSubscribed` in the dependency array + console.log(subscriptionRemoved) + } + + if (stableArg === skipToken) { + lastPromise?.unsubscribe() + promiseRef.current = undefined + return + } + + const lastSubscriptionOptions = promiseRef.current?.subscriptionOptions + + if (!lastPromise || lastPromise.arg !== stableArg) { + lastPromise?.unsubscribe() + const promise = dispatch( + initiate(stableArg, { + subscriptionOptions: stableSubscriptionOptions, + forceRefetch: refetchOnMountOrArgChange, + }), + ) + + promiseRef.current = promise + } else if (stableSubscriptionOptions !== lastSubscriptionOptions) { + lastPromise.updateSubscriptionOptions(stableSubscriptionOptions) + } + }, [ + dispatch, + initiate, + refetchOnMountOrArgChange, + stableArg, + stableSubscriptionOptions, + subscriptionRemoved, + ]) + + const subscriptionOptionsRef = useRef(stableSubscriptionOptions) + usePossiblyImmediateEffect(() => { + subscriptionOptionsRef.current = stableSubscriptionOptions + }, [stableSubscriptionOptions]) + + const trigger = useCallback( + function ( + arg: any, + data: InfiniteData, + direction: 'forward' | 'backwards', + ) { + let promise: InfiniteQueryActionCreatorResult + + batch(() => { + promiseRef.current?.unsubscribe() + + promiseRef.current = promise = dispatch( + initiate(arg, { + data: data, + subscriptionOptions: subscriptionOptionsRef.current, + direction, + }), + ) + + // setArg(arg) + }) + + return promise! + }, + [dispatch, initiate], + ) + + useEffect(() => { + return () => { + promiseRef.current?.unsubscribe() + promiseRef.current = undefined + } + }, []) + + return useMemo(() => [trigger, arg] as const, [trigger, arg]) + } + + const useInfiniteQueryState: UseInfiniteQueryState = ( + arg: any, + { getNextPageParam, getPreviousPageParam }, + { skip = false, selectFromResult, trigger } = {}, + ) => { + const { select, initiate } = api.endpoints[ + name + ] as unknown as ApiEndpointInfiniteQuery< + InfiniteQueryDefinition, + Definitions + > + const stableArg = useStableQueryArgs( + skip ? skipToken : arg, + serializeQueryArgs, + context.endpointDefinitions[name], + name, + ) + + type ApiRootState = Parameters>[0] + + const lastValue = useRef() + + const selectDefaultResult: Selector = useMemo( + () => + createSelector( + [ + select(stableArg), + (_: ApiRootState, lastResult: any) => lastResult, + (_: ApiRootState) => stableArg, + ], + infiniteQueryStatePreSelector, + { + memoizeOptions: { + resultEqualityCheck: shallowEqual, + }, + }, + ), + [select, stableArg, trigger], + ) + + const querySelector: Selector = useMemo( + () => + selectFromResult + ? createSelector([selectDefaultResult], selectFromResult, { + devModeChecks: { identityFunctionCheck: 'never' }, + }) + : selectDefaultResult, + [selectDefaultResult, selectFromResult], + ) + + const currentState = useSelector( + (state: RootState) => + querySelector(state, lastValue.current), + shallowEqual, + ) + + const store = useStore>() + const newLastValue = selectDefaultResult( + store.getState(), + lastValue.current, + ) + useIsomorphicLayoutEffect(() => { + lastValue.current = newLastValue + }, [newLastValue]) + + return currentState + } + + return { + useInfiniteQueryState, + useInfiniteQuerySubscription, + useInfiniteQuery(arg, options) { + const [trigger] = useInfiniteQuerySubscription(arg, options) + const queryStateResults = useInfiniteQueryState( + arg, + { + getNextPageParam: options?.getNextPageParam!, + getPreviousPageParam: options?.getPreviousPageParam, + }, + { + selectFromResult: + arg === skipToken || options?.skip + ? undefined + : noPendingQueryStateSelector, + trigger, + ...options, + }, + ) + + const info = useMemo(() => ({ lastArg: arg }), [arg]) + + const { + data, + status, + isLoading, + isSuccess, + isError, + error, + hasNextPage, + hasPreviousPage, + } = queryStateResults + useDebugValue({ + data, + status, + isLoading, + isSuccess, + isError, + error, + hasNextPage, + hasPreviousPage, + }) + + const fetchNextPage = useCallback(() => { + // if (!hasNextPage) return + return trigger(arg, queryStateResults.data, 'forward') + }, [trigger, hasNextPage, queryStateResults.data]) + + const fetchPreviousPage = useCallback(() => { + if (!hasPreviousPage) return + return trigger(arg, queryStateResults.data, 'backwards') + }, [trigger, hasPreviousPage, queryStateResults.data]) + + return useMemo( + () => ({ ...queryStateResults, fetchNextPage, fetchPreviousPage }), + [queryStateResults, fetchNextPage, fetchPreviousPage], + ) + }, + } + } + function buildMutationHook(name: string): UseMutation { return ({ selectFromResult, fixedCacheKey } = {}) => { const { select, initiate } = api.endpoints[name] as ApiEndpointMutation< diff --git a/packages/toolkit/src/query/react/module.ts b/packages/toolkit/src/query/react/module.ts index 0f0816b451..4e86e24c74 100644 --- a/packages/toolkit/src/query/react/module.ts +++ b/packages/toolkit/src/query/react/module.ts @@ -16,10 +16,19 @@ import { useStore as rrUseStore, } from 'react-redux' import { createSelector as _createSelector } from 'reselect' -import { isMutationDefinition, isQueryDefinition } from '../endpointDefinitions' +import type { InfiniteQueryDefinition } from '../endpointDefinitions' +import { + isInfiniteQueryDefinition, + isMutationDefinition, + isQueryDefinition, +} from '../endpointDefinitions' import { safeAssign } from '../tsHelpers' import { capitalize, countObjectKeys } from '../utils' -import type { MutationHooks, QueryHooks } from './buildHooks' +import type { + InfiniteQueryHooks, + MutationHooks, + QueryHooks, +} from './buildHooks' import { buildHooks } from './buildHooks' import type { HooksWithUniqueNames } from './namedHooks' @@ -51,7 +60,15 @@ declare module '@reduxjs/toolkit/query' { ? QueryHooks : Definitions[K] extends MutationDefinition ? MutationHooks - : never + : Definitions[K] extends InfiniteQueryDefinition< + any, + any, + any, + any, + any + > + ? InfiniteQueryHooks + : never } /** * A hook that accepts a string endpoint name, and provides a callback that when called, pre-fetches the data for that endpoint. @@ -189,7 +206,12 @@ export const reactHooksModule = ({ any, ReactHooksModule > - const { buildQueryHooks, buildMutationHook, usePrefetch } = buildHooks({ + const { + buildQueryHooks, + buildInfiniteQueryHooks, + buildMutationHook, + usePrefetch, + } = buildHooks({ api, moduleOptions: { batch, @@ -223,13 +245,27 @@ export const reactHooksModule = ({ ;(api as any)[`use${capitalize(endpointName)}Query`] = useQuery ;(api as any)[`useLazy${capitalize(endpointName)}Query`] = useLazyQuery - } else if (isMutationDefinition(definition)) { + } + if (isMutationDefinition(definition)) { const useMutation = buildMutationHook(endpointName) safeAssign(anyApi.endpoints[endpointName], { useMutation, }) ;(api as any)[`use${capitalize(endpointName)}Mutation`] = useMutation + } else if (isInfiniteQueryDefinition(definition)) { + const { + useInfiniteQuery, + useInfiniteQuerySubscription, + useInfiniteQueryState, + } = buildInfiniteQueryHooks(endpointName) + safeAssign(anyApi.endpoints[endpointName], { + useInfiniteQuery, + useInfiniteQuerySubscription, + useInfiniteQueryState, + }) + ;(api as any)[`use${capitalize(endpointName)}InfiniteQuery`] = + useInfiniteQuery } }, } diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 5ec6b32c14..6c7e41fecb 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -766,6 +766,86 @@ describe('hooks tests', () => { expect(res.data!.amount).toBeGreaterThan(originalAmount) }) + + test('Infinite Query Hook getNextPage Trigger', async () => { + 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') + + const results = { title: `page ${pageNum}`, info: 'more name' } + return HttpResponse.json(results) + }) + ) + + + const pokemonApi = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), + endpoints: (builder) => ({ + getInfinitePokemon: builder.infiniteQuery({ + infiniteQueryOptions: { + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => lastPageParam + 1 + }, + query(pageParam = 0) { + return `https://example.com/listItems?page=${pageParam}` + } + }) + }) + }) + + + const storeRef = setupApiStore(pokemonApi, undefined, { + withoutTestLifecycles: true + }) + + const checkNumQueries = (count: number) => { + const cacheEntries = Object.keys((storeRef.store.getState()).api.queries) + const queries = cacheEntries.length + console.log('queries', queries) + console.log(storeRef.store.getState().api.queries) + + expect(queries).toBe(count) + } + + function User() { + const { data, isFetching, isUninitialized, fetchNextPage } = + pokemonApi.endpoints.getInfinitePokemon.useInfiniteQuery(0, { getNextPageParam: (lastPageParam) => lastPageParam + 1 }) + + return ( +
    +
    {String(isUninitialized)}
    +
    {String(isFetching)}
    +
    + {data?.pages.map((page: any, i: number | null | undefined) => ( +
    {JSON.stringify(page)}
    + ))} +
    + +
    + ) + } + + render(, { wrapper: storeRef.wrapper }) + expect(screen.getByTestId('data').textContent).toBe('') + checkNumQueries(1) + + await waitFor(() => + expect(screen.getByTestId('isUninitialized').textContent).toBe('false'), + ) + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('false'), + ) + fireEvent.click(screen.getByTestId('nextPage')) + checkNumQueries(1) + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('false'), + ) + expect(screen.getByTestId('data').textContent).toBe('{"title":"page 0","info":"more name"}{"title":"page 1","info":"more name"}') + }) + // See https://github.com/reduxjs/redux-toolkit/issues/4267 - Memory leak in useQuery rapid query arg changes test('Hook subscriptions are properly cleaned up when query is fulfilled/rejected', async () => { // This is imported already, but it seems to be causing issues with the test on certain matrixes diff --git a/packages/toolkit/src/query/tests/infiniteQueries.test.ts b/packages/toolkit/src/query/tests/infiniteQueries.test.ts new file mode 100644 index 0000000000..eab4176672 --- /dev/null +++ b/packages/toolkit/src/query/tests/infiniteQueries.test.ts @@ -0,0 +1,116 @@ +import { configureStore, isAllOf } from '@reduxjs/toolkit' +import { renderHook, waitFor } from '@testing-library/react' +import { HttpResponse, http } from 'msw' +import util from 'util' +import { + QueryStatus, + createApi, + fetchBaseQuery, + skipToken, +} from '@reduxjs/toolkit/query/react' +import { + actionsReducer, + setupApiStore, + withProvider, +} from '../../tests/utils/helpers' +import type { BaseQueryApi } from '../baseQueryTypes' +import { server } from '@internal/query/tests/mocks/server' + +describe('Infinite queries', () => { + beforeEach(() => { + server.resetHandlers() + }) + + test('Basic infinite query behavior', async () => { + type Pokemon = { + id: string + name: string + } + + type PokemonQueryArg = { + type: string + page: number + } + + 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') + + const results: Pokemon[] = [ + { id: `${pageNum}`, name: `Pokemon ${pageNum}` }, + ] + return HttpResponse.json(results) + }), + ) + + 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: { + getNextPageParam: ( + lastPage, + allPages, + // ✅Currently: page param type is `number` + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + }, + // ❌ This seems to be controlled by `BaseEndpointDefinition` + // GOAL: should be `pageParam: number` + query(pageParam) { + return `https://example.com/listItems?page=${pageParam}` + }, + }), + }), + }) + + const storeRef = setupApiStore(pokemonApi, undefined, { + withoutTestLifecycles: true, + }) + + const res = storeRef.store.dispatch( + // ❌ This seems to be controlled by `BaseEndpointDefinition`. + // GOAL: should be `arg: string` + pokemonApi.endpoints.getInfinitePokemon.initiate('fire', {}), + ) + + const firstResult = await res + expect(firstResult.status).toBe(QueryStatus.fulfilled) + console.log('Value: ', util.inspect(firstResult, { depth: Infinity })) + + if (firstResult.status === QueryStatus.fulfilled) { + expect(firstResult.data.pages).toEqual([ + // one page, one entry + [{ id: '0', name: 'Pokemon 0' }], + ]) + } + + const secondRes = storeRef.store.dispatch( + pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { + direction: 'forward', + data: firstResult.data, + }), + ) + + const secondResult = await secondRes + expect(secondResult.status).toBe(QueryStatus.fulfilled) + console.log('Value: ', util.inspect(secondResult, { depth: Infinity })) + if (secondResult.status === QueryStatus.fulfilled) { + expect(secondResult.data.pages).toEqual([ + // two pages, one entry each + [{ id: '0', name: 'Pokemon 0' }], + [{ id: '1', name: 'Pokemon 1' }], + ]) + } + + console.log( + 'API state: ', + util.inspect(storeRef.store.getState().api, { depth: Infinity }), + ) + }) +})