diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index c472268371..3a159203a2 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -3,6 +3,7 @@ on: pull_request: branches: - master + - 'feature/infinite-query-integration' permissions: pull-requests: write jobs: diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index 0567328ac1..72171fdae2 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -1,8 +1,8 @@ import type { + AsyncThunkAction, SafePromise, SerializedError, ThunkAction, - ThunkDispatch, UnknownAction, } from '@reduxjs/toolkit' import type { Dispatch } from 'redux' @@ -10,15 +10,17 @@ 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, +import { + isQueryDefinition, + type EndpointDefinition, + type EndpointDefinitions, + type InfiniteQueryArgFrom, + type InfiniteQueryDefinition, + type MutationDefinition, + type PageParamFrom, + type QueryArgFrom, + type QueryDefinition, + type ResultTypeFrom, } from '../endpointDefinitions' import { countObjectKeys, getOrInsert, isNotNullish } from '../utils' import type { @@ -33,11 +35,13 @@ import type { } from './buildSelectors' import type { InfiniteQueryThunk, + InfiniteQueryThunkArg, MutationThunk, QueryThunk, QueryThunkArg, + ThunkApiMetaConfig, } from './buildThunks' -import type { ApiEndpointInfiniteQuery, ApiEndpointQuery } from './module' +import type { ApiEndpointQuery } from './module' export type BuildInitiateApiEndpointQuery< Definition extends QueryDefinition, @@ -70,20 +74,20 @@ export type StartQueryActionCreatorOptions = { export type StartInfiniteQueryActionCreatorOptions< D extends InfiniteQueryDefinition, -> = { - subscribe?: boolean - forceRefetch?: boolean | number - subscriptionOptions?: SubscriptionOptions +> = StartQueryActionCreatorOptions & { direction?: InfiniteQueryDirection - [forceQueryFnSymbol]?: () => QueryReturnValue param?: unknown - previous?: boolean } & Partial< - Pick< - Partial, PageParamFrom>>, - 'initialPageParam' + Pick< + Partial, PageParamFrom>>, + 'initialPageParam' + > > -> + +type AnyQueryActionCreator> = ( + arg: any, + options?: StartQueryActionCreatorOptions, +) => ThunkAction type StartQueryActionCreator< D extends QueryDefinition, @@ -94,43 +98,46 @@ type StartQueryActionCreator< // placeholder type which // may attempt to derive the list of args to query in pagination -type StartInfiniteQueryActionCreator< +export type StartInfiniteQueryActionCreator< D extends InfiniteQueryDefinition, > = ( arg: InfiniteQueryArgFrom, options?: StartInfiniteQueryActionCreatorOptions, -) => ( - dispatch: ThunkDispatch, - getState: () => any, -) => InfiniteQueryActionCreatorResult +) => ThunkAction, any, any, UnknownAction> -export type QueryActionCreatorResult< - D extends QueryDefinition, -> = SafePromise> & { - arg: QueryArgFrom +type QueryActionCreatorFields = { requestId: string subscriptionOptions: SubscriptionOptions | undefined abort(): void - unwrap(): Promise> unsubscribe(): void - refetch(): QueryActionCreatorResult updateSubscriptionOptions(options: SubscriptionOptions): void queryCacheKey: string } +type AnyActionCreatorResult = SafePromise & + QueryActionCreatorFields & { + arg: any + unwrap(): Promise + refetch(): AnyActionCreatorResult + } + +export type QueryActionCreatorResult< + D extends QueryDefinition, +> = SafePromise> & + QueryActionCreatorFields & { + arg: QueryArgFrom + unwrap(): Promise> + refetch(): QueryActionCreatorResult + } + export type InfiniteQueryActionCreatorResult< D extends InfiniteQueryDefinition, -> = Promise> & { - arg: InfiniteQueryArgFrom - requestId: string - subscriptionOptions: SubscriptionOptions | undefined - abort(): void - unwrap(): Promise> - unsubscribe(): void - refetch(): InfiniteQueryActionCreatorResult - updateSubscriptionOptions(options: SubscriptionOptions): void - queryCacheKey: string -} +> = SafePromise> & + QueryActionCreatorFields & { + arg: InfiniteQueryArgFrom + unwrap(): Promise, PageParamFrom>> + refetch(): InfiniteQueryActionCreatorResult + } type StartMutationActionCreator< D extends MutationDefinition, @@ -360,11 +367,13 @@ You must add the middleware for RTK-Query to function correctly!`, } } - function buildInitiateQuery( + function buildInitiateAnyQuery( endpointName: string, - endpointDefinition: QueryDefinition, + endpointDefinition: + | QueryDefinition + | InfiniteQueryDefinition, ) { - const queryAction: StartQueryActionCreator = + const queryAction: AnyQueryActionCreator = ( arg, { @@ -382,9 +391,11 @@ You must add the middleware for RTK-Query to function correctly!`, endpointName, }) - const thunk = queryThunk({ + let thunk: AsyncThunkAction + + const commonThunkArgs = { ...rest, - type: 'query', + type: 'query' as const, subscribe, forceRefetch: forceRefetch, subscriptionOptions, @@ -392,7 +403,24 @@ You must add the middleware for RTK-Query to function correctly!`, originalArgs: arg, queryCacheKey, [forceQueryFnSymbol]: forceQueryFn, - }) + } + + if (isQueryDefinition(endpointDefinition)) { + thunk = queryThunk(commonThunkArgs) + } else { + const { direction, initialPageParam } = rest as Pick< + InfiniteQueryThunkArg, + 'direction' | 'initialPageParam' + > + thunk = infiniteQueryThunk({ + ...(commonThunkArgs as InfiniteQueryThunkArg), + // Supply these even if undefined. This helps with a field existence + // check over in `buildSlice.ts` + direction, + initialPageParam, + }) + } + const selector = ( api.endpoints[endpointName] as ApiEndpointQuery ).select(arg) @@ -409,7 +437,7 @@ You must add the middleware for RTK-Query to function correctly!`, const runningQuery = runningQueries.get(dispatch)?.[queryCacheKey] const selectFromState = () => selector(getState()) - const statePromise: QueryActionCreatorResult = Object.assign( + const statePromise: AnyActionCreatorResult = 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 @@ -482,138 +510,25 @@ You must add the middleware for RTK-Query to function correctly!`, return queryAction } - // Concept for the pagination thunk which queries for each page + function buildInitiateQuery( + endpointName: string, + endpointDefinition: QueryDefinition, + ) { + const queryAction: StartQueryActionCreator = buildInitiateAnyQuery( + endpointName, + endpointDefinition, + ) + + return queryAction + } 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) + buildInitiateAnyQuery(endpointName, endpointDefinition) - 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 } diff --git a/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts b/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts index 8b3ae6b198..2f1024d13b 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts @@ -43,6 +43,7 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({ queryThunk, context, internalState, + selectors: { selectQueryEntry, selectConfig }, }) => { const { removeQueryResult, unsubscribeQueryResult, cacheEntriesUpserted } = api.internalActions @@ -66,8 +67,9 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({ mwApi, internalState, ) => { + const state = mwApi.getState() + const config = selectConfig(state) if (canTriggerUnsubscribe(action)) { - const state = mwApi.getState()[reducerPath] let queryCacheKeys: QueryCacheKey[] if (cacheEntriesUpserted.match(action)) { @@ -81,14 +83,7 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({ queryCacheKeys = [queryCacheKey] } - for (const queryCacheKey of queryCacheKeys) { - handleUnsubscribe( - queryCacheKey, - state.queries[queryCacheKey]?.endpointName, - mwApi, - state.config, - ) - } + handleUnsubscribeMany(queryCacheKeys, mwApi, config) } if (api.util.resetApiState.match(action)) { @@ -99,19 +94,27 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({ } if (context.hasRehydrationInfo(action)) { - const state = mwApi.getState()[reducerPath] const { queries } = context.extractRehydrationInfo(action)! - for (const [queryCacheKey, queryState] of Object.entries(queries)) { - // Gotcha: - // If rehydrating before the endpoint has been injected,the global `keepUnusedDataFor` - // will be used instead of the endpoint-specific one. - handleUnsubscribe( - queryCacheKey as QueryCacheKey, - queryState?.endpointName, - mwApi, - state.config, - ) - } + // Gotcha: + // If rehydrating before the endpoint has been injected,the global `keepUnusedDataFor` + // will be used instead of the endpoint-specific one. + handleUnsubscribeMany( + Object.keys(queries) as QueryCacheKey[], + mwApi, + config, + ) + } + } + + function handleUnsubscribeMany( + cacheKeys: QueryCacheKey[], + api: SubMiddlewareApi, + config: ConfigState, + ) { + const state = api.getState() + for (const queryCacheKey of cacheKeys) { + const entry = selectQueryEntry(state, queryCacheKey) + handleUnsubscribe(queryCacheKey, entry?.endpointName, api, config) } } diff --git a/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts b/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts index 3f2424b930..edf435d8a9 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/cacheLifecycle.ts @@ -2,7 +2,7 @@ import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' import type { BaseQueryFn, BaseQueryMeta } from '../../baseQueryTypes' import type { BaseEndpointDefinition } from '../../endpointDefinitions' import { DefinitionType } from '../../endpointDefinitions' -import type { RootState } from '../apiState' +import type { QueryCacheKey, RootState } from '../apiState' import type { MutationResultSelectorResult, QueryResultSelectorResult, @@ -185,6 +185,7 @@ export const buildCacheLifecycleHandler: InternalHandlerBuilder = ({ queryThunk, mutationThunk, internalState, + selectors: { selectQueryEntry, selectApiState }, }) => { const isQueryThunk = isAsyncThunkAction(queryThunk) const isMutationThunk = isAsyncThunkAction(mutationThunk) @@ -225,17 +226,17 @@ export const buildCacheLifecycleHandler: InternalHandlerBuilder = ({ mwApi, stateBefore, ) => { - const cacheKey = getCacheKey(action) + const cacheKey = getCacheKey(action) as QueryCacheKey function checkForNewCacheKey( endpointName: string, - cacheKey: string, + cacheKey: QueryCacheKey, requestId: string, originalArgs: unknown, ) { - const oldState = stateBefore[reducerPath].queries[cacheKey] - const state = mwApi.getState()[reducerPath].queries[cacheKey] - if (!oldState && state) { + const oldEntry = selectQueryEntry(stateBefore, cacheKey) + const newEntry = selectQueryEntry(mwApi.getState(), cacheKey) + if (!oldEntry && newEntry) { handleNewKey(endpointName, originalArgs, cacheKey, mwApi, requestId) } } diff --git a/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts b/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts index 7dda07a7f2..78d90aa725 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts @@ -76,11 +76,11 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({ function hasPendingRequests( state: CombinedState, ) { - for (const key in state.queries) { - if (state.queries[key]?.status === QueryStatus.pending) return true - } - for (const key in state.mutations) { - if (state.mutations[key]?.status === QueryStatus.pending) return true + const { queries, mutations } = state + for (const cacheRecord of [queries, mutations]) { + for (const key in cacheRecord) { + if (cacheRecord[key]?.status === QueryStatus.pending) return true + } } return false } diff --git a/packages/toolkit/src/query/core/buildMiddleware/polling.ts b/packages/toolkit/src/query/core/buildMiddleware/polling.ts index dbf3a8533b..94b6258842 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/polling.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/polling.ts @@ -1,4 +1,8 @@ -import type { QuerySubstateIdentifier, Subscribers } from '../apiState' +import type { + QueryCacheKey, + QuerySubstateIdentifier, + Subscribers, +} from '../apiState' import { QueryStatus } from '../apiState' import type { QueryStateMeta, @@ -49,6 +53,20 @@ export const buildPollingHandler: InternalHandlerBuilder = ({ } } + function getCacheEntrySubscriptions( + queryCacheKey: QueryCacheKey, + api: SubMiddlewareApi, + ) { + const state = api.getState()[reducerPath] + const querySubState = state.queries[queryCacheKey] + const subscriptions = internalState.currentSubscriptions[queryCacheKey] + + if (!querySubState || querySubState.status === QueryStatus.uninitialized) + return + + return subscriptions + } + function startNextPoll( { queryCacheKey }: QuerySubstateIdentifier, api: SubMiddlewareApi, diff --git a/packages/toolkit/src/query/core/buildMiddleware/types.ts b/packages/toolkit/src/query/core/buildMiddleware/types.ts index bd93f0a2e2..a95acd2bf9 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/types.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/types.ts @@ -26,6 +26,7 @@ import type { ThunkResult, } from '../buildThunks' import type { QueryActionCreatorResult } from '../buildInitiate' +import type { AllSelectors } from '../buildSelectors' export type QueryStateMeta = Record export type TimeoutId = ReturnType @@ -52,6 +53,7 @@ export interface BuildMiddlewareInput< infiniteQueryThunk: InfiniteQueryThunk api: Api assertTagType: AssertTagTypes + selectors: AllSelectors } export type SubMiddlewareApi = MiddlewareAPI< @@ -69,6 +71,7 @@ export interface BuildSubMiddlewareInput >, ): ThunkAction, any, any, UnknownAction> isThisApiSliceAction: (action: Action) => boolean + selectors: AllSelectors } export type SubMiddlewareBuilder = ( diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index 65c42bdd7e..3daca29696 100644 --- a/packages/toolkit/src/query/core/buildSelectors.ts +++ b/packages/toolkit/src/query/core/buildSelectors.ts @@ -1,5 +1,6 @@ import type { InternalSerializeQueryArgs } from '../defaultSerializeQueryArgs' import type { + EndpointDefinition, EndpointDefinitions, InfiniteQueryArgFrom, InfiniteQueryDefinition, @@ -158,6 +159,8 @@ const defaultMutationSubState = /* @__PURE__ */ createNextState( () => {}, ) +export type AllSelectors = ReturnType + export function buildSelectors< Definitions extends EndpointDefinitions, ReducerPath extends string, @@ -181,6 +184,11 @@ export function buildSelectors< buildMutationSelector, selectInvalidatedBy, selectCachedArgsForQuery, + selectApiState, + selectQueries, + selectMutations, + selectQueryEntry, + selectConfig, } function withRequestFlags( @@ -192,12 +200,12 @@ export function buildSelectors< } } - function selectInternalState(rootState: RootState) { + function selectApiState(rootState: RootState) { const state = rootState[reducerPath] if (process.env.NODE_ENV !== 'production') { if (!state) { - if ((selectInternalState as any).triggered) return state - ;(selectInternalState as any).triggered = true + if ((selectApiState as any).triggered) return state + ;(selectApiState as any).triggered = true console.error( `Error: No data found at \`state.${reducerPath}\`. Did you forget to add the reducer to the store?`, ) @@ -206,92 +214,98 @@ export function buildSelectors< return state } - function buildQuerySelector( + function selectQueries(rootState: RootState) { + return selectApiState(rootState)?.queries + } + + function selectQueryEntry(rootState: RootState, cacheKey: QueryCacheKey) { + return selectQueries(rootState)?.[cacheKey] + } + + function selectMutations(rootState: RootState) { + return selectApiState(rootState)?.mutations + } + + function selectConfig(rootState: RootState) { + return selectApiState(rootState)?.config + } + + function buildAnyQuerySelector( endpointName: string, - endpointDefinition: QueryDefinition, + endpointDefinition: EndpointDefinition, + combiner: ( + substate: T, + ) => T & RequestStatusFlags, ) { - return ((queryArgs: any) => { + return (queryArgs: any) => { + // Avoid calling serializeQueryArgs if the arg is skipToken if (queryArgs === skipToken) { - return createSelector(selectSkippedQuery, withRequestFlags) + return createSelector(selectSkippedQuery, combiner) } + const serializedArgs = serializeQueryArgs({ queryArgs, endpointDefinition, endpointName, }) const selectQuerySubstate = (state: RootState) => - selectInternalState(state)?.queries?.[serializedArgs] ?? - defaultQuerySubState + selectQueryEntry(state, serializedArgs) ?? defaultQuerySubState - return createSelector(selectQuerySubstate, withRequestFlags) - }) as QueryResultSelectorFactory + return createSelector(selectQuerySubstate, combiner) + } + } + + function buildQuerySelector( + endpointName: string, + endpointDefinition: QueryDefinition, + ) { + return buildAnyQuerySelector( + endpointName, + endpointDefinition, + withRequestFlags, + ) 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 - - const { infiniteQueryOptions } = endpointDefinition - - function withInfiniteQueryResultFlags( - substate: T, - ): T & RequestStatusFlags & InfiniteQueryResultFlags { - const infiniteSubstate = substate as InfiniteQuerySubState - const fetchDirection = infiniteSubstate.direction - const stateWithRequestFlags = { - ...infiniteSubstate, - ...getRequestStatusFlags(substate.status), - } - - const { isLoading, isError } = stateWithRequestFlags - - const isFetchNextPageError = isError && fetchDirection === 'forward' - const isFetchingNextPage = isLoading && fetchDirection === 'forward' - - const isFetchPreviousPageError = - isError && fetchDirection === 'backward' - const isFetchingPreviousPage = - isLoading && fetchDirection === 'backward' - - const hasNextPage = getHasNextPage( + const { infiniteQueryOptions } = endpointDefinition + + function withInfiniteQueryResultFlags( + substate: T, + ): T & RequestStatusFlags & InfiniteQueryResultFlags { + const stateWithRequestFlags = { + ...(substate as InfiniteQuerySubState), + ...getRequestStatusFlags(substate.status), + } + + const { isLoading, isError, direction } = stateWithRequestFlags + const isForward = direction === 'forward' + const isBackward = direction === 'backward' + + return { + ...stateWithRequestFlags, + hasNextPage: getHasNextPage( infiniteQueryOptions, stateWithRequestFlags.data, - ) - const hasPreviousPage = getHasPreviousPage( + ), + hasPreviousPage: getHasPreviousPage( infiniteQueryOptions, stateWithRequestFlags.data, - ) - - return { - ...stateWithRequestFlags, - hasNextPage, - hasPreviousPage, - isFetchingNextPage, - isFetchingPreviousPage, - isFetchNextPageError, - isFetchPreviousPageError, - } + ), + isFetchingNextPage: isLoading && isForward, + isFetchingPreviousPage: isLoading && isBackward, + isFetchNextPageError: isError && isForward, + isFetchPreviousPageError: isError && isBackward, } + } - return createSelector( - finalSelectQuerySubState, - withInfiniteQueryResultFlags, - ) - }) as InfiniteQueryResultSelectorFactory + return buildAnyQuerySelector( + endpointName, + endpointDefinition, + withInfiniteQueryResultFlags, + ) as unknown as InfiniteQueryResultSelectorFactory } function buildMutationSelector() { @@ -303,7 +317,7 @@ export function buildSelectors< mutationId = id } const selectMutationSubstate = (state: RootState) => - selectInternalState(state)?.mutations?.[mutationId as string] ?? + selectApiState(state)?.mutations?.[mutationId as string] ?? defaultMutationSubState const finalSelectMutationSubstate = mutationId === skipToken @@ -362,7 +376,7 @@ export function buildSelectors< state: RootState, queryName: QueryName, ): Array> { - return Object.values(state[reducerPath].queries as QueryState) + return Object.values(selectQueries(state) as QueryState) .filter( ( entry, diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index b8df5856bc..32fb1f5e04 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -6,7 +6,6 @@ import type { ThunkDispatch, UnknownAction, } from '@reduxjs/toolkit' -import util from 'util' import type { Patch } from 'immer' import { isDraftable, produceWithPatches } from 'immer' import type { Api, ApiContext } from '../apiTypes' @@ -53,6 +52,7 @@ import type { StartQueryActionCreatorOptions, } from './buildInitiate' import { forceQueryFnSymbol, isUpsertQuery } from './buildInitiate' +import type { AllSelectors } from './buildSelectors' import type { ApiEndpointQuery, PrefetchOptions } from './module' import { createAsyncThunk, @@ -136,7 +136,6 @@ export type InfiniteQueryThunkArg< originalArgs: unknown endpointName: string param: unknown - previous?: boolean direction?: InfiniteQueryDirection } @@ -296,6 +295,21 @@ export type PatchCollection = { undo: () => void } +type TransformCallback = ( + baseQueryReturnValue: unknown, + meta: unknown, + arg: unknown, +) => any + +export const addShouldAutoBatch = >( + arg: T = {} as T, +): T & { [SHOULD_AUTOBATCH]: true } => { + return { + ...arg, + [SHOULD_AUTOBATCH]: true, + } +} + export function buildThunks< BaseQuery extends BaseQueryFn, ReducerPath extends string, @@ -307,6 +321,7 @@ export function buildThunks< serializeQueryArgs, api, assertTagType, + selectors, }: { baseQuery: BaseQuery reducerPath: ReducerPath @@ -314,6 +329,7 @@ export function buildThunks< serializeQueryArgs: InternalSerializeQueryArgs api: Api assertTagType: AssertTagTypes + selectors: AllSelectors }) { type State = RootState @@ -443,6 +459,15 @@ export function buildThunks< return res } + const getTransformCallbackForEndpoint = ( + endpointDefinition: EndpointDefinition, + transformFieldName: 'transformResponse' | 'transformErrorResponse', + ): TransformCallback => { + return endpointDefinition.query && endpointDefinition[transformFieldName] + ? endpointDefinition[transformFieldName]! + : defaultTransformResponse + } + // The generic async payload function for all of our thunks const executeEndpoint: AsyncThunkPayloadCreator< ThunkResult, @@ -463,14 +488,8 @@ export function buildThunks< const endpointDefinition = endpointDefinitions[arg.endpointName] try { - let transformResponse: ( - baseQueryReturnValue: any, - meta: any, - arg: any, - ) => any = - endpointDefinition.query && endpointDefinition.transformResponse - ? endpointDefinition.transformResponse - : defaultTransformResponse + let transformResponse: TransformCallback = + getTransformCallbackForEndpoint(endpointDefinition, 'transformResponse') const baseQueryApi = { signal, @@ -506,7 +525,6 @@ export function buildThunks< const pageResponse = await executeRequest(param) - // TODO Get maxPages from endpoint config const addTo = previous ? addToStart : addToEnd return { @@ -523,6 +541,7 @@ export function buildThunks< finalQueryArg: unknown, ): Promise { let result: QueryReturnValue + const { extraOptions } = endpointDefinition if (forceQueryFn) { // upsertQueryData relies on this to pass in the user-provided value @@ -531,19 +550,14 @@ export function buildThunks< result = await baseQuery( endpointDefinition.query(finalQueryArg), baseQueryApi, - endpointDefinition.extraOptions as any, + extraOptions as any, ) } else { result = await endpointDefinition.queryFn( finalQueryArg, baseQueryApi, - endpointDefinition.extraOptions as any, - (arg) => - baseQuery( - arg, - baseQueryApi, - endpointDefinition.extraOptions as any, - ), + extraOptions as any, + (arg) => baseQuery(arg, baseQueryApi, extraOptions as any), ) } @@ -609,8 +623,10 @@ export function buildThunks< // Start by looking up the existing InfiniteData value from state, // falling back to an empty value if it doesn't exist yet const blankData = { pages: [], pageParams: [] } - const cachedData = getState()[reducerPath].queries[arg.queryCacheKey] - ?.data as InfiniteData | undefined + const cachedData = selectors.selectQueryEntry( + getState(), + arg.queryCacheKey, + )?.data as InfiniteData | undefined // Don't want to use `isForcedQuery` here, because that // includes `refetchOnMountOrArgChange`. const existingData = ( @@ -652,7 +668,7 @@ export function buildThunks< // Fetch remaining pages for (let i = 1; i < totalPages; i++) { const param = getNextPageParam( - endpointDefinition.infiniteQueryOptions, + infiniteQueryOptions, result.data as InfiniteData, ) result = await fetchPage( @@ -670,22 +686,21 @@ export function buildThunks< } // console.log('Final result: ', transformedData) - return fulfillWithValue(finalQueryReturnValue.data, { - fulfilledTimeStamp: Date.now(), - baseQueryMeta: finalQueryReturnValue.meta, - [SHOULD_AUTOBATCH]: true, - }) + return fulfillWithValue( + finalQueryReturnValue.data, + addShouldAutoBatch({ + fulfilledTimeStamp: Date.now(), + baseQueryMeta: finalQueryReturnValue.meta, + }), + ) } catch (error) { let catchedError = error if (catchedError instanceof HandledError) { - let transformErrorResponse: ( - baseQueryReturnValue: any, - meta: any, - arg: any, - ) => any = - endpointDefinition.query && endpointDefinition.transformErrorResponse - ? endpointDefinition.transformErrorResponse - : defaultTransformResponse + let transformErrorResponse: TransformCallback = + getTransformCallbackForEndpoint( + endpointDefinition, + 'transformErrorResponse', + ) try { return rejectWithValue( @@ -694,7 +709,7 @@ export function buildThunks< catchedError.meta, arg.originalArgs, ), - { baseQueryMeta: catchedError.meta, [SHOULD_AUTOBATCH]: true }, + addShouldAutoBatch({ baseQueryMeta: catchedError.meta }), ) } catch (e) { catchedError = e @@ -720,9 +735,9 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` arg: QueryThunkArg, state: RootState, ) { - const requestState = state[reducerPath]?.queries?.[arg.queryCacheKey] + const requestState = selectors.selectQueryEntry(state, arg.queryCacheKey) const baseFetchOnMountOrArgChange = - state[reducerPath]?.config.refetchOnMountOrArgChange + selectors.selectConfig(state).refetchOnMountOrArgChange const fulfilledVal = requestState?.fulfilledTimeStamp const refetchVal = @@ -738,116 +753,84 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` return false } - const queryThunk = createAsyncThunk< - ThunkResult, - QueryThunkArg, - ThunkApiMetaConfig & { state: RootState } - >(`${reducerPath}/executeQuery`, executeEndpoint, { - getPendingMeta() { - return { startedTimeStamp: Date.now(), [SHOULD_AUTOBATCH]: true } - }, - 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] - - // 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 - } + const createQueryThunk = < + ThunkArgType extends QueryThunkArg | InfiniteQueryThunkArg, + >() => { + const generatedQueryThunk = createAsyncThunk< + ThunkResult, + ThunkArgType, + ThunkApiMetaConfig & { state: RootState } + >(`${reducerPath}/executeQuery`, executeEndpoint, { + getPendingMeta({ arg }) { + const endpointDefinition = endpointDefinitions[arg.endpointName] + return addShouldAutoBatch({ + startedTimeStamp: Date.now(), + ...(isInfiniteQueryDefinition(endpointDefinition) + ? { + direction: (arg as InfiniteQueryThunkArg).direction, + } + : {}), + }) + }, + condition(queryThunkArg, { getState }) { + const state = getState() - if ( - isQueryDefinition(endpointDefinition) && - endpointDefinition?.forceRefetch?.({ - currentArg, - previousArg, - endpointState: requestState, + const requestState = selectors.selectQueryEntry( state, - }) - ) { - return true - } + queryThunkArg.queryCacheKey, + ) + const fulfilledVal = requestState?.fulfilledTimeStamp + const currentArg = queryThunkArg.originalArgs + const previousArg = requestState?.originalArgs + const endpointDefinition = + endpointDefinitions[queryThunkArg.endpointName] + const direction = (queryThunkArg as InfiniteQueryThunkArg) + .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(queryThunkArg)) { + return true + } - // Pull from the cache unless we explicitly force refetch or qualify based on time - if (fulfilledVal) { - // Value is cached and we didn't specify to refresh, skip it. - return false - } + // Don't retry a request that's currently in-flight + if (requestState?.status === 'pending') { + return false + } - return true - }, - dispatchConditionRejection: true, - }) + // if this is forced, continue + if (isForcedQuery(queryThunkArg, state)) { + return 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 ( + isQueryDefinition(endpointDefinition) && + endpointDefinition?.forceRefetch?.({ + currentArg, + previousArg, + endpointState: requestState, + state, + }) + ) { + return true + } - // if this is forced, continue - if (isForcedQuery(queryThunkArgs, 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 + } - // 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, + }) + return generatedQueryThunk + } - return true - }, - dispatchConditionRejection: true, - }) + const queryThunk = createQueryThunk() + const infiniteQueryThunk = createQueryThunk>() const mutationThunk = createAsyncThunk< ThunkResult, @@ -855,7 +838,7 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` ThunkApiMetaConfig & { state: RootState } >(`${reducerPath}/executeMutation`, executeEndpoint, { getPendingMeta() { - return { startedTimeStamp: Date.now(), [SHOULD_AUTOBATCH]: true } + return addShouldAutoBatch({ startedTimeStamp: Date.now() }) }, }) diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index 1a4d0a353c..004a6e8f80 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -546,6 +546,22 @@ export const coreModule = ({ util: {}, }) + const selectors = buildSelectors({ + serializeQueryArgs: serializeQueryArgs as any, + reducerPath, + createSelector, + }) + + const { + selectInvalidatedBy, + selectCachedArgsForQuery, + buildQuerySelector, + buildInfiniteQuerySelector, + buildMutationSelector, + } = selectors + + safeAssign(api.util, { selectInvalidatedBy, selectCachedArgsForQuery }) + const { queryThunk, infiniteQueryThunk, @@ -562,6 +578,7 @@ export const coreModule = ({ api, serializeQueryArgs, assertTagType, + selectors, }) const { reducer, actions: sliceActions } = buildSlice({ @@ -600,25 +617,12 @@ export const coreModule = ({ infiniteQueryThunk, api, assertTagType, + selectors, }) safeAssign(api.util, middlewareActions) safeAssign(api, { reducer: reducer as any, middleware }) - const { - buildQuerySelector, - buildInfiniteQuerySelector, - buildMutationSelector, - selectInvalidatedBy, - selectCachedArgsForQuery, - } = buildSelectors({ - serializeQueryArgs: serializeQueryArgs as any, - reducerPath, - createSelector, - }) - - safeAssign(api.util, { selectInvalidatedBy, selectCachedArgsForQuery }) - const { buildInitiateQuery, buildInitiateInfiniteQuery, @@ -653,10 +657,11 @@ export const coreModule = ({ string, CoreModule > - anyApi.endpoints[endpointName] ??= {} as any + const endpoint = (anyApi.endpoints[endpointName] ??= {} as any) + if (isQueryDefinition(definition)) { safeAssign( - anyApi.endpoints[endpointName], + endpoint, { name: endpointName, select: buildQuerySelector(endpointName, definition), @@ -667,7 +672,7 @@ export const coreModule = ({ } if (isMutationDefinition(definition)) { safeAssign( - anyApi.endpoints[endpointName], + endpoint, { name: endpointName, select: buildMutationSelector(), @@ -678,7 +683,7 @@ export const coreModule = ({ } if (isInfiniteQueryDefinition(definition)) { safeAssign( - anyApi.endpoints[endpointName], + endpoint, { name: endpointName, select: buildInfiniteQuerySelector(endpointName, definition), diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 1396bf8584..9923b23990 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -64,6 +64,8 @@ import type { ReactHooksModuleOptions } from './module' import { useStableQueryArgs } from './useSerializedStableValue' import { useShallowStableValue } from './useShallowStableValue' import type { InfiniteQueryDirection } from '../core/apiState' +import { isInfiniteQueryDefinition } from '../endpointDefinitions' +import { StartInfiniteQueryActionCreator } from '../core/buildInitiate' // Copy-pasted from React-Redux const canUseDOM = () => @@ -859,6 +861,8 @@ export type UseInfiniteQuerySubscriptionResult< D extends InfiniteQueryDefinition, > = Pick, 'refetch'> & { trigger: LazyInfiniteQueryTrigger + fetchNextPage: () => InfiniteQueryActionCreatorResult + fetchPreviousPage: () => InfiniteQueryActionCreatorResult } /** @@ -892,10 +896,11 @@ export type UseInfiniteQuery< arg: InfiniteQueryArgFrom | SkipToken, options?: UseInfiniteQuerySubscriptionOptions & UseInfiniteQueryStateOptions, -) => UseInfiniteQueryHookResult & { - fetchNextPage: () => InfiniteQueryActionCreatorResult - fetchPreviousPage: () => InfiniteQueryActionCreatorResult -} +) => UseInfiniteQueryHookResult & + Pick< + UseInfiniteQuerySubscriptionResult, + 'fetchNextPage' | 'fetchPreviousPage' + > export type UseInfiniteQueryState< D extends InfiniteQueryDefinition, @@ -903,6 +908,7 @@ export type UseInfiniteQueryState< arg: QueryArgFrom | SkipToken, options?: UseInfiniteQueryStateOptions, ) => UseInfiniteQueryStateResult + export type TypedUseInfiniteQueryState< ResultType, QueryArg, @@ -1203,6 +1209,23 @@ const noPendingQueryStateSelector: QueryStateSelector = ( return selected } +function pick(obj: T, ...keys: K[]): Pick { + const ret: any = {} + keys.forEach((key) => { + ret[key] = obj[key] + }) + return ret +} + +const COMMON_HOOK_DEBUG_FIELDS = [ + 'data', + 'status', + 'isLoading', + 'isSuccess', + 'isError', + 'error', +] as const + type GenericPrefetchThunk = ( endpointName: any, arg: any, @@ -1340,7 +1363,10 @@ export function buildHooks({ // 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 + const isLoading = + (!lastResult || lastResult.isLoading || lastResult.isUninitialized) && + !hasData && + isFetching // isSuccess = true when data is present const isSuccess = currentState.isSuccess || (isFetching && hasData) @@ -1373,167 +1399,286 @@ export function buildHooks({ ) } - function buildQueryHooks(name: string): QueryHooks { - const useQuerySubscription: UseQuerySubscription = ( - arg: any, - { - refetchOnReconnect, - refetchOnFocus, - refetchOnMountOrArgChange, - skip = false, - pollingInterval = 0, - skipPollingIfUnfocused = false, - } = {}, - ) => { - const { initiate } = api.endpoints[name] as ApiEndpointQuery< - QueryDefinition, - Definitions - > - const dispatch = useDispatch>() + function useQuerySubscriptionCommonImpl< + T extends + | QueryActionCreatorResult + | InfiniteQueryActionCreatorResult, + >( + endpointName: string, + arg: unknown | SkipToken, + { + refetchOnReconnect, + refetchOnFocus, + refetchOnMountOrArgChange, + skip = false, + pollingInterval = 0, + skipPollingIfUnfocused = false, + ...rest + }: UseQuerySubscriptionOptions = {}, + ) { + const { initiate } = api.endpoints[endpointName] as ApiEndpointQuery< + QueryDefinition, + Definitions + > + const dispatch = useDispatch>() - // TODO: Change this to `useRef(undefined)` after upgrading to React 19. - /** - * @todo Change this to `useRef(undefined)` after upgrading to React 19. - */ - const subscriptionSelectorsRef = useRef< - SubscriptionSelectors | undefined - >(undefined) + // TODO: Change this to `useRef(undefined)` after upgrading to React 19. + const subscriptionSelectorsRef = useRef( + undefined, + ) - if (!subscriptionSelectorsRef.current) { - const returnedValue = dispatch( - api.internalActions.internal_getRTKQSubscriptions(), - ) + 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. + 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[endpointName], + endpointName, + ) + const stableSubscriptionOptions = useShallowStableValue({ + refetchOnReconnect, + refetchOnFocus, + pollingInterval, + skipPollingIfUnfocused, + }) + + const lastRenderHadSubscription = useRef(false) + + const initialPageParam = (rest as UseInfiniteQuerySubscriptionOptions) + .initialPageParam + const stableInitialPageParam = useShallowStableValue(initialPageParam) + + /** + * @todo Change this to `useRef>(undefined)` after upgrading to React 19. + */ + const promiseRef = useRef(undefined) + + 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 + }) - subscriptionSelectorsRef.current = - returnedValue as unknown as SubscriptionSelectors + usePossiblyImmediateEffect((): void | undefined => { + if (subscriptionRemoved) { + promiseRef.current = undefined } + }, [subscriptionRemoved]) + + usePossiblyImmediateEffect((): void | undefined => { + const lastPromise = promiseRef.current + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV === 'removeMeOnCompilation' + ) { + // this is only present to enforce the rule of hooks to keep `isSubscribed` in the dependency array + console.log(subscriptionRemoved) + } + + if (stableArg === skipToken) { + lastPromise?.unsubscribe() + promiseRef.current = undefined + return + } + + const lastSubscriptionOptions = promiseRef.current?.subscriptionOptions + + if (!lastPromise || lastPromise.arg !== stableArg) { + lastPromise?.unsubscribe() + const promise = dispatch( + initiate(stableArg, { + subscriptionOptions: stableSubscriptionOptions, + forceRefetch: refetchOnMountOrArgChange, + ...(isInfiniteQueryDefinition( + context.endpointDefinitions[endpointName], + ) + ? { + initialPageParam: stableInitialPageParam, + } + : {}), + }), + ) + + promiseRef.current = promise as T + } else if (stableSubscriptionOptions !== lastSubscriptionOptions) { + lastPromise.updateSubscriptionOptions(stableSubscriptionOptions) + } + }, [ + dispatch, + initiate, + refetchOnMountOrArgChange, + stableArg, + stableSubscriptionOptions, + subscriptionRemoved, + stableInitialPageParam, + endpointName, + ]) + + return [promiseRef, dispatch, initiate, stableSubscriptionOptions] as const + } + + function buildUseQueryState( + endpointName: string, + preSelector: + | typeof queryStatePreSelector + | typeof infiniteQueryStatePreSelector, + ) { + const useQueryState = ( + arg: any, + { + skip = false, + selectFromResult, + }: + | UseQueryStateOptions + | UseInfiniteQueryStateOptions = {}, + ) => { + const { select } = api.endpoints[endpointName] as ApiEndpointQuery< + QueryDefinition, + Definitions + > 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, + serializeQueryArgs, + context.endpointDefinitions[endpointName], + endpointName, ) - const stableSubscriptionOptions = useShallowStableValue({ - refetchOnReconnect, - refetchOnFocus, - pollingInterval, - skipPollingIfUnfocused, - }) - - const lastRenderHadSubscription = useRef(false) - // TODO: Change this to `useRef>(undefined)` after upgrading to React 19. - /** - * @todo Change this to `useRef>(undefined)` after upgrading to React 19. - */ - const promiseRef = useRef | undefined>( - undefined, - ) + type ApiRootState = Parameters>[0] - let { queryCacheKey, requestId } = promiseRef.current || {} + const lastValue = useRef(undefined) - // 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 selectDefaultResult: Selector = useMemo( + () => + // Normally ts-ignores are bad and should be avoided, but we're + // already casting this selector to be `Selector` anyway, + // so the inconsistencies don't matter here + // @ts-ignore + createSelector( + [ + // @ts-ignore + select(stableArg), + (_: ApiRootState, lastResult: any) => lastResult, + (_: ApiRootState) => stableArg, + ], + preSelector, + { + memoizeOptions: { + resultEqualityCheck: shallowEqual, + }, + }, + ), + [select, stableArg], + ) - const subscriptionRemoved = - !currentRenderHasSubscription && lastRenderHadSubscription.current + const querySelector: Selector = useMemo( + () => + selectFromResult + ? createSelector([selectDefaultResult], selectFromResult, { + devModeChecks: { identityFunctionCheck: 'never' }, + }) + : selectDefaultResult, + [selectDefaultResult, selectFromResult], + ) - usePossiblyImmediateEffect(() => { - lastRenderHadSubscription.current = currentRenderHasSubscription - }) + const currentState = useSelector( + (state: RootState) => + querySelector(state, lastValue.current), + shallowEqual, + ) - usePossiblyImmediateEffect((): void | undefined => { - if (subscriptionRemoved) { - promiseRef.current = undefined - } - }, [subscriptionRemoved]) + const store = useStore>() + const newLastValue = selectDefaultResult( + store.getState(), + lastValue.current, + ) + useIsomorphicLayoutEffect(() => { + lastValue.current = newLastValue + }, [newLastValue]) - 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) - } + return currentState + } - if (stableArg === skipToken) { - lastPromise?.unsubscribe() - promiseRef.current = undefined - return - } + return useQueryState + } - const lastSubscriptionOptions = promiseRef.current?.subscriptionOptions + function usePromiseRefUnsubscribeOnUnmount( + promiseRef: React.RefObject<{ unsubscribe?: () => void } | undefined>, + ) { + useEffect(() => { + return () => { + promiseRef.current?.unsubscribe?.() + // eslint-disable-next-line react-hooks/exhaustive-deps + ;(promiseRef.current as any) = undefined + } + }, [promiseRef]) + } - if (!lastPromise || lastPromise.arg !== stableArg) { - lastPromise?.unsubscribe() - const promise = dispatch( - initiate(stableArg, { - subscriptionOptions: stableSubscriptionOptions, - forceRefetch: refetchOnMountOrArgChange, - }), - ) + function refetchOrErrorIfUnmounted< + T extends + | QueryActionCreatorResult + | InfiniteQueryActionCreatorResult, + >(promiseRef: React.RefObject): T { + if (!promiseRef.current) + throw new Error('Cannot refetch a query that has not been started yet.') + return promiseRef.current.refetch() as T + } - promiseRef.current = promise - } else if (stableSubscriptionOptions !== lastSubscriptionOptions) { - lastPromise.updateSubscriptionOptions(stableSubscriptionOptions) - } - }, [ - dispatch, - initiate, - refetchOnMountOrArgChange, - stableArg, - stableSubscriptionOptions, - subscriptionRemoved, - ]) + function buildQueryHooks(endpointName: string): QueryHooks { + const useQuerySubscription: UseQuerySubscription = ( + arg: any, + options = {}, + ) => { + const [promiseRef] = useQuerySubscriptionCommonImpl< + QueryActionCreatorResult + >(endpointName, arg, options) - useEffect(() => { - return () => { - promiseRef.current?.unsubscribe() - promiseRef.current = undefined - } - }, []) + usePromiseRefUnsubscribeOnUnmount(promiseRef) return useMemo( () => ({ /** * A method to manually refetch data for the query */ - refetch: () => { - if (!promiseRef.current) - throw new Error( - 'Cannot refetch a query that has not been started yet.', - ) - return promiseRef.current?.refetch() - }, + refetch: () => refetchOrErrorIfUnmounted(promiseRef), }), - [], + [promiseRef], ) } @@ -1543,7 +1688,7 @@ export function buildHooks({ pollingInterval = 0, skipPollingIfUnfocused = false, } = {}) => { - const { initiate } = api.endpoints[name] as ApiEndpointQuery< + const { initiate } = api.endpoints[endpointName] as ApiEndpointQuery< QueryDefinition, Definitions > @@ -1633,70 +1778,10 @@ export function buildHooks({ ) } - const useQueryState: UseQueryState = ( - arg: any, - { skip = false, selectFromResult } = {}, - ) => { - const { select } = api.endpoints[name] as ApiEndpointQuery< - QueryDefinition, - Definitions - > - const stableArg = useStableQueryArgs( - skip ? skipToken : arg, - serializeQueryArgs, - context.endpointDefinitions[name], - name, - ) - - type ApiRootState = Parameters>[0] - - const lastValue = useRef(undefined) - - const selectDefaultResult: Selector = useMemo( - () => - createSelector( - [ - select(stableArg), - (_: ApiRootState, lastResult: any) => lastResult, - (_: ApiRootState) => stableArg, - ], - queryStatePreSelector, - { - 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 - } + const useQueryState: UseQueryState = buildUseQueryState( + endpointName, + queryStatePreSelector, + ) return { useQueryState, @@ -1725,9 +1810,8 @@ export function buildHooks({ ...options, }) - const { data, status, isLoading, isSuccess, isError, error } = - queryStateResults - useDebugValue({ data, status, isLoading, isSuccess, isError, error }) + const debugValue = pick(queryStateResults, ...COMMON_HOOK_DEBUG_FIELDS) + useDebugValue(debugValue) return useMemo( () => ({ ...queryStateResults, ...querySubscriptionResults }), @@ -1737,141 +1821,20 @@ export function buildHooks({ } } - function buildInfiniteQueryHooks(name: string): InfiniteQueryHooks { + function buildInfiniteQueryHooks( + endpointName: string, + ): InfiniteQueryHooks { const useInfiniteQuerySubscription: UseInfiniteQuerySubscription = ( arg: any, - { - refetchOnReconnect, - refetchOnFocus, - refetchOnMountOrArgChange, - skip = false, - pollingInterval = 0, - skipPollingIfUnfocused = false, - initialPageParam, - } = {}, + options = {}, ) => { - const { initiate } = api.endpoints[ - name - ] as unknown as ApiEndpointInfiniteQuery< - InfiniteQueryDefinition, - Definitions - > - const dispatch = useDispatch>() - const subscriptionSelectorsRef = useRef< - SubscriptionSelectors | undefined - >(undefined) - if (!subscriptionSelectorsRef.current) { - const returnedValue = dispatch( - api.internalActions.internal_getRTKQSubscriptions(), + const [promiseRef, dispatch, initiate, stableSubscriptionOptions] = + useQuerySubscriptionCommonImpl>( + endpointName, + arg, + options, ) - 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< - InfiniteQueryActionCreatorResult | undefined - >(undefined) - - 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 @@ -1885,7 +1848,7 @@ export function buildHooks({ promiseRef.current?.unsubscribe() promiseRef.current = promise = dispatch( - initiate(arg, { + (initiate as StartInfiniteQueryActionCreator)(arg, { subscriptionOptions: subscriptionOptionsRef.current, direction, }), @@ -1894,106 +1857,44 @@ export function buildHooks({ return promise! }, - [dispatch, initiate], + [promiseRef, dispatch, initiate], ) - useEffect(() => { - return () => { - promiseRef.current?.unsubscribe() - promiseRef.current = undefined + usePromiseRefUnsubscribeOnUnmount(promiseRef) + + return useMemo(() => { + const fetchNextPage = () => { + // TODO the hasNextPage bailout breaks things + //if (!hasNextPage) return + return trigger(arg, 'forward') } - }, []) - return useMemo( - () => ({ + const fetchPreviousPage = () => { + //if (!hasPreviousPage) return + return trigger(arg, 'backward') + } + + return { trigger, /** * A method to manually refetch data for the query */ - refetch: () => { - if (!promiseRef.current) - throw new Error( - 'Cannot refetch a query that has not been started yet.', - ) - return promiseRef.current?.refetch() - }, - }), - [trigger], - ) + refetch: () => refetchOrErrorIfUnmounted(promiseRef), + fetchNextPage, + fetchPreviousPage, + } + }, [promiseRef, trigger, arg]) } - const useInfiniteQueryState: UseInfiniteQueryState = ( - arg: any, - { skip = false, selectFromResult } = {}, - ) => { - const { select } = 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(undefined) - - 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 - } + const useInfiniteQueryState: UseInfiniteQueryState = + buildUseQueryState(endpointName, infiniteQueryStatePreSelector) return { useInfiniteQueryState, useInfiniteQuerySubscription, useInfiniteQuery(arg, options) { - const { trigger, refetch } = useInfiniteQuerySubscription(arg, options) + const { refetch, fetchNextPage, fetchPreviousPage } = + useInfiniteQuerySubscription(arg, options) const queryStateResults = useInfiniteQueryState(arg, { selectFromResult: arg === skipToken || options?.skip @@ -2002,37 +1903,13 @@ export function buildHooks({ ...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]) + const debugValue = pick( + queryStateResults, + ...COMMON_HOOK_DEBUG_FIELDS, + 'hasNextPage', + 'hasPreviousPage', + ) + useDebugValue(debugValue) return useMemo( () => ({ @@ -2106,24 +1983,12 @@ export function buildHooks({ }) }, [dispatch, fixedCacheKey, promise, requestId]) - const { - endpointName, - data, - status, - isLoading, - isSuccess, - isError, - error, - } = currentState - useDebugValue({ - endpointName, - data, - status, - isLoading, - isSuccess, - isError, - error, - }) + const debugValue = pick( + currentState, + ...COMMON_HOOK_DEBUG_FIELDS, + 'endpointName', + ) + useDebugValue(debugValue) const finalState = useMemo( () => ({ ...currentState, originalArgs, reset }), diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index cc828fa2d5..a9535aca52 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -34,8 +34,8 @@ import { import { userEvent } from '@testing-library/user-event' import type { SyncScreen } from '@testing-library/react-render-stream/pure' import { createRenderStream } from '@testing-library/react-render-stream/pure' -import { HttpResponse, http } from 'msw' -import { useEffect, useState } from 'react' +import { HttpResponse, http, delay } from 'msw' +import { useEffect, useMemo, useState } from 'react' import type { InfiniteQueryResultFlags } from '../core/buildSelectors' // Just setup a temporary in-memory counter for tests that `getIncrementedAmount`. @@ -929,9 +929,6 @@ describe('hooks tests', () => { // See https://github.com/reduxjs/redux-toolkit/issues/4267 - Memory leak in useQuery rapid query arg changes test('Hook subscriptions are properly cleaned up when query is fulfilled/rejected', async () => { // This is imported already, but it seems to be causing issues with the test on certain matrixes - function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) - } const pokemonApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), @@ -1974,6 +1971,250 @@ describe('hooks tests', () => { hasPreviousPage: true, }) }) + + test('Object page params does not keep forcing refetching', async () => { + type Project = { + id: number + createdAt: string + } + + type ProjectsResponse = { + projects: Project[] + numFound: number + serverTime: string + } + + interface ProjectsInitialPageParam { + offset: number + limit: number + } + + const apiWithInfiniteScroll = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/' }), + endpoints: (builder) => ({ + projectsLimitOffset: builder.infiniteQuery< + ProjectsResponse, + void, + ProjectsInitialPageParam + >({ + infiniteQueryOptions: { + initialPageParam: { + offset: 0, + limit: 20, + }, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => { + const nextOffset = lastPageParam.offset + lastPageParam.limit + const remainingItems = lastPage?.numFound - nextOffset + + if (remainingItems <= 0) { + return undefined + } + + return { + ...lastPageParam, + offset: nextOffset, + } + }, + getPreviousPageParam: ( + firstPage, + allPages, + firstPageParam, + allPageParams, + ) => { + const prevOffset = firstPageParam.offset - firstPageParam.limit + if (prevOffset < 0) return undefined + + return { + ...firstPageParam, + offset: firstPageParam.offset - firstPageParam.limit, + } + }, + }, + query: ({ offset, limit }) => { + return { + url: `https://example.com/api/projectsLimitOffset?offset=${offset}&limit=${limit}`, + method: 'GET', + } + }, + }), + }), + }) + + const projects = Array.from({ length: 50 }, (_, i) => { + return { + id: i, + createdAt: Date.now() + i * 1000, + } + }) + + let numRequests = 0 + + server.use( + http.get( + 'https://example.com/api/projectsLimitOffset', + async ({ request }) => { + const url = new URL(request.url) + const limit = parseInt(url.searchParams.get('limit') ?? '5', 10) + let offset = parseInt(url.searchParams.get('offset') ?? '0', 10) + + numRequests++ + + if (isNaN(offset) || offset < 0) { + offset = 0 + } + if (isNaN(limit) || limit <= 0) { + return HttpResponse.json( + { + message: + "Invalid 'limit' parameter. It must be a positive integer.", + } as any, + { status: 400 }, + ) + } + + const result = projects.slice(offset, offset + limit) + + await delay(10) + return HttpResponse.json({ + projects: result, + serverTime: Date.now(), + numFound: projects.length, + }) + }, + ), + ) + + function LimitOffsetExample() { + const { + data, + hasPreviousPage, + hasNextPage, + error, + isFetching, + isLoading, + isError, + fetchNextPage, + fetchPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + status, + } = apiWithInfiniteScroll.useProjectsLimitOffsetInfiniteQuery( + undefined, + { + initialPageParam: { + offset: 10, + limit: 10, + }, + }, + ) + + const [counter, setCounter] = useState(0) + + const combinedData = useMemo(() => { + return data?.pages?.map((item) => item?.projects)?.flat() + }, [data]) + + return ( +
+

Limit and Offset Infinite Scroll

+ +
Counter: {counter}
+ {isLoading ? ( +

Loading...

+ ) : isError ? ( + Error: {error.message} + ) : null} + + <> +
+ +
+
+ {combinedData?.map((project, index, arr) => { + return ( +
+
+
{`Project ${project.id} (created at: ${project.createdAt})`}
+
+
+ ) + })} +
+
+ +
+
+ {isFetching && !isFetchingPreviousPage && !isFetchingNextPage + ? 'Background Updating...' + : null} +
+ +
+ ) + } + + const storeRef = setupApiStore( + apiWithInfiniteScroll, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + const { takeRender, render, totalRenderCount } = createRenderStream({ + snapshotDOM: true, + }) + + render(, { + wrapper: storeRef.wrapper, + }) + + { + const { withinDOM } = await takeRender() + withinDOM().getByText('Counter: 0') + withinDOM().getByText('Loading...') + } + + { + const { withinDOM } = await takeRender() + withinDOM().getByText('Counter: 0') + withinDOM().getByText('Loading...') + } + + { + const { withinDOM } = await takeRender() + withinDOM().getByText('Counter: 0') + + expect(withinDOM().getAllByTestId('project').length).toBe(10) + expect(withinDOM().queryByTestId('Loading...')).toBeNull() + } + + expect(totalRenderCount()).toBe(3) + expect(numRequests).toBe(1) + }) }) describe('useMutation', () => {