From 3971f7978d6ba2aaa6491002b31fbaf73adce266 Mon Sep 17 00:00:00 2001 From: daishi Date: Tue, 21 Jan 2020 06:15:39 +0900 Subject: [PATCH 1/2] Stale-props-free useSelector --- CHANGELOG.md | 2 ++ src/useSelector.js | 48 +++++++++++++++------------------------------- 2 files changed, 17 insertions(+), 33 deletions(-) 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/src/useSelector.js b/src/useSelector.js index 9ea6868..075bcda 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,33 +10,22 @@ 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, nextState) => { + const nextSelected = selector(nextState); + if (equalityFn(prevSelected, nextSelected)) return prevSelected; + return nextSelected; + }, state, selector); + let selectedToReturn = selected; + const currSelected = selector(state); + if (!equalityFn(selected, currSelected)) { + // schedule another update, because state from context has been changed + updateSelected(state); + selectedToReturn = currSelected; + } 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; + return selectedToReturn; }; From cbfad09f27db80429714dbe93b7853f0a959cc8b Mon Sep 17 00:00:00 2001 From: daishi Date: Tue, 21 Jan 2020 07:39:18 +0900 Subject: [PATCH 2/2] add spec, fix impl --- __tests__/03_stale_props.js | 73 +++++++++++++++++++++++++++++++++++++ src/useSelector.js | 13 ++----- 2 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 __tests__/03_stale_props.js 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 075bcda..3410d83 100644 --- a/src/useSelector.js +++ b/src/useSelector.js @@ -11,21 +11,14 @@ export const useSelector = (selector, eqlFn, opts) => { customContext = defaultContext, } = opts || (!isFunction(eqlFn) && eqlFn) || {}; const { state, subscribe } = useContext(customContext); - const [selected, updateSelected] = useReducer((prevSelected, nextState) => { - const nextSelected = selector(nextState); + const [selected, updateSelected] = useReducer((prevSelected) => { + const nextSelected = selector(state); if (equalityFn(prevSelected, nextSelected)) return prevSelected; return nextSelected; }, state, selector); - let selectedToReturn = selected; - const currSelected = selector(state); - if (!equalityFn(selected, currSelected)) { - // schedule another update, because state from context has been changed - updateSelected(state); - selectedToReturn = currSelected; - } useEffect(() => { const unsubscribe = subscribe(updateSelected); return unsubscribe; }, [subscribe]); - return selectedToReturn; + return selected; };