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 6 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 |
---|---|---|
@@ -1,35 +1,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 +66,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 +98,49 @@ 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 = { | ||
updater: this.createUpdater() | ||
props, | ||
childPropsSelector, | ||
childProps: {}, | ||
store, | ||
storeState, | ||
error: null, | ||
} | ||
this.state = { | ||
...this.state, | ||
...Connect.getChildPropsState(props, this.state) | ||
} | ||
this.initSubscription() | ||
} | ||
|
||
static getChildPropsState(props, state) { | ||
try { | ||
const nextProps = state.childPropsSelector(state.store.getState(), props) | ||
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. 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 commentThe 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: Parent <- redux
Parent updates state to store new redux state
Parent gDSFP called
Parent props change,
Parent render called, renders children
Child <- props
child gDSFP called
...
Child <- redux 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 |
||
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 | ||
} | ||
|
||
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 +160,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 +190,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 +227,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 +239,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 +263,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 +273,6 @@ export default function connectAdvanced( | |
Connect.childContextTypes = childContextTypes | ||
Connect.contextTypes = contextTypes | ||
Connect.propTypes = contextTypes | ||
Connect.getDerivedStateFromProps = getDerivedStateFromProps | ||
|
||
if (process.env.NODE_ENV !== 'production') { | ||
Connect.prototype.componentDidUpdate = function componentDidUpdate() { | ||
|
@@ -276,15 +297,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.
We're not going to break React compat in this version yet. We need to still support <=16.2, otherwise this is a major revision.
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.
#965 (comment) Could you and @markerikson decide what you want and let me know?
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.
(point of clarification) Because gDSFP changed in React 16.4, the only way to support both is to have parallel implementations, and a runtime check when connectAdvanced is loaded, something like this pseudo-code:
Or you could use the 5.1 (current test.1 version) with the polyfill for the older React versions.
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.
gDSFP changed in 16.4 to fire more often (specifically on any update, not just ones from props changes), so it should be compatible with 16.3 and react-lifecycle-compat. If it's not, then this still suffers from a buggy or improper implementation.
One thing that might of use is in #856, which makes use of a setState callback that returns null to cancel the update. I might not take that PR entirely verbatim, but the concept may be helpful to the implementation here.
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.
Because gDSFP has no way of knowing whether an update comes from a state change or from a props change, there is no way to support receiving both props and state (redux changes) that works in both 16.3 and 16.4.
I'm baffled as to why you would think it necessary to explain how the gDSFP API changed as if it wasn't the core of the original bug report this PR and my prior version addresses. Perhaps you're not reading the PR carefully enough?
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.
You certainly can know and you've implemented such a check.
this.state.props
is essentiallyprevProps
, which you can check againstnextProps
to determine the source.Or, if you wanted to do it differently, you can add something to the setState (which comes through with
prevState
) to signal that this is a state-based update.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.
I'm sorry, but I need to make a pointed remark. Please don't take it personally, I am merely pointing out an inefficiency in our communication which I would love to see remedied so we can actually solve this problem.
I don't believe you've actually taken any substantive time to look at this pull request before dismissing it, because it literally does everything you have just described. For instance, here is the source code to gDSFP:
Note the comparison of props to state.props.
I misspoke and did not check to see if the patch worked in 16.0-16.3, which would have saved some time as well.
So, I just tried the pull request in React 16.3, and React 16.0 and the latest React 15. All tests except the strict mode test (which only exists in 16.3+) pass in all React 16 versions. In React 15, all the tests fail because the test renderer isn't working to return the root, and that is with the polyfill. I haven't done the research to see why that is happening, but it is an issue with the tests. I also haven't taken the time yet to see if the thing works in a test project even though all the tests fail.
So the main issue is that the peer dependency in package.json is too restrictive, and can be relaxed to include React 16+, and possibly 15, if the issue with the tests can be determined to be only in the tests.