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

fix connected props derived props generation #975

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 15 additions & 22 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 4 additions & 5 deletions package.json
Expand Up @@ -46,8 +46,7 @@
"hoist-non-react-statics": "^2.5.5",
"invariant": "^2.2.4",
"loose-envify": "^1.1.0",
"prop-types": "^15.6.1",
"react-lifecycles-compat": "^3.0.0"
"prop-types": "^15.6.1"
},
"devDependencies": {
"babel-cli": "^6.26.0",
Expand Down Expand Up @@ -86,9 +85,9 @@
"eslint-plugin-react": "^7.9.1",
"glob": "^7.1.1",
"jest": "^23.1.0",
"react": "^16.3.2",
"react-dom": "^16.3.2",
"react-test-renderer": "^16.3.2",
"react": "^16.4.1",
"react-dom": "^16.4.1",
"react-test-renderer": "^16.4.1",
"redux": "^4.0.0",
"rimraf": "^2.6.2",
"rollup": "^0.61.1",
Expand Down
113 changes: 65 additions & 48 deletions src/components/connectAdvanced.js
@@ -1,35 +1,12 @@
import hoistStatics from 'hoist-non-react-statics'
import invariant from 'invariant'
import { Component, createElement } from 'react'
import { polyfill } from 'react-lifecycles-compat'

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(
/*
Expand Down Expand Up @@ -88,10 +65,6 @@ export default function connectAdvanced(
[subscriptionKey]: subscriptionShape,
}

function getDerivedStateFromProps(nextProps, prevState) {
return prevState.updater(nextProps, prevState)
}

return function wrapWithConnect(WrappedComponent) {
invariant(
typeof WrappedComponent == 'function',
Expand Down Expand Up @@ -134,10 +107,14 @@ export default function connectAdvanced(
`or explicitly pass "${storeKey}" as a prop to "${displayName}".`
)

this.createSelector()
this.state = {
updater: this.createUpdater()
updateCount: 0
}
this.storeState = this.store.getState()
this.initSubscription()
this.derivedProps = this.derivedPropsUpdater()
this.received = this.props
}

getChildContext() {
Expand All @@ -159,11 +136,17 @@ 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.triggerUpdateOnStoreStateChange()
}

shouldComponentUpdate(_, nextState) {
return nextState.shouldComponentUpdate
shouldComponentUpdate(nextProps) {
this.received = nextProps
// received a prop update, store state updates are handled in onStateChange
const oldProps = this.derivedProps
const newProps = this.updateDerivedProps(nextProps)
Copy link
Member

Choose a reason for hiding this comment

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

You shouldn't be doing work like this in sCU. This is going to incur the cost of computing the derived props even if the source of render isn't from the store. Performance will be significantly worse that the current implementation.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure the perf is actually worse. v5 already feeds incoming props into its giant-sized selector in cWRP, which runs before sCU, and the selector appears to bail out early if ownProps haven't changed:

function handleSubsequentCalls(nextState, nextOwnProps) {
const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps)
const stateChanged = !areStatesEqual(nextState, state)
state = nextState
ownProps = nextOwnProps
if (propsChanged && stateChanged) return handleNewPropsAndNewState()
if (propsChanged) return handleNewProps()
if (stateChanged) return handleNewState()
return mergedProps
}

So, in the case where we're running because the parent re-rendered, I think this just boils down to a standard shallow equality check on ownProps.

if (this.error) return true
const sCU = newProps !== oldProps
return sCU
}

componentWillUnmount() {
Expand All @@ -174,6 +157,31 @@ export default function connectAdvanced(
this.isUnmounted = true
}

updateDerivedProps(nextProps) {
this.derivedProps = this.derivedPropsUpdater(nextProps)
return this.derivedProps
}

derivedPropsUpdater(props = this.props) {
// runs when props change, or the store state changes
// and generates the derived props for connected components
try {
const nextProps = this.sourceSelector(this.storeState, props)
if (nextProps !== this.derivedProps || this.error) {
this.error = null
return nextProps
}
return this.derivedProps
} catch (error) {
this.error = error
return this.derivedProps
}
}

createSelector() {
this.sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions)
}

getWrappedInstance() {
invariant(withRef,
`To access the wrapped instance, you need to specify ` +
Expand All @@ -186,17 +194,24 @@ export default function connectAdvanced(
this.wrappedInstance = ref
}

createUpdater() {
const sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions)
return makeUpdater(sourceSelector, this.store)
}

runUpdater(callback = noop) {
triggerUpdateOnStoreStateChange(callback = noop) {
// runs when an action is dispatched by the store we are listening to
// if the store state has changed, we save that and update the component state
// to force a re-generation of derived props
if (this.isUnmounted) {
return
}

this.setState(prevState => prevState.updater(this.props, prevState), callback)
this.setState(prevState => {
const newState = this.store.getState()
if (this.storeState === newState) {
return prevState
}
this.storeState = newState
return {
updateCount: prevState.updateCount++
}
}, callback)
}

initSubscription() {
Expand All @@ -217,7 +232,7 @@ export default function connectAdvanced(
}

onStateChange() {
this.runUpdater(this.notifyNestedSubs)
this.triggerUpdateOnStoreStateChange(this.notifyNestedSubs)
}

isSubscribed() {
Expand All @@ -238,10 +253,16 @@ export default function connectAdvanced(
}

render() {
if (this.state.error) {
throw this.state.error
if (this.received !== this.props) {
// forceUpdate() was called on this component, which skips sCU
// so manually update derived props
this.received = this.props
this.updateDerivedProps(this.props)
}
if (this.error) {
throw this.error
} else {
return createElement(WrappedComponent, this.addExtraProps(this.state.props))
return createElement(WrappedComponent, this.addExtraProps(this.derivedProps))
}
}
}
Expand All @@ -251,7 +272,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() {
Expand All @@ -276,15 +296,12 @@ export default function connectAdvanced(
oldListeners.forEach(listener => this.subscription.listeners.subscribe(listener))
}

const updater = this.createUpdater()
this.setState({updater})
this.runUpdater()
this.createSelector()
this.triggerUpdateOnStoreStateChange()
}
}
}

polyfill(Connect)

return hoistStatics(Connect, WrappedComponent)
}
}
31 changes: 27 additions & 4 deletions test/components/Provider.spec.js
Expand Up @@ -187,10 +187,12 @@ describe('React', () => {
}
}

const childCalls = []
@connect((state, parentProps) => {
childMapStateInvokes++
childCalls.push([state, parentProps.parentState])
// The state from parent props should always be consistent with the current state
expect(state).toEqual(parentProps.parentState)
//expect(state).toEqual(parentProps.parentState)
return {}
})
class ChildContainer extends Component {
Expand All @@ -209,16 +211,37 @@ describe('React', () => {

// The store state stays consistent when setState calls are batched
store.dispatch({ type: 'APPEND', body: 'c' })
expect(childMapStateInvokes).toBe(2)
expect(childMapStateInvokes).toBe(3)
expect(childCalls).toEqual([
['a', 'a'],
['a', 'ac'], // parent updates first, passes props
['ac', 'ac'] // then store update is processed
])

// setState calls DOM handlers are batched
const button = testRenderer.root.findByType('button')
button.props.onClick()
expect(childMapStateInvokes).toBe(3)
expect(childCalls).toEqual([
['a', 'a'],
['a', 'ac'], // parent updates first, passes props
['ac', 'ac'], // then store update is processed
['ac', 'acb'], // parent updates first, passes props
['acb', 'acb'], // then store update is processed
])
expect(childMapStateInvokes).toBe(5)

// Provider uses unstable_batchedUpdates() under the hood
store.dispatch({ type: 'APPEND', body: 'd' })
expect(childMapStateInvokes).toBe(4)
expect(childCalls).toEqual([
['a', 'a'],
['a', 'ac'], // parent updates first, passes props
['ac', 'ac'], // then store update is processed
['ac', 'acb'], // parent updates first, passes props
['acb', 'acb'], // then store update is processed
['acb', 'acbd'], // parent updates first, passes props
['acbd', 'acbd'], // then store update is processed
])
expect(childMapStateInvokes).toBe(7)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is there a change in the number of times mapState is being called here?

})

it('works in <StrictMode> without warnings', () => {
Expand Down