Skip to content

Commit 43d1cc6

Browse files
committed
Switch back to Subscription in useSelector to fix unsubscribe perf
The Subscription impl originally used an array of listeners just like the actual Redux store, but in #1523 we changed it to be a linked list to fix perf issues. The use of `indexOf+splice` caused a quadratic cost to unmounting many components. In v8, I originally dropped use of `Subscription` to potentially save on bundle size. However, `Provider` still uses `Subscription`, so that will always be in the bundle. For this issue, a user pointed out that directly subscribing to the store brought back the original quadratic unsubscription behavior, which I've confirmed locally. Swapping `store.subscribe` for `subscription.addNestedSub` fixes that issue. I've added a unit test that mounts and unmounts a massive tree and measures the elapsed time. There's a distinct difference between the "correct" and quadratic behavior. Weirdly, there's also a big diff in "correct" time between React 18, and 17 + the "compat" entry point, and I have no idea why. It's not as bad as the quadratic time, but it's still pretty expensive. I've written the test to set an acceptable max unmount time based on which React version we're running against, with some buffer added.
1 parent 93d5ee5 commit 43d1cc6

File tree

2 files changed

+95
-53
lines changed

2 files changed

+95
-53
lines changed

src/hooks/useSelector.ts

+3-4
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

+92-49
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)