Skip to content

Commit a30e8b4

Browse files
authored
Merge pull request #1870 from reduxjs/bugfix/v8-unsubscribe-perf
2 parents 87c5621 + 43d1cc6 commit a30e8b4

File tree

2 files changed

+95
-53
lines changed

2 files changed

+95
-53
lines changed

src/hooks/useSelector.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useContext, useDebugValue } from 'react'
1+
import { useContext, useDebugValue, useCallback } from 'react'
22

33
import { useReduxContext as useDefaultReduxContext } from './useReduxContext'
44
import { ReactReduxContext } from '../components/Context'
@@ -48,12 +48,11 @@ export function createSelectorHook(
4848
}
4949
}
5050

51-
const { store, getServerState } = useReduxContext()!
51+
const { store, subscription, getServerState } = useReduxContext()!
5252

5353
const selectedState = useSyncExternalStoreWithSelector(
54-
store.subscribe,
54+
subscription.addNestedSub,
5555
store.getState,
56-
// TODO Need a server-side snapshot here
5756
getServerState || store.getState,
5857
selector,
5958
equalityFn

test/hooks/useSelector.spec.tsx

Lines changed: 92 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
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'
410
import { createStore } from 'redux'
511
import * as rtl from '@testing-library/react'
612
import {
713
Provider as ProviderMock,
814
useSelector,
15+
useDispatch,
916
shallowEqual,
1017
connect,
1118
createSelectorHook,
19+
ReactReduxContext,
20+
Subscription,
1221
} from '../../src/index'
1322
import 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

Comments
 (0)