Skip to content

Commit 53697b7

Browse files
committed
Implement aborting running queries on cache entry removal
1 parent 15e2dd9 commit 53697b7

File tree

2 files changed

+112
-16
lines changed

2 files changed

+112
-16
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/tests/buildHooks.test.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,89 @@ 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+
// Capture the abort signal for testing
1202+
abortSignalFromQueryFn = signal
1203+
1204+
// Simulate a long-running request that should be aborted
1205+
await new Promise((resolve, reject) => {
1206+
const timeout = setTimeout(resolve, 5000) // 5 second delay
1207+
1208+
signal.addEventListener('abort', () => {
1209+
clearTimeout(timeout)
1210+
reject(new Error('Aborted'))
1211+
})
1212+
})
1213+
1214+
return { data: 'data!' }
1215+
},
1216+
keepUnusedDataFor: 0.01, // Very short timeout (10ms)
1217+
}),
1218+
}),
1219+
})
1220+
1221+
const storeRef = setupApiStore(pokemonApi, undefined, {
1222+
withoutTestLifecycles: true,
1223+
})
1224+
1225+
function TestComponent() {
1226+
const { data, isFetching } = pokemonApi.endpoints.getTest.useQuery(1)
1227+
1228+
return (
1229+
<div>
1230+
<div data-testid="isFetching">{String(isFetching)}</div>
1231+
<div data-testid="data">{data || 'no data'}</div>
1232+
</div>
1233+
)
1234+
}
1235+
1236+
function App() {
1237+
const [showComponent, setShowComponent] = useState(true)
1238+
1239+
return (
1240+
<div>
1241+
{showComponent && <TestComponent />}
1242+
<button
1243+
data-testid="unmount"
1244+
onClick={() => setShowComponent(false)}
1245+
>
1246+
Unmount Component
1247+
</button>
1248+
</div>
1249+
)
1250+
}
1251+
1252+
render(<App />, { wrapper: storeRef.wrapper })
1253+
1254+
// Wait for the query to start
1255+
await waitFor(() =>
1256+
expect(screen.getByTestId('isFetching').textContent).toBe('true'),
1257+
)
1258+
1259+
// Verify we have an abort signal
1260+
expect(abortSignalFromQueryFn).toBeDefined()
1261+
expect(abortSignalFromQueryFn!.aborted).toBe(false)
1262+
1263+
// Unmount the component
1264+
fireEvent.click(screen.getByTestId('unmount'))
1265+
1266+
// Wait for the cache entry to be removed (keepUnusedDataFor: 0.01s = 10ms)
1267+
await act(async () => {
1268+
await delay(100) // Wait a bit longer than keepUnusedDataFor
1269+
})
1270+
1271+
// The abort signal should now be aborted
1272+
// This test is expected to fail initially until we implement the abort logic
1273+
expect(abortSignalFromQueryFn!.aborted).toBe(true)
1274+
})
1275+
11931276
describe('Hook middleware requirements', () => {
11941277
const consoleErrorSpy = vi
11951278
.spyOn(console, 'error')
@@ -1898,7 +1981,6 @@ describe('hooks tests', () => {
18981981
const checkNumQueries = (count: number) => {
18991982
const cacheEntries = Object.keys(storeRef.store.getState().api.queries)
19001983
const queries = cacheEntries.length
1901-
//console.log('queries', queries, storeRef.store.getState().api.queries)
19021984

19031985
expect(queries).toBe(count)
19041986
}

0 commit comments

Comments
 (0)