-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
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
Fix getDerivedStateFromProps usage #980
Changes from 7 commits
b631441
25e3fc6
de4e4f3
f751ac9
9284894
948c20f
490b0e3
86e986b
1d96dac
26e4fb8
6b83e13
8a8e151
52d4f45
3333363
d50fb7d
1d8ab34
fe60a4b
72fca1c
1285e4e
35e9d12
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,34 +2,13 @@ import hoistStatics from 'hoist-non-react-statics' | |
import invariant from 'invariant' | ||
import { Component, createElement } from 'react' | ||
import { polyfill } from 'react-lifecycles-compat' | ||
import shallowEqual from '../utils/shallowEqual' | ||
|
||
import Subscription from '../utils/Subscription' | ||
import { storeShape, subscriptionShape } from '../utils/PropTypes' | ||
|
||
let hotReloadingVersion = 0 | ||
function noop() {} | ||
function makeUpdater(sourceSelector, store) { | ||
return function updater(props, prevState) { | ||
try { | ||
const nextProps = sourceSelector(store.getState(), props) | ||
if (nextProps !== prevState.props || prevState.error) { | ||
return { | ||
shouldComponentUpdate: true, | ||
props: nextProps, | ||
error: null, | ||
} | ||
} | ||
return { | ||
shouldComponentUpdate: false, | ||
} | ||
} catch (error) { | ||
return { | ||
shouldComponentUpdate: true, | ||
error, | ||
} | ||
} | ||
} | ||
} | ||
|
||
export default function connectAdvanced( | ||
/* | ||
|
@@ -88,10 +67,6 @@ export default function connectAdvanced( | |
[subscriptionKey]: subscriptionShape, | ||
} | ||
|
||
function getDerivedStateFromProps(nextProps, prevState) { | ||
return prevState.updater(nextProps, prevState) | ||
} | ||
|
||
return function wrapWithConnect(WrappedComponent) { | ||
invariant( | ||
typeof WrappedComponent == 'function', | ||
|
@@ -124,22 +99,52 @@ export default function connectAdvanced( | |
|
||
this.version = version | ||
this.renderCount = 0 | ||
this.store = props[storeKey] || context[storeKey] | ||
const store = props[storeKey] || context[storeKey] | ||
this.propsMode = Boolean(props[storeKey]) | ||
this.setWrappedInstance = this.setWrappedInstance.bind(this) | ||
|
||
invariant(this.store, | ||
invariant(store, | ||
`Could not find "${storeKey}" in either the context or props of ` + | ||
`"${displayName}". Either wrap the root component in a <Provider>, ` + | ||
`or explicitly pass "${storeKey}" as a prop to "${displayName}".` | ||
) | ||
const storeState = store.getState() | ||
|
||
const childPropsSelector = this.createChildSelector(store) | ||
this.state = { | ||
props, | ||
childPropsSelector, | ||
childProps: {}, | ||
store, | ||
storeState, | ||
error: null, | ||
} | ||
this.state = { | ||
updater: this.createUpdater() | ||
...this.state, | ||
...Connect.getChildPropsState(props, this.state) | ||
} | ||
this.initSubscription() | ||
} | ||
|
||
static getChildPropsState(props, state) { | ||
try { | ||
const nextProps = state.childPropsSelector(state.store.getState(), props) | ||
if (nextProps === state.childProps) return null | ||
return { childProps: nextProps } | ||
} catch (error) { | ||
return { error } | ||
} | ||
} | ||
|
||
static getDerivedStateFromProps(props, state) { | ||
if (connectOptions.pure && shallowEqual(props, state.props) || state.error) return null | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe add parentheses around the |
||
const nextChildProps = Connect.getChildPropsState(props, state) | ||
return { | ||
...nextChildProps, | ||
props | ||
} | ||
} | ||
|
||
getChildContext() { | ||
// If this component received store from props, its subscription should be transparent | ||
// to any descendants receiving store+subscription from context; it passes along | ||
|
@@ -159,11 +164,14 @@ export default function connectAdvanced( | |
// dispatching an action in its componentWillMount, we have to re-run the select and maybe | ||
// re-render. | ||
this.subscription.trySubscribe() | ||
this.runUpdater() | ||
this.updateChildPropsFromReduxStore(false) | ||
} | ||
|
||
shouldComponentUpdate(_, nextState) { | ||
return nextState.shouldComponentUpdate | ||
if (!connectOptions['pure']) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any particular reason for the bracketed field access here? Pretty sure you should be able to dot-access it - that would only be an issue if |
||
return true | ||
} | ||
return nextState.childProps !== this.state.childProps || nextState.error | ||
} | ||
|
||
componentWillUnmount() { | ||
|
@@ -186,17 +194,35 @@ export default function connectAdvanced( | |
this.wrappedInstance = ref | ||
} | ||
|
||
createUpdater() { | ||
const sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions) | ||
return makeUpdater(sourceSelector, this.store) | ||
createChildSelector(store = this.state.store) { | ||
return selectorFactory(store.dispatch, selectorFactoryOptions) | ||
} | ||
|
||
runUpdater(callback = noop) { | ||
updateChildPropsFromReduxStore(notify = true) { | ||
if (this.isUnmounted) { | ||
return | ||
} | ||
|
||
this.setState(prevState => prevState.updater(this.props, prevState), callback) | ||
this.setState(prevState => { | ||
const nextState = this.state.store.getState() | ||
if (nextState === prevState.storeState) { | ||
return null | ||
} | ||
const childPropsState = Connect.getChildPropsState(this.props, { | ||
...this.state, | ||
storeState: nextState | ||
}) | ||
const ret = { | ||
storeState: nextState, | ||
...childPropsState | ||
} | ||
if (notify && childPropsState === null) { | ||
this.notifyNestedSubs() | ||
} | ||
return ret | ||
}, () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could change this to |
||
if (notify) this.notifyNestedSubs() | ||
}) | ||
} | ||
|
||
initSubscription() { | ||
|
@@ -205,7 +231,7 @@ export default function connectAdvanced( | |
// parentSub's source should match where store came from: props vs. context. A component | ||
// connected to the store via props shouldn't use subscription from context, or vice versa. | ||
const parentSub = (this.propsMode ? this.props : this.context)[subscriptionKey] | ||
this.subscription = new Subscription(this.store, parentSub, this.onStateChange.bind(this)) | ||
this.subscription = new Subscription(this.state.store, parentSub, this.onStateChange.bind(this)) | ||
|
||
// `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in | ||
// the middle of the notification loop, where `this.subscription` will then be null. An | ||
|
@@ -217,7 +243,7 @@ export default function connectAdvanced( | |
} | ||
|
||
onStateChange() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If all this does is call |
||
this.runUpdater(this.notifyNestedSubs) | ||
this.updateChildPropsFromReduxStore() | ||
} | ||
|
||
isSubscribed() { | ||
|
@@ -241,7 +267,7 @@ export default function connectAdvanced( | |
if (this.state.error) { | ||
throw this.state.error | ||
} else { | ||
return createElement(WrappedComponent, this.addExtraProps(this.state.props)) | ||
return createElement(WrappedComponent, this.addExtraProps(this.state.childProps)) | ||
} | ||
} | ||
} | ||
|
@@ -251,7 +277,7 @@ export default function connectAdvanced( | |
Connect.childContextTypes = childContextTypes | ||
Connect.contextTypes = contextTypes | ||
Connect.propTypes = contextTypes | ||
Connect.getDerivedStateFromProps = getDerivedStateFromProps | ||
polyfill(Connect) | ||
|
||
if (process.env.NODE_ENV !== 'production') { | ||
Connect.prototype.componentDidUpdate = function componentDidUpdate() { | ||
|
@@ -276,15 +302,13 @@ export default function connectAdvanced( | |
oldListeners.forEach(listener => this.subscription.listeners.subscribe(listener)) | ||
} | ||
|
||
const updater = this.createUpdater() | ||
this.setState({updater}) | ||
this.runUpdater() | ||
const childPropsSelector = this.createChildSelector() | ||
const childProps = childPropsSelector(this.props, this.state.storeState) | ||
this.setState({ childPropsSelector, childProps }) | ||
} | ||
} | ||
} | ||
|
||
polyfill(Connect) | ||
|
||
return hoistStatics(Connect, WrappedComponent) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reading the store state here is still my biggest concern, async-wise, because it seems vulnerable to the "time-slicing" behavior. But, I guess we can go with it until we get more examples from the React team for what proper patterns look like.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the problem is that if we don't do this, then we will always call mapStateToProps twice, once for props change, once for redux state change, whenever an action is dispatched. This is because when an update occurs, it follows this path:
This is because the parent call to update child subscriptions happens in the getState callback, which is not called until the entire tree has been rendered (at least in the tests). Basically, we can't rely on the subscription happening at a deterministic moment, and so the only way to get a non-stale redux state is to retrieve it just-in-time when a props change comes in.
I don't think this will be a problem, because in the worst case (as I understand it), a calculation might get thrown out and re-started. In other words, I don't think it is possible for a major delay between gDSFP and render, because if rendering is suspended, and redux state changes, we will get an async event that calls setState, which will re-trigger gDSFP when rendering resumes.
In addition, if props change after a delay, gDSFP will have to be called again prior to rendering. The worst case is that the stored redux state is stale, which is OK, because if an action is dispatched, we will compare the stale state to the actual state, and determine that we should re-run mSP to see if derived props have changed. So we won't ever be incorrect, just possibly slightly inefficient. But since we are talking about a single extra call to mSP over the course of many seconds, it should not be significant for all but the most unusual cases where the redux state is updating constantly and a large number of components are rendering in response to changes.
So I put this in because in my reasoning, it saves us from constantly calling mSP unnecessarily twice as often, at the expense of occasionally calling it twice after a long delay due to suspense (as in seconds), where performance will not be a noticeable issue.
The next thing is to start playing with the suspense demo and see if we can get it to break the expectations I laid out above