Skip to content

Commit 407688d

Browse files
authored
Abort pending requests if the cache entry is removed (#5061)
* Expose `getRunningQueryThunk` into RTKQ middleware * Implement aborting running queries on cache entry removal * Add test to verify retryCondition can use the abort signal
1 parent 85a533e commit 407688d

File tree

5 files changed

+213
-30
lines changed

5 files changed

+213
-30
lines changed

packages/toolkit/src/query/core/buildMiddleware/cacheCollection.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { QueryDefinition } from '../../endpointDefinitions'
2-
import type { ConfigState, QueryCacheKey } from '../apiState'
2+
import type { ConfigState, QueryCacheKey, QuerySubState } from '../apiState'
33
import { isAnyOf } from '../rtkImports'
44
import type {
55
ApiMiddlewareInternalHandler,
@@ -11,16 +11,6 @@ import type {
1111

1212
export type ReferenceCacheCollection = never
1313

14-
function isObjectEmpty(obj: Record<any, any>) {
15-
// Apparently a for..in loop is faster than `Object.keys()` here:
16-
// https://stackoverflow.com/a/59787784/62937
17-
for (const k in obj) {
18-
// If there is at least one key, it's not empty
19-
return false
20-
}
21-
return true
22-
}
23-
2414
export type CacheCollectionQueryExtraOptions = {
2515
/**
2616
* Overrides the api-wide definition of `keepUnusedDataFor` for this endpoint only. _(This value is in seconds.)_
@@ -44,6 +34,7 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
4434
context,
4535
internalState,
4636
selectors: { selectQueryEntry, selectConfig },
37+
getRunningQueryThunk,
4738
}) => {
4839
const { removeQueryResult, unsubscribeQueryResult, cacheEntriesUpserted } =
4940
api.internalActions
@@ -57,7 +48,18 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
5748

5849
function anySubscriptionsRemainingForKey(queryCacheKey: string) {
5950
const subscriptions = internalState.currentSubscriptions[queryCacheKey]
60-
return !!subscriptions && !isObjectEmpty(subscriptions)
51+
if (!subscriptions) {
52+
return false
53+
}
54+
55+
// Check if there are any keys that are NOT _running subscriptions
56+
for (const key in subscriptions) {
57+
if (!key.endsWith('_running')) {
58+
return true
59+
}
60+
}
61+
// Only _running subscriptions remain (or empty)
62+
return false
6163
}
6264

6365
const currentRemovalTimeouts: QueryStateMeta<TimeoutId> = {}
@@ -69,6 +71,7 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
6971
) => {
7072
const state = mwApi.getState()
7173
const config = selectConfig(state)
74+
7275
if (canTriggerUnsubscribe(action)) {
7376
let queryCacheKeys: QueryCacheKey[]
7477

@@ -114,18 +117,20 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
114117
const state = api.getState()
115118
for (const queryCacheKey of cacheKeys) {
116119
const entry = selectQueryEntry(state, queryCacheKey)
117-
handleUnsubscribe(queryCacheKey, entry?.endpointName, api, config)
120+
if (entry?.endpointName) {
121+
handleUnsubscribe(queryCacheKey, entry.endpointName, api, config)
122+
}
118123
}
119124
}
120125

121126
function handleUnsubscribe(
122127
queryCacheKey: QueryCacheKey,
123-
endpointName: string | undefined,
128+
endpointName: string,
124129
api: SubMiddlewareApi,
125130
config: ConfigState<string>,
126131
) {
127132
const endpointDefinition = context.endpointDefinitions[
128-
endpointName!
133+
endpointName
129134
] as QueryDefinition<any, any, any, any>
130135
const keepUnusedDataFor =
131136
endpointDefinition?.keepUnusedDataFor ?? config.keepUnusedDataFor
@@ -151,6 +156,15 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
151156

152157
currentRemovalTimeouts[queryCacheKey] = setTimeout(() => {
153158
if (!anySubscriptionsRemainingForKey(queryCacheKey)) {
159+
// Try to abort any running query for this cache key
160+
const entry = selectQueryEntry(api.getState(), queryCacheKey)
161+
162+
if (entry?.endpointName) {
163+
const runningQuery = api.dispatch(
164+
getRunningQueryThunk(entry.endpointName, entry.originalArgs),
165+
)
166+
runningQuery?.abort()
167+
}
154168
api.dispatch(removeQueryResult({ queryCacheKey }))
155169
}
156170
delete currentRemovalTimeouts![queryCacheKey]

packages/toolkit/src/query/core/buildMiddleware/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
Action,
33
AsyncThunkAction,
4+
Dispatch,
45
Middleware,
56
MiddlewareAPI,
67
ThunkAction,
@@ -54,6 +55,10 @@ export interface BuildMiddlewareInput<
5455
api: Api<any, Definitions, ReducerPath, TagTypes>
5556
assertTagType: AssertTagTypes
5657
selectors: AllSelectors
58+
getRunningQueryThunk: (
59+
endpointName: string,
60+
queryArgs: any,
61+
) => (dispatch: Dispatch) => QueryActionCreatorResult<any> | undefined
5762
}
5863

5964
export type SubMiddlewareApi = MiddlewareAPI<

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -618,20 +618,6 @@ export const coreModule = ({
618618
})
619619
safeAssign(api.internalActions, sliceActions)
620620

621-
const { middleware, actions: middlewareActions } = buildMiddleware({
622-
reducerPath,
623-
context,
624-
queryThunk,
625-
mutationThunk,
626-
infiniteQueryThunk,
627-
api,
628-
assertTagType,
629-
selectors,
630-
})
631-
safeAssign(api.util, middlewareActions)
632-
633-
safeAssign(api, { reducer: reducer as any, middleware })
634-
635621
const {
636622
buildInitiateQuery,
637623
buildInitiateInfiniteQuery,
@@ -656,6 +642,21 @@ export const coreModule = ({
656642
getRunningQueriesThunk,
657643
})
658644

645+
const { middleware, actions: middlewareActions } = buildMiddleware({
646+
reducerPath,
647+
context,
648+
queryThunk,
649+
mutationThunk,
650+
infiniteQueryThunk,
651+
api,
652+
assertTagType,
653+
selectors,
654+
getRunningQueryThunk,
655+
})
656+
safeAssign(api.util, middlewareActions)
657+
658+
safeAssign(api, { reducer: reducer as any, middleware })
659+
659660
return {
660661
name: coreModuleName,
661662
injectEndpoint(endpointName, definition) {

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

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,87 @@ describe('hooks tests', () => {
11901190
).toBe(-1)
11911191
})
11921192

1193+
test('query thunk should be aborted when component unmounts and cache entry is removed', async () => {
1194+
let abortSignalFromQueryFn: AbortSignal | undefined
1195+
1196+
const pokemonApi = createApi({
1197+
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
1198+
endpoints: (builder) => ({
1199+
getTest: builder.query<string, number>({
1200+
async queryFn(arg, { signal }) {
1201+
abortSignalFromQueryFn = signal
1202+
1203+
// Simulate a long-running request that should be aborted
1204+
await new Promise((resolve, reject) => {
1205+
const timeout = setTimeout(resolve, 5000)
1206+
1207+
signal.addEventListener('abort', () => {
1208+
clearTimeout(timeout)
1209+
reject(new Error('Aborted'))
1210+
})
1211+
})
1212+
1213+
return { data: 'data!' }
1214+
},
1215+
keepUnusedDataFor: 0.01, // Very short timeout (10ms)
1216+
}),
1217+
}),
1218+
})
1219+
1220+
const storeRef = setupApiStore(pokemonApi, undefined, {
1221+
withoutTestLifecycles: true,
1222+
})
1223+
1224+
function TestComponent() {
1225+
const { data, isFetching } = pokemonApi.endpoints.getTest.useQuery(1)
1226+
1227+
return (
1228+
<div>
1229+
<div data-testid="isFetching">{String(isFetching)}</div>
1230+
<div data-testid="data">{data || 'no data'}</div>
1231+
</div>
1232+
)
1233+
}
1234+
1235+
function App() {
1236+
const [showComponent, setShowComponent] = useState(true)
1237+
1238+
return (
1239+
<div>
1240+
{showComponent && <TestComponent />}
1241+
<button
1242+
data-testid="unmount"
1243+
onClick={() => setShowComponent(false)}
1244+
>
1245+
Unmount Component
1246+
</button>
1247+
</div>
1248+
)
1249+
}
1250+
1251+
render(<App />, { wrapper: storeRef.wrapper })
1252+
1253+
// Wait for the query to start
1254+
await waitFor(() =>
1255+
expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1256+
)
1257+
1258+
// Verify we have an abort signal
1259+
expect(abortSignalFromQueryFn).toBeDefined()
1260+
expect(abortSignalFromQueryFn!.aborted).toBe(false)
1261+
1262+
// Unmount the component
1263+
fireEvent.click(screen.getByTestId('unmount'))
1264+
1265+
// Wait for the cache entry to be removed (keepUnusedDataFor: 0.01s = 10ms)
1266+
await act(async () => {
1267+
await delay(100)
1268+
})
1269+
1270+
// The abort signal should now be aborted
1271+
expect(abortSignalFromQueryFn!.aborted).toBe(true)
1272+
})
1273+
11931274
describe('Hook middleware requirements', () => {
11941275
const consoleErrorSpy = vi
11951276
.spyOn(console, 'error')
@@ -1898,7 +1979,6 @@ describe('hooks tests', () => {
18981979
const checkNumQueries = (count: number) => {
18991980
const cacheEntries = Object.keys(storeRef.store.getState().api.queries)
19001981
const queries = cacheEntries.length
1901-
//console.log('queries', queries, storeRef.store.getState().api.queries)
19021982

19031983
expect(queries).toBe(count)
19041984
}

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,4 +465,87 @@ describe('configuration', () => {
465465

466466
expect(baseBaseQuery).toHaveBeenCalledOnce()
467467
})
468+
469+
test('retryCondition receives abort signal and stops retrying when cache entry is removed', async () => {
470+
let capturedSignal: AbortSignal | undefined
471+
let retryAttempts = 0
472+
473+
const baseBaseQuery = vi.fn<
474+
Parameters<BaseQueryFn>,
475+
ReturnType<BaseQueryFn>
476+
>()
477+
478+
// Always return an error to trigger retries
479+
baseBaseQuery.mockResolvedValue({ error: 'network error' })
480+
481+
let retryConditionCalled = false
482+
483+
const baseQuery = retry(baseBaseQuery, {
484+
retryCondition: (error, args, { attempt, baseQueryApi }) => {
485+
retryConditionCalled = true
486+
retryAttempts = attempt
487+
capturedSignal = baseQueryApi.signal
488+
489+
// Stop retrying if the signal is aborted
490+
if (baseQueryApi.signal.aborted) {
491+
return false
492+
}
493+
494+
// Otherwise, retry up to 10 times
495+
return attempt <= 10
496+
},
497+
backoff: async () => {
498+
// Short backoff for faster test
499+
await new Promise((resolve) => setTimeout(resolve, 10))
500+
},
501+
})
502+
503+
const api = createApi({
504+
baseQuery,
505+
endpoints: (build) => ({
506+
getTest: build.query<string, number>({
507+
query: (id) => ({ url: `test/${id}` }),
508+
keepUnusedDataFor: 0.01, // Very short timeout (10ms)
509+
}),
510+
}),
511+
})
512+
513+
const storeRef = setupApiStore(api, undefined, {
514+
withoutTestLifecycles: true,
515+
})
516+
517+
// Start the query
518+
const queryPromise = storeRef.store.dispatch(
519+
api.endpoints.getTest.initiate(1),
520+
)
521+
522+
// Wait for the first retry to happen so we capture the signal
523+
await loopTimers(2)
524+
525+
// Verify the retry condition was called and we have a signal
526+
expect(retryConditionCalled).toBe(true)
527+
expect(capturedSignal).toBeDefined()
528+
expect(capturedSignal!.aborted).toBe(false)
529+
530+
// Unsubscribe to trigger cache removal
531+
queryPromise.unsubscribe()
532+
533+
// Wait for the cache entry to be removed (keepUnusedDataFor: 0.01s = 10ms)
534+
await vi.advanceTimersByTimeAsync(50)
535+
536+
// Allow some time for more retries to potentially happen
537+
await loopTimers(3)
538+
539+
// The signal should now be aborted
540+
expect(capturedSignal!.aborted).toBe(true)
541+
542+
// We should have stopped retrying early due to the abort signal
543+
// If abort signal wasn't working, we'd see many more retry attempts
544+
expect(retryAttempts).toBeLessThan(10)
545+
546+
// The base query should have been called at least once (initial attempt)
547+
// but not the full 10+ times it would without abort signal
548+
expect(baseBaseQuery).toHaveBeenCalled()
549+
expect(baseBaseQuery.mock.calls.length).toBeLessThan(10)
550+
})
468551
})

0 commit comments

Comments
 (0)