From d0b207794ecada8daf080d566a6675e753a74a0a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 12:29:30 -0500 Subject: [PATCH 01/19] Byte-shave infinite query selectors --- .../toolkit/src/query/core/buildSelectors.ts | 111 ++++++++---------- 1 file changed, 50 insertions(+), 61 deletions(-) diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index 65c42bdd7e..90cc3b4041 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, @@ -206,14 +207,14 @@ export function buildSelectors< return state } - function buildQuerySelector( + function buildAnyQuerySelector( endpointName: string, - endpointDefinition: QueryDefinition, + endpointDefinition: EndpointDefinition, + combiner: ( + substate: T, + ) => T & RequestStatusFlags, ) { - return ((queryArgs: any) => { - if (queryArgs === skipToken) { - return createSelector(selectSkippedQuery, withRequestFlags) - } + return (queryArgs: any) => { const serializedArgs = serializeQueryArgs({ queryArgs, endpointDefinition, @@ -222,76 +223,64 @@ export function buildSelectors< const selectQuerySubstate = (state: RootState) => selectInternalState(state)?.queries?.[serializedArgs] ?? defaultQuerySubState + const finalSelectQuerySubState = + queryArgs === skipToken ? selectSkippedQuery : selectQuerySubstate + + return createSelector(finalSelectQuerySubState, combiner) + } + } - return createSelector(selectQuerySubstate, withRequestFlags) - }) as QueryResultSelectorFactory + 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 { infiniteQueryOptions } = endpointDefinition + + function withInfiniteQueryResultFlags( + substate: T, + ): T & RequestStatusFlags & InfiniteQueryResultFlags { + const stateWithRequestFlags = { + ...(substate as InfiniteQuerySubState), + ...getRequestStatusFlags(substate.status), + } - const isFetchPreviousPageError = - isError && fetchDirection === 'backward' - const isFetchingPreviousPage = - isLoading && fetchDirection === 'backward' + const { isLoading, isError, direction } = stateWithRequestFlags + const isForward = direction === 'forward' + const isBackward = direction === 'backward' - const hasNextPage = getHasNextPage( + 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() { From f1755db5482320f3e19852c572d76ef5f4036dc1 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 16:06:10 -0500 Subject: [PATCH 02/19] Export reusable internal selectors --- .../src/query/core/buildMiddleware/types.ts | 3 ++ .../toolkit/src/query/core/buildSelectors.ts | 36 +++++++++++++++---- packages/toolkit/src/query/core/module.ts | 30 ++++++++-------- 3 files changed, 48 insertions(+), 21 deletions(-) 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 90cc3b4041..65deb23d00 100644 --- a/packages/toolkit/src/query/core/buildSelectors.ts +++ b/packages/toolkit/src/query/core/buildSelectors.ts @@ -159,6 +159,8 @@ const defaultMutationSubState = /* @__PURE__ */ createNextState( () => {}, ) +export type AllSelectors = ReturnType + export function buildSelectors< Definitions extends EndpointDefinitions, ReducerPath extends string, @@ -182,6 +184,11 @@ export function buildSelectors< buildMutationSelector, selectInvalidatedBy, selectCachedArgsForQuery, + selectApiState, + selectQueries, + selectMutations, + selectQueryEntry, + selectConfig, } function withRequestFlags( @@ -193,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?`, ) @@ -207,6 +214,22 @@ export function buildSelectors< return state } + 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: EndpointDefinition, @@ -221,8 +244,7 @@ export function buildSelectors< endpointName, }) const selectQuerySubstate = (state: RootState) => - selectInternalState(state)?.queries?.[serializedArgs] ?? - defaultQuerySubState + selectQueryEntry(state, serializedArgs) ?? defaultQuerySubState const finalSelectQuerySubState = queryArgs === skipToken ? selectSkippedQuery : selectQuerySubstate @@ -292,7 +314,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 @@ -351,7 +373,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/module.ts b/packages/toolkit/src/query/core/module.ts index 1a4d0a353c..d7aafa6415 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, @@ -605,20 +621,6 @@ export const coreModule = ({ 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, From d157a85887e2fa96ab6e5dd2e6b06f9a74986881 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 16:15:36 -0500 Subject: [PATCH 03/19] Fix selector skiptoken usage --- packages/toolkit/src/query/core/buildSelectors.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/toolkit/src/query/core/buildSelectors.ts b/packages/toolkit/src/query/core/buildSelectors.ts index 65deb23d00..3daca29696 100644 --- a/packages/toolkit/src/query/core/buildSelectors.ts +++ b/packages/toolkit/src/query/core/buildSelectors.ts @@ -238,6 +238,11 @@ export function buildSelectors< ) => T & RequestStatusFlags, ) { return (queryArgs: any) => { + // Avoid calling serializeQueryArgs if the arg is skipToken + if (queryArgs === skipToken) { + return createSelector(selectSkippedQuery, combiner) + } + const serializedArgs = serializeQueryArgs({ queryArgs, endpointDefinition, @@ -245,10 +250,8 @@ export function buildSelectors< }) const selectQuerySubstate = (state: RootState) => selectQueryEntry(state, serializedArgs) ?? defaultQuerySubState - const finalSelectQuerySubState = - queryArgs === skipToken ? selectSkippedQuery : selectQuerySubstate - return createSelector(finalSelectQuerySubState, combiner) + return createSelector(selectQuerySubstate, combiner) } } From e890918a21187ae65d5dc82a7a0f976db944a742 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 16:21:47 -0500 Subject: [PATCH 04/19] Use selectors in buildThunks --- packages/toolkit/src/query/core/buildThunks.ts | 13 +++++++++---- packages/toolkit/src/query/core/module.ts | 2 ++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index b8df5856bc..1564876d1c 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -53,6 +53,7 @@ import type { StartQueryActionCreatorOptions, } from './buildInitiate' import { forceQueryFnSymbol, isUpsertQuery } from './buildInitiate' +import type { AllSelectors } from './buildSelectors' import type { ApiEndpointQuery, PrefetchOptions } from './module' import { createAsyncThunk, @@ -307,6 +308,7 @@ export function buildThunks< serializeQueryArgs, api, assertTagType, + selectors, }: { baseQuery: BaseQuery reducerPath: ReducerPath @@ -314,6 +316,7 @@ export function buildThunks< serializeQueryArgs: InternalSerializeQueryArgs api: Api assertTagType: AssertTagTypes + selectors: AllSelectors }) { type State = RootState @@ -609,8 +612,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 = ( @@ -720,9 +725,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 = diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index d7aafa6415..d47444f8e0 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -578,6 +578,7 @@ export const coreModule = ({ api, serializeQueryArgs, assertTagType, + selectors, }) const { reducer, actions: sliceActions } = buildSlice({ @@ -616,6 +617,7 @@ export const coreModule = ({ infiniteQueryThunk, api, assertTagType, + selectors, }) safeAssign(api.util, middlewareActions) From 458947241fecd6aeaea86b081659e22c5053ced4 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 16:22:08 -0500 Subject: [PATCH 05/19] Byte-shave cache middleware --- .../core/buildMiddleware/cacheCollection.ts | 45 ++++++++++--------- .../core/buildMiddleware/cacheLifecycle.ts | 13 +++--- .../buildMiddleware/invalidationByTags.ts | 10 ++--- .../src/query/core/buildMiddleware/polling.ts | 20 ++++++++- 4 files changed, 55 insertions(+), 33 deletions(-) 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, From b0c489bd52cc1b536569ddecf04aae031432b2e1 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 16:22:25 -0500 Subject: [PATCH 06/19] Byte-shave endpoint assignments --- packages/toolkit/src/query/core/module.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index d47444f8e0..004a6e8f80 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -657,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), @@ -671,7 +672,7 @@ export const coreModule = ({ } if (isMutationDefinition(definition)) { safeAssign( - anyApi.endpoints[endpointName], + endpoint, { name: endpointName, select: buildMutationSelector(), @@ -682,7 +683,7 @@ export const coreModule = ({ } if (isInfiniteQueryDefinition(definition)) { safeAssign( - anyApi.endpoints[endpointName], + endpoint, { name: endpointName, select: buildInfiniteQuerySelector(endpointName, definition), From 576a99cc1df3857473aef1c0c5aaca1e7920e452 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 16:22:41 -0500 Subject: [PATCH 07/19] Deduplicate buildThunks --- .../toolkit/src/query/core/buildThunks.ts | 251 ++++++++---------- 1 file changed, 115 insertions(+), 136 deletions(-) diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index 1564876d1c..3869f0ab68 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' @@ -297,6 +296,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, @@ -446,6 +460,15 @@ export function buildThunks< return res } + const getTransformCallbackForEndpoint = ( + endpointDefinition: EndpointDefinition, + transformFieldName: 'transformResponse' | 'transformErrorResponse', + ) => { + return endpointDefinition.query && endpointDefinition[transformFieldName] + ? endpointDefinition[transformFieldName] + : defaultTransformResponse + } + // The generic async payload function for all of our thunks const executeEndpoint: AsyncThunkPayloadCreator< ThunkResult, @@ -466,14 +489,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, @@ -509,7 +526,6 @@ export function buildThunks< const pageResponse = await executeRequest(param) - // TODO Get maxPages from endpoint config const addTo = previous ? addToStart : addToEnd return { @@ -526,6 +542,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 @@ -534,19 +551,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), ) } @@ -657,7 +669,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( @@ -675,22 +687,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( @@ -699,7 +710,7 @@ export function buildThunks< catchedError.meta, arg.originalArgs, ), - { baseQueryMeta: catchedError.meta, [SHOULD_AUTOBATCH]: true }, + addShouldAutoBatch({ baseQueryMeta: catchedError.meta }), ) } catch (e) { catchedError = e @@ -743,116 +754,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, @@ -860,7 +839,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() }) }, }) From f7754b21a58d6a2cb3f7ffdd1391b94f6e1dad51 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 18:05:36 -0500 Subject: [PATCH 08/19] Deduplicate buildInitiate --- .../toolkit/src/query/core/buildInitiate.ts | 263 ++++++------------ .../toolkit/src/query/core/buildThunks.ts | 1 - 2 files changed, 89 insertions(+), 175 deletions(-) diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index 0567328ac1..d5fc1981cc 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, @@ -99,38 +103,41 @@ type StartInfiniteQueryActionCreator< > = ( 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/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index 3869f0ab68..e7cdc438ea 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -136,7 +136,6 @@ export type InfiniteQueryThunkArg< originalArgs: unknown endpointName: string param: unknown - previous?: boolean direction?: InfiniteQueryDirection } From eb566194e7a1b8c4819427bd841fccdf8fded32b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 18:28:07 -0500 Subject: [PATCH 09/19] Tweak TransformCallback usage --- packages/toolkit/src/query/core/buildThunks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index e7cdc438ea..32fb1f5e04 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -462,9 +462,9 @@ export function buildThunks< const getTransformCallbackForEndpoint = ( endpointDefinition: EndpointDefinition, transformFieldName: 'transformResponse' | 'transformErrorResponse', - ) => { + ): TransformCallback => { return endpointDefinition.query && endpointDefinition[transformFieldName] - ? endpointDefinition[transformFieldName] + ? endpointDefinition[transformFieldName]! : defaultTransformResponse } From b602b65f68e93f36fcb33982c8694ce98b534a7e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 3 Jan 2025 19:18:37 -0500 Subject: [PATCH 10/19] Run size checks against the integration branch --- .github/workflows/size.yml | 1 + 1 file changed, 1 insertion(+) 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: From a7246a6dffff867de14533010a3fc94ad7d38b55 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 14:06:09 -0500 Subject: [PATCH 11/19] Fix infinite query preselector loading state --- packages/toolkit/src/query/react/buildHooks.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 1396bf8584..cd4ca9d5b8 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -1340,7 +1340,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) From 5d48471385c5248e2e6fdb82387fc1114b90851e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 14:40:10 -0500 Subject: [PATCH 12/19] Clarify endpointName --- .../toolkit/src/query/react/buildHooks.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index cd4ca9d5b8..c23ef30459 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -1376,7 +1376,7 @@ export function buildHooks({ ) } - function buildQueryHooks(name: string): QueryHooks { + function buildQueryHooks(endpointName: string): QueryHooks { const useQuerySubscription: UseQuerySubscription = ( arg: any, { @@ -1388,7 +1388,7 @@ export function buildHooks({ skipPollingIfUnfocused = false, } = {}, ) => { - const { initiate } = api.endpoints[name] as ApiEndpointQuery< + const { initiate } = api.endpoints[endpointName] as ApiEndpointQuery< QueryDefinition, Definitions > @@ -1430,8 +1430,8 @@ export function buildHooks({ // 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, + context.endpointDefinitions[endpointName], + endpointName, ) const stableSubscriptionOptions = useShallowStableValue({ refetchOnReconnect, @@ -1546,7 +1546,7 @@ export function buildHooks({ pollingInterval = 0, skipPollingIfUnfocused = false, } = {}) => { - const { initiate } = api.endpoints[name] as ApiEndpointQuery< + const { initiate } = api.endpoints[endpointName] as ApiEndpointQuery< QueryDefinition, Definitions > @@ -1640,15 +1640,15 @@ export function buildHooks({ arg: any, { skip = false, selectFromResult } = {}, ) => { - const { select } = api.endpoints[name] as ApiEndpointQuery< + const { select } = api.endpoints[endpointName] as ApiEndpointQuery< QueryDefinition, Definitions > const stableArg = useStableQueryArgs( skip ? skipToken : arg, serializeQueryArgs, - context.endpointDefinitions[name], - name, + context.endpointDefinitions[endpointName], + endpointName, ) type ApiRootState = Parameters>[0] @@ -1740,7 +1740,9 @@ export function buildHooks({ } } - function buildInfiniteQueryHooks(name: string): InfiniteQueryHooks { + function buildInfiniteQueryHooks( + endpointName: string, + ): InfiniteQueryHooks { const useInfiniteQuerySubscription: UseInfiniteQuerySubscription = ( arg: any, { @@ -1754,7 +1756,7 @@ export function buildHooks({ } = {}, ) => { const { initiate } = api.endpoints[ - name + endpointName ] as unknown as ApiEndpointInfiniteQuery< InfiniteQueryDefinition, Definitions @@ -1791,8 +1793,8 @@ export function buildHooks({ // 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, + context.endpointDefinitions[endpointName], + endpointName, ) const stableSubscriptionOptions = useShallowStableValue({ refetchOnReconnect, @@ -1930,7 +1932,7 @@ export function buildHooks({ { skip = false, selectFromResult } = {}, ) => { const { select } = api.endpoints[ - name + endpointName ] as unknown as ApiEndpointInfiniteQuery< InfiniteQueryDefinition, Definitions @@ -1938,8 +1940,8 @@ export function buildHooks({ const stableArg = useStableQueryArgs( skip ? skipToken : arg, serializeQueryArgs, - context.endpointDefinitions[name], - name, + context.endpointDefinitions[endpointName], + endpointName, ) type ApiRootState = Parameters>[0] From d4a99da5e2409b64a35b1829b16406213814e3c8 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 15:33:59 -0500 Subject: [PATCH 13/19] Deduplicate useQuerySubscription --- .../toolkit/src/query/core/buildInitiate.ts | 2 +- .../toolkit/src/query/react/buildHooks.ts | 547 ++++++++++-------- 2 files changed, 293 insertions(+), 256 deletions(-) diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index d5fc1981cc..72171fdae2 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -98,7 +98,7 @@ 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, diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index c23ef30459..a8e72463af 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -64,6 +64,11 @@ import type { ReactHooksModuleOptions } from './module' import { useStableQueryArgs } from './useSerializedStableValue' import { useShallowStableValue } from './useShallowStableValue' import type { InfiniteQueryDirection } from '../core/apiState' +import { isInfiniteQueryDefinition } from '../endpointDefinitions' +import { + StartInfiniteQueryActionCreatorOptions, + StartInfiniteQueryActionCreator, +} from '../core/buildInitiate' // Copy-pasted from React-Redux const canUseDOM = () => @@ -1376,152 +1381,176 @@ export function buildHooks({ ) } - function buildQueryHooks(endpointName: string): QueryHooks { - const useQuerySubscription: UseQuerySubscription = ( - arg: any, - { - refetchOnReconnect, - refetchOnFocus, - refetchOnMountOrArgChange, - skip = false, - pollingInterval = 0, - skipPollingIfUnfocused = false, - } = {}, - ) => { - const { initiate } = api.endpoints[endpointName] 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) + 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, + }) - // 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, - ) + const lastRenderHadSubscription = useRef(false) - let { queryCacheKey, requestId } = promiseRef.current || {} + const initialPageParam = (rest as UseInfiniteQuerySubscriptionOptions) + .initialPageParam + const stableInitialPageParam = useShallowStableValue(initialPageParam) - // 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, - ) - } + /** + * @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 + const subscriptionRemoved = + !currentRenderHasSubscription && lastRenderHadSubscription.current - usePossiblyImmediateEffect(() => { - lastRenderHadSubscription.current = currentRenderHasSubscription - }) + usePossiblyImmediateEffect(() => { + lastRenderHadSubscription.current = currentRenderHasSubscription + }) - usePossiblyImmediateEffect((): void | undefined => { - if (subscriptionRemoved) { - promiseRef.current = undefined - } - }, [subscriptionRemoved]) + 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) - } + 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 - } + if (stableArg === skipToken) { + lastPromise?.unsubscribe() + promiseRef.current = undefined + return + } - const lastSubscriptionOptions = promiseRef.current?.subscriptionOptions + const lastSubscriptionOptions = promiseRef.current?.subscriptionOptions - if (!lastPromise || lastPromise.arg !== stableArg) { - lastPromise?.unsubscribe() - const promise = dispatch( - initiate(stableArg, { - subscriptionOptions: stableSubscriptionOptions, - forceRefetch: refetchOnMountOrArgChange, - }), - ) + 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 - } else if (stableSubscriptionOptions !== lastSubscriptionOptions) { - lastPromise.updateSubscriptionOptions(stableSubscriptionOptions) - } - }, [ - dispatch, - initiate, - refetchOnMountOrArgChange, - stableArg, - stableSubscriptionOptions, - subscriptionRemoved, - ]) + 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 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 } - }, []) + }, [promiseRef]) return useMemo( () => ({ @@ -1536,7 +1565,7 @@ export function buildHooks({ return promiseRef.current?.refetch() }, }), - [], + [promiseRef], ) } @@ -1745,137 +1774,145 @@ export function buildHooks({ ): InfiniteQueryHooks { const useInfiniteQuerySubscription: UseInfiniteQuerySubscription = ( arg: any, - { - refetchOnReconnect, - refetchOnFocus, - refetchOnMountOrArgChange, - skip = false, - pollingInterval = 0, - skipPollingIfUnfocused = false, - initialPageParam, - } = {}, + options = {}, + // { + // refetchOnReconnect, + // refetchOnFocus, + // refetchOnMountOrArgChange, + // skip = false, + // pollingInterval = 0, + // skipPollingIfUnfocused = false, + // initialPageParam, + // } = {}, ) => { - const { initiate } = api.endpoints[ - endpointName - ] 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[endpointName], - endpointName, - ) - 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 { initiate } = api.endpoints[ + // endpointName + // ] 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(), + // ) + + // 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 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(() => { @@ -1890,7 +1927,7 @@ export function buildHooks({ promiseRef.current?.unsubscribe() promiseRef.current = promise = dispatch( - initiate(arg, { + (initiate as StartInfiniteQueryActionCreator)(arg, { subscriptionOptions: subscriptionOptionsRef.current, direction, }), @@ -1899,7 +1936,7 @@ export function buildHooks({ return promise! }, - [dispatch, initiate], + [promiseRef, dispatch, initiate], ) useEffect(() => { @@ -1907,7 +1944,7 @@ export function buildHooks({ promiseRef.current?.unsubscribe() promiseRef.current = undefined } - }, []) + }, [promiseRef]) return useMemo( () => ({ @@ -1923,7 +1960,7 @@ export function buildHooks({ return promiseRef.current?.refetch() }, }), - [trigger], + [promiseRef, trigger], ) } From 67522269a815e729372a011fb86e23026ab7a68f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 15:44:42 -0500 Subject: [PATCH 14/19] Move fetch page functions into subscription hook --- .../toolkit/src/query/react/buildHooks.ts | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index a8e72463af..863f93872a 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -864,6 +864,8 @@ export type UseInfiniteQuerySubscriptionResult< D extends InfiniteQueryDefinition, > = Pick, 'refetch'> & { trigger: LazyInfiniteQueryTrigger + fetchNextPage: () => InfiniteQueryActionCreatorResult + fetchPreviousPage: () => InfiniteQueryActionCreatorResult } /** @@ -897,10 +899,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, @@ -1946,8 +1949,19 @@ export function buildHooks({ } }, [promiseRef]) - return useMemo( - () => ({ + return useMemo(() => { + const fetchNextPage = () => { + // TODO the hasNextPage bailout breaks things + //if (!hasNextPage) return + return trigger(arg, 'forward') + } + + const fetchPreviousPage = () => { + //if (!hasPreviousPage) return + return trigger(arg, 'backward') + } + + return { trigger, /** * A method to manually refetch data for the query @@ -1959,9 +1973,10 @@ export function buildHooks({ ) return promiseRef.current?.refetch() }, - }), - [promiseRef, trigger], - ) + fetchNextPage, + fetchPreviousPage, + } + }, [promiseRef, trigger, arg]) } const useInfiniteQueryState: UseInfiniteQueryState = ( @@ -2035,7 +2050,8 @@ export function buildHooks({ 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 @@ -2065,17 +2081,6 @@ export function buildHooks({ 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, From 2124217228a3ebb0527b40e6a7c10dc07c07caca Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 16:23:32 -0500 Subject: [PATCH 15/19] Deduplicate debug values --- .../toolkit/src/query/react/buildHooks.ts | 73 ++++++++----------- 1 file changed, 32 insertions(+), 41 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 863f93872a..2258c76fc5 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -1211,6 +1211,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, @@ -1760,9 +1777,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 }), @@ -2060,26 +2076,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 debugValue = pick( + queryStateResults, + ...COMMON_HOOK_DEBUG_FIELDS, + 'hasNextPage', + 'hasPreviousPage', + ) + useDebugValue(debugValue) return useMemo( () => ({ @@ -2153,24 +2156,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 }), From 312f008090c3f8cff431b4c1b7cc82a14676ecdd Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 16:23:43 -0500 Subject: [PATCH 16/19] Deduplicate useQueryState --- .../toolkit/src/query/react/buildHooks.ts | 351 +++++------------- 1 file changed, 90 insertions(+), 261 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 2258c76fc5..a3a736a556 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -911,6 +911,7 @@ export type UseInfiniteQueryState< arg: QueryArgFrom | SkipToken, options?: UseInfiniteQueryStateOptions, ) => UseInfiniteQueryStateResult + export type TypedUseInfiniteQueryState< ResultType, QueryArg, @@ -1556,6 +1557,89 @@ export function buildHooks({ 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, + serializeQueryArgs, + context.endpointDefinitions[endpointName], + endpointName, + ) + + type ApiRootState = Parameters>[0] + + const lastValue = useRef(undefined) + + 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( + [ + select(stableArg), + (_: ApiRootState, lastResult: any) => lastResult, + (_: ApiRootState) => stableArg, + ], + preSelector, + { + 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 useQueryState + } + function buildQueryHooks(endpointName: string): QueryHooks { const useQuerySubscription: UseQuerySubscription = ( arg: any, @@ -1685,70 +1769,10 @@ export function buildHooks({ ) } - const useQueryState: UseQueryState = ( - arg: any, - { skip = false, selectFromResult } = {}, - ) => { - const { select } = api.endpoints[endpointName] as ApiEndpointQuery< - QueryDefinition, - Definitions - > - const stableArg = useStableQueryArgs( - skip ? skipToken : arg, - serializeQueryArgs, - context.endpointDefinitions[endpointName], - endpointName, - ) - - 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, @@ -1794,15 +1818,6 @@ export function buildHooks({ const useInfiniteQuerySubscription: UseInfiniteQuerySubscription = ( arg: any, options = {}, - // { - // refetchOnReconnect, - // refetchOnFocus, - // refetchOnMountOrArgChange, - // skip = false, - // pollingInterval = 0, - // skipPollingIfUnfocused = false, - // initialPageParam, - // } = {}, ) => { const [promiseRef, dispatch, initiate, stableSubscriptionOptions] = useQuerySubscriptionCommonImpl>( @@ -1811,128 +1826,6 @@ export function buildHooks({ options, ) - // const { initiate } = api.endpoints[ - // endpointName - // ] 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(), - // ) - - // 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 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 @@ -1995,72 +1888,8 @@ export function buildHooks({ }, [promiseRef, trigger, arg]) } - const useInfiniteQueryState: UseInfiniteQueryState = ( - arg: any, - { skip = false, selectFromResult } = {}, - ) => { - const { select } = api.endpoints[ - endpointName - ] as unknown as ApiEndpointInfiniteQuery< - InfiniteQueryDefinition, - Definitions - > - const stableArg = useStableQueryArgs( - skip ? skipToken : arg, - serializeQueryArgs, - context.endpointDefinitions[endpointName], - endpointName, - ) - - 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, From f8f0b00e06182a99c01bb59053e8438f2480fed7 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 16:34:33 -0500 Subject: [PATCH 17/19] Deduplicate hook unsubs and refetches --- .../toolkit/src/query/react/buildHooks.ts | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index a3a736a556..9bd297b02f 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -1595,6 +1595,7 @@ export function buildHooks({ // @ts-ignore createSelector( [ + // @ts-ignore select(stableArg), (_: ApiRootState, lastResult: any) => lastResult, (_: ApiRootState) => stableArg, @@ -1640,6 +1641,27 @@ export function buildHooks({ return useQueryState } + function usePromiseRefUnsubscribeOnUnmount( + promiseRef: React.RefObject<{ unsubscribe?: () => void } | undefined>, + ) { + useEffect(() => { + return () => { + promiseRef.current?.unsubscribe?.() + promiseRef.current = undefined + } + }, [promiseRef]) + } + + 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 + } + function buildQueryHooks(endpointName: string): QueryHooks { const useQuerySubscription: UseQuerySubscription = ( arg: any, @@ -1649,25 +1671,14 @@ export function buildHooks({ QueryActionCreatorResult >(endpointName, arg, options) - useEffect(() => { - return () => { - promiseRef.current?.unsubscribe() - promiseRef.current = undefined - } - }, [promiseRef]) + 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], ) @@ -1851,12 +1862,7 @@ export function buildHooks({ [promiseRef, dispatch, initiate], ) - useEffect(() => { - return () => { - promiseRef.current?.unsubscribe() - promiseRef.current = undefined - } - }, [promiseRef]) + usePromiseRefUnsubscribeOnUnmount(promiseRef) return useMemo(() => { const fetchNextPage = () => { @@ -1875,13 +1881,7 @@ export function buildHooks({ /** * 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), fetchNextPage, fetchPreviousPage, } From 249e0ecc7166ced8460a1c250b92d96d7c7e1e3b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 16:49:00 -0500 Subject: [PATCH 18/19] Add test for initial page param references --- .../src/query/tests/buildHooks.test.tsx | 251 +++++++++++++++++- 1 file changed, 246 insertions(+), 5 deletions(-) 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', () => { From f93bec933c019c9b6c61373787dc009ea7664472 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 5 Jan 2025 17:05:50 -0500 Subject: [PATCH 19/19] Fix cleanup hook issues --- packages/toolkit/src/query/react/buildHooks.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 9bd297b02f..9923b23990 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -65,10 +65,7 @@ import { useStableQueryArgs } from './useSerializedStableValue' import { useShallowStableValue } from './useShallowStableValue' import type { InfiniteQueryDirection } from '../core/apiState' import { isInfiniteQueryDefinition } from '../endpointDefinitions' -import { - StartInfiniteQueryActionCreatorOptions, - StartInfiniteQueryActionCreator, -} from '../core/buildInitiate' +import { StartInfiniteQueryActionCreator } from '../core/buildInitiate' // Copy-pasted from React-Redux const canUseDOM = () => @@ -1647,7 +1644,8 @@ export function buildHooks({ useEffect(() => { return () => { promiseRef.current?.unsubscribe?.() - promiseRef.current = undefined + // eslint-disable-next-line react-hooks/exhaustive-deps + ;(promiseRef.current as any) = undefined } }, [promiseRef]) }