11/*eslint-disable react/prop-types*/
22
3- import React , { useCallback , useReducer , useLayoutEffect } from 'react'
3+ import React , {
4+ useCallback ,
5+ useReducer ,
6+ useLayoutEffect ,
7+ useState ,
8+ useContext ,
9+ } from 'react'
410import { createStore } from 'redux'
511import * as rtl from '@testing-library/react'
612import {
713 Provider as ProviderMock ,
814 useSelector ,
15+ useDispatch ,
916 shallowEqual ,
1017 connect ,
1118 createSelectorHook ,
19+ ReactReduxContext ,
20+ Subscription ,
1221} from '../../src/index'
1322import type {
1423 TypedUseSelectorHook ,
@@ -29,7 +38,6 @@ describe('React', () => {
2938 let renderedItems : any [ ] = [ ]
3039 type RootState = ReturnType < typeof normalStore . getState >
3140 let useNormalSelector : TypedUseSelectorHook < RootState > = useSelector
32- type VoidFunc = ( ) => void
3341
3442 beforeEach ( ( ) => {
3543 normalStore = createStore (
@@ -123,65 +131,42 @@ describe('React', () => {
123131 } )
124132
125133 it ( 'subscribes to the store synchronously' , ( ) => {
126- const listeners = new Set < VoidFunc > ( )
127- const originalSubscribe = normalStore . subscribe
128-
129- jest
130- . spyOn ( normalStore , 'subscribe' )
131- . mockImplementation ( ( callback : VoidFunc ) => {
132- listeners . add ( callback )
133- const originalUnsubscribe = originalSubscribe ( callback )
134-
135- return ( ) => {
136- listeners . delete ( callback )
137- originalUnsubscribe ( )
138- }
139- } )
134+ let appSubscription : Subscription | null = null
140135
141- const Parent = ( ) => {
136+ const Child = ( ) => {
142137 const count = useNormalSelector ( ( s ) => s . count )
143- return count === 1 ? < Child /> : null
138+ return < div > { count } </ div >
144139 }
145140
146- const Child = ( ) => {
141+ const Parent = ( ) => {
142+ const { subscription } = useContext ( ReactReduxContext )
143+ appSubscription = subscription
147144 const count = useNormalSelector ( ( s ) => s . count )
148- return < div > { count } </ div >
145+ return count === 1 ? < Child /> : null
149146 }
150147
151148 rtl . render (
152149 < ProviderMock store = { normalStore } >
153150 < Parent />
154151 </ ProviderMock >
155152 )
156- // Provider + 1 component
157- expect ( listeners . size ) . toBe ( 2 )
153+ // Parent component only
154+ expect ( appSubscription ! . getListeners ( ) . get ( ) . length ) . toBe ( 1 )
158155
159156 rtl . act ( ( ) => {
160157 normalStore . dispatch ( { type : '' } )
161158 } )
162159
163- // Provider + 2 components
164- expect ( listeners . size ) . toBe ( 3 )
160+ // Parent component + 1 child component
161+ expect ( appSubscription ! . getListeners ( ) . get ( ) . length ) . toBe ( 2 )
165162 } )
166163
167164 it ( 'unsubscribes when the component is unmounted' , ( ) => {
168- const originalSubscribe = normalStore . subscribe
169-
170- const listeners = new Set < VoidFunc > ( )
171-
172- jest
173- . spyOn ( normalStore , 'subscribe' )
174- . mockImplementation ( ( callback : VoidFunc ) => {
175- listeners . add ( callback )
176- const originalUnsubscribe = originalSubscribe ( callback )
177-
178- return ( ) => {
179- listeners . delete ( callback )
180- originalUnsubscribe ( )
181- }
182- } )
165+ let appSubscription : Subscription | null = null
183166
184167 const Parent = ( ) => {
168+ const { subscription } = useContext ( ReactReduxContext )
169+ appSubscription = subscription
185170 const count = useNormalSelector ( ( s ) => s . count )
186171 return count === 0 ? < Child /> : null
187172 }
@@ -196,15 +181,15 @@ describe('React', () => {
196181 < Parent />
197182 </ ProviderMock >
198183 )
199- // Provider + 2 components
200- expect ( listeners . size ) . toBe ( 3 )
184+ // Parent + 1 child component
185+ expect ( appSubscription ! . getListeners ( ) . get ( ) . length ) . toBe ( 2 )
201186
202187 rtl . act ( ( ) => {
203188 normalStore . dispatch ( { type : '' } )
204189 } )
205190
206- // Provider + 1 component
207- expect ( listeners . size ) . toBe ( 2 )
191+ // Parent component only
192+ expect ( appSubscription ! . getListeners ( ) . get ( ) . length ) . toBe ( 1 )
208193 } )
209194
210195 it ( 'notices store updates between render and store subscription effect' , ( ) => {
@@ -504,12 +489,7 @@ describe('React', () => {
504489 )
505490
506491 const doDispatch = ( ) => normalStore . dispatch ( { type : '' } )
507- // Seems to be an alteration in behavior - not sure if 17/18, or shim/built-in
508- if ( IS_REACT_18 ) {
509- expect ( doDispatch ) . not . toThrowError ( )
510- } else {
511- expect ( doDispatch ) . toThrowError ( )
512- }
492+ expect ( doDispatch ) . not . toThrowError ( )
513493
514494 spy . mockRestore ( )
515495 } )
@@ -660,6 +640,69 @@ describe('React', () => {
660640 expect ( renderedItems . length ) . toBe ( 2 )
661641 expect ( renderedItems [ 0 ] ) . toBe ( renderedItems [ 1 ] )
662642 } )
643+
644+ it ( 'should have linear or better unsubscribe time, not quadratic' , ( ) => {
645+ const reducer = ( state : number = 0 , action : any ) =>
646+ action . type === 'INC' ? state + 1 : state
647+ const store = createStore ( reducer )
648+ const increment = ( ) => ( { type : 'INC' } )
649+
650+ const numChildren = 100000
651+
652+ function App ( ) {
653+ useSelector ( ( s : number ) => s )
654+ const dispatch = useDispatch ( )
655+
656+ const [ children , setChildren ] = useState ( numChildren )
657+
658+ const toggleChildren = ( ) =>
659+ setChildren ( ( c ) => ( c ? 0 : numChildren ) )
660+
661+ return (
662+ < div >
663+ < button onClick = { toggleChildren } > Toggle Children</ button >
664+ < button onClick = { ( ) => dispatch ( increment ( ) ) } > Increment</ button >
665+ { [ ...Array ( children ) . keys ( ) ] . map ( ( i ) => (
666+ < Child key = { i } />
667+ ) ) }
668+ </ div >
669+ )
670+ }
671+
672+ function Child ( ) {
673+ useSelector ( ( s : number ) => s )
674+ // Deliberately do not return any DOM here - we want to isolate the cost of
675+ // unsubscribing, and tearing down thousands of JSDOM nodes is expensive and irrelevant
676+ return null
677+ }
678+
679+ const { getByText } = rtl . render (
680+ < ProviderMock store = { store } >
681+ < App />
682+ </ ProviderMock >
683+ )
684+
685+ const timeBefore = Date . now ( )
686+
687+ const button = getByText ( 'Toggle Children' )
688+ rtl . act ( ( ) => {
689+ rtl . fireEvent . click ( button )
690+ } )
691+
692+ const timeAfter = Date . now ( )
693+ const elapsedTime = timeAfter - timeBefore
694+
695+ // Seeing an unexpected variation in elapsed time between React 18 and React 17 + the compat entry point.
696+ // With 18, I see around 75ms with correct implementation on my machine, with 100K items.
697+ // With 17 + compat, the same correct impl takes about 4200-5000ms.
698+ // With the quadratic behavior, this is at least 13000ms (or worse!) under 18, and 22000ms+ with 17.
699+ // The 13000ms time for 18 stays the same if I use the shim, so it must be a 17 vs 18 difference somehow,
700+ // although I can't imagine why, and if I remove the `useSelector` calls both tests drop to ~50ms.
701+ // So, we'll modify our expectations here depending on whether this is an 18 or 17 compat test,
702+ // and give some buffer time to allow for variations in test machines.
703+ const expectedMaxUnmountTime = IS_REACT_18 ? 500 : 7000
704+ expect ( elapsedTime ) . toBeLessThan ( expectedMaxUnmountTime )
705+ } )
663706 } )
664707
665708 describe ( 'error handling for invalid arguments' , ( ) => {
0 commit comments