Skip to content

Commit 128ce81

Browse files
authoredMar 18, 2024
Fixed memory leak in rapid hook arg changing (#4268)
* Added unsubscribeHandler for fulfilled/rejected queryThunk * Added testcase for memory leak listed in github Issues * Removed unnecessary delay call * Re-defined delay function and removed Math.random() in favour of i++ * Moved near identical solutions into one conditional * Reformat for visual clarity * Added additional check to clearly show resolution of queries * Refactored unsubscribe matching to make better use of type narrowing
1 parent e31224f commit 128ce81

File tree

2 files changed

+74
-2
lines changed

2 files changed

+74
-2
lines changed
 

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

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isAnyOf } from '@reduxjs/toolkit'
12
import type { BaseQueryFn } from '../../baseQueryTypes'
23
import type { QueryDefinition } from '../../endpointDefinitions'
34
import type { ConfigState, QueryCacheKey } from '../apiState'
@@ -49,11 +50,18 @@ export const THIRTY_TWO_BIT_MAX_TIMER_SECONDS = 2_147_483_647 / 1_000 - 1
4950
export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
5051
reducerPath,
5152
api,
53+
queryThunk,
5254
context,
5355
internalState,
5456
}) => {
5557
const { removeQueryResult, unsubscribeQueryResult } = api.internalActions
5658

59+
const canTriggerUnsubscribe = isAnyOf(
60+
unsubscribeQueryResult.match,
61+
queryThunk.fulfilled,
62+
queryThunk.rejected
63+
)
64+
5765
function anySubscriptionsRemainingForKey(queryCacheKey: string) {
5866
const subscriptions = internalState.currentSubscriptions[queryCacheKey]
5967
return !!subscriptions && !isObjectEmpty(subscriptions)
@@ -66,9 +74,11 @@ export const buildCacheCollectionHandler: InternalHandlerBuilder = ({
6674
mwApi,
6775
internalState,
6876
) => {
69-
if (unsubscribeQueryResult.match(action)) {
77+
if (canTriggerUnsubscribe(action)) {
7078
const state = mwApi.getState()[reducerPath]
71-
const { queryCacheKey } = action.payload
79+
const { queryCacheKey } = unsubscribeQueryResult.match(action)
80+
? action.payload
81+
: action.meta.arg
7282

7383
handleUnsubscribe(
7484
queryCacheKey,

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

+62
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,68 @@ describe('hooks tests', () => {
724724
expect(res.data!.amount).toBeGreaterThan(originalAmount)
725725
})
726726

727+
// See https://github.com/reduxjs/redux-toolkit/issues/4267 - Memory leak in useQuery rapid query arg changes
728+
test('Hook subscriptions are properly cleaned up when query is fulfilled/rejected', async () => {
729+
// This is imported already, but it seems to be causing issues with the test on certain matrixes
730+
function delay(ms: number) {
731+
return new Promise((resolve) => setTimeout(resolve, ms))
732+
}
733+
734+
const pokemonApi = createApi({
735+
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
736+
endpoints: (builder) => ({
737+
getTest: builder.query<string, number>({
738+
async queryFn() {
739+
await new Promise((resolve) => setTimeout(resolve, 1000));
740+
return { data: "data!" };
741+
},
742+
keepUnusedDataFor: 0,
743+
}),
744+
}),
745+
})
746+
747+
const storeRef = setupApiStore(pokemonApi, undefined, {
748+
withoutTestLifecycles: true,
749+
})
750+
751+
const checkNumQueries = (count: number) => {
752+
const cacheEntries = Object.keys((storeRef.store.getState()).api.queries)
753+
const queries = cacheEntries.length
754+
755+
expect(queries).toBe(count)
756+
}
757+
758+
let i = 0;
759+
760+
function User() {
761+
const [fetchTest, { isFetching, isUninitialized }] =
762+
pokemonApi.endpoints.getTest.useLazyQuery()
763+
764+
return (
765+
<div>
766+
<div data-testid="isUninitialized">{String(isUninitialized)}</div>
767+
<div data-testid="isFetching">{String(isFetching)}</div>
768+
<button data-testid="fetchButton" onClick={() => fetchTest(i++)}>
769+
fetchUser
770+
</button>
771+
</div>
772+
)
773+
}
774+
775+
render(<User />, { wrapper: storeRef.wrapper })
776+
fireEvent.click(screen.getByTestId('fetchButton'))
777+
fireEvent.click(screen.getByTestId('fetchButton'))
778+
fireEvent.click(screen.getByTestId('fetchButton'))
779+
checkNumQueries(3)
780+
781+
await act(async () => {
782+
await delay(1500)
783+
})
784+
785+
// There should only be one stored query once they have had time to resolve
786+
checkNumQueries( 1)
787+
})
788+
727789
// See https://github.com/reduxjs/redux-toolkit/issues/3182
728790
test('Hook subscriptions are properly cleaned up when changing skip back and forth', async () => {
729791
const pokemonApi = createApi({

0 commit comments

Comments
 (0)