diff --git a/examples/query/react/prefetching/src/features/posts/PostsManager.tsx b/examples/query/react/prefetching/src/features/posts/PostsManager.tsx index 2aaebf2c7c..cade728bed 100644 --- a/examples/query/react/prefetching/src/features/posts/PostsManager.tsx +++ b/examples/query/react/prefetching/src/features/posts/PostsManager.tsx @@ -30,7 +30,7 @@ const getColorForStatus = (status: Post['status']) => { const PostList = () => { const [page, setPage] = useState(1) const { data: posts, isLoading, isFetching } = useListPostsQuery(page) - const prefetchPage = usePrefetch('listPosts') + const prefetchPage = usePrefetch('listPosts', { keepSubscriptionFor: 5 }) const prefetchNext = useCallback(() => { prefetchPage(page + 1) diff --git a/packages/toolkit/src/query/apiTypes.ts b/packages/toolkit/src/query/apiTypes.ts index 3dafe46b02..f0f5778d8e 100644 --- a/packages/toolkit/src/query/apiTypes.ts +++ b/packages/toolkit/src/query/apiTypes.ts @@ -42,6 +42,7 @@ export type Module = { | 'reducerPath' | 'serializeQueryArgs' | 'keepUnusedDataFor' + | 'keepPrefetchSubscriptionsFor' | 'refetchOnMountOrArgChange' | 'refetchOnFocus' | 'refetchOnReconnect' diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index dad155b3b8..68dbd69444 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -253,7 +253,8 @@ export type ConfigState = RefetchConfigOptions & { } & ModifiableConfigState export type ModifiableConfigState = { - keepUnusedDataFor: number + keepUnusedDataFor: number, + keepPrefetchSubscriptionsFor: number } & RefetchConfigOptions export type MutationState = { diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index 2ecc8eb376..89911c422b 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -33,10 +33,15 @@ declare module './module' { } } +export interface PrefetchSubscribriptionOptions { + keepSubscriptionFor?: number; +} + export interface StartQueryActionCreatorOptions { subscribe?: boolean forceRefetch?: boolean | number - subscriptionOptions?: SubscriptionOptions + subscriptionOptions?: SubscriptionOptions, + prefetch?: boolean | PrefetchSubscribriptionOptions, } type StartQueryActionCreator< @@ -258,7 +263,7 @@ Features like automatic cache collection, automatic refetching etc. will not be endpointDefinition: QueryDefinition ) { const queryAction: StartQueryActionCreator = - (arg, { subscribe = true, forceRefetch, subscriptionOptions } = {}) => + (arg, { subscribe = true, forceRefetch, subscriptionOptions, prefetch } = {}) => (dispatch, getState) => { const queryCacheKey = serializeQueryArgs({ queryArgs: arg, @@ -269,12 +274,15 @@ Features like automatic cache collection, automatic refetching etc. will not be type: 'query', subscribe, forceRefetch, + prefetch, subscriptionOptions, endpointName, originalArgs: arg, queryCacheKey, + reducerPath: api.reducerPath, }) const thunkResult = dispatch(thunk) + middlewareWarning(getState) const { requestId, abort } = thunkResult diff --git a/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts b/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts index 128c0ebe19..0433828e33 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts @@ -2,6 +2,7 @@ import type { BaseQueryFn } from '../../baseQueryTypes' import type { QueryDefinition } from '../../endpointDefinitions' import type { ConfigState, QueryCacheKey } from '../apiState' import { QuerySubstateIdentifier } from '../apiState' +import type { PrefetchSubscribriptionOptions } from '../buildInitiate' import type { QueryStateMeta, SubMiddlewareApi, @@ -28,11 +29,35 @@ declare module '../../endpointDefinitions' { } } -export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => { +/** + * Output is in *milliseconds*. + */ +const getPrefetchSubscriptionTTLMs = ( + prefetch: true | PrefetchSubscribriptionOptions, + config: ConfigState +): number => { + if ( + typeof prefetch === 'object' && + prefetch !== null && + typeof prefetch.keepSubscriptionFor === 'number' + ) { + return prefetch.keepSubscriptionFor * 1000 + } + + return config.keepPrefetchSubscriptionsFor * 1000 +} + +export const build: SubMiddlewareBuilder = ({ + reducerPath, + api, + context, + queryThunk, +}) => { const { removeQueryResult, unsubscribeQueryResult } = api.internalActions return (mwApi) => { const currentRemovalTimeouts: QueryStateMeta = {} + const autoUnsubscribeTimeouts: QueryStateMeta = {} return (next) => (action): any => { @@ -50,8 +75,31 @@ export const build: SubMiddlewareBuilder = ({ reducerPath, api, context }) => { ) } + if (queryThunk.pending.match(action) && action.meta.arg.prefetch) { + const requestId = action.meta.requestId + const currentTimeout = autoUnsubscribeTimeouts[requestId] + + if (currentTimeout) { + clearTimeout(currentTimeout) + } + + autoUnsubscribeTimeouts[requestId] = setTimeout( + mwApi.dispatch, + getPrefetchSubscriptionTTLMs( + action.meta.arg.prefetch, + mwApi.getState()[reducerPath].config + ), + unsubscribeQueryResult({ + requestId, + queryCacheKey: action.meta.arg.queryCacheKey, + }) + ) + } + if (api.util.resetApiState.match(action)) { - for (const [key, timeout] of Object.entries(currentRemovalTimeouts)) { + for (const [key, timeout] of Object.entries( + currentRemovalTimeouts + ).concat(Object.entries(autoUnsubscribeTimeouts))) { if (timeout) clearTimeout(timeout) delete currentRemovalTimeouts[key] } diff --git a/packages/toolkit/src/query/core/buildMiddleware/index.ts b/packages/toolkit/src/query/core/buildMiddleware/index.ts index c7a9ed1330..edd9604b9a 100644 --- a/packages/toolkit/src/query/core/buildMiddleware/index.ts +++ b/packages/toolkit/src/query/core/buildMiddleware/index.ts @@ -80,6 +80,7 @@ export function buildMiddleware< originalArgs: querySubState.originalArgs, subscribe: false, forceRefetch: true, + reducerPath: reducerPath, queryCacheKey: queryCacheKey as any, ...override, }) diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index 04f6e06ce9..511db71f03 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -18,13 +18,14 @@ import type { ResultTypeFrom, } from '../endpointDefinitions' import { calculateProvidedBy } from '../endpointDefinitions' -import type { AsyncThunkPayloadCreator, Draft } from '@reduxjs/toolkit' import { isAllOf, isFulfilled, isPending, isRejected, isRejectedWithValue, + nanoid, + createAsyncThunk, } from '@reduxjs/toolkit' import type { Patch } from 'immer' import { isDraftable, produceWithPatches } from 'immer' @@ -33,9 +34,9 @@ import type { ThunkAction, ThunkDispatch, AsyncThunk, + AsyncThunkPayloadCreator, + Draft, } from '@reduxjs/toolkit' -import { createAsyncThunk } from '@reduxjs/toolkit' - import { HandledError } from '../HandledError' import type { ApiEndpointQuery, PrefetchOptions } from './module' @@ -105,6 +106,7 @@ export interface QueryThunkArg type: 'query' originalArgs: unknown endpointName: string + reducerPath: string } export interface MutationThunkArg { @@ -411,6 +413,13 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` return true }, + idGenerator(args): string { + if (args.prefetch) { + return `${args.reducerPath}-${args.queryCacheKey}-prefetch` + } + + return nanoid() + }, dispatchConditionRejection: true, }) @@ -443,7 +452,7 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` const queryAction = (force: boolean = true) => (api.endpoints[endpointName] as ApiEndpointQuery).initiate( arg, - { forceRefetch: force } + { forceRefetch: force, prefetch: options || true } ) const latestStateValue = ( api.endpoints[endpointName] as ApiEndpointQuery diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index 91f048a322..a85b46d305 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -49,16 +49,21 @@ import { enablePatches } from 'immer' /** * `ifOlderThan` - (default: `false` | `number`) - _number is value in seconds_ * - If specified, it will only run the query if the difference between `new Date()` and the last `fulfilledTimeStamp` is greater than the given value - * + * + * - `keepSubscriptionFor`: how long before the data is considered unused; + * defaults to `api.config.keepPrefetchSubscriptionsFor`. - _number is value in seconds_ + * + * * @overloadSummary * `force` * - If `force: true`, it will ignore the `ifOlderThan` value if it is set and the query will be run even if it exists in the cache. */ export type PrefetchOptions = | { - ifOlderThan?: false | number + ifOlderThan?: false | number, + keepSubscriptionFor?: number, } - | { force?: boolean } + | { force?: boolean, keepSubscriptionFor?: number, } export const coreModuleName = /* @__PURE__ */ Symbol() export type CoreModule = @@ -365,6 +370,7 @@ export const coreModule = (): Module => ({ reducerPath, serializeQueryArgs, keepUnusedDataFor, + keepPrefetchSubscriptionsFor, refetchOnMountOrArgChange, refetchOnFocus, refetchOnReconnect, @@ -427,6 +433,7 @@ export const coreModule = (): Module => ({ refetchOnReconnect, refetchOnMountOrArgChange, keepUnusedDataFor, + keepPrefetchSubscriptionsFor, reducerPath, }, }) diff --git a/packages/toolkit/src/query/createApi.ts b/packages/toolkit/src/query/createApi.ts index c63c5a2b82..62fb3fdbd7 100644 --- a/packages/toolkit/src/query/createApi.ts +++ b/packages/toolkit/src/query/createApi.ts @@ -126,6 +126,36 @@ export interface CreateApiOptions< * ``` */ keepUnusedDataFor?: number + + /** + * Defaults to `10` _(this value is in seconds)_. + * + * The default time to live of prefetch subscriptions. + * + * ```ts + * // codeblock-meta title="keepPrefetchSubscriptionsFor example" + * + * import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + * interface Post { + * id: number + * name: string + * } + * type PostsResponse = Post[] + * + * const api = createApi({ + * baseQuery: fetchBaseQuery({ baseUrl: '/' }), + * keepPrefetchSubscriptionsFor: 5, + * endpoints: (build) => ({ + * getPosts: build.query({ + * query: () => 'posts', + * // highlight-start + * // highlight-end + * }) + * }) + * }) + * ``` + */ + keepPrefetchSubscriptionsFor?: number /** * Defaults to `false`. This setting allows you to control whether if a cached result is already available RTK Query will only serve a cached result, or if it should `refetch` when set to `true` or if an adequate amount of time has passed since the last successful query result. * - `false` - Will not cause a query to be performed _unless_ it does not exist yet. @@ -240,6 +270,7 @@ export function buildCreateApi, ...Module[]]>( reducerPath: 'api', serializeQueryArgs: defaultSerializeQueryArgs, keepUnusedDataFor: 60, + keepPrefetchSubscriptionsFor: 10, refetchOnMountOrArgChange: false, refetchOnFocus: false, refetchOnReconnect: false, diff --git a/packages/toolkit/src/query/tests/buildSlice.test.ts b/packages/toolkit/src/query/tests/buildSlice.test.ts index 6be8ccc6fb..fc012c3d40 100644 --- a/packages/toolkit/src/query/tests/buildSlice.test.ts +++ b/packages/toolkit/src/query/tests/buildSlice.test.ts @@ -42,6 +42,7 @@ it('only resets the api state when resetApiState is dispatched', async () => { config: { focused: true, keepUnusedDataFor: 60, + keepPrefetchSubscriptionsFor: 10, middlewareRegistered: true, online: true, reducerPath: 'api', diff --git a/packages/toolkit/src/query/tests/prefetch.test.tsx b/packages/toolkit/src/query/tests/prefetch.test.tsx new file mode 100644 index 0000000000..1d877830fe --- /dev/null +++ b/packages/toolkit/src/query/tests/prefetch.test.tsx @@ -0,0 +1,151 @@ +import { createApi } from '@reduxjs/toolkit/query/react' +import { setupApiStore, waitMs } from './helpers' + +beforeAll(() => { + jest.useFakeTimers() +}) + +const mockBaseQuery = jest + .fn() + .mockImplementation((args: any) => Promise.resolve({ data: args })) + +describe('Query prefetches', () => { + const apiKeepPrefetchSubscriptionsFor = 5 + const apiKeepUnusedDataFor = 5 + const queryKey = 'query("dada")' + const api = createApi({ + baseQuery: mockBaseQuery, + keepPrefetchSubscriptionsFor: apiKeepPrefetchSubscriptionsFor, + keepUnusedDataFor: apiKeepUnusedDataFor, + endpoints: (build) => ({ + query: build.query({ + query: () => '/success', + }), + }), + }) + + const { store } = setupApiStore(api) + + const getSubscriptionsKeysOf = ( + store: ReturnType['store'], + key: string + ) => { + return Object.keys(store.getState().api.subscriptions[key] ?? {}) + } + + test('prefetch subscription gets removed after options.keepSubscriptionFor', async () => { + store.dispatch( + api.util.prefetch('query', 'dada', { + force: true, + keepSubscriptionFor: 1, + }) + ) + + await api.util.getRunningOperationPromises() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(1) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'pending' + ) + + jest.advanceTimersByTime(1_000) + await waitMs() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(0) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'fulfilled' + ) + + jest.advanceTimersByTime(apiKeepUnusedDataFor * 1000) + await waitMs() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(0) + expect(store.getState().api.queries[queryKey]).not.toBeDefined() + }) + + test('prefetch subscription gets removed after api.config if options.keepSubscriptionFor is not provided', async () => { + store.dispatch(api.util.prefetch('query', 'dada', { force: true })) + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(1) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'pending' + ) + + jest.advanceTimersByTime(apiKeepPrefetchSubscriptionsFor * 1000) + await waitMs() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(0) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'fulfilled' + ) + }) + + test('multiple prefetch invocations create at most a single subscription', async () => { + const prefetchOptions = { force: true, keepSubscriptionFor: 10 } + + store.dispatch(api.util.prefetch('query', 'dada', prefetchOptions)) + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(1) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'pending' + ) + + jest.advanceTimersByTime(1000) + await waitMs() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(1) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'fulfilled' + ) + + store.dispatch(api.util.prefetch('query', 'dada', prefetchOptions)) + + jest.advanceTimersByTime(1000) + await waitMs() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(1) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'fulfilled' + ) + }) + + test('prefetch invocations reset the subscription timeout', async () => { + const prefetchOptions = { force: true, keepSubscriptionFor: 10 } + + store.dispatch(api.util.prefetch('query', 'dada', prefetchOptions)) + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(1) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'pending' + ) + + jest.advanceTimersByTime(9000) + await waitMs() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(1) + expect(store.getState().api.queries[queryKey]).toHaveProperty( + 'status', + 'fulfilled' + ) + + store.dispatch(api.util.prefetch('query', 'dada', prefetchOptions)) + + jest.advanceTimersByTime(3000) + await waitMs() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(1) + + jest.advanceTimersByTime(9000) + await waitMs() + + expect(getSubscriptionsKeysOf(store, queryKey)).toHaveLength(0) + }) +})