diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index dee00cc37a..8a92cccf29 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -58,6 +58,7 @@ "@size-limit/webpack": "^11.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.0.1", + "@testing-library/react-render-stream": "^1.0.3", "@testing-library/user-event": "^14.5.2", "@types/babel__core": "^7.20.5", "@types/babel__helper-module-imports": "^7.18.3", diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index 4a2a664ab4..36dea32cc4 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -1,12 +1,14 @@ import type { SerializedError } from '@reduxjs/toolkit' import type { BaseQueryError } from '../baseQueryTypes' import type { - QueryDefinition, - MutationDefinition, - EndpointDefinitions, BaseEndpointDefinition, - ResultTypeFrom, + EndpointDefinitions, + InfiniteQueryDefinition, + MutationDefinition, + PageParamFrom, QueryArgFrom, + QueryDefinition, + ResultTypeFrom, } from '../endpointDefinitions' import type { Id, WithRequiredProp } from '../tsHelpers' @@ -28,6 +30,35 @@ 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 = { + initialPageParam: TPageParam + /** + * 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 +164,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 +179,7 @@ type BaseQuerySubState> = { /** * The received data from the query */ - data?: ResultTypeFrom + data?: DataType /** * The received error if applicable */ @@ -166,21 +200,31 @@ type BaseQuerySubState> = { * Time that the latest query was fulfilled */ fulfilledTimeStamp?: number + /** + * Infinite Query Specific substate properties + */ + hasNextPage?: boolean + hasPreviousPage?: boolean + direction?: 'forward' | 'backward' + 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 +237,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' | 'backward' + } + : never + type BaseMutationSubState> = { requestId: string data?: ResultTypeFrom @@ -249,7 +308,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 ca2d6854e0..5132224bed 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -1,26 +1,42 @@ import type { + SafePromise, SerializedError, ThunkAction, + ThunkDispatch, UnknownAction, } from '@reduxjs/toolkit' import type { Dispatch } from 'redux' -import type { SafePromise } from '../../tsHelpers' import { asSafePromise } from '../../tsHelpers' import type { Api, ApiContext } from '../apiTypes' import type { BaseQueryError, QueryReturnValue } from '../baseQueryTypes' import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' import type { EndpointDefinitions, + InfiniteQueryArgFrom, + InfiniteQueryDefinition, MutationDefinition, + PageParamFrom, QueryArgFrom, QueryDefinition, ResultTypeFrom, } from '../endpointDefinitions' import { countObjectKeys, getOrInsert, 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 { + InfiniteData, + InfiniteQueryConfigOptions, + SubscriptionOptions, +} 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 +44,12 @@ export type BuildInitiateApiEndpointQuery< initiate: StartQueryActionCreator } +export type BuildInitiateApiEndpointInfiniteQuery< + Definition extends InfiniteQueryDefinition, +> = { + initiate: StartInfiniteQueryActionCreator +} + export type BuildInitiateApiEndpointMutation< Definition extends MutationDefinition, > = { @@ -45,6 +67,23 @@ export type StartQueryActionCreatorOptions = { [forceQueryFnSymbol]?: () => QueryReturnValue } +export type StartInfiniteQueryActionCreatorOptions< + D extends InfiniteQueryDefinition, +> = { + subscribe?: boolean + forceRefetch?: boolean | number + subscriptionOptions?: SubscriptionOptions + direction?: 'forward' | 'backward' + [forceQueryFnSymbol]?: () => QueryReturnValue + param?: unknown + previous?: boolean +} & Partial< + Pick< + Partial, PageParamFrom>>, + 'initialPageParam' + > +> + type StartQueryActionCreator< D extends QueryDefinition, > = ( @@ -52,6 +91,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: InfiniteQueryArgFrom, + options?: StartInfiniteQueryActionCreatorOptions, +) => ( + dispatch: ThunkDispatch, + getState: () => any, +) => InfiniteQueryActionCreatorResult + export type QueryActionCreatorResult< D extends QueryDefinition, > = SafePromise> & { @@ -66,6 +117,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 +254,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 +287,7 @@ export function buildInitiate({ } = api.internalActions return { buildInitiateQuery, + buildInitiateInfiniteQuery, buildInitiateMutation, getRunningQueryThunk, getRunningMutationThunk, @@ -232,6 +305,7 @@ export function buildInitiate({ }) return runningQueries.get(dispatch)?.[queryCacheKey] as | QueryActionCreatorResult + | InfiniteQueryActionCreatorResult | undefined } } @@ -407,6 +481,141 @@ 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, + initialPageParam, + [forceQueryFnSymbol]: forceQueryFn, + direction, + 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, + param, + previous, + direction, + initialPageParam, + }) + 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, + 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..c13a89957d 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, +> = CacheLifecycleQueryExtraOptions< + ResultType, + QueryArg, + BaseQuery, + ReducerPath +> + 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 7e80d8c96e..4746823b05 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/queryLifecycle.ts @@ -121,6 +121,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, +> = QueryLifecycleQueryExtraOptions< + ResultType, + QueryArg, + BaseQuery, + ReducerPath +> + 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..bd93f0a2e2 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/types.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/types.ts @@ -19,6 +19,7 @@ import type { SubscriptionState, } from '../apiState' import type { + InfiniteQueryThunk, MutationThunk, QueryThunk, QueryThunkArg, @@ -48,6 +49,7 @@ export interface BuildMiddlewareInput< context: ApiContext queryThunk: QueryThunk mutationThunk: MutationThunk + infiniteQueryThunk: InfiniteQueryThunk api: Api assertTagType: AssertTagTypes } @@ -64,7 +66,7 @@ export interface BuildSubMiddlewareInput querySubState: Exclude< QuerySubState, { status: QueryStatus.uninitialized } - > + >, ): ThunkAction, any, any, UnknownAction> isThisApiSliceAction: (action: Action) => boolean } diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index c20db23950..d270c77493 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, isNotNullish } 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, @@ -184,6 +212,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..8f9461728e 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 && + 'param' in arg && + 'direction' in arg + ) { + substate.param = arg.param + substate.direction = arg.direction as 'forward' | 'backward' | 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..7b45aa105b 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -19,6 +19,7 @@ import type { AssertTagTypes, EndpointDefinition, EndpointDefinitions, + InfiniteQueryDefinition, MutationDefinition, QueryArgFrom, QueryDefinition, @@ -27,10 +28,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 { + RootState, + QueryKeys, + QuerySubstateIdentifier, + InfiniteData, + InfiniteQueryConfigOptions, + QueryCacheKey, +} from './apiState' import { QueryStatus } from './apiState' import type { QueryActionCreatorResult, + StartInfiniteQueryActionCreatorOptions, StartQueryActionCreatorOptions, } from './buildInitiate' import { forceQueryFnSymbol, isUpsertQuery } from './buildInitiate' @@ -49,6 +58,10 @@ export type BuildThunksApiEndpointQuery< Definition extends QueryDefinition, > = Matchers +export type BuildThunksApiEndpointInfiniteQuery< + Definition extends InfiniteQueryDefinition, +> = Matchers + export type BuildThunksApiEndpointMutation< Definition extends MutationDefinition, > = Matchers @@ -105,6 +118,18 @@ export type QueryThunkArg = QuerySubstateIdentifier & endpointName: string } +export type InfiniteQueryThunkArg< + D extends InfiniteQueryDefinition, +> = QuerySubstateIdentifier & + StartInfiniteQueryActionCreatorOptions & { + type: `query` + originalArgs: unknown + endpointName: string + param: unknown + previous?: boolean + direction?: 'forward' | 'backward' + } + type MutationThunkArg = { type: 'mutation' originalArgs: unknown @@ -135,6 +160,9 @@ export type QueryThunk = AsyncThunk< QueryThunkArg, ThunkApiMetaConfig > +export type InfiniteQueryThunk< + D extends InfiniteQueryDefinition, +> = AsyncThunk, 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) => { @@ -341,9 +379,10 @@ export function buildThunks< ) } + // The generic async payload function for all of our thunks const executeEndpoint: AsyncThunkPayloadCreator< ThunkResult, - QueryThunkArg | MutationThunkArg, + QueryThunkArg | MutationThunkArg | InfiniteQueryThunkArg, ThunkApiMetaConfig & { state: RootState } > = async ( arg, @@ -364,8 +403,11 @@ export function buildThunks< baseQueryReturnValue: any, meta: any, arg: any, - ) => any = defaultTransformResponse - let result: QueryReturnValue + ) => any = + endpointDefinition.query && endpointDefinition.transformResponse + ? endpointDefinition.transformResponse + : defaultTransformResponse + const baseQueryApi = { signal, abort, @@ -381,74 +423,186 @@ export function buildThunks< const forceQueryFn = arg.type === 'query' ? arg[forceQueryFnSymbol] : undefined - if (forceQueryFn) { - result = forceQueryFn() - } else if (endpointDefinition.query) { - result = await baseQuery( - endpointDefinition.query(arg.originalArgs), - baseQueryApi, - endpointDefinition.extraOptions as any, - ) - if (endpointDefinition.transformResponse) { - transformResponse = endpointDefinition.transformResponse + let finalQueryReturnValue: QueryReturnValue + + // Infinite query wrapper, which executes the request and returns + // the InfiniteData `{pages, pageParams}` structure + const fetchPage = async ( + data: InfiniteData, + param: unknown, + previous?: boolean, + ): Promise => { + // This should handle cases where there is no `getPrevPageParam`, + // or `getPPP` returned nullish + if (param == null && data.pages.length) { + return Promise.resolve({ data }) + } + + const pageResponse = await executeRequest(param) + + // TODO Get maxPages from endpoint config + const maxPages = 20 + const addTo = previous ? addToStart : addToEnd + + return { + data: { + pages: addTo(data.pages, pageResponse.data, maxPages), + pageParams: addTo(data.pageParams, param, maxPages), + }, } - } else { - result = await endpointDefinition.queryFn( - arg.originalArgs, - baseQueryApi, - endpointDefinition.extraOptions as any, - (arg) => - baseQuery( - arg, - baseQueryApi, - endpointDefinition.extraOptions as any, - ), - ) } - if ( - typeof process !== 'undefined' && - process.env.NODE_ENV === 'development' - ) { - const what = endpointDefinition.query ? '`baseQuery`' : '`queryFn`' - let err: undefined | string - if (!result) { - err = `${what} did not return anything.` - } else if (typeof result !== 'object') { - err = `${what} did not return an object.` - } else if (result.error && result.data) { - err = `${what} returned an object containing both \`error\` and \`result\`.` - } else if (result.error === undefined && result.data === undefined) { - err = `${what} returned an object containing neither a valid \`error\` and \`result\`. At least one of them should not be \`undefined\`` + + // Wrapper for executing either `query` or `queryFn`, + // and handling any errors + async function executeRequest( + finalQueryArg: unknown, + ): Promise { + let result: QueryReturnValue + + if (forceQueryFn) { + // upsertQueryData relies on this to pass in the user-provided value + result = forceQueryFn() + } else if (endpointDefinition.query) { + result = await baseQuery( + endpointDefinition.query(finalQueryArg), + baseQueryApi, + endpointDefinition.extraOptions as any, + ) } else { - for (const key of Object.keys(result)) { - if (key !== 'error' && key !== 'data' && key !== 'meta') { - err = `The object returned by ${what} has the unknown property ${key}.` - break + result = await endpointDefinition.queryFn( + finalQueryArg, + baseQueryApi, + endpointDefinition.extraOptions as any, + (arg) => + baseQuery( + arg, + baseQueryApi, + endpointDefinition.extraOptions as any, + ), + ) + } + + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'development' + ) { + const what = endpointDefinition.query ? '`baseQuery`' : '`queryFn`' + let err: undefined | string + if (!result) { + err = `${what} did not return anything.` + } else if (typeof result !== 'object') { + err = `${what} did not return an object.` + } else if (result.error && result.data) { + err = `${what} returned an object containing both \`error\` and \`result\`.` + } else if (result.error === undefined && result.data === undefined) { + err = `${what} returned an object containing neither a valid \`error\` and \`result\`. At least one of them should not be \`undefined\`` + } else { + for (const key of Object.keys(result)) { + if (key !== 'error' && key !== 'data' && key !== 'meta') { + err = `The object returned by ${what} has the unknown property ${key}.` + break + } } } + if (err) { + console.error( + `Error encountered handling the endpoint ${arg.endpointName}. + ${err} + It needs to return an object with either the shape \`{ data: }\` or \`{ error: }\` that may contain an optional \`meta\` property. + Object returned was:`, + result, + ) + } } - if (err) { - console.error( - `Error encountered handling the endpoint ${arg.endpointName}. - ${err} - It needs to return an object with either the shape \`{ data: }\` or \`{ error: }\` that may contain an optional \`meta\` property. - Object returned was:`, - result, - ) + + if (result.error) throw new HandledError(result.error, result.meta) + + const transformedResponse = await transformResponse( + result.data, + result.meta, + finalQueryArg, + ) + + return { + ...result, + data: transformedResponse, } } - if (result.error) throw new HandledError(result.error, result.meta) + if ( + arg.type === 'query' && + 'infiniteQueryOptions' in endpointDefinition + ) { + // This is an infinite query endpoint - return fulfillWithValue( - await transformResponse(result.data, result.meta, arg.originalArgs), - { - fulfilledTimeStamp: Date.now(), - baseQueryMeta: result.meta, - [SHOULD_AUTOBATCH]: true, - }, - ) + let result: QueryReturnValue + + // Start by looking up the existing InfiniteData value from state, + // falling back to an empty value if it doesn't exist yet + const existingData = (getState()[reducerPath].queries[arg.queryCacheKey] + ?.data ?? { pages: [], pageParams: [] }) as InfiniteData< + unknown, + unknown + > + + // If the thunk specified a direction and we do have at least one page, + // fetch the next or previous page + 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, + ) + + result = await fetchPage(existingData, param, previous) + } else { + // Otherwise, fetch the first page and then any remaining pages + + const { + initialPageParam = endpointDefinition.infiniteQueryOptions + .initialPageParam, + } = arg as InfiniteQueryThunkArg + + // Fetch first page + result = await fetchPage( + existingData, + existingData.pageParams[0] ?? initialPageParam, + ) + + //original + // const remainingPages = pages ?? oldPages.length + // const remainingPages = oldPages.length + + // TODO This seems pretty wrong + const remainingPages = existingData.pages.length + + // Fetch remaining pages + for (let i = 1; i < remainingPages; i++) { + const param = getNextPageParam( + endpointDefinition.infiniteQueryOptions, + result.data as InfiniteData, + ) + result = await fetchPage( + result.data as InfiniteData, + param, + ) + } + } + + finalQueryReturnValue = result + } else { + // Non-infinite endpoint. Just run the one request. + finalQueryReturnValue = await executeRequest(arg.originalArgs) + } + + // console.log('Final result: ', transformedData) + return fulfillWithValue(finalQueryReturnValue.data, { + fulfilledTimeStamp: Date.now(), + baseQueryMeta: finalQueryReturnValue.meta, + [SHOULD_AUTOBATCH]: true, + }) } catch (error) { let catchedError = error if (catchedError instanceof HandledError) { @@ -456,14 +610,11 @@ export function buildThunks< baseQueryReturnValue: any, meta: any, arg: any, - ) => any = defaultTransformResponse + ) => any = + endpointDefinition.query && endpointDefinition.transformErrorResponse + ? endpointDefinition.transformErrorResponse + : defaultTransformResponse - if ( - endpointDefinition.query && - endpointDefinition.transformErrorResponse - ) { - transformErrorResponse = endpointDefinition.transformErrorResponse - } try { return rejectWithValue( await transformErrorResponse( @@ -493,6 +644,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 +750,70 @@ 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, + } + }, + 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 +893,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/index.ts b/packages/toolkit/src/query/core/index.ts index a79ac4d9bd..636d3ede98 100644 --- a/packages/toolkit/src/query/core/index.ts +++ b/packages/toolkit/src/query/core/index.ts @@ -6,6 +6,9 @@ export const createApi = /* @__PURE__ */ buildCreateApi(coreModule()) export { QueryStatus } from './apiState' export type { CombinedState, + InfiniteData, + InfiniteQueryConfigOptions, + InfiniteQuerySubState, MutationKeys, QueryCacheKey, QueryKeys, @@ -14,6 +17,7 @@ export type { SubscriptionOptions, } from './apiState' export type { + InfiniteQueryActionCreatorResult, MutationActionCreatorResult, QueryActionCreatorResult, StartQueryActionCreatorOptions, @@ -29,6 +33,7 @@ export type { } from './buildMiddleware/index' export { skipToken } from './buildSelectors' export type { + InfiniteQueryResultSelectorResult, MutationResultSelectorResult, QueryResultSelectorResult, SkipToken, @@ -41,6 +46,7 @@ export type { } from './buildThunks' export { coreModuleName } from './module' export type { + ApiEndpointInfiniteQuery, ApiEndpointMutation, ApiEndpointQuery, CoreModule, diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index 6bb43bc411..1a4d0a353c 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 8f7955468c..a80c11f4ca 100644 --- a/packages/toolkit/src/query/endpointDefinitions.ts +++ b/packages/toolkit/src/query/endpointDefinitions.ts @@ -9,16 +9,22 @@ 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' +import type { + InfiniteQueryConfigOptions, + QuerySubState, + RootState, +} from './core/index' import type { SerializeQueryArgs } from './defaultSerializeQueryArgs' import type { NEVER } from './fakeBaseQuery' import type { @@ -212,6 +218,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< @@ -536,6 +544,157 @@ 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 + + /** + * Can be provided to return a custom cache key value based on the query arguments. + * + * This is primarily intended for cases where a non-serializable value is passed as part of the query arg object and should be excluded from the cache key. It may also be used for cases where an endpoint should only have a single cache entry, such as an infinite loading / pagination implementation. + * + * Unlike the `createApi` version which can _only_ return a string, this per-endpoint option can also return an an object, number, or boolean. If it returns a string, that value will be used as the cache key directly. If it returns an object / number / boolean, that value will be passed to the built-in `defaultSerializeQueryArgs`. This simplifies the use case of stripping out args you don't want included in the cache key. + * + * + * @example + * + * ```ts + * // codeblock-meta title="serializeQueryArgs : exclude value" + * + * import { createApi, fetchBaseQuery, defaultSerializeQueryArgs } from '@reduxjs/toolkit/query/react' + * interface Post { + * id: number + * name: string + * } + * + * interface MyApiClient { + * fetchPost: (id: string) => Promise + * } + * + * createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * endpoints: (build) => ({ + * // Example: an endpoint with an API client passed in as an argument, + * // but only the item ID should be used as the cache key + * getPost: build.query({ + * queryFn: async ({ id, client }) => { + * const post = await client.fetchPost(id) + * return { data: post } + * }, + * // highlight-start + * serializeQueryArgs: ({ queryArgs, endpointDefinition, endpointName }) => { + * const { id } = queryArgs + * // This can return a string, an object, a number, or a boolean. + * // If it returns an object, number or boolean, that value + * // will be serialized automatically via `defaultSerializeQueryArgs` + * return { id } // omit `client` from the cache key + * + * // Alternately, you can use `defaultSerializeQueryArgs` yourself: + * // return defaultSerializeQueryArgs({ + * // endpointName, + * // queryArgs: { id }, + * // endpointDefinition + * // }) + * // Or create and return a string yourself: + * // return `getPost(${id})` + * }, + * // highlight-end + * }), + * }), + *}) + * ``` + */ + serializeQueryArgs?: SerializeQueryArgs< + QueryArg, + string | number | boolean | Record + > + + /** + * 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, +> = + // Intentionally use `PageParam` as the QueryArg` type + BaseEndpointDefinition & + InfiniteQueryExtraOptions< + TagTypes, + ResultType, + QueryArg, + PageParam, + BaseQuery, + ReducerPath + > + type MutationTypes< QueryArg, BaseQuery extends BaseQueryFn, @@ -662,9 +821,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, @@ -683,6 +851,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, @@ -758,6 +932,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 @@ -800,7 +995,15 @@ export function expandTagDescription( } export type QueryArgFrom> = - D extends BaseEndpointDefinition ? QA : unknown + D extends BaseEndpointDefinition ? QA : never + +// Just extracting `QueryArg` from `BaseEndpointDefinition` +// doesn't sufficiently match here. +// We need to explicitly match against `InfiniteQueryDefinition` +export type InfiniteQueryArgFrom< + D extends BaseEndpointDefinition, +> = D extends InfiniteQueryDefinition ? QA : never + export type ResultTypeFrom> = D extends BaseEndpointDefinition ? RT : unknown @@ -811,6 +1014,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 @@ -852,7 +1060,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, @@ -887,5 +1111,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 a7d9114145..1d89d7a3bc 100644 --- a/packages/toolkit/src/query/index.ts +++ b/packages/toolkit/src/query/index.ts @@ -32,6 +32,10 @@ export type { QueryDefinition, MutationDefinition, MutationExtraOptions, + InfiniteQueryArgFrom, + InfiniteQueryDefinition, + InfiniteQueryExtraOptions, + PageParamFrom, TagDescription, QueryArgFrom, QueryExtraOptions, @@ -70,12 +74,18 @@ export { _NEVER, fakeBaseQuery } from './fakeBaseQuery' export { copyWithStructuralSharing } from './utils/copyWithStructuralSharing' export { createApi, coreModule, coreModuleName } from './core/index' export type { + InfiniteData, + InfiniteQueryActionCreatorResult, + InfiniteQueryConfigOptions, + InfiniteQueryResultSelectorResult, + InfiniteQuerySubState, TypedMutationOnQueryStarted, TypedQueryOnQueryStarted, } from './core/index' 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 daec42efe2..458af1fbbd 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -7,14 +7,21 @@ import type { import type { Api, ApiContext, + ApiEndpointInfiniteQuery, ApiEndpointMutation, ApiEndpointQuery, BaseQueryFn, CoreModule, EndpointDefinitions, + InfiniteQueryActionCreatorResult, + InfiniteQueryArgFrom, + InfiniteQueryDefinition, + InfiniteQueryResultSelectorResult, + InfiniteQuerySubState, MutationActionCreatorResult, MutationDefinition, MutationResultSelectorResult, + PageParamFrom, PrefetchOptions, QueryActionCreatorResult, QueryArgFrom, @@ -32,7 +39,11 @@ import type { TSHelpersNoInfer, TSHelpersOverride, } from '@reduxjs/toolkit/query' -import { QueryStatus, skipToken } from '@reduxjs/toolkit/query' +import { + defaultSerializeQueryArgs, + QueryStatus, + skipToken, +} from '@reduxjs/toolkit/query' import type { DependencyList } from 'react' import { useCallback, @@ -44,8 +55,9 @@ import { useState, } from 'react' import { shallowEqual } from 'react-redux' -import type { SubscriptionSelectors } from '../core' -import { defaultSerializeQueryArgs } from '../defaultSerializeQueryArgs' + +import type { SubscriptionSelectors } from '../core/buildMiddleware/index' +import type { InfiniteData, InfiniteQueryConfigOptions } from '../core/index' import type { UninitializedValue } from './constants' import { UNINITIALIZED_VALUE } from './constants' import type { ReactHooksModuleOptions } from './module' @@ -85,6 +97,14 @@ export type QueryHooks< useQueryState: UseQueryState } +export type InfiniteQueryHooks< + Definition extends InfiniteQueryDefinition, +> = { + useInfiniteQuery: UseInfiniteQuery + useInfiniteQuerySubscription: UseInfiniteQuerySubscription + useInfiniteQueryState: UseInfiniteQueryState +} + export type MutationHooks< Definition extends MutationDefinition, > = { @@ -741,6 +761,335 @@ 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, + direction: 'forward' | 'backward', + ): InfiniteQueryActionCreatorResult +} + +interface UseInfiniteQuerySubscriptionOptions< + D extends InfiniteQueryDefinition, +> 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 + initialPageParam?: PageParamFrom +} + +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: InfiniteQueryArgFrom | SkipToken, + options?: UseInfiniteQuerySubscriptionOptions & + UseInfiniteQueryStateOptions, +) => UseInfiniteQueryHookResult + +export type UseInfiniteQueryState< + D extends InfiniteQueryDefinition, +> = = UseInfiniteQueryStateDefaultResult>( + arg: QueryArgFrom | SkipToken, + 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, +> = InfiniteQuerySubState & { + /** + * 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?: InfiniteData, PageParamFrom> + /** + * 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, @@ -891,7 +1240,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, @@ -952,6 +1306,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 === 'backward' + + return { + ...currentState, + data, + currentData: currentState.data, + isFetching, + isLoading, + isSuccess, + isFetchingNextPage, + isFetchingPreviousPage, + } as UseInfiniteQueryStateDefaultResult + } + function usePrefetch>( endpointName: EndpointName, defaultOptions?: PrefetchOptions, @@ -1335,6 +1746,293 @@ export function buildHooks({ } } + function buildInfiniteQueryHooks(name: string): InfiniteQueryHooks { + const useInfiniteQuerySubscription: UseInfiniteQuerySubscription = ( + arg: any, + { + refetchOnReconnect, + refetchOnFocus, + refetchOnMountOrArgChange, + skip = false, + pollingInterval = 0, + skipPollingIfUnfocused = false, + initialPageParam, + } = {}, + ) => { + 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, { + initialPageParam, + subscriptionOptions: stableSubscriptionOptions, + forceRefetch: refetchOnMountOrArgChange, + }), + ) + + promiseRef.current = promise + } else if (stableSubscriptionOptions !== lastSubscriptionOptions) { + lastPromise.updateSubscriptionOptions(stableSubscriptionOptions) + } + }, [ + dispatch, + initiate, + refetchOnMountOrArgChange, + stableArg, + stableSubscriptionOptions, + subscriptionRemoved, + initialPageParam, + ]) + + const subscriptionOptionsRef = useRef(stableSubscriptionOptions) + usePossiblyImmediateEffect(() => { + subscriptionOptionsRef.current = stableSubscriptionOptions + }, [stableSubscriptionOptions]) + + const trigger: LazyInfiniteQueryTrigger = useCallback( + function (arg: unknown, direction: 'forward' | 'backward') { + let promise: InfiniteQueryActionCreatorResult + + batch(() => { + promiseRef.current?.unsubscribe() + + promiseRef.current = promise = dispatch( + initiate(arg, { + subscriptionOptions: subscriptionOptionsRef.current, + direction, + }), + ) + }) + + 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, + { 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], + ) + + 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, { + selectFromResult: + arg === skipToken || options?.skip + ? undefined + : noPendingQueryStateSelector, + trigger, + ...options, + }) + + const { + data, + status, + isLoading, + isSuccess, + isError, + error, + hasNextPage, + hasPreviousPage, + } = queryStateResults + useDebugValue({ + data, + status, + isLoading, + isSuccess, + isError, + error, + hasNextPage, + hasPreviousPage, + }) + + const fetchNextPage = useCallback(() => { + // TODO the hasNextPage bailout breaks things + //if (!hasNextPage) return + return trigger(arg, 'forward') + }, [trigger, arg]) + + const fetchPreviousPage = useCallback(() => { + //if (!hasPreviousPage) return + return trigger(arg, 'backward') + }, [trigger, arg]) + + 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..423444a9a0 100644 --- a/packages/toolkit/src/query/react/module.ts +++ b/packages/toolkit/src/query/react/module.ts @@ -2,6 +2,7 @@ import type { Api, BaseQueryFn, EndpointDefinitions, + InfiniteQueryDefinition, Module, MutationDefinition, PrefetchOptions, @@ -16,10 +17,18 @@ import { useStore as rrUseStore, } from 'react-redux' import { createSelector as _createSelector } from 'reselect' -import { isMutationDefinition, isQueryDefinition } 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/react/namedHooks.ts b/packages/toolkit/src/query/react/namedHooks.ts index 7a21b993f4..07b7ac7417 100644 --- a/packages/toolkit/src/query/react/namedHooks.ts +++ b/packages/toolkit/src/query/react/namedHooks.ts @@ -1,10 +1,10 @@ -import type { UseMutation, UseLazyQuery, UseQuery } from './buildHooks' import type { DefinitionType, EndpointDefinitions, MutationDefinition, QueryDefinition, } from '@reduxjs/toolkit/query' +import type { UseLazyQuery, UseMutation, UseQuery } from './buildHooks' type QueryHookNames = { [K in keyof Definitions as Definitions[K] extends { diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 50ff561148..c0f010e362 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -30,6 +30,10 @@ import { screen, waitFor, } from '@testing-library/react' +import { + createRenderStream, + SyncScreen, +} from '@testing-library/react-render-stream/pure' import userEvent from '@testing-library/user-event' import { HttpResponse, http } from 'msw' import { useEffect, useState } from 'react' @@ -181,6 +185,8 @@ afterEach(() => { nextItemId = 0 amount = 0 listenerMiddleware.clearListeners() + + server.resetHandlers() }) let getRenderCount: () => number = () => 0 @@ -1557,7 +1563,7 @@ describe('hooks tests', () => { setDataFromTrigger(res) // adding client side state here will cause stale data } catch (error) { - console.error(error) + console.error('Error handling increment trigger', error) } } @@ -1567,7 +1573,7 @@ describe('hooks tests', () => { // Force the lazy trigger to refetch await handleLoad() } catch (error) { - console.error(error) + console.error('Error handling mutate trigger', error) } } @@ -1675,6 +1681,184 @@ describe('hooks tests', () => { }) }) + describe('useInfiniteQuery', () => { + type Pokemon = { + id: string + name: string + } + + const pokemonApi = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), + endpoints: (builder) => ({ + getInfinitePokemon: builder.infiniteQuery({ + 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}` + }, + }), + }), + }) + + function PokemonList({ + arg = 'fire', + initialPageParam = 0, + }: { + arg?: string + initialPageParam?: number + }) { + const { + data, + isFetching, + isUninitialized, + fetchNextPage, + fetchPreviousPage, + } = pokemonApi.endpoints.getInfinitePokemon.useInfiniteQuery(arg, { + initialPageParam, + }) + + const handlePreviousPage = async () => { + const res = await fetchPreviousPage() + } + + const handleNextPage = async () => { + const res = await fetchNextPage() + } + + return ( +
    +
    {String(isUninitialized)}
    +
    {String(isFetching)}
    +
    Type: {arg}
    +
    + {data?.pages.map((page, i: number | null | undefined) => ( +
    {page.name}
    + ))} +
    + + +
    + ) + } + + 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') + + const results: Pokemon = { + id: `${pageNum}`, + name: `Pokemon ${pageNum}`, + } + + return HttpResponse.json(results) + }), + ) + }) + + test('useInfiniteQuery fetchNextPage Trigger', async () => { + const storeRef = setupApiStore(pokemonApi, undefined, { + withoutTestLifecycles: true, + }) + + const { takeRender, render, getCurrentRender } = createRenderStream({ + snapshotDOM: true, + }) + + const checkNumQueries = (count: number) => { + const cacheEntries = Object.keys(storeRef.store.getState().api.queries) + const queries = cacheEntries.length + //console.log('queries', queries, storeRef.store.getState().api.queries) + + expect(queries).toBe(count) + } + + const checkPageRows = ( + withinDOM: () => SyncScreen, + type: string, + ids: number[], + ) => { + expect(withinDOM().getByText(`Type: ${type}`)).toBeTruthy() + for (const id of ids) { + expect(withinDOM().getByText(`Pokemon ${id}`)).toBeTruthy() + } + } + + async function waitForFetch(handleExtraMiddleRender = false) { + { + const { withinDOM } = await takeRender() + expect(withinDOM().getByTestId('isFetching').textContent).toBe('true') + } + + // We seem to do an extra render when fetching an uninitialized entry + if (handleExtraMiddleRender) { + { + const { withinDOM } = await takeRender() + expect(withinDOM().getByTestId('isFetching').textContent).toBe( + 'true', + ) + } + } + + { + // Second fetch complete + const { withinDOM } = await takeRender() + expect(withinDOM().getByTestId('isFetching').textContent).toBe( + 'false', + ) + } + } + + const utils = render(, { wrapper: storeRef.wrapper }) + checkNumQueries(1) + await waitForFetch(true) + checkNumQueries(1) + checkPageRows(getCurrentRender().withinDOM, 'fire', [0]) + + fireEvent.click(screen.getByTestId('nextPage'), {}) + await waitForFetch() + checkPageRows(getCurrentRender().withinDOM, 'fire', [0, 1]) + + fireEvent.click(screen.getByTestId('nextPage')) + await waitForFetch() + checkPageRows(getCurrentRender().withinDOM, 'fire', [0, 1, 2]) + + utils.rerender() + await waitForFetch(true) + checkNumQueries(2) + checkPageRows(getCurrentRender().withinDOM, 'water', [3]) + + fireEvent.click(screen.getByTestId('nextPage')) + await waitForFetch() + checkPageRows(getCurrentRender().withinDOM, 'water', [3, 4]) + + fireEvent.click(screen.getByTestId('prevPage')) + await waitForFetch() + checkPageRows(getCurrentRender().withinDOM, 'water', [2, 3, 4]) + }) + }) + describe('useMutation', () => { test('useMutation hook sets and unsets the isLoading flag when running', async () => { function User() { diff --git a/packages/toolkit/src/query/tests/infiniteQueries.test-d.ts b/packages/toolkit/src/query/tests/infiniteQueries.test-d.ts new file mode 100644 index 0000000000..67b52ac7bb --- /dev/null +++ b/packages/toolkit/src/query/tests/infiniteQueries.test-d.ts @@ -0,0 +1,114 @@ +import type { skipToken, InfiniteData } from '@reduxjs/toolkit/query/react' +import { + createApi, + fetchBaseQuery, + QueryStatus, +} from '@reduxjs/toolkit/query/react' +import { setupApiStore } from '../../tests/utils/helpers' + +describe('Infinite queries', () => { + test('Basic infinite query behavior', async () => { + type Pokemon = { + id: string + name: string + } + + const pokemonApi = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), + endpoints: (builder) => ({ + getInfinitePokemon: builder.infiniteQuery({ + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => { + expectTypeOf(lastPage).toEqualTypeOf() + + expectTypeOf(allPages).toEqualTypeOf() + + expectTypeOf(lastPageParam).toBeNumber() + + expectTypeOf(allPageParams).toEqualTypeOf() + + return lastPageParam + 1 + }, + }, + query(pageParam) { + expectTypeOf(pageParam).toBeNumber() + + return `https://example.com/listItems?page=${pageParam}` + }, + }), + }), + }) + + const storeRef = setupApiStore(pokemonApi, undefined, { + withoutTestLifecycles: true, + }) + + expectTypeOf(pokemonApi.endpoints.getInfinitePokemon.initiate) + .parameter(0) + .toBeString() + + const res = storeRef.store.dispatch( + pokemonApi.endpoints.getInfinitePokemon.initiate('fire', {}), + ) + + const firstResult = await res + + if (firstResult.status === QueryStatus.fulfilled) { + expectTypeOf(firstResult.data.pages).toEqualTypeOf() + expectTypeOf(firstResult.data.pageParams).toEqualTypeOf() + } + + storeRef.store.dispatch( + pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { + direction: 'forward', + }), + ) + + const useGetInfinitePokemonQuery = + pokemonApi.endpoints.getInfinitePokemon.useInfiniteQuery + + expectTypeOf(useGetInfinitePokemonQuery) + .parameter(0) + .toEqualTypeOf() + + function PokemonList() { + const { + data, + currentData, + isFetching, + isUninitialized, + isSuccess, + fetchNextPage, + } = useGetInfinitePokemonQuery('a') + + expectTypeOf(data).toEqualTypeOf< + InfiniteData | undefined + >() + + if (isSuccess) { + expectTypeOf(data.pages).toEqualTypeOf() + expectTypeOf(data.pageParams).toEqualTypeOf() + } + + if (currentData) { + expectTypeOf(currentData.pages).toEqualTypeOf() + expectTypeOf(currentData.pageParams).toEqualTypeOf() + } + + const handleClick = async () => { + const res = await fetchNextPage() + + if (res.status === QueryStatus.fulfilled) { + expectTypeOf(res.data.pages).toEqualTypeOf() + expectTypeOf(res.data.pageParams).toEqualTypeOf() + } + } + } + }) +}) 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..692a43e21c --- /dev/null +++ b/packages/toolkit/src/query/tests/infiniteQueries.test.ts @@ -0,0 +1,184 @@ +import { configureStore, isAllOf } from '@reduxjs/toolkit' +import { + act, + fireEvent, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +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', () => { + type Pokemon = { + id: string + name: string + } + + 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: { + 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 + }, + }, + + // Actual query arg type should be `number` + query(pageParam) { + return `https://example.com/listItems?page=${pageParam}` + }, + }), + }), + }) + + let storeRef = setupApiStore(pokemonApi, undefined, { + withoutTestLifecycles: true, + }) + + beforeEach(() => { + storeRef = setupApiStore(pokemonApi, undefined, { + withoutTestLifecycles: true, + }) + }) + + test('Basic infinite query behavior', async () => { + const res1 = storeRef.store.dispatch( + // Should be `arg: string` + pokemonApi.endpoints.getInfinitePokemon.initiate('fire', {}), + ) + + const entry1InitialLoad = await res1 + expect(entry1InitialLoad.status).toBe(QueryStatus.fulfilled) + // console.log('Value: ', util.inspect(entry1InitialLoad, { depth: Infinity })) + + if (entry1InitialLoad.status === QueryStatus.fulfilled) { + expect(entry1InitialLoad.data.pages).toEqual([ + // one page, one entry + [{ id: '0', name: 'Pokemon 0' }], + ]) + } + + const entry1SecondPage = await storeRef.store.dispatch( + pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { + direction: 'forward', + }), + ) + + 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 + [{ id: '0', name: 'Pokemon 0' }], + [{ id: '1', name: 'Pokemon 1' }], + ]) + } + + const entry1PrevPageMissing = await storeRef.store.dispatch( + pokemonApi.endpoints.getInfinitePokemon.initiate('fire', { + direction: 'backward', + }), + ) + + if (entry1PrevPageMissing.status === QueryStatus.fulfilled) { + // There is no p + expect(entry1PrevPageMissing.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 }), + // ) + + const entry2InitialLoad = await storeRef.store.dispatch( + pokemonApi.endpoints.getInfinitePokemon.initiate('water', { + initialPageParam: 3, + }), + ) + + if (entry2InitialLoad.status === QueryStatus.fulfilled) { + expect(entry2InitialLoad.data.pages).toEqual([ + // one page, one entry + [{ id: '3', name: 'Pokemon 3' }], + ]) + } + + const entry2NextPage = await storeRef.store.dispatch( + pokemonApi.endpoints.getInfinitePokemon.initiate('water', { + direction: 'forward', + }), + ) + + if (entry2NextPage.status === QueryStatus.fulfilled) { + expect(entry2NextPage.data.pages).toEqual([ + // two pages, one entry each + [{ id: '3', name: 'Pokemon 3' }], + [{ id: '4', name: 'Pokemon 4' }], + ]) + } + + const entry2PrevPage = await storeRef.store.dispatch( + pokemonApi.endpoints.getInfinitePokemon.initiate('water', { + direction: 'backward', + }), + ) + + if (entry2PrevPage.status === QueryStatus.fulfilled) { + expect(entry2PrevPage.data.pages).toEqual([ + // three pages, one entry each + [{ id: '2', name: 'Pokemon 2' }], + [{ id: '3', name: 'Pokemon 3' }], + [{ id: '4', name: 'Pokemon 4' }], + ]) + } + }) +}) diff --git a/yarn.lock b/yarn.lock index b503e74683..95f6db27c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6680,6 +6680,7 @@ __metadata: "@size-limit/webpack": "npm:^11.0.1" "@testing-library/dom": "npm:^10.4.0" "@testing-library/react": "npm:^16.0.1" + "@testing-library/react-render-stream": "npm:^1.0.3" "@testing-library/user-event": "npm:^14.5.2" "@types/babel__core": "npm:^7.20.5" "@types/babel__helper-module-imports": "npm:^7.18.3" @@ -7548,6 +7549,23 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-render-stream@npm:^1.0.3": + version: 1.0.3 + resolution: "@testing-library/react-render-stream@npm:1.0.3" + dependencies: + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/react": "npm:^16.0.1" + jsdom: "npm:^25.0.1" + rehackt: "npm:^0.1.0" + peerDependencies: + "@jest/globals": "*" + expect: "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 + checksum: 10/13e030f0002c3414a7a83adba3842329244c06f566543c1390b17227b056e3d9e3aaa1e006b940ae9fd6af1544adcce439ffd95bed01edc57408184940bac015 + languageName: node + linkType: hard + "@testing-library/react@npm:^16.0.1": version: 16.0.1 resolution: "@testing-library/react@npm:16.0.1" @@ -9561,6 +9579,15 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0": + version: 7.1.1 + resolution: "agent-base@npm:7.1.1" + dependencies: + debug: "npm:^4.3.4" + checksum: 10/c478fec8f79953f118704d007a38f2a185458853f5c45579b9669372bd0e12602e88dc2ad0233077831504f7cd6fcc8251c383375bba5eaaf563b102938bda26 + languageName: node + linkType: hard + "agentkeepalive@npm:^4.1.3": version: 4.1.4 resolution: "agentkeepalive@npm:4.1.4" @@ -12624,6 +12651,15 @@ __metadata: languageName: node linkType: hard +"cssstyle@npm:^4.1.0": + version: 4.1.0 + resolution: "cssstyle@npm:4.1.0" + dependencies: + rrweb-cssom: "npm:^0.7.1" + checksum: 10/8ca9e2d1f1b24f93bb5f3f20a7a1e271e58060957880e985ee55614e196a798ffab309ec6bac105af8a439a6764546761813835ebb7f929d60823637ee838a8f + languageName: node + linkType: hard + "csstype@npm:3.0.3": version: 3.0.3 resolution: "csstype@npm:3.0.3" @@ -12674,6 +12710,16 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^5.0.0": + version: 5.0.0 + resolution: "data-urls@npm:5.0.0" + dependencies: + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + checksum: 10/5c40568c31b02641a70204ff233bc4e42d33717485d074244a98661e5f2a1e80e38fe05a5755dfaf2ee549f2ab509d6a3af2a85f4b2ad2c984e5d176695eaf46 + languageName: node + linkType: hard + "dataloader@npm:2.0.0": version: 2.0.0 resolution: "dataloader@npm:2.0.0" @@ -12760,7 +12806,7 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.2.1, decimal.js@npm:^10.4.2": +"decimal.js@npm:^10.2.1, decimal.js@npm:^10.4.2, decimal.js@npm:^10.4.3": version: 10.4.3 resolution: "decimal.js@npm:10.4.3" checksum: 10/de663a7bc4d368e3877db95fcd5c87b965569b58d16cdc4258c063d231ca7118748738df17cd638f7e9dd0be8e34cec08d7234b20f1f2a756a52fc5a38b188d0 @@ -13554,6 +13600,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.5.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10/ede2a35c9bce1aeccd055a1b445d41c75a14a2bb1cd22e242f20cf04d236cdcd7f9c859eb83f76885327bfae0c25bf03303665ee1ce3d47c5927b98b0e3e3d48 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -16156,6 +16209,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^4.0.0": + version: 4.0.0 + resolution: "html-encoding-sniffer@npm:4.0.0" + dependencies: + whatwg-encoding: "npm:^3.1.1" + checksum: 10/e86efd493293a5671b8239bd099d42128433bb3c7b0fdc7819282ef8e118a21f5dead0ad6f358e024a4e5c84f17ebb7a9b36075220fac0a6222b207248bede6f + languageName: node + linkType: hard + "html-entities@npm:^2.1.0, html-entities@npm:^2.3.2": version: 2.3.3 resolution: "html-entities@npm:2.3.3" @@ -16308,6 +16370,16 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^7.0.2": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10/d062acfa0cb82beeb558f1043c6ba770ea892b5fb7b28654dbc70ea2aeea55226dd34c02a294f6c1ca179a5aa483c4ea641846821b182edbd9cc5d89b54c6848 + languageName: node + linkType: hard + "http-proxy-middleware@npm:^2.0.3": version: 2.0.6 resolution: "http-proxy-middleware@npm:2.0.6" @@ -16364,6 +16436,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^7.0.5": + version: 7.0.5 + resolution: "https-proxy-agent@npm:7.0.5" + dependencies: + agent-base: "npm:^7.0.2" + debug: "npm:4" + checksum: 10/6679d46159ab3f9a5509ee80c3a3fc83fba3a920a5e18d32176c3327852c3c00ad640c0c4210a8fd70ea3c4a6d3a1b375bf01942516e7df80e2646bdc77658ab + languageName: node + linkType: hard + "human-signals@npm:^1.1.1": version: 1.1.1 resolution: "human-signals@npm:1.1.1" @@ -18485,6 +18567,40 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^25.0.1": + version: 25.0.1 + resolution: "jsdom@npm:25.0.1" + dependencies: + cssstyle: "npm:^4.1.0" + data-urls: "npm:^5.0.0" + decimal.js: "npm:^10.4.3" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^4.0.0" + http-proxy-agent: "npm:^7.0.2" + https-proxy-agent: "npm:^7.0.5" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.12" + parse5: "npm:^7.1.2" + rrweb-cssom: "npm:^0.7.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^5.0.0" + w3c-xmlserializer: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^3.1.1" + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + ws: "npm:^8.18.0" + xml-name-validator: "npm:^5.0.0" + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10/e6bf7250ddd2fbcf68da0ea041a0dc63545dc4bf77fa3ff40a46ae45b1dac1ca55b87574ab904d1f8baeeb547c52cec493a22f545d7d413b320011f41150ec49 + languageName: node + linkType: hard + "jsesc@npm:^2.5.1": version: 2.5.2 resolution: "jsesc@npm:2.5.2" @@ -20456,6 +20572,13 @@ __metadata: languageName: node linkType: hard +"nwsapi@npm:^2.2.12": + version: 2.2.16 + resolution: "nwsapi@npm:2.2.16" + checksum: 10/1e5e086cdd4ca4a45f414d37f49bf0ca81d84ed31c6871ac68f531917d2910845db61f77c6d844430dc90fda202d43fce9603024e74038675de95229eb834dba + languageName: node + linkType: hard + "oas-kit-common@npm:^1.0.8": version: 1.0.8 resolution: "oas-kit-common@npm:1.0.8" @@ -21104,6 +21227,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.1.2": + version: 7.2.1 + resolution: "parse5@npm:7.2.1" + dependencies: + entities: "npm:^4.5.0" + checksum: 10/fd1a8ad1540d871e1ad6ca9bf5b67e30280886f1ce4a28052c0cb885723aa984d8cb1ec3da998349a6146960c8a84aa87b1a42600eb3b94495c7303476f2f88e + languageName: node + linkType: hard + "parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -22995,6 +23127,13 @@ __metadata: languageName: node linkType: hard +"punycode@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10/febdc4362bead22f9e2608ff0171713230b57aff9dddc1c273aa2a651fbd366f94b7d6a71d78342a7c0819906750351ca7f2edd26ea41b626d87d6a13d1bd059 + languageName: node + linkType: hard + "pupa@npm:^2.1.1": version: 2.1.1 resolution: "pupa@npm:2.1.1" @@ -23923,6 +24062,21 @@ __metadata: languageName: node linkType: hard +"rehackt@npm:^0.1.0": + version: 0.1.0 + resolution: "rehackt@npm:0.1.0" + peerDependencies: + "@types/react": "*" + react: "*" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + checksum: 10/c81adead82c165dffc574cbf9e1de3605522782a56b48df48b68d53d45c4d8c9253df3790109335bf97072424e54ad2423bb9544ca3a985fa91995dda43452fc + languageName: node + linkType: hard + "relateurl@npm:^0.2.7": version: 0.2.7 resolution: "relateurl@npm:0.2.7" @@ -24608,6 +24762,13 @@ __metadata: languageName: node linkType: hard +"rrweb-cssom@npm:^0.7.1": + version: 0.7.1 + resolution: "rrweb-cssom@npm:0.7.1" + checksum: 10/e80cf25c223a823921d7ab57c0ce78f5b7ebceab857b400cce99dd4913420ce679834bc5707e8ada47d062e21ad368108a9534c314dc8d72c20aa4a4fa0ed16a + languageName: node + linkType: hard + "rtk-monorepo@workspace:.": version: 0.0.0-use.local resolution: "rtk-monorepo@workspace:." @@ -26664,6 +26825,24 @@ __metadata: languageName: node linkType: hard +"tldts-core@npm:^6.1.64": + version: 6.1.64 + resolution: "tldts-core@npm:6.1.64" + checksum: 10/1383a0edd2eee8fdb5d90c6f87b509954135c9032e104bbc5518f2eb5f48cbda27adaed0c5c6d8f9a940d3e63fc8ade781527035522ad4096968a8212a74b47b + languageName: node + linkType: hard + +"tldts@npm:^6.1.32": + version: 6.1.64 + resolution: "tldts@npm:6.1.64" + dependencies: + tldts-core: "npm:^6.1.64" + bin: + tldts: bin/cli.js + checksum: 10/776671db50c0b08f8fc4ee43f9328f377436ab008bcd72a58a3b0e2af9cae3447df015d4b079511b80627bd791fefb813d08857385a3dfdc6803317836276f92 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -26736,6 +26915,15 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^5.0.0": + version: 5.0.0 + resolution: "tough-cookie@npm:5.0.0" + dependencies: + tldts: "npm:^6.1.32" + checksum: 10/a98d3846ed386e399e8b470c1eb08a6a296944246eabc55c9fe79d629bd2cdaa62f5a6572f271fe0060987906bd20468d72a219a3b4cbe51086bea48d2d677b6 + languageName: node + linkType: hard + "tr46@npm:^1.0.1": version: 1.0.1 resolution: "tr46@npm:1.0.1" @@ -26763,6 +26951,15 @@ __metadata: languageName: node linkType: hard +"tr46@npm:^5.0.0": + version: 5.0.0 + resolution: "tr46@npm:5.0.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10/29155adb167d048d3c95d181f7cb5ac71948b4e8f3070ec455986e1f34634acae50ae02a3c8d448121c3afe35b76951cd46ed4c128fd80264280ca9502237a3e + languageName: node + linkType: hard + "tr46@npm:~0.0.3": version: 0.0.3 resolution: "tr46@npm:0.0.3" @@ -28232,6 +28429,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: "npm:^5.0.0" + checksum: 10/d78f59e6b4f924aa53b6dfc56949959229cae7fe05ea9374eb38d11edcec01398b7f5d7a12576bd5acc57ff446abb5c9115cd83b9d882555015437cf858d42f0 + languageName: node + linkType: hard + "wait-on@npm:^6.0.1": version: 6.0.1 resolution: "wait-on@npm:6.0.1" @@ -28611,6 +28817,15 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^3.1.1": + version: 3.1.1 + resolution: "whatwg-encoding@npm:3.1.1" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10/bbef815eb67f91487c7f2ef96329743f5fd8357d7d62b1119237d25d41c7e452dff8197235b2d3c031365a17f61d3bb73ca49d0ed1582475aa4a670815e79534 + languageName: node + linkType: hard + "whatwg-fetch@npm:^3.4.1, whatwg-fetch@npm:^3.6.2": version: 3.6.2 resolution: "whatwg-fetch@npm:3.6.2" @@ -28632,6 +28847,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^4.0.0": + version: 4.0.0 + resolution: "whatwg-mimetype@npm:4.0.0" + checksum: 10/894a618e2d90bf444b6f309f3ceb6e58cf21b2beaa00c8b333696958c4076f0c7b30b9d33413c9ffff7c5832a0a0c8569e5bb347ef44beded72aeefd0acd62e8 + languageName: node + linkType: hard + "whatwg-url@npm:^11.0.0": version: 11.0.0 resolution: "whatwg-url@npm:11.0.0" @@ -28642,6 +28864,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^14.0.0": + version: 14.0.0 + resolution: "whatwg-url@npm:14.0.0" + dependencies: + tr46: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10/67ea7a359a90663b28c816d76379b4be62d13446e9a4c0ae0b5ae0294b1c22577750fcdceb40827bb35a61777b7093056953c856604a28b37d6a209ba59ad062 + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" @@ -29131,6 +29363,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.18.0 + resolution: "ws@npm:8.18.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/70dfe53f23ff4368d46e4c0b1d4ca734db2c4149c6f68bc62cb16fc21f753c47b35fcc6e582f3bdfba0eaeb1c488cddab3c2255755a5c3eecb251431e42b3ff6 + languageName: node + linkType: hard + "ws@npm:^8.4.2": version: 8.8.0 resolution: "ws@npm:8.8.0" @@ -29178,6 +29425,13 @@ __metadata: languageName: node linkType: hard +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10/43f30f3f6786e406dd665acf08cd742d5f8a46486bd72517edb04b27d1bcd1599664c2a4a99fc3f1e56a3194bff588b12f178b7972bc45c8047bdc4c3ac8d4a1 + languageName: node + linkType: hard + "xmlchars@npm:^2.2.0": version: 2.2.0 resolution: "xmlchars@npm:2.2.0"