1
1
/*eslint-disable react/prop-types*/
2
2
3
- import React , { useCallback , useReducer , useLayoutEffect } from 'react'
3
+ import React , {
4
+ useCallback ,
5
+ useReducer ,
6
+ useLayoutEffect ,
7
+ useState ,
8
+ useContext ,
9
+ } from 'react'
4
10
import { createStore } from 'redux'
5
11
import * as rtl from '@testing-library/react'
6
12
import {
7
13
Provider as ProviderMock ,
8
14
useSelector ,
15
+ useDispatch ,
9
16
shallowEqual ,
10
17
connect ,
11
18
createSelectorHook ,
19
+ ReactReduxContext ,
20
+ Subscription ,
12
21
} from '../../src/index'
13
22
import type {
14
23
TypedUseSelectorHook ,
@@ -29,7 +38,6 @@ describe('React', () => {
29
38
let renderedItems : any [ ] = [ ]
30
39
type RootState = ReturnType < typeof normalStore . getState >
31
40
let useNormalSelector : TypedUseSelectorHook < RootState > = useSelector
32
- type VoidFunc = ( ) => void
33
41
34
42
beforeEach ( ( ) => {
35
43
normalStore = createStore (
@@ -123,65 +131,42 @@ describe('React', () => {
123
131
} )
124
132
125
133
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
140
135
141
- const Parent = ( ) => {
136
+ const Child = ( ) => {
142
137
const count = useNormalSelector ( ( s ) => s . count )
143
- return count === 1 ? < Child /> : null
138
+ return < div > { count } </ div >
144
139
}
145
140
146
- const Child = ( ) => {
141
+ const Parent = ( ) => {
142
+ const { subscription } = useContext ( ReactReduxContext )
143
+ appSubscription = subscription
147
144
const count = useNormalSelector ( ( s ) => s . count )
148
- return < div > { count } </ div >
145
+ return count === 1 ? < Child /> : null
149
146
}
150
147
151
148
rtl . render (
152
149
< ProviderMock store = { normalStore } >
153
150
< Parent />
154
151
</ ProviderMock >
155
152
)
156
- // Provider + 1 component
157
- expect ( listeners . size ) . toBe ( 2 )
153
+ // Parent component only
154
+ expect ( appSubscription ! . getListeners ( ) . get ( ) . length ) . toBe ( 1 )
158
155
159
156
rtl . act ( ( ) => {
160
157
normalStore . dispatch ( { type : '' } )
161
158
} )
162
159
163
- // Provider + 2 components
164
- expect ( listeners . size ) . toBe ( 3 )
160
+ // Parent component + 1 child component
161
+ expect ( appSubscription ! . getListeners ( ) . get ( ) . length ) . toBe ( 2 )
165
162
} )
166
163
167
164
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
183
166
184
167
const Parent = ( ) => {
168
+ const { subscription } = useContext ( ReactReduxContext )
169
+ appSubscription = subscription
185
170
const count = useNormalSelector ( ( s ) => s . count )
186
171
return count === 0 ? < Child /> : null
187
172
}
@@ -196,15 +181,15 @@ describe('React', () => {
196
181
< Parent />
197
182
</ ProviderMock >
198
183
)
199
- // Provider + 2 components
200
- expect ( listeners . size ) . toBe ( 3 )
184
+ // Parent + 1 child component
185
+ expect ( appSubscription ! . getListeners ( ) . get ( ) . length ) . toBe ( 2 )
201
186
202
187
rtl . act ( ( ) => {
203
188
normalStore . dispatch ( { type : '' } )
204
189
} )
205
190
206
- // Provider + 1 component
207
- expect ( listeners . size ) . toBe ( 2 )
191
+ // Parent component only
192
+ expect ( appSubscription ! . getListeners ( ) . get ( ) . length ) . toBe ( 1 )
208
193
} )
209
194
210
195
it ( 'notices store updates between render and store subscription effect' , ( ) => {
@@ -504,12 +489,7 @@ describe('React', () => {
504
489
)
505
490
506
491
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 ( )
513
493
514
494
spy . mockRestore ( )
515
495
} )
@@ -660,6 +640,69 @@ describe('React', () => {
660
640
expect ( renderedItems . length ) . toBe ( 2 )
661
641
expect ( renderedItems [ 0 ] ) . toBe ( renderedItems [ 1 ] )
662
642
} )
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
+ } )
663
706
} )
664
707
665
708
describe ( 'error handling for invalid arguments' , ( ) => {
0 commit comments