From 03513d1dddf4a506973e2fed77684751d81bd09e Mon Sep 17 00:00:00 2001 From: Ross Martin <2498502+rossmartin@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:44:42 -0500 Subject: [PATCH 1/4] Add refetchCachedPages option for infinite query --- packages/toolkit/src/query/core/apiState.ts | 7 +++++ .../toolkit/src/query/core/buildThunks.ts | 29 ++++++++++--------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts index 3a22a542cc..6f21d5dc12 100644 --- a/packages/toolkit/src/query/core/apiState.ts +++ b/packages/toolkit/src/query/core/apiState.ts @@ -58,6 +58,13 @@ export type InfiniteQueryConfigOptions = { * direction will be dropped from the cache. */ maxPages?: number + /** + * Defaults to `true`. When this is `true` and an infinite query endpoint is refetched + * (due to tag invalidation, polling, arg change configuration, or manual refetching), + * RTK Query will try to sequentially refetch all pages currently in the cache. + * When `false` only the first page will be refetched. + */ + refetchCachedPages?: boolean } export type InfiniteData = { diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index c5f6df14d7..8fdc78f6e9 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -681,7 +681,8 @@ export function buildThunks< const { infiniteQueryOptions } = endpointDefinition // Runtime checks should guarantee this is a positive number if provided - const { maxPages = Infinity } = infiniteQueryOptions + const { maxPages = Infinity, refetchCachedPages = true } = + infiniteQueryOptions let result: QueryReturnValue @@ -740,18 +741,20 @@ export function buildThunks< } as QueryReturnValue } - // Fetch remaining pages - for (let i = 1; i < totalPages; i++) { - const param = getNextPageParam( - infiniteQueryOptions, - result.data as InfiniteData, - arg.originalArgs, - ) - result = await fetchPage( - result.data as InfiniteData, - param, - maxPages, - ) + if (refetchCachedPages) { + // Fetch remaining pages + for (let i = 1; i < totalPages; i++) { + const param = getNextPageParam( + infiniteQueryOptions, + result.data as InfiniteData, + arg.originalArgs, + ) + result = await fetchPage( + result.data as InfiniteData, + param, + maxPages, + ) + } } } From 4b66a1fdc40038f386b533815c5189110e6b46c7 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 23 Nov 2025 16:30:22 -0500 Subject: [PATCH 2/4] Add refetchCachedPages as option to thunks --- .../toolkit/src/query/core/buildInitiate.ts | 33 +- .../toolkit/src/query/core/buildThunks.ts | 10 +- .../src/query/tests/infiniteQueries.test.ts | 799 ++++++++++++++++++ 3 files changed, 831 insertions(+), 11 deletions(-) diff --git a/packages/toolkit/src/query/core/buildInitiate.ts b/packages/toolkit/src/query/core/buildInitiate.ts index bf61a38e9c..ec71f0d387 100644 --- a/packages/toolkit/src/query/core/buildInitiate.ts +++ b/packages/toolkit/src/query/core/buildInitiate.ts @@ -74,6 +74,10 @@ export type StartQueryActionCreatorOptions = { [forceQueryFnSymbol]?: () => QueryReturnValue } +type RefetchOptions = { + refetchCachedPages?: boolean +} + export type StartInfiniteQueryActionCreatorOptions< D extends InfiniteQueryDefinition, > = StartQueryActionCreatorOptions & { @@ -88,7 +92,7 @@ export type StartInfiniteQueryActionCreatorOptions< InfiniteQueryArgFrom > >, - 'initialPageParam' + 'initialPageParam' | 'refetchCachedPages' > > @@ -124,7 +128,7 @@ type AnyActionCreatorResult = SafePromise & QueryActionCreatorFields & { arg: any unwrap(): Promise - refetch(): AnyActionCreatorResult + refetch(options?: RefetchOptions): AnyActionCreatorResult } export type QueryActionCreatorResult< @@ -142,7 +146,12 @@ export type InfiniteQueryActionCreatorResult< QueryActionCreatorFields & { arg: InfiniteQueryArgFrom unwrap(): Promise, PageParamFrom>> - refetch(): InfiniteQueryActionCreatorResult + refetch( + options?: Pick< + StartInfiniteQueryActionCreatorOptions, + 'refetchCachedPages' + >, + ): InfiniteQueryActionCreatorResult } type StartMutationActionCreator< @@ -407,16 +416,18 @@ You must add the middleware for RTK-Query to function correctly!`, if (isQueryDefinition(endpointDefinition)) { thunk = queryThunk(commonThunkArgs) } else { - const { direction, initialPageParam } = rest as Pick< - InfiniteQueryThunkArg, - 'direction' | 'initialPageParam' - > + const { direction, initialPageParam, refetchCachedPages } = + rest as Pick< + InfiniteQueryThunkArg, + 'direction' | 'initialPageParam' | 'refetchCachedPages' + > thunk = infiniteQueryThunk({ ...(commonThunkArgs as InfiniteQueryThunkArg), // Supply these even if undefined. This helps with a field existence // check over in `buildSlice.ts` direction, initialPageParam, + refetchCachedPages, }) } @@ -465,9 +476,13 @@ You must add the middleware for RTK-Query to function correctly!`, return result.data }, - refetch: () => + refetch: (options?: RefetchOptions) => dispatch( - queryAction(arg, { subscribe: false, forceRefetch: true }), + queryAction(arg, { + subscribe: false, + forceRefetch: true, + ...options, + }), ), unsubscribe() { if (subscribe) diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index 8fdc78f6e9..0018073d16 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -163,6 +163,7 @@ export type InfiniteQueryThunkArg< endpointName: string param: unknown direction?: InfiniteQueryDirection + refetchCachedPages?: boolean } type MutationThunkArg = { @@ -681,8 +682,13 @@ export function buildThunks< const { infiniteQueryOptions } = endpointDefinition // Runtime checks should guarantee this is a positive number if provided - const { maxPages = Infinity, refetchCachedPages = true } = - infiniteQueryOptions + const { maxPages = Infinity } = infiniteQueryOptions + + // Priority: per-call override > endpoint config > default (true) + const refetchCachedPages = + (arg as InfiniteQueryThunkArg).refetchCachedPages ?? + infiniteQueryOptions.refetchCachedPages ?? + true let result: QueryReturnValue diff --git a/packages/toolkit/src/query/tests/infiniteQueries.test.ts b/packages/toolkit/src/query/tests/infiniteQueries.test.ts index da5e10ae70..6c0864e5e9 100644 --- a/packages/toolkit/src/query/tests/infiniteQueries.test.ts +++ b/packages/toolkit/src/query/tests/infiniteQueries.test.ts @@ -1012,4 +1012,803 @@ describe('Infinite queries', () => { { items: [{ id: '1', name: 'Pokemon 1' }], page: 1 }, ]) }) + + describe('refetchCachedPages option', () => { + test('refetches all pages by default (refetchCachedPages: true)', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + tagTypes: ['Counter'], + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + }, + providesTags: ['Counter'], + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + const thirdPromise = storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + const thirdRes = await thirdPromise + + // Should have 3 pages with hitCounters 1, 2, 3 + expect(thirdRes.data!.pages).toEqual([ + { page: 0, hitCounter: 1 }, + { page: 1, hitCounter: 2 }, + { page: 2, hitCounter: 3 }, + ]) + + // Refetch without specifying refetchCachedPages + const refetchRes = await thirdPromise.refetch() + + // All 3 pages should be refetched (hitCounters 4, 5, 6) + expect(refetchRes.data!.pages).toEqual([ + { page: 0, hitCounter: 4 }, + { page: 1, hitCounter: 5 }, + { page: 2, hitCounter: 6 }, + ]) + expect(refetchRes.data!.pages).toHaveLength(3) + }) + + test('refetches all pages when refetchCachedPages is explicitly true', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + tagTypes: ['Counter'], + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + refetchCachedPages: true, // Explicit true + }, + providesTags: ['Counter'], + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + const thirdPromise = storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + const thirdRes = await thirdPromise + + expect(thirdRes.data!.pages).toHaveLength(3) + + // Refetch + const refetchRes = await thirdPromise.refetch() + + // All 3 pages should be refetched + expect(refetchRes.data!.pages).toHaveLength(3) + expect(refetchRes.data!.pages[0].hitCounter).toBeGreaterThan( + thirdRes.data!.pages[0].hitCounter, + ) + }) + + test('refetches only first page when refetchCachedPages is false', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + tagTypes: ['Counter'], + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + refetchCachedPages: false, // Only refetch first page + }, + providesTags: ['Counter'], + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + const thirdPromise = storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + const thirdRes = await thirdPromise + + // Should have 3 pages with hitCounters 1, 2, 3 + expect(thirdRes.data!.pages).toEqual([ + { page: 0, hitCounter: 1 }, + { page: 1, hitCounter: 2 }, + { page: 2, hitCounter: 3 }, + ]) + + // Refetch with refetchCachedPages: false + const refetchRes = await thirdPromise.refetch() + + // Only first page should be refetched, cache reset to 1 page + expect(refetchRes.data!.pages).toEqual([{ page: 0, hitCounter: 4 }]) + expect(refetchRes.data!.pageParams).toEqual([0]) + }) + + test('refetches only first page on tag invalidation when refetchCachedPages is false', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + tagTypes: ['Counter'], + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + refetchCachedPages: false, + }, + providesTags: ['Counter'], + }), + mutation: build.mutation({ + queryFn: async () => ({ data: null }), + invalidatesTags: ['Counter'], + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + // Verify we have 3 pages + let entry = countersApi.endpoints.counters.select('item')( + storeRef.store.getState(), + ) + expect(entry.data?.pages).toHaveLength(3) + + // Trigger mutation to invalidate tags + await storeRef.store.dispatch(countersApi.endpoints.mutation.initiate()) + + // Wait for refetch to complete + const promise = storeRef.store.dispatch( + countersApi.util.getRunningQueryThunk('counters', 'item'), + ) + const finalRes = await promise + + // Only first page should be refetched + expect((finalRes as any).data.pages).toEqual([{ page: 0, hitCounter: 4 }]) + }) + + test('refetches only first page during polling when refetchCachedPages is false', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + refetchCachedPages: false, + }, + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + const thirdPromise = storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + await thirdPromise + + // Enable polling + thirdPromise.updateSubscriptionOptions({ + pollingInterval: 50, + }) + + // Wait for first poll + await delay(75) + + const entry = countersApi.endpoints.counters.select('item')( + storeRef.store.getState(), + ) + + // Should only have 1 page after poll + expect(entry.data?.pages).toEqual([{ page: 0, hitCounter: 4 }]) + }) + + test('refetchCachedPages: false works with maxPages', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + maxPages: 3, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + getPreviousPageParam: ( + firstPage, + allPages, + firstPageParam, + allPageParams, + ) => (firstPageParam > 0 ? firstPageParam - 1 : undefined), + refetchCachedPages: false, + }, + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 5 pages (but maxPages will limit to 3) + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + for (let i = 0; i < 4; i++) { + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + } + + let entry = countersApi.endpoints.counters.select('item')( + storeRef.store.getState(), + ) + + // Should have 3 pages due to maxPages + expect(entry.data?.pages).toHaveLength(3) + + // Refetch + const refetchPromise = storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + forceRefetch: true, + }), + ) + + const refetchRes = await refetchPromise + + // Should only have 1 page after refetch (refetchCachedPages: false) + // Note: With maxPages: 3, the cache kept pages 2, 3, 4 + // So refetch starts from the first cached page param, which is 2 + expect(refetchRes.data!.pages).toHaveLength(1) + expect(refetchRes.data!.pages[0].page).toBe(2) + }) + + test('can fetch next page after refetch with refetchCachedPages: false', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + refetchCachedPages: false, + }, + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + const thirdPromise = storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + await thirdPromise + + // Refetch (resets to 1 page) + await thirdPromise.refetch() + + let entry = countersApi.endpoints.counters.select('item')( + storeRef.store.getState(), + ) + expect(entry.data?.pages).toHaveLength(1) + + // Fetch next page + const nextPageRes = await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + // Should now have 2 pages + expect(nextPageRes.data!.pages).toEqual([ + { page: 0, hitCounter: 4 }, + { page: 1, hitCounter: 5 }, + ]) + }) + + describe('per-call refetchCachedPages override', () => { + test('per-call false overrides endpoint true', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + refetchCachedPages: true, // Endpoint default: refetch all + }, + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + // Should have 3 pages with hitCounters 1, 2, 3 + let entry = countersApi.endpoints.counters.select('item')( + storeRef.store.getState(), + ) + expect(entry.data?.pages).toEqual([ + { page: 0, hitCounter: 1 }, + { page: 1, hitCounter: 2 }, + { page: 2, hitCounter: 3 }, + ]) + + // Refetch with per-call override: false + const refetchRes = await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + forceRefetch: true, + refetchCachedPages: false, // Override to false + }), + ) + + // Only first page should be refetched (hitCounter 4) + expect(refetchRes.data!.pages).toEqual([{ page: 0, hitCounter: 4 }]) + expect(refetchRes.data!.pageParams).toEqual([0]) + }) + + test('per-call true overrides endpoint false', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + refetchCachedPages: false, // Endpoint default: only first page + }, + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + // Should have 3 pages + let entry = countersApi.endpoints.counters.select('item')( + storeRef.store.getState(), + ) + expect(entry.data?.pages).toHaveLength(3) + + // Refetch with per-call override: true + const refetchRes = await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + forceRefetch: true, + refetchCachedPages: true, // Override to true + }), + ) + + // All 3 pages should be refetched + expect(refetchRes.data!.pages).toEqual([ + { page: 0, hitCounter: 4 }, + { page: 1, hitCounter: 5 }, + { page: 2, hitCounter: 6 }, + ]) + expect(refetchRes.data!.pages).toHaveLength(3) + }) + + test('uses endpoint config when no per-call override', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + refetchCachedPages: false, // Endpoint config + }, + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + // Refetch without per-call override + const refetchRes = await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + forceRefetch: true, + // No refetchCachedPages specified + }), + ) + + // Should use endpoint config (false) - only first page + expect(refetchRes.data!.pages).toEqual([{ page: 0, hitCounter: 4 }]) + }) + + test('defaults to true when no config at any level', async () => { + let hitCounter = 0 + + const countersApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + counters: build.infiniteQuery({ + queryFn({ pageParam }) { + hitCounter++ + return { data: { page: pageParam, hitCounter } } + }, + infiniteQueryOptions: { + initialPageParam: 0, + getNextPageParam: ( + lastPage, + allPages, + lastPageParam, + allPageParams, + ) => lastPageParam + 1, + // No refetchCachedPages specified + }, + }), + }), + }) + + const storeRef = setupApiStore( + countersApi, + { ...actionsReducer }, + { + withoutTestLifecycles: true, + }, + ) + + // Load 3 pages + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + initialPageParam: 0, + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + direction: 'forward', + }), + ) + + // Refetch without any config + const refetchRes = await storeRef.store.dispatch( + countersApi.endpoints.counters.initiate('item', { + forceRefetch: true, + }), + ) + + // Should default to true - refetch all pages + expect(refetchRes.data!.pages).toEqual([ + { page: 0, hitCounter: 4 }, + { page: 1, hitCounter: 5 }, + { page: 2, hitCounter: 6 }, + ]) + }) + }) + }) }) From aa64f8e36a5e7900db0c073eaaf9ee3bf1c4eb1e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 23 Nov 2025 17:15:10 -0500 Subject: [PATCH 3/4] Add refetchCachedPages as hook and refetch option --- .../toolkit/src/query/react/buildHooks.ts | 53 ++++++- .../src/query/tests/buildHooks.test.tsx | 132 ++++++++++++++++++ 2 files changed, 182 insertions(+), 3 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 3289bf82c3..7abf2f9a40 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -870,6 +870,16 @@ export type UseInfiniteQuerySubscriptionOptions< */ refetchOnMountOrArgChange?: boolean | number initialPageParam?: PageParamFrom + /** + * Defaults to `true`. When this is `true` and an infinite query endpoint is refetched + * (due to tag invalidation, polling, arg change configuration, or manual refetching), + * RTK Query will try to sequentially refetch all pages currently in the cache. + * When `false` only the first page will be refetched. + * + * This option applies to all automatic refetches for this subscription (polling, tag invalidation, etc.). + * It can be overridden on a per-call basis using the `refetch()` method. + */ + refetchCachedPages?: boolean } export type TypedUseInfiniteQuerySubscription< @@ -890,7 +900,13 @@ export type TypedUseInfiniteQuerySubscription< export type UseInfiniteQuerySubscriptionResult< D extends InfiniteQueryDefinition, -> = Pick, 'refetch'> & { +> = { + refetch: ( + options?: Pick< + UseInfiniteQuerySubscriptionOptions, + 'refetchCachedPages' + >, + ) => InfiniteQueryActionCreatorResult trigger: LazyInfiniteQueryTrigger fetchNextPage: () => InfiniteQueryActionCreatorResult fetchPreviousPage: () => InfiniteQueryActionCreatorResult @@ -1682,6 +1698,11 @@ export function buildHooks({ .initialPageParam const stableInitialPageParam = useShallowStableValue(initialPageParam) + const refetchCachedPages = ( + rest as UseInfiniteQuerySubscriptionOptions + ).refetchCachedPages + const stableRefetchCachedPages = useShallowStableValue(refetchCachedPages) + /** * @todo Change this to `useRef>(undefined)` after upgrading to React 19. */ @@ -1736,6 +1757,7 @@ export function buildHooks({ ...(isInfiniteQueryDefinition(endpointDefinitions[endpointName]) ? { initialPageParam: stableInitialPageParam, + refetchCachedPages: stableRefetchCachedPages, } : {}), }), @@ -1753,6 +1775,7 @@ export function buildHooks({ stableSubscriptionOptions, subscriptionRemoved, stableInitialPageParam, + stableRefetchCachedPages, endpointName, ]) @@ -2040,6 +2063,14 @@ export function buildHooks({ subscriptionOptionsRef.current = stableSubscriptionOptions }, [stableSubscriptionOptions]) + // Extract and stabilize the hook-level refetchCachedPages option + const hookRefetchCachedPages = ( + options as UseInfiniteQuerySubscriptionOptions + ).refetchCachedPages + const stableHookRefetchCachedPages = useShallowStableValue( + hookRefetchCachedPages, + ) + const trigger: LazyInfiniteQueryTrigger = useCallback( function (arg: unknown, direction: 'forward' | 'backward') { let promise: InfiniteQueryActionCreatorResult @@ -2065,8 +2096,24 @@ export function buildHooks({ const stableArg = useStableQueryArgs(options.skip ? skipToken : arg) const refetch = useCallback( - () => refetchOrErrorIfUnmounted(promiseRef), - [promiseRef], + ( + options?: Pick< + UseInfiniteQuerySubscriptionOptions, + 'refetchCachedPages' + >, + ) => { + if (!promiseRef.current) + throw new Error( + 'Cannot refetch a query that has not been started yet.', + ) + // Merge per-call options with hook-level default + const mergedOptions = { + refetchCachedPages: + options?.refetchCachedPages ?? stableHookRefetchCachedPages, + } + return promiseRef.current.refetch(mergedOptions) + }, + [promiseRef, stableHookRefetchCachedPages], ) return useMemo(() => { diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index 31429ed66b..f97951756b 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -2480,6 +2480,138 @@ describe('hooks tests', () => { expect(getRenderCount()).toBe(2) }, ) + + test('useInfiniteQuery hook option refetchCachedPages: false only refetches first page', async () => { + const storeRef = setupApiStore(pokemonApi, undefined, { + withoutTestLifecycles: true, + }) + + function PokemonList() { + const { data, fetchNextPage, refetch } = + pokemonApi.useGetInfinitePokemonInfiniteQuery('fire', { + refetchCachedPages: false, + }) + + return ( +
+
+ {data?.pages.map((page, i) => ( +
+ {page.name} +
+ ))} +
+ + +
+ ) + } + + render(, { wrapper: storeRef.wrapper }) + + // Wait for initial page to load + await waitFor(() => { + expect(screen.getByTestId('page-0').textContent).toBe('Pokemon 0') + }) + + // Fetch second page + fireEvent.click(screen.getByTestId('nextPage')) + await waitFor(() => { + expect(screen.getByTestId('page-1').textContent).toBe('Pokemon 1') + }) + + // Fetch third page + fireEvent.click(screen.getByTestId('nextPage')) + await waitFor(() => { + expect(screen.getByTestId('page-2').textContent).toBe('Pokemon 2') + }) + + // Now we have 3 pages. Refetch with refetchCachedPages: false should only refetch page 0 + fireEvent.click(screen.getByTestId('refetch')) + + await waitFor( + () => { + // Should only have 1 page + expect(screen.queryByTestId('page-0')).toBeTruthy() + expect(screen.queryByTestId('page-1')).toBeNull() + expect(screen.queryByTestId('page-2')).toBeNull() + }, + { timeout: 1000 }, + ) + + // Verify we only have 1 page (not refetched all) + const pages = screen.getAllByTestId(/^page-/) + expect(pages).toHaveLength(1) + }) + + test('useInfiniteQuery refetch() method option refetchCachedPages: false only refetches first page', async () => { + const storeRef = setupApiStore(pokemonApi, undefined, { + withoutTestLifecycles: true, + }) + + function PokemonList() { + const { data, fetchNextPage, refetch } = + pokemonApi.useGetInfinitePokemonInfiniteQuery('fire') + + return ( +
+
+ {data?.pages.map((page, i) => ( +
+ {page.name} +
+ ))} +
+ + +
+ ) + } + + render(, { wrapper: storeRef.wrapper }) + + // Wait for initial page to load + await waitFor(() => { + expect(screen.getByTestId('page-0').textContent).toBe('Pokemon 0') + }) + + // Fetch second page + fireEvent.click(screen.getByTestId('nextPage')) + await waitFor(() => { + expect(screen.getByTestId('page-1').textContent).toBe('Pokemon 1') + }) + + // Fetch third page + fireEvent.click(screen.getByTestId('nextPage')) + await waitFor(() => { + expect(screen.getByTestId('page-2').textContent).toBe('Pokemon 2') + }) + + // Now we have 3 pages. Refetch with refetchCachedPages: false should only refetch page 0 + fireEvent.click(screen.getByTestId('refetch')) + + await waitFor(() => { + // Should only have 1 page + expect(screen.queryByTestId('page-0')).toBeTruthy() + expect(screen.queryByTestId('page-1')).toBeNull() + expect(screen.queryByTestId('page-2')).toBeNull() + }) + + // Verify we only have 1 page (not refetched all) + const pages = screen.getAllByTestId(/^page-/) + expect(pages).toHaveLength(1) + }) }) describe('useMutation', () => { From 81715b235c7fb3ef9034707f8fbd0835c0c22560 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 23 Nov 2025 17:26:37 -0500 Subject: [PATCH 4/4] Document refetchCachedPages --- docs/rtk-query/api/createApi.mdx | 7 +++++++ docs/rtk-query/api/created-api/hooks.mdx | 5 ++++- docs/rtk-query/usage/infinite-queries.mdx | 10 +++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/rtk-query/api/createApi.mdx b/docs/rtk-query/api/createApi.mdx index 81c8c16d20..17dbf54705 100644 --- a/docs/rtk-query/api/createApi.mdx +++ b/docs/rtk-query/api/createApi.mdx @@ -329,6 +329,13 @@ export type InfiniteQueryDefinition< * direction will be dropped from the cache. */ maxPages?: number + /** + * Defaults to `true`. When this is `true` and an infinite query endpoint is refetched + * (due to tag invalidation, polling, arg change configuration, or manual refetching), + * RTK Query will try to sequentially refetch all pages currently in the cache. + * When `false` only the first page will be refetched. + */ + refetchCachedPages?: boolean } } ``` diff --git a/docs/rtk-query/api/created-api/hooks.mdx b/docs/rtk-query/api/created-api/hooks.mdx index 333f91ee7e..55db43d946 100644 --- a/docs/rtk-query/api/created-api/hooks.mdx +++ b/docs/rtk-query/api/created-api/hooks.mdx @@ -463,6 +463,7 @@ type UseInfiniteQueryOptions = { refetchOnMountOrArgChange?: boolean | number selectFromResult?: (result: UseQueryStateDefaultResult) => any initialPageParam?: PageParam + refetchCachedPages?: boolean } type UseInfiniteQueryResult = { @@ -514,7 +515,9 @@ type UseInfiniteQueryResult = { isFetchPreviousPageError: boolean // A function to force refetch the query - returns a Promise with additional methods - refetch: () => InfiniteQueryActionCreatorResult + refetch: (options?: { + refetchCachedPages?: boolean + }) => InfiniteQueryActionCreatorResult // Triggers a fetch for the next page, based on the current cache fetchNextPage: () => InfiniteQueryActionCreatorResult diff --git a/docs/rtk-query/usage/infinite-queries.mdx b/docs/rtk-query/usage/infinite-queries.mdx index 361fac13e1..f85e89560b 100644 --- a/docs/rtk-query/usage/infinite-queries.mdx +++ b/docs/rtk-query/usage/infinite-queries.mdx @@ -277,10 +277,18 @@ The promise returned from `fetchNextPage()` does have [a `promise.abort()` metho ### Refetching -When an infinite query endpoint is refetched (due to tag invalidation, polling, arg change configuration, or manual refetching), RTK Query will try to sequentially refetch all pages currently in the cache. This ensures that the client is always working with the latest data, and avoids stale cursors or duplicate records. +When an infinite query endpoint is refetched (due to tag invalidation, polling, arg change configuration, or manual refetching), RTK Query's default behavior is **sequentially refetching _all_ pages currently in the cache**. This ensures that the client is always working with the latest data, and avoids stale cursors or duplicate records. If the cache entry is ever removed and then re-added, it will start with only fetching the initial page. +There may be cases when you want a refetch to _only_ refetch the first page, and not any of the other pages in cache (ie, the refetch shrinks the cache from N pages to 1 page). This can be done via the `refetchCachedPages` option, which can be passed in several different places: + +- Defined on the endpoint as part of `infiniteQueryOptions`: applies to all attempted refetches +- Passed as an option to a `useInfiniteQuery` hook: applies to all attempted refetches +- Passed as an option to `endpoint.initiate()`, or the `refetch` method available on the `initiate` or hook result objects: applies only to the manually triggered refetch + +Overall, the refetch logic defaults to refetching all pages, but will override that with the option provided to the endpoint or a manual refetch. + ### Limiting Cache Entry Size All fetched pages for a given query arg are stored in the `pages` array in that cache entry. By default, there is no limit to the number of stored pages - if you call `fetchNextPage()` 1000 times, `data.pages` will have 1000 pages stored.