diff --git a/CHANGELOG.md b/CHANGELOG.md index 43de1eb..ec74446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Change Log ## [Unreleased] +### Changed +- useSelector avoids stale props without try-catch mitigation ## [4.4.0] - 2019-10-17 ### Added diff --git a/__tests__/03_stale_props.js b/__tests__/03_stale_props.js new file mode 100644 index 0000000..4448888 --- /dev/null +++ b/__tests__/03_stale_props.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { createStore } from 'redux'; + +import { render, cleanup, act } from '@testing-library/react'; + +import { Provider, useSelector } from '../src/index'; + +describe('stale props spec', () => { + afterEach(cleanup); + + it('ignores transient errors in selector (e.g. due to stale props)', () => { + const Parent = () => { + const count = useSelector((state) => state.count); + return ; + }; + + const Child = ({ parentCount }) => { + const selector = (state) => { + if (state.count !== parentCount) { + throw new Error(); + } + return state.count + parentCount; + }; + const result = useSelector(selector); + return
{result}
; + }; + + const store = createStore((state = { count: -1 }) => ({ count: state.count + 1 })); + + const App = () => ( + + + + ); + + render(); + act(() => { + expect(() => store.dispatch({ type: '' })).not.toThrowError(); + }); + }); + + it('ensures consistency of state and props in selector', () => { + let selectorSawInconsistencies = false; + + const Parent = () => { + const count = useSelector((state) => state.count); + return ; + }; + + const Child = ({ parentCount }) => { + const selector = (state) => { + selectorSawInconsistencies = selectorSawInconsistencies || (state.count !== parentCount); + return state.count + parentCount; + }; + const result = useSelector(selector); + return
{result}
; + }; + + const store = createStore((state = { count: -1 }) => ({ count: state.count + 1 })); + + const App = () => ( + + + + ); + + render(); + act(() => { + store.dispatch({ type: '' }); + }); + expect(selectorSawInconsistencies).toBe(false); + }); +}); diff --git a/src/useSelector.js b/src/useSelector.js index 9ea6868..3410d83 100644 --- a/src/useSelector.js +++ b/src/useSelector.js @@ -1,14 +1,7 @@ -import { - useContext, - useEffect, - useRef, - useReducer, -} from 'react'; +import { useContext, useEffect, useReducer } from 'react'; import { defaultContext } from './Provider'; -import { useIsomorphicLayoutEffect } from './utils'; - const isFunction = (f) => typeof f === 'function'; const defaultEqualityFn = (a, b) => a === b; @@ -17,32 +10,14 @@ export const useSelector = (selector, eqlFn, opts) => { equalityFn = isFunction(eqlFn) ? eqlFn : defaultEqualityFn, customContext = defaultContext, } = opts || (!isFunction(eqlFn) && eqlFn) || {}; - const [, forceUpdate] = useReducer((c) => c + 1, 0); const { state, subscribe } = useContext(customContext); - const selected = selector(state); - const ref = useRef(null); - useIsomorphicLayoutEffect(() => { - ref.current = { - equalityFn, - selector, - state, - selected, - }; - }); + const [selected, updateSelected] = useReducer((prevSelected) => { + const nextSelected = selector(state); + if (equalityFn(prevSelected, nextSelected)) return prevSelected; + return nextSelected; + }, state, selector); useEffect(() => { - const callback = (nextState) => { - try { - if (ref.current.state === nextState - || ref.current.equalityFn(ref.current.selected, ref.current.selector(nextState))) { - // not changed - return; - } - } catch (e) { - // ignored (stale props or some other reason) - } - forceUpdate(); - }; - const unsubscribe = subscribe(callback); + const unsubscribe = subscribe(updateSelected); return unsubscribe; }, [subscribe]); return selected;