Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch back to Subscription in useSelector to fix unsubscribe perf #1870

Merged
merged 1 commit into from
Feb 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/hooks/useSelector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useContext, useDebugValue } from 'react'
import { useContext, useDebugValue, useCallback } from 'react'

import { useReduxContext as useDefaultReduxContext } from './useReduxContext'
import { ReactReduxContext } from '../components/Context'
Expand Down Expand Up @@ -48,12 +48,11 @@ export function createSelectorHook(
}
}

const { store, getServerState } = useReduxContext()!
const { store, subscription, getServerState } = useReduxContext()!

const selectedState = useSyncExternalStoreWithSelector(
store.subscribe,
subscription.addNestedSub,
store.getState,
// TODO Need a server-side snapshot here
getServerState || store.getState,
selector,
equalityFn
Expand Down
141 changes: 92 additions & 49 deletions test/hooks/useSelector.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
/*eslint-disable react/prop-types*/

import React, { useCallback, useReducer, useLayoutEffect } from 'react'
import React, {
useCallback,
useReducer,
useLayoutEffect,
useState,
useContext,
} from 'react'
import { createStore } from 'redux'
import * as rtl from '@testing-library/react'
import {
Provider as ProviderMock,
useSelector,
useDispatch,
shallowEqual,
connect,
createSelectorHook,
ReactReduxContext,
Subscription,
} from '../../src/index'
import type {
TypedUseSelectorHook,
Expand All @@ -29,7 +38,6 @@ describe('React', () => {
let renderedItems: any[] = []
type RootState = ReturnType<typeof normalStore.getState>
let useNormalSelector: TypedUseSelectorHook<RootState> = useSelector
type VoidFunc = () => void

beforeEach(() => {
normalStore = createStore(
Expand Down Expand Up @@ -123,65 +131,42 @@ describe('React', () => {
})

it('subscribes to the store synchronously', () => {
const listeners = new Set<VoidFunc>()
const originalSubscribe = normalStore.subscribe

jest
.spyOn(normalStore, 'subscribe')
.mockImplementation((callback: VoidFunc) => {
listeners.add(callback)
const originalUnsubscribe = originalSubscribe(callback)

return () => {
listeners.delete(callback)
originalUnsubscribe()
}
})
let appSubscription: Subscription | null = null

const Parent = () => {
const Child = () => {
const count = useNormalSelector((s) => s.count)
return count === 1 ? <Child /> : null
return <div>{count}</div>
}

const Child = () => {
const Parent = () => {
const { subscription } = useContext(ReactReduxContext)
appSubscription = subscription
const count = useNormalSelector((s) => s.count)
return <div>{count}</div>
return count === 1 ? <Child /> : null
}

rtl.render(
<ProviderMock store={normalStore}>
<Parent />
</ProviderMock>
)
// Provider + 1 component
expect(listeners.size).toBe(2)
// Parent component only
expect(appSubscription!.getListeners().get().length).toBe(1)

rtl.act(() => {
normalStore.dispatch({ type: '' })
})

// Provider + 2 components
expect(listeners.size).toBe(3)
// Parent component + 1 child component
expect(appSubscription!.getListeners().get().length).toBe(2)
})

it('unsubscribes when the component is unmounted', () => {
const originalSubscribe = normalStore.subscribe

const listeners = new Set<VoidFunc>()

jest
.spyOn(normalStore, 'subscribe')
.mockImplementation((callback: VoidFunc) => {
listeners.add(callback)
const originalUnsubscribe = originalSubscribe(callback)

return () => {
listeners.delete(callback)
originalUnsubscribe()
}
})
let appSubscription: Subscription | null = null

const Parent = () => {
const { subscription } = useContext(ReactReduxContext)
appSubscription = subscription
const count = useNormalSelector((s) => s.count)
return count === 0 ? <Child /> : null
}
Expand All @@ -196,15 +181,15 @@ describe('React', () => {
<Parent />
</ProviderMock>
)
// Provider + 2 components
expect(listeners.size).toBe(3)
// Parent + 1 child component
expect(appSubscription!.getListeners().get().length).toBe(2)

rtl.act(() => {
normalStore.dispatch({ type: '' })
})

// Provider + 1 component
expect(listeners.size).toBe(2)
// Parent component only
expect(appSubscription!.getListeners().get().length).toBe(1)
})

it('notices store updates between render and store subscription effect', () => {
Expand Down Expand Up @@ -504,12 +489,7 @@ describe('React', () => {
)

const doDispatch = () => normalStore.dispatch({ type: '' })
// Seems to be an alteration in behavior - not sure if 17/18, or shim/built-in
if (IS_REACT_18) {
expect(doDispatch).not.toThrowError()
} else {
expect(doDispatch).toThrowError()
}
expect(doDispatch).not.toThrowError()

spy.mockRestore()
})
Expand Down Expand Up @@ -660,6 +640,69 @@ describe('React', () => {
expect(renderedItems.length).toBe(2)
expect(renderedItems[0]).toBe(renderedItems[1])
})

it('should have linear or better unsubscribe time, not quadratic', () => {
const reducer = (state: number = 0, action: any) =>
action.type === 'INC' ? state + 1 : state
const store = createStore(reducer)
const increment = () => ({ type: 'INC' })

const numChildren = 100000

function App() {
useSelector((s: number) => s)
const dispatch = useDispatch()

const [children, setChildren] = useState(numChildren)

const toggleChildren = () =>
setChildren((c) => (c ? 0 : numChildren))

return (
<div>
<button onClick={toggleChildren}>Toggle Children</button>
<button onClick={() => dispatch(increment())}>Increment</button>
{[...Array(children).keys()].map((i) => (
<Child key={i} />
))}
</div>
)
}

function Child() {
useSelector((s: number) => s)
// Deliberately do not return any DOM here - we want to isolate the cost of
// unsubscribing, and tearing down thousands of JSDOM nodes is expensive and irrelevant
return null
}

const { getByText } = rtl.render(
<ProviderMock store={store}>
<App />
</ProviderMock>
)

const timeBefore = Date.now()

const button = getByText('Toggle Children')
rtl.act(() => {
rtl.fireEvent.click(button)
})

const timeAfter = Date.now()
const elapsedTime = timeAfter - timeBefore

// Seeing an unexpected variation in elapsed time between React 18 and React 17 + the compat entry point.
// With 18, I see around 75ms with correct implementation on my machine, with 100K items.
// With 17 + compat, the same correct impl takes about 4200-5000ms.
// With the quadratic behavior, this is at least 13000ms (or worse!) under 18, and 22000ms+ with 17.
// The 13000ms time for 18 stays the same if I use the shim, so it must be a 17 vs 18 difference somehow,
// although I can't imagine why, and if I remove the `useSelector` calls both tests drop to ~50ms.
// So, we'll modify our expectations here depending on whether this is an 18 or 17 compat test,
// and give some buffer time to allow for variations in test machines.
const expectedMaxUnmountTime = IS_REACT_18 ? 500 : 7000
expect(elapsedTime).toBeLessThan(expectedMaxUnmountTime)
Comment on lines +703 to +704
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do worry about the stability of this. This seems like a future case of "works on my machine!" being a false-positive result.

I don't think so based on how things are set up, but would it be possible to compare the React 17 vs 18 times to have an acceptable ratio instead? That would maintain stability between different CPU speeds.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't particularly like it either :) But the "incorrect" times I was seeing were 13.5s for 18 and 23s for 17, and given the number of children being unmounted I see that as a big enough gap that I don't think we're likely to have a false positive due to machine/environment variance.

I do kinda want to investigate why it's slower with React 17 a bit further. But the immediate goal for this test is just to specifically document that "we expect unsubscribing lots of components to not be quadratic" so that we don't forget in the future, and catch that if we were to ever somehow lose that behavior. I think the current test at least accomplishes that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worst case, the test randomly starts failing due to CPU/env stuff, and we change how it works then.

})
})

describe('error handling for invalid arguments', () => {
Expand Down