Skip to content

Commit 85a533e

Browse files
authored
Assorted bugfixes for 2.8.3 (#5060)
* Fix broken extractRehydrationInfo * Loosen infinite query default page fields to boolean * Test both `skip` and `skipToken` for infinite queries * Add tests to verify cross-store promise behavior
1 parent d8190e3 commit 85a533e

File tree

5 files changed

+209
-44
lines changed

5 files changed

+209
-44
lines changed

packages/toolkit/src/query/core/buildSlice.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -519,13 +519,12 @@ export function buildSlice({
519519
providedTags as FullTagDescription<string>[]
520520
}
521521
},
522-
prepare:
523-
prepareAutoBatched<
524-
Array<{
525-
queryCacheKey: QueryCacheKey
526-
providedTags: readonly FullTagDescription<string>[]
527-
}>
528-
>(),
522+
prepare: prepareAutoBatched<
523+
Array<{
524+
queryCacheKey: QueryCacheKey
525+
providedTags: readonly FullTagDescription<string>[]
526+
}>
527+
>(),
529528
},
530529
},
531530
extraReducers(builder) {
@@ -538,7 +537,9 @@ export function buildSlice({
538537
)
539538
.addMatcher(hasRehydrationInfo, (draft, action) => {
540539
const { provided } = extractRehydrationInfo(action)!
541-
for (const [type, incomingTags] of Object.entries(provided)) {
540+
for (const [type, incomingTags] of Object.entries(
541+
provided.tags ?? {},
542+
)) {
542543
for (const [id, cacheKeys] of Object.entries(incomingTags)) {
543544
const subscribedQueries = ((draft.tags[type] ??= {})[
544545
id || '__internal_without_id'
@@ -549,6 +550,7 @@ export function buildSlice({
549550
if (!alreadySubscribed) {
550551
subscribedQueries.push(queryCacheKey)
551552
}
553+
draft.keys[queryCacheKey] = provided.keys[queryCacheKey]
552554
}
553555
}
554556
}

packages/toolkit/src/query/react/buildHooks.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,10 +1234,10 @@ type UseInfiniteQueryStateBaseResult<
12341234
* Query is currently in "error" state.
12351235
*/
12361236
isError: false
1237-
hasNextPage: false
1238-
hasPreviousPage: false
1239-
isFetchingNextPage: false
1240-
isFetchingPreviousPage: false
1237+
hasNextPage: boolean
1238+
hasPreviousPage: boolean
1239+
isFetchingNextPage: boolean
1240+
isFetchingPreviousPage: boolean
12411241
}
12421242

12431243
type UseInfiniteQueryStateDefaultResult<

packages/toolkit/src/query/tests/buildHooks.test.tsx

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2282,40 +2282,51 @@ describe('hooks tests', () => {
22822282
expect(numRequests).toBe(1)
22832283
})
22842284

2285-
test('useInfiniteQuery hook does not fetch when the skip token is set', async () => {
2286-
function Pokemon() {
2287-
const [value, setValue] = useState(0)
2288-
2289-
const { isFetching } = pokemonApi.useGetInfinitePokemonInfiniteQuery(
2290-
'fire',
2291-
{
2292-
skip: value < 1,
2293-
},
2294-
)
2295-
getRenderCount = useRenderCounter()
2285+
test.each([
2286+
['skip token', true],
2287+
['skip option', false],
2288+
])(
2289+
'useInfiniteQuery hook does not fetch when skipped via %s',
2290+
async (_, useSkipToken) => {
2291+
function Pokemon() {
2292+
const [value, setValue] = useState(0)
2293+
2294+
const shouldFetch = value > 0
2295+
2296+
const arg = shouldFetch || !useSkipToken ? 'fire' : skipToken
2297+
const skip = useSkipToken ? undefined : shouldFetch ? undefined : true
2298+
2299+
const { isFetching } = pokemonApi.useGetInfinitePokemonInfiniteQuery(
2300+
arg,
2301+
{
2302+
skip,
2303+
},
2304+
)
2305+
getRenderCount = useRenderCounter()
22962306

2297-
return (
2298-
<div>
2299-
<div data-testid="isFetching">{String(isFetching)}</div>
2300-
<button onClick={() => setValue((val) => val + 1)}>
2301-
Increment value
2302-
</button>
2303-
</div>
2304-
)
2305-
}
2307+
return (
2308+
<div>
2309+
<div data-testid="isFetching">{String(isFetching)}</div>
2310+
<button onClick={() => setValue((val) => val + 1)}>
2311+
Increment value
2312+
</button>
2313+
</div>
2314+
)
2315+
}
23062316

2307-
render(<Pokemon />, { wrapper: storeRef.wrapper })
2308-
expect(getRenderCount()).toBe(1)
2317+
render(<Pokemon />, { wrapper: storeRef.wrapper })
2318+
expect(getRenderCount()).toBe(1)
23092319

2310-
await waitFor(() =>
2311-
expect(screen.getByTestId('isFetching').textContent).toBe('false'),
2312-
)
2313-
fireEvent.click(screen.getByText('Increment value'))
2314-
await waitFor(() =>
2315-
expect(screen.getByTestId('isFetching').textContent).toBe('true'),
2316-
)
2317-
expect(getRenderCount()).toBe(2)
2318-
})
2320+
await waitFor(() =>
2321+
expect(screen.getByTestId('isFetching').textContent).toBe('false'),
2322+
)
2323+
fireEvent.click(screen.getByText('Increment value'))
2324+
await waitFor(() =>
2325+
expect(screen.getByTestId('isFetching').textContent).toBe('true'),
2326+
)
2327+
expect(getRenderCount()).toBe(2)
2328+
},
2329+
)
23192330
})
23202331

23212332
describe('useMutation', () => {

packages/toolkit/src/query/tests/buildInitiate.test.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,130 @@ describe('calling initiate should have resulting queryCacheKey match baseQuery q
173173
)
174174
})
175175
})
176+
177+
describe('getRunningQueryThunk with multiple stores', () => {
178+
test('should isolate running queries between different store instances using the same API', async () => {
179+
// Create a shared API instance
180+
const sharedApi = createApi({
181+
baseQuery: fakeBaseQuery(),
182+
endpoints: (build) => ({
183+
testQuery: build.query<string, string>({
184+
async queryFn(arg) {
185+
// Add delay to ensure queries are running when we check
186+
await new Promise((resolve) => setTimeout(resolve, 50))
187+
return { data: `result-${arg}` }
188+
},
189+
}),
190+
}),
191+
})
192+
193+
// Create two separate stores using the same API instance
194+
const store1 = setupApiStore(sharedApi, undefined, {
195+
withoutTestLifecycles: true,
196+
}).store
197+
const store2 = setupApiStore(sharedApi, undefined, {
198+
withoutTestLifecycles: true,
199+
}).store
200+
201+
// Start queries on both stores
202+
const query1Promise = store1.dispatch(
203+
sharedApi.endpoints.testQuery.initiate('arg1'),
204+
)
205+
const query2Promise = store2.dispatch(
206+
sharedApi.endpoints.testQuery.initiate('arg2'),
207+
)
208+
209+
// Verify that getRunningQueryThunk returns the correct query for each store
210+
const runningQuery1 = store1.dispatch(
211+
sharedApi.util.getRunningQueryThunk('testQuery', 'arg1'),
212+
)
213+
const runningQuery2 = store2.dispatch(
214+
sharedApi.util.getRunningQueryThunk('testQuery', 'arg2'),
215+
)
216+
217+
// Each store should only see its own running query
218+
expect(runningQuery1).toBeDefined()
219+
expect(runningQuery2).toBeDefined()
220+
expect(runningQuery1?.requestId).toBe(query1Promise.requestId)
221+
expect(runningQuery2?.requestId).toBe(query2Promise.requestId)
222+
223+
// Cross-store queries should not be visible
224+
const crossQuery1 = store1.dispatch(
225+
sharedApi.util.getRunningQueryThunk('testQuery', 'arg2'),
226+
)
227+
const crossQuery2 = store2.dispatch(
228+
sharedApi.util.getRunningQueryThunk('testQuery', 'arg1'),
229+
)
230+
231+
expect(crossQuery1).toBeUndefined()
232+
expect(crossQuery2).toBeUndefined()
233+
234+
// Wait for queries to complete
235+
await Promise.all([query1Promise, query2Promise])
236+
237+
// After completion, getRunningQueryThunk should return undefined for both stores
238+
const completedQuery1 = store1.dispatch(
239+
sharedApi.util.getRunningQueryThunk('testQuery', 'arg1'),
240+
)
241+
const completedQuery2 = store2.dispatch(
242+
sharedApi.util.getRunningQueryThunk('testQuery', 'arg2'),
243+
)
244+
245+
expect(completedQuery1).toBeUndefined()
246+
expect(completedQuery2).toBeUndefined()
247+
})
248+
249+
test('should handle same query args on different stores independently', async () => {
250+
// Create a shared API instance
251+
const sharedApi = createApi({
252+
baseQuery: fakeBaseQuery(),
253+
endpoints: (build) => ({
254+
sameArgQuery: build.query<string, string>({
255+
async queryFn(arg) {
256+
await new Promise((resolve) => setTimeout(resolve, 50))
257+
return { data: `result-${arg}-${Math.random()}` }
258+
},
259+
}),
260+
}),
261+
})
262+
263+
// Create two separate stores
264+
const store1 = setupApiStore(sharedApi, undefined, {
265+
withoutTestLifecycles: true,
266+
}).store
267+
const store2 = setupApiStore(sharedApi, undefined, {
268+
withoutTestLifecycles: true,
269+
}).store
270+
271+
// Start the same query on both stores
272+
const sameArg = 'shared-arg'
273+
const query1Promise = store1.dispatch(
274+
sharedApi.endpoints.sameArgQuery.initiate(sameArg),
275+
)
276+
const query2Promise = store2.dispatch(
277+
sharedApi.endpoints.sameArgQuery.initiate(sameArg),
278+
)
279+
280+
// Both stores should see their own running query with the same cache key
281+
const runningQuery1 = store1.dispatch(
282+
sharedApi.util.getRunningQueryThunk('sameArgQuery', sameArg),
283+
)
284+
const runningQuery2 = store2.dispatch(
285+
sharedApi.util.getRunningQueryThunk('sameArgQuery', sameArg),
286+
)
287+
288+
expect(runningQuery1).toBeDefined()
289+
expect(runningQuery2).toBeDefined()
290+
expect(runningQuery1?.requestId).toBe(query1Promise.requestId)
291+
expect(runningQuery2?.requestId).toBe(query2Promise.requestId)
292+
293+
// The request IDs should be different even though the cache key is the same
294+
expect(runningQuery1?.requestId).not.toBe(runningQuery2?.requestId)
295+
296+
// But the cache keys should be the same
297+
expect(runningQuery1?.queryCacheKey).toBe(runningQuery2?.queryCacheKey)
298+
299+
// Wait for completion
300+
await Promise.all([query1Promise, query2Promise])
301+
})
302+
})

packages/toolkit/src/query/tests/buildSlice.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import { createSlice } from '@reduxjs/toolkit'
1+
import { createSlice, createAction } from '@reduxjs/toolkit'
2+
import type { CombinedState } from '@reduxjs/toolkit/query'
23
import { createApi } from '@reduxjs/toolkit/query'
34
import { delay } from 'msw'
45
import { setupApiStore } from '../../tests/utils/helpers'
56

67
let shouldApiResponseSuccess = true
78

9+
const rehydrateAction = createAction<{ api: CombinedState<any, any, any> }>(
10+
'persist/REHYDRATE',
11+
)
12+
813
const baseQuery = (args?: any) => ({ data: args })
914
const api = createApi({
1015
baseQuery,
@@ -17,6 +22,12 @@ const api = createApi({
1722
providesTags: (result) => (result?.success ? ['SUCCEED'] : ['FAILED']),
1823
}),
1924
}),
25+
extractRehydrationInfo(action, { reducerPath }) {
26+
if (rehydrateAction.match(action)) {
27+
return action.payload?.[reducerPath]
28+
}
29+
return undefined
30+
},
2031
})
2132
const { getUser } = api.endpoints
2233

@@ -114,6 +125,20 @@ describe('buildSlice', () => {
114125
api.util.selectInvalidatedBy(storeRef.store.getState(), ['FAILED']),
115126
).toHaveLength(1)
116127
})
128+
129+
it('handles extractRehydrationInfo correctly', async () => {
130+
await storeRef.store.dispatch(getUser.initiate(1))
131+
await storeRef.store.dispatch(getUser.initiate(2))
132+
133+
const stateWithUser = storeRef.store.getState()
134+
135+
storeRef.store.dispatch(api.util.resetApiState())
136+
137+
storeRef.store.dispatch(rehydrateAction({ api: stateWithUser.api }))
138+
139+
const rehydratedState = storeRef.store.getState()
140+
expect(rehydratedState).toEqual(stateWithUser)
141+
})
117142
})
118143

119144
describe('`merge` callback', () => {

0 commit comments

Comments
 (0)