From 43417d7123cc1c95ca6605c128b9258784436670 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 3 Aug 2025 12:40:04 -0400 Subject: [PATCH 1/4] Fix broken extractRehydrationInfo --- packages/toolkit/src/query/core/buildSlice.ts | 18 +++++++------ .../src/query/tests/buildSlice.test.ts | 27 ++++++++++++++++++- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/toolkit/src/query/core/buildSlice.ts b/packages/toolkit/src/query/core/buildSlice.ts index b0f09693ef..d9f720c33a 100644 --- a/packages/toolkit/src/query/core/buildSlice.ts +++ b/packages/toolkit/src/query/core/buildSlice.ts @@ -519,13 +519,12 @@ export function buildSlice({ providedTags as FullTagDescription[] } }, - prepare: - prepareAutoBatched< - Array<{ - queryCacheKey: QueryCacheKey - providedTags: readonly FullTagDescription[] - }> - >(), + prepare: prepareAutoBatched< + Array<{ + queryCacheKey: QueryCacheKey + providedTags: readonly FullTagDescription[] + }> + >(), }, }, extraReducers(builder) { @@ -538,7 +537,9 @@ export function buildSlice({ ) .addMatcher(hasRehydrationInfo, (draft, action) => { const { provided } = extractRehydrationInfo(action)! - for (const [type, incomingTags] of Object.entries(provided)) { + for (const [type, incomingTags] of Object.entries( + provided.tags ?? {}, + )) { for (const [id, cacheKeys] of Object.entries(incomingTags)) { const subscribedQueries = ((draft.tags[type] ??= {})[ id || '__internal_without_id' @@ -549,6 +550,7 @@ export function buildSlice({ if (!alreadySubscribed) { subscribedQueries.push(queryCacheKey) } + draft.keys[queryCacheKey] = provided.keys[queryCacheKey] } } } diff --git a/packages/toolkit/src/query/tests/buildSlice.test.ts b/packages/toolkit/src/query/tests/buildSlice.test.ts index 72ac45b7a2..25456d159c 100644 --- a/packages/toolkit/src/query/tests/buildSlice.test.ts +++ b/packages/toolkit/src/query/tests/buildSlice.test.ts @@ -1,10 +1,15 @@ -import { createSlice } from '@reduxjs/toolkit' +import { createSlice, createAction } from '@reduxjs/toolkit' +import type { CombinedState } from '@reduxjs/toolkit/query' import { createApi } from '@reduxjs/toolkit/query' import { delay } from 'msw' import { setupApiStore } from '../../tests/utils/helpers' let shouldApiResponseSuccess = true +const rehydrateAction = createAction<{ api: CombinedState }>( + 'persist/REHYDRATE', +) + const baseQuery = (args?: any) => ({ data: args }) const api = createApi({ baseQuery, @@ -17,6 +22,12 @@ const api = createApi({ providesTags: (result) => (result?.success ? ['SUCCEED'] : ['FAILED']), }), }), + extractRehydrationInfo(action, { reducerPath }) { + if (rehydrateAction.match(action)) { + return action.payload?.[reducerPath] + } + return undefined + }, }) const { getUser } = api.endpoints @@ -114,6 +125,20 @@ describe('buildSlice', () => { api.util.selectInvalidatedBy(storeRef.store.getState(), ['FAILED']), ).toHaveLength(1) }) + + it('handles extractRehydrationInfo correctly', async () => { + await storeRef.store.dispatch(getUser.initiate(1)) + await storeRef.store.dispatch(getUser.initiate(2)) + + const stateWithUser = storeRef.store.getState() + + storeRef.store.dispatch(api.util.resetApiState()) + + storeRef.store.dispatch(rehydrateAction({ api: stateWithUser.api })) + + const rehydratedState = storeRef.store.getState() + expect(rehydratedState).toEqual(stateWithUser) + }) }) describe('`merge` callback', () => { From 9e0465bcaed5477fb204c88ba63accbe3af4749d Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 3 Aug 2025 12:42:49 -0400 Subject: [PATCH 2/4] Loosen infinite query default page fields to boolean --- packages/toolkit/src/query/react/buildHooks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index 702652df2a..37b081454d 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -1234,10 +1234,10 @@ type UseInfiniteQueryStateBaseResult< * Query is currently in "error" state. */ isError: false - hasNextPage: false - hasPreviousPage: false - isFetchingNextPage: false - isFetchingPreviousPage: false + hasNextPage: boolean + hasPreviousPage: boolean + isFetchingNextPage: boolean + isFetchingPreviousPage: boolean } type UseInfiniteQueryStateDefaultResult< From 90015e645460066a600738331cecfdc74b052023 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 26 Aug 2025 13:10:37 -0400 Subject: [PATCH 3/4] Test both `skip` and `skipToken` for infinite queries --- .../src/query/tests/buildHooks.test.tsx | 73 +++++++++++-------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index c371e5efaa..36e3b9d944 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -2282,40 +2282,51 @@ describe('hooks tests', () => { expect(numRequests).toBe(1) }) - test('useInfiniteQuery hook does not fetch when the skip token is set', async () => { - function Pokemon() { - const [value, setValue] = useState(0) - - const { isFetching } = pokemonApi.useGetInfinitePokemonInfiniteQuery( - 'fire', - { - skip: value < 1, - }, - ) - getRenderCount = useRenderCounter() + test.each([ + ['skip token', true], + ['skip option', false], + ])( + 'useInfiniteQuery hook does not fetch when skipped via %s', + async (_, useSkipToken) => { + function Pokemon() { + const [value, setValue] = useState(0) + + const shouldFetch = value > 0 + + const arg = shouldFetch || !useSkipToken ? 'fire' : skipToken + const skip = useSkipToken ? undefined : shouldFetch ? undefined : true + + const { isFetching } = pokemonApi.useGetInfinitePokemonInfiniteQuery( + arg, + { + skip, + }, + ) + getRenderCount = useRenderCounter() - return ( -
-
{String(isFetching)}
- -
- ) - } + return ( +
+
{String(isFetching)}
+ +
+ ) + } - render(, { wrapper: storeRef.wrapper }) - expect(getRenderCount()).toBe(1) + render(, { wrapper: storeRef.wrapper }) + expect(getRenderCount()).toBe(1) - await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('false'), - ) - fireEvent.click(screen.getByText('Increment value')) - await waitFor(() => - expect(screen.getByTestId('isFetching').textContent).toBe('true'), - ) - expect(getRenderCount()).toBe(2) - }) + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('false'), + ) + fireEvent.click(screen.getByText('Increment value')) + await waitFor(() => + expect(screen.getByTestId('isFetching').textContent).toBe('true'), + ) + expect(getRenderCount()).toBe(2) + }, + ) }) describe('useMutation', () => { From 38bff5ae66dd33cd653bc2039c2d57ab96f4ba11 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 26 Aug 2025 14:05:41 -0400 Subject: [PATCH 4/4] Add tests to verify cross-store promise behavior --- .../src/query/tests/buildInitiate.test.tsx | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/packages/toolkit/src/query/tests/buildInitiate.test.tsx b/packages/toolkit/src/query/tests/buildInitiate.test.tsx index 08be5a3558..bbced70b49 100644 --- a/packages/toolkit/src/query/tests/buildInitiate.test.tsx +++ b/packages/toolkit/src/query/tests/buildInitiate.test.tsx @@ -173,3 +173,130 @@ describe('calling initiate should have resulting queryCacheKey match baseQuery q ) }) }) + +describe('getRunningQueryThunk with multiple stores', () => { + test('should isolate running queries between different store instances using the same API', async () => { + // Create a shared API instance + const sharedApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + testQuery: build.query({ + async queryFn(arg) { + // Add delay to ensure queries are running when we check + await new Promise((resolve) => setTimeout(resolve, 50)) + return { data: `result-${arg}` } + }, + }), + }), + }) + + // Create two separate stores using the same API instance + const store1 = setupApiStore(sharedApi, undefined, { + withoutTestLifecycles: true, + }).store + const store2 = setupApiStore(sharedApi, undefined, { + withoutTestLifecycles: true, + }).store + + // Start queries on both stores + const query1Promise = store1.dispatch( + sharedApi.endpoints.testQuery.initiate('arg1'), + ) + const query2Promise = store2.dispatch( + sharedApi.endpoints.testQuery.initiate('arg2'), + ) + + // Verify that getRunningQueryThunk returns the correct query for each store + const runningQuery1 = store1.dispatch( + sharedApi.util.getRunningQueryThunk('testQuery', 'arg1'), + ) + const runningQuery2 = store2.dispatch( + sharedApi.util.getRunningQueryThunk('testQuery', 'arg2'), + ) + + // Each store should only see its own running query + expect(runningQuery1).toBeDefined() + expect(runningQuery2).toBeDefined() + expect(runningQuery1?.requestId).toBe(query1Promise.requestId) + expect(runningQuery2?.requestId).toBe(query2Promise.requestId) + + // Cross-store queries should not be visible + const crossQuery1 = store1.dispatch( + sharedApi.util.getRunningQueryThunk('testQuery', 'arg2'), + ) + const crossQuery2 = store2.dispatch( + sharedApi.util.getRunningQueryThunk('testQuery', 'arg1'), + ) + + expect(crossQuery1).toBeUndefined() + expect(crossQuery2).toBeUndefined() + + // Wait for queries to complete + await Promise.all([query1Promise, query2Promise]) + + // After completion, getRunningQueryThunk should return undefined for both stores + const completedQuery1 = store1.dispatch( + sharedApi.util.getRunningQueryThunk('testQuery', 'arg1'), + ) + const completedQuery2 = store2.dispatch( + sharedApi.util.getRunningQueryThunk('testQuery', 'arg2'), + ) + + expect(completedQuery1).toBeUndefined() + expect(completedQuery2).toBeUndefined() + }) + + test('should handle same query args on different stores independently', async () => { + // Create a shared API instance + const sharedApi = createApi({ + baseQuery: fakeBaseQuery(), + endpoints: (build) => ({ + sameArgQuery: build.query({ + async queryFn(arg) { + await new Promise((resolve) => setTimeout(resolve, 50)) + return { data: `result-${arg}-${Math.random()}` } + }, + }), + }), + }) + + // Create two separate stores + const store1 = setupApiStore(sharedApi, undefined, { + withoutTestLifecycles: true, + }).store + const store2 = setupApiStore(sharedApi, undefined, { + withoutTestLifecycles: true, + }).store + + // Start the same query on both stores + const sameArg = 'shared-arg' + const query1Promise = store1.dispatch( + sharedApi.endpoints.sameArgQuery.initiate(sameArg), + ) + const query2Promise = store2.dispatch( + sharedApi.endpoints.sameArgQuery.initiate(sameArg), + ) + + // Both stores should see their own running query with the same cache key + const runningQuery1 = store1.dispatch( + sharedApi.util.getRunningQueryThunk('sameArgQuery', sameArg), + ) + const runningQuery2 = store2.dispatch( + sharedApi.util.getRunningQueryThunk('sameArgQuery', sameArg), + ) + + expect(runningQuery1).toBeDefined() + expect(runningQuery2).toBeDefined() + expect(runningQuery1?.requestId).toBe(query1Promise.requestId) + expect(runningQuery2?.requestId).toBe(query2Promise.requestId) + + // The request IDs should be different even though the cache key is the same + expect(runningQuery1?.requestId).not.toBe(runningQuery2?.requestId) + + // But the cache keys should be the same + expect(runningQuery1?.queryCacheKey).toBe(runningQuery2?.queryCacheKey) + + // Wait for completion + await Promise.all([query1Promise, query2Promise]) + }) +})