Skip to content

Commit 4fba13e

Browse files
authored
Merge pull request #3188 from reduxjs/bugfix/2871-skip-data
2 parents 43d94a0 + 364ff51 commit 4fba13e

File tree

2 files changed

+148
-8
lines changed

2 files changed

+148
-8
lines changed

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,9 +626,7 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
626626
)
627627
lastResult = undefined
628628
}
629-
if (queryArgs === skipToken) {
630-
lastResult = undefined
631-
}
629+
632630
// data is the last known good request result we have tracked - or if none has been tracked yet the last good result for the current args
633631
let data = currentState.isSuccess ? currentState.data : lastResult?.data
634632
if (data === undefined) data = currentState.data
@@ -742,7 +740,9 @@ export function buildHooks<Definitions extends EndpointDefinitions>({
742740
})
743741

744742
usePossiblyImmediateEffect((): void | undefined => {
745-
promiseRef.current = undefined
743+
if (subscriptionRemoved) {
744+
promiseRef.current = undefined
745+
}
746746
}, [subscriptionRemoved])
747747

748748
usePossiblyImmediateEffect((): void | undefined => {

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

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ import {
99
QueryStatus,
1010
skipToken,
1111
} from '@reduxjs/toolkit/query/react'
12-
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
12+
import {
13+
act,
14+
fireEvent,
15+
render,
16+
screen,
17+
waitFor,
18+
renderHook,
19+
} from '@testing-library/react'
1320
import userEvent from '@testing-library/user-event'
1421
import { rest } from 'msw'
1522
import {
@@ -27,7 +34,6 @@ import type { AnyAction } from 'redux'
2734
import type { SubscriptionOptions } from '@reduxjs/toolkit/dist/query/core/apiState'
2835
import type { SerializedError } from '@reduxjs/toolkit'
2936
import { createListenerMiddleware, configureStore } from '@reduxjs/toolkit'
30-
import { renderHook } from '@testing-library/react'
3137
import { delay } from '../../utils'
3238

3339
// Just setup a temporary in-memory counter for tests that `getIncrementedAmount`.
@@ -714,6 +720,94 @@ describe('hooks tests', () => {
714720
expect(res.data!.amount).toBeGreaterThan(originalAmount)
715721
})
716722

723+
// See https://github.com/reduxjs/redux-toolkit/issues/3182
724+
test('Hook subscriptions are properly cleaned up when changing skip back and forth', async () => {
725+
const pokemonApi = createApi({
726+
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
727+
endpoints: (builder) => ({
728+
getPokemonByName: builder.query({
729+
queryFn: (name: string) => ({ data: null }),
730+
keepUnusedDataFor: 1,
731+
}),
732+
}),
733+
})
734+
735+
const storeRef = setupApiStore(pokemonApi, undefined, {
736+
withoutTestLifecycles: true,
737+
})
738+
739+
const getSubscriptions = () => storeRef.store.getState().api.subscriptions
740+
741+
const checkNumSubscriptions = (arg: string, count: number) => {
742+
const subscriptions = getSubscriptions()
743+
const cacheKeyEntry = subscriptions[arg]
744+
745+
if (cacheKeyEntry) {
746+
expect(Object.values(cacheKeyEntry).length).toBe(count)
747+
}
748+
}
749+
750+
// 1) Initial state: an active subscription
751+
const { result, rerender, unmount } = renderHook(
752+
([arg, options]: Parameters<
753+
typeof pokemonApi.useGetPokemonByNameQuery
754+
>) => pokemonApi.useGetPokemonByNameQuery(arg, options),
755+
{
756+
wrapper: storeRef.wrapper,
757+
initialProps: ['a'],
758+
}
759+
)
760+
761+
await act(async () => {
762+
await delay(1)
763+
})
764+
765+
// 2) Set the current subscription to `{skip: true}
766+
await act(async () => {
767+
rerender(['a', { skip: true }])
768+
})
769+
770+
// 3) Change _both_ the cache key _and_ `{skip: false}` at the same time.
771+
// This causes the `subscriptionRemoved` check to be `true`.
772+
await act(async () => {
773+
rerender(['b'])
774+
})
775+
776+
// There should only be one active subscription after changing the arg
777+
checkNumSubscriptions('b', 1)
778+
779+
// 4) Re-render with the same arg.
780+
// This causes the `subscriptionRemoved` check to be `false`.
781+
// Correct behavior is this does _not_ clear the promise ref,
782+
// so
783+
await act(async () => {
784+
rerender(['b'])
785+
})
786+
787+
// There should only be one active subscription after changing the arg
788+
checkNumSubscriptions('b', 1)
789+
790+
await act(async () => {
791+
await delay(1)
792+
})
793+
794+
unmount()
795+
796+
await act(async () => {
797+
await delay(1)
798+
})
799+
800+
// There should be no subscription entries left over after changing
801+
// cache key args and swapping `skip` on and off
802+
checkNumSubscriptions('b', 0)
803+
804+
const finalSubscriptions = getSubscriptions()
805+
806+
for (let cacheKeyEntry of Object.values(finalSubscriptions)) {
807+
expect(Object.values(cacheKeyEntry!).length).toBe(0)
808+
}
809+
})
810+
717811
describe('Hook middleware requirements', () => {
718812
let mock: jest.SpyInstance
719813

@@ -2472,7 +2566,11 @@ describe('skip behaviour', () => {
24722566
await act(async () => {
24732567
rerender([1, { skip: true }])
24742568
})
2475-
expect(result.current).toEqual(uninitialized)
2569+
expect(result.current).toEqual({
2570+
...uninitialized,
2571+
currentData: undefined,
2572+
data: { name: 'Timmy' },
2573+
})
24762574
await delay(1)
24772575
expect(subscriptionCount('getUser(1)')).toBe(0)
24782576
})
@@ -2489,6 +2587,7 @@ describe('skip behaviour', () => {
24892587

24902588
expect(result.current).toEqual(uninitialized)
24912589
await delay(1)
2590+
24922591
expect(subscriptionCount('getUser(1)')).toBe(0)
24932592
// also no subscription on `getUser(skipToken)` or similar:
24942593
expect(storeRef.store.getState().api.subscriptions).toEqual({})
@@ -2504,10 +2603,51 @@ describe('skip behaviour', () => {
25042603
await act(async () => {
25052604
rerender([skipToken])
25062605
})
2507-
expect(result.current).toEqual(uninitialized)
2606+
expect(result.current).toEqual({
2607+
...uninitialized,
2608+
currentData: undefined,
2609+
data: { name: 'Timmy' },
2610+
})
25082611
await delay(1)
25092612
expect(subscriptionCount('getUser(1)')).toBe(0)
25102613
})
2614+
2615+
test('skipping a previously fetched query retains the existing value as `data`, but clears `currentData`', async () => {
2616+
const { result, rerender } = renderHook(
2617+
([arg, options]: Parameters<typeof api.endpoints.getUser.useQuery>) =>
2618+
api.endpoints.getUser.useQuery(arg, options),
2619+
{
2620+
wrapper: storeRef.wrapper,
2621+
initialProps: [1],
2622+
}
2623+
)
2624+
2625+
await act(async () => {
2626+
await delay(1)
2627+
})
2628+
2629+
// Normal fulfilled result, with both `data` and `currentData`
2630+
expect(result.current).toMatchObject({
2631+
status: QueryStatus.fulfilled,
2632+
isSuccess: true,
2633+
data: { name: 'Timmy' },
2634+
currentData: { name: 'Timmy' },
2635+
})
2636+
2637+
await act(async () => {
2638+
rerender([1, { skip: true }])
2639+
await delay(1)
2640+
})
2641+
2642+
// After skipping, the query is "uninitialized", but still retains the last fetched `data`
2643+
// even though it's skipped. `currentData` is undefined, since that matches the current arg.
2644+
expect(result.current).toMatchObject({
2645+
status: QueryStatus.uninitialized,
2646+
isSuccess: false,
2647+
data: { name: 'Timmy' },
2648+
currentData: undefined,
2649+
})
2650+
})
25112651
})
25122652

25132653
// type tests:

0 commit comments

Comments
 (0)