Skip to content

Commit 96038ff

Browse files
committed
Don't create subscriptions for prefetch calls
1 parent c86d948 commit 96038ff

File tree

4 files changed

+220
-39
lines changed

4 files changed

+220
-39
lines changed

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -994,14 +994,17 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".`
994994
<EndpointName extends QueryKeys<Definitions>>(
995995
endpointName: EndpointName,
996996
arg: any,
997-
options: PrefetchOptions,
997+
options: PrefetchOptions = {},
998998
): ThunkAction<void, any, any, UnknownAction> =>
999999
(dispatch: ThunkDispatch<any, any, any>, getState: () => any) => {
10001000
const force = hasTheForce(options) && options.force
10011001
const maxAge = hasMaxAge(options) && options.ifOlderThan
10021002

10031003
const queryAction = (force: boolean = true) => {
1004-
const options = { forceRefetch: force, isPrefetch: true }
1004+
const options: StartQueryActionCreatorOptions = {
1005+
forceRefetch: force,
1006+
subscribe: false,
1007+
}
10051008
return (
10061009
api.endpoints[endpointName] as ApiEndpointQuery<any, any>
10071010
).initiate(arg, options)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export interface ApiModules<
248248
prefetch<EndpointName extends QueryKeys<Definitions>>(
249249
endpointName: EndpointName,
250250
arg: QueryArgFrom<Definitions[EndpointName]>,
251-
options: PrefetchOptions,
251+
options?: PrefetchOptions,
252252
): ThunkAction<void, any, any, UnknownAction>
253253
/**
254254
* A Redux thunk action creator that, when dispatched, creates and applies a set of JSON diff/patch objects to the current state. This immediately updates the Redux state with those changes.

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

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1592,7 +1592,6 @@ describe('hooks tests', () => {
15921592

15931593
// Create a fresh API instance with fetchBaseQuery and timeout, matching the user's example
15941594
const timeoutApi = createApi({
1595-
reducerPath: 'timeoutApi',
15961595
baseQuery: fetchBaseQuery({
15971596
baseUrl: 'https://example.com',
15981597
timeout: 5000,
@@ -1604,7 +1603,7 @@ describe('hooks tests', () => {
16041603
}),
16051604
})
16061605

1607-
const timeoutStoreRef = setupApiStore(timeoutApi as any, undefined, {
1606+
const timeoutStoreRef = setupApiStore(timeoutApi, undefined, {
16081607
withoutTestLifecycles: true,
16091608
})
16101609

@@ -2861,7 +2860,7 @@ describe('hooks tests', () => {
28612860
})
28622861
})
28632862

2864-
test('usePrefetch returns the last success result when ifOlderThan evalutes to false', async () => {
2863+
test('usePrefetch returns the last success result when ifOlderThan evaluates to false', async () => {
28652864
const user = userEvent.setup()
28662865

28672866
const { usePrefetch } = api
@@ -2944,6 +2943,60 @@ describe('hooks tests', () => {
29442943
status: 'pending',
29452944
})
29462945
})
2946+
2947+
it('should create subscription when hook mounts after prefetch', async () => {
2948+
const api = createApi({
2949+
baseQuery: async () => ({ data: 'test data' }),
2950+
endpoints: (build) => ({
2951+
getTest: build.query<string, void>({
2952+
query: () => '',
2953+
}),
2954+
}),
2955+
})
2956+
const storeRef = setupApiStore(api, undefined, { withoutListeners: true })
2957+
2958+
// 1. Prefetch data (no subscription)
2959+
await storeRef.store.dispatch(api.util.prefetch('getTest', undefined))
2960+
2961+
// Verify data is cached
2962+
await waitFor(() => {
2963+
let state = storeRef.store.getState()
2964+
expect(state.api.queries['getTest(undefined)']?.data).toBe('test data')
2965+
})
2966+
2967+
// Verify no subscription exists
2968+
const subscriptions = storeRef.store.dispatch(
2969+
api.internalActions.internal_getRTKQSubscriptions(),
2970+
) as any
2971+
expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(0)
2972+
2973+
// 2. Mount component with useQuery hook
2974+
function TestComponent() {
2975+
const result = api.endpoints.getTest.useQuery()
2976+
return <div>{result.data}</div>
2977+
}
2978+
2979+
const { unmount } = render(<TestComponent />, {
2980+
wrapper: storeRef.wrapper,
2981+
})
2982+
2983+
// Wait for hook to initialize
2984+
await waitFor(() => {
2985+
// EXPECTED: Subscription should be created
2986+
expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(1)
2987+
})
2988+
2989+
// 3. Verify data is still available
2990+
let state = storeRef.store.getState()
2991+
expect(state.api.queries['getTest(undefined)']?.data).toBe('test data')
2992+
2993+
// 4. Unmount and verify subscription is removed
2994+
unmount()
2995+
2996+
await waitFor(() => {
2997+
expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(0)
2998+
})
2999+
})
29473000
})
29483001

29493002
describe('useQuery and useMutation invalidation behavior', () => {

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

Lines changed: 158 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { configureStore, isAllOf } from '@reduxjs/toolkit'
1+
import { configureStore } from '@reduxjs/toolkit'
22
import { createApi } from '@reduxjs/toolkit/query/react'
33
import { renderHook, waitFor } from '@testing-library/react'
4-
import { actionsReducer, withProvider } from '../../tests/utils/helpers'
4+
import {
5+
actionsReducer,
6+
setupApiStore,
7+
withProvider,
8+
} from '../../tests/utils/helpers'
59
import type { BaseQueryApi } from '../baseQueryTypes'
610

711
describe('baseline thunk behavior', () => {
@@ -246,50 +250,171 @@ describe('re-triggering behavior on arg change', () => {
246250
})
247251

248252
describe('prefetch', () => {
249-
const baseQuery = () => ({ data: null })
253+
const baseQuery = () => ({ data: { name: 'Test User' } })
254+
250255
const api = createApi({
251256
baseQuery,
257+
tagTypes: ['User'],
252258
endpoints: (build) => ({
253-
getUser: build.query<any, any>({
254-
query: (obj) => obj,
259+
getUser: build.query<any, number>({
260+
query: (id) => ({ url: `user/${id}` }),
261+
providesTags: (result, error, id) => [{ type: 'User', id }],
262+
}),
263+
updateUser: build.mutation<any, { id: number; name: string }>({
264+
query: ({ id, name }) => ({
265+
url: `user/${id}`,
266+
method: 'PUT',
267+
body: { name },
268+
}),
269+
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
255270
}),
256271
}),
272+
keepUnusedDataFor: 0.1, // 100ms for faster test cleanup
257273
})
258274

259-
const store = configureStore({
260-
reducer: { [api.reducerPath]: api.reducer, ...actionsReducer },
261-
middleware: (gDM) => gDM().concat(api.middleware),
275+
let storeRef = setupApiStore(
276+
api,
277+
{ ...actionsReducer },
278+
{
279+
withoutListeners: true,
280+
},
281+
)
282+
283+
let getSubscriptions: () => Map<string, any>
284+
let getSubscriptionCount: (queryCacheKey: string) => number
285+
286+
beforeEach(() => {
287+
storeRef = setupApiStore(
288+
api,
289+
{ ...actionsReducer },
290+
{
291+
withoutListeners: true,
292+
},
293+
)
294+
// Get subscription helpers
295+
const subscriptionSelectors = storeRef.store.dispatch(
296+
api.internalActions.internal_getRTKQSubscriptions(),
297+
) as any
298+
getSubscriptions = subscriptionSelectors.getSubscriptions
299+
getSubscriptionCount = subscriptionSelectors.getSubscriptionCount
262300
})
263-
it('should attach isPrefetch if prefetching', async () => {
264-
store.dispatch(api.util.prefetch('getUser', 1, {}))
265301

266-
await Promise.all(store.dispatch(api.util.getRunningQueriesThunk()))
302+
describe('subscription behavior', () => {
303+
it('prefetch should NOT create a subscription', async () => {
304+
const queryCacheKey = 'getUser(1)'
267305

268-
const isPrefetch = (
269-
action: any,
270-
): action is { meta: { arg: { isPrefetch: true } } } =>
271-
action?.meta?.arg?.isPrefetch
306+
// Initially no subscriptions
307+
expect(getSubscriptionCount(queryCacheKey)).toBe(0)
272308

273-
expect(store.getState().actions).toMatchSequence(
274-
api.internalActions.middlewareRegistered.match,
275-
isAllOf(api.endpoints.getUser.matchPending, isPrefetch),
276-
isAllOf(api.endpoints.getUser.matchFulfilled, isPrefetch),
277-
)
309+
// Dispatch prefetch
310+
storeRef.store.dispatch(api.util.prefetch('getUser', 1, {}))
311+
await Promise.all(
312+
storeRef.store.dispatch(api.util.getRunningQueriesThunk()),
313+
)
278314

279-
// compare against a regular initiate call
280-
await store.dispatch(
281-
api.endpoints.getUser.initiate(1, { forceRefetch: true }),
282-
)
315+
expect(getSubscriptionCount(queryCacheKey)).toBe(0)
316+
})
283317

284-
const isNotPrefetch = (action: any): action is unknown =>
285-
!isPrefetch(action)
318+
it('prefetch allows cache cleanup after keepUnusedDataFor', async () => {
319+
const queryCacheKey = 'getUser(1)'
286320

287-
expect(store.getState().actions).toMatchSequence(
288-
api.internalActions.middlewareRegistered.match,
289-
isAllOf(api.endpoints.getUser.matchPending, isPrefetch),
290-
isAllOf(api.endpoints.getUser.matchFulfilled, isPrefetch),
291-
isAllOf(api.endpoints.getUser.matchPending, isNotPrefetch),
292-
isAllOf(api.endpoints.getUser.matchFulfilled, isNotPrefetch),
293-
)
321+
// Prefetch the data
322+
storeRef.store.dispatch(api.util.prefetch('getUser', 1, {}))
323+
await Promise.all(
324+
storeRef.store.dispatch(api.util.getRunningQueriesThunk()),
325+
)
326+
327+
// Verify data is in cache
328+
let state = api.endpoints.getUser.select(1)(storeRef.store.getState())
329+
expect(state.data).toEqual({ name: 'Test User' })
330+
331+
// Wait longer than keepUnusedDataFor
332+
await new Promise((resolve) => setTimeout(resolve, 150))
333+
334+
state = api.endpoints.getUser.select(1)(storeRef.store.getState())
335+
expect(state.status).toBe('uninitialized')
336+
expect(state.data).toBeUndefined()
337+
})
338+
339+
it('prefetch does NOT trigger refetch on tag invalidation', async () => {
340+
// Prefetch user 1
341+
storeRef.store.dispatch(api.util.prefetch('getUser', 1, {}))
342+
await Promise.all(
343+
storeRef.store.dispatch(api.util.getRunningQueriesThunk()),
344+
)
345+
346+
// Verify data is in cache
347+
let state = api.endpoints.getUser.select(1)(storeRef.store.getState())
348+
expect(state.data).toEqual({ name: 'Test User' })
349+
350+
// Invalidate the tag by updating the user
351+
await storeRef.store.dispatch(
352+
api.endpoints.updateUser.initiate({ id: 1, name: 'Updated' }),
353+
)
354+
355+
// Since there's no subscription, the cache entry gets removed on invalidation
356+
await Promise.all(
357+
storeRef.store.dispatch(api.util.getRunningQueriesThunk()),
358+
)
359+
360+
// Cache entry should be cleared (no subscription to keep it alive)
361+
state = api.endpoints.getUser.select(1)(storeRef.store.getState())
362+
expect(state.status).toBe('uninitialized')
363+
expect(state.data).toBeUndefined()
364+
})
365+
366+
it('multiple prefetches do not accumulate subscriptions', async () => {
367+
const queryCacheKey = 'getUser(1)'
368+
369+
expect(getSubscriptionCount(queryCacheKey)).toBe(0)
370+
371+
// First prefetch
372+
storeRef.store.dispatch(api.util.prefetch('getUser', 1, {}))
373+
await Promise.all(
374+
storeRef.store.dispatch(api.util.getRunningQueriesThunk()),
375+
)
376+
expect(getSubscriptionCount(queryCacheKey)).toBe(0)
377+
378+
// Second prefetch (force refetch)
379+
storeRef.store.dispatch(api.util.prefetch('getUser', 1, { force: true }))
380+
await Promise.all(
381+
storeRef.store.dispatch(api.util.getRunningQueriesThunk()),
382+
)
383+
384+
// Still no subscriptions
385+
expect(getSubscriptionCount(queryCacheKey)).toBe(0)
386+
387+
// Third prefetch
388+
storeRef.store.dispatch(api.util.prefetch('getUser', 1, { force: true }))
389+
await Promise.all(
390+
storeRef.store.dispatch(api.util.getRunningQueriesThunk()),
391+
)
392+
expect(getSubscriptionCount(queryCacheKey)).toBe(0)
393+
})
394+
395+
it('prefetch followed by regular query should work correctly', async () => {
396+
const queryCacheKey = 'getUser(1)'
397+
398+
// Prefetch first
399+
storeRef.store.dispatch(api.util.prefetch('getUser', 1, {}))
400+
await Promise.all(
401+
storeRef.store.dispatch(api.util.getRunningQueriesThunk()),
402+
)
403+
404+
// No subscription from prefetch
405+
expect(getSubscriptionCount(queryCacheKey)).toBe(0)
406+
407+
// Now create a real subscription via initiate
408+
const promise = storeRef.store.dispatch(api.endpoints.getUser.initiate(1))
409+
410+
// Should have 1 subscription from the initiate call
411+
expect(getSubscriptionCount(queryCacheKey)).toBe(1)
412+
413+
// Unsubscribe
414+
promise.unsubscribe()
415+
416+
// Subscription should be cleaned up
417+
expect(getSubscriptionCount(queryCacheKey)).toBe(0)
418+
})
294419
})
295420
})

0 commit comments

Comments
 (0)