@@ -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'
1320import userEvent from '@testing-library/user-event'
1421import { rest } from 'msw'
1522import {
@@ -27,7 +34,6 @@ import type { AnyAction } from 'redux'
2734import type { SubscriptionOptions } from '@reduxjs/toolkit/dist/query/core/apiState'
2835import type { SerializedError } from '@reduxjs/toolkit'
2936import { createListenerMiddleware , configureStore } from '@reduxjs/toolkit'
30- import { renderHook } from '@testing-library/react'
3137import { 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