Skip to content

Commit aaa0470

Browse files
committed
fix connected props derived props generation
This commit fixes reduxjs#965 The essence of the problem is that getDerivedStateFromProps is called when the incoming props OR incoming local state changes. So we cannot store anything in state that is needed in shouldComponentUpdate. This commit splits up the tracking of incoming props, incoming store state changes, and removes getDerivedStateFromProps and the usage of local state to store any information. Instead, local state is used as a flag solely to track whether the incoming store state has changed. Since derived props are needed in shouldComponentUpdate, it is generated there and then compared to the previous version of derived props. If forceUpdate() is called, this bypasses sCU, and so a check in render() compares the props that should have been passed to sCU to those passed to render(). If they are different, it generates them just-in-time. To summarize: 1) shouldComponentUpdate is ONLY used to process changes to incoming props 2) runUpdater (renamed to triggerUpdateOnStoreStateChange) checks to see if the store state has changed, and stores the state, then updates the counter in local state in order to trigger a new sCU call to re-generate derived state. Because of these changes, getDerivedStateFromProps and the polyfill are both removed. All tests pass on my machine, but there is at least 1 side effects to the new design: - Many of the tests pass state unchanged to props, and pass this to child components. With these changes, the two updates are processed separately. Props changes are processed first, and then state changes are processed. I updated the affected tests to show that there are "in-between" states where the state and props are inconsistent and mapStateToProps is called with these changes. If the old behavior is desired, that would require another redesign, I suspect.
1 parent 3e53ff9 commit aaa0470

File tree

5 files changed

+135
-66
lines changed

5 files changed

+135
-66
lines changed

package-lock.json

Lines changed: 0 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,7 @@
4646
"hoist-non-react-statics": "^2.5.5",
4747
"invariant": "^2.2.4",
4848
"loose-envify": "^1.1.0",
49-
"prop-types": "^15.6.1",
50-
"react-lifecycles-compat": "^3.0.0"
49+
"prop-types": "^15.6.1"
5150
},
5251
"devDependencies": {
5352
"babel-cli": "^6.26.0",

src/components/connectAdvanced.js

Lines changed: 65 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,12 @@
11
import hoistStatics from 'hoist-non-react-statics'
22
import invariant from 'invariant'
33
import { Component, createElement } from 'react'
4-
import { polyfill } from 'react-lifecycles-compat'
54

65
import Subscription from '../utils/Subscription'
76
import { storeShape, subscriptionShape } from '../utils/PropTypes'
87

98
let hotReloadingVersion = 0
109
function noop() {}
11-
function makeUpdater(sourceSelector, store) {
12-
return function updater(props, prevState) {
13-
try {
14-
const nextProps = sourceSelector(store.getState(), props)
15-
if (nextProps !== prevState.props || prevState.error) {
16-
return {
17-
shouldComponentUpdate: true,
18-
props: nextProps,
19-
error: null,
20-
}
21-
}
22-
return {
23-
shouldComponentUpdate: false,
24-
}
25-
} catch (error) {
26-
return {
27-
shouldComponentUpdate: true,
28-
error,
29-
}
30-
}
31-
}
32-
}
3310

3411
export default function connectAdvanced(
3512
/*
@@ -88,10 +65,6 @@ export default function connectAdvanced(
8865
[subscriptionKey]: subscriptionShape,
8966
}
9067

91-
function getDerivedStateFromProps(nextProps, prevState) {
92-
return prevState.updater(nextProps, prevState)
93-
}
94-
9568
return function wrapWithConnect(WrappedComponent) {
9669
invariant(
9770
typeof WrappedComponent == 'function',
@@ -134,10 +107,14 @@ export default function connectAdvanced(
134107
`or explicitly pass "${storeKey}" as a prop to "${displayName}".`
135108
)
136109

110+
this.createSelector()
137111
this.state = {
138-
updater: this.createUpdater()
112+
updateCount: 0
139113
}
114+
this.storeState = this.store.getState()
140115
this.initSubscription()
116+
this.derivedProps = this.derivedPropsUpdater()
117+
this.received = this.props
141118
}
142119

143120
getChildContext() {
@@ -159,11 +136,17 @@ export default function connectAdvanced(
159136
// dispatching an action in its componentWillMount, we have to re-run the select and maybe
160137
// re-render.
161138
this.subscription.trySubscribe()
162-
this.runUpdater()
139+
this.triggerUpdateOnStoreStateChange()
163140
}
164141

165-
shouldComponentUpdate(_, nextState) {
166-
return nextState.shouldComponentUpdate
142+
shouldComponentUpdate(nextProps) {
143+
this.received = nextProps
144+
// received a prop update, store state updates are handled in onStateChange
145+
const oldProps = this.derivedProps
146+
const newProps = this.updateDerivedProps(nextProps)
147+
if (this.error) return true
148+
const sCU = newProps !== oldProps
149+
return sCU
167150
}
168151

169152
componentWillUnmount() {
@@ -174,6 +157,31 @@ export default function connectAdvanced(
174157
this.isUnmounted = true
175158
}
176159

160+
updateDerivedProps(nextProps) {
161+
this.derivedProps = this.derivedPropsUpdater(nextProps)
162+
return this.derivedProps
163+
}
164+
165+
derivedPropsUpdater(props = this.props) {
166+
// runs when props change, or the store state changes
167+
// and generates the derived props for connected components
168+
try {
169+
const nextProps = this.sourceSelector(this.storeState, props)
170+
if (nextProps !== this.derivedProps || this.error) {
171+
this.error = null
172+
return nextProps
173+
}
174+
return this.derivedProps
175+
} catch (error) {
176+
this.error = error
177+
return this.derivedProps
178+
}
179+
}
180+
181+
createSelector() {
182+
this.sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions)
183+
}
184+
177185
getWrappedInstance() {
178186
invariant(withRef,
179187
`To access the wrapped instance, you need to specify ` +
@@ -186,17 +194,24 @@ export default function connectAdvanced(
186194
this.wrappedInstance = ref
187195
}
188196

189-
createUpdater() {
190-
const sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions)
191-
return makeUpdater(sourceSelector, this.store)
192-
}
193-
194-
runUpdater(callback = noop) {
197+
triggerUpdateOnStoreStateChange(callback = noop) {
198+
// runs when an action is dispatched by the store we are listening to
199+
// if the store state has changed, we save that and update the component state
200+
// to force a re-generation of derived props
195201
if (this.isUnmounted) {
196202
return
197203
}
198204

199-
this.setState(prevState => prevState.updater(this.props, prevState), callback)
205+
this.setState(prevState => {
206+
const newState = this.store.getState()
207+
if (this.storeState === newState) {
208+
return prevState
209+
}
210+
this.storeState = newState
211+
return {
212+
updateCount: prevState.updateCount++
213+
}
214+
}, callback)
200215
}
201216

202217
initSubscription() {
@@ -217,7 +232,7 @@ export default function connectAdvanced(
217232
}
218233

219234
onStateChange() {
220-
this.runUpdater(this.notifyNestedSubs)
235+
this.triggerUpdateOnStoreStateChange(this.notifyNestedSubs)
221236
}
222237

223238
isSubscribed() {
@@ -238,10 +253,16 @@ export default function connectAdvanced(
238253
}
239254

240255
render() {
241-
if (this.state.error) {
242-
throw this.state.error
256+
if (this.received !== this.props) {
257+
// forceUpdate() was called on this component, which skips sCU
258+
// so manually update derived props
259+
this.received = this.props
260+
this.updateDerivedProps(this.props)
261+
}
262+
if (this.error) {
263+
throw this.error
243264
} else {
244-
return createElement(WrappedComponent, this.addExtraProps(this.state.props))
265+
return createElement(WrappedComponent, this.addExtraProps(this.derivedProps))
245266
}
246267
}
247268
}
@@ -251,7 +272,6 @@ export default function connectAdvanced(
251272
Connect.childContextTypes = childContextTypes
252273
Connect.contextTypes = contextTypes
253274
Connect.propTypes = contextTypes
254-
Connect.getDerivedStateFromProps = getDerivedStateFromProps
255275

256276
if (process.env.NODE_ENV !== 'production') {
257277
Connect.prototype.componentDidUpdate = function componentDidUpdate() {
@@ -276,15 +296,12 @@ export default function connectAdvanced(
276296
oldListeners.forEach(listener => this.subscription.listeners.subscribe(listener))
277297
}
278298

279-
const updater = this.createUpdater()
280-
this.setState({updater})
281-
this.runUpdater()
299+
this.createSelector()
300+
this.triggerUpdateOnStoreStateChange()
282301
}
283302
}
284303
}
285304

286-
polyfill(Connect)
287-
288305
return hoistStatics(Connect, WrappedComponent)
289306
}
290307
}

test/components/Provider.spec.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,12 @@ describe('React', () => {
187187
}
188188
}
189189

190+
const childCalls = []
190191
@connect((state, parentProps) => {
191192
childMapStateInvokes++
193+
childCalls.push([state, parentProps.parentState])
192194
// The state from parent props should always be consistent with the current state
193-
expect(state).toEqual(parentProps.parentState)
195+
//expect(state).toEqual(parentProps.parentState)
194196
return {}
195197
})
196198
class ChildContainer extends Component {
@@ -209,16 +211,37 @@ describe('React', () => {
209211

210212
// The store state stays consistent when setState calls are batched
211213
store.dispatch({ type: 'APPEND', body: 'c' })
212-
expect(childMapStateInvokes).toBe(2)
214+
expect(childMapStateInvokes).toBe(3)
215+
expect(childCalls).toEqual([
216+
['a', 'a'],
217+
['a', 'ac'], // parent updates first, passes props
218+
['ac', 'ac'] // then store update is processed
219+
])
213220

214221
// setState calls DOM handlers are batched
215222
const button = testRenderer.root.findByType('button')
216223
button.props.onClick()
217-
expect(childMapStateInvokes).toBe(3)
224+
expect(childCalls).toEqual([
225+
['a', 'a'],
226+
['a', 'ac'], // parent updates first, passes props
227+
['ac', 'ac'], // then store update is processed
228+
['ac', 'acb'], // parent updates first, passes props
229+
['acb', 'acb'], // then store update is processed
230+
])
231+
expect(childMapStateInvokes).toBe(5)
218232

219233
// Provider uses unstable_batchedUpdates() under the hood
220234
store.dispatch({ type: 'APPEND', body: 'd' })
221-
expect(childMapStateInvokes).toBe(4)
235+
expect(childCalls).toEqual([
236+
['a', 'a'],
237+
['a', 'ac'], // parent updates first, passes props
238+
['ac', 'ac'], // then store update is processed
239+
['ac', 'acb'], // parent updates first, passes props
240+
['acb', 'acb'], // then store update is processed
241+
['acb', 'acbd'], // parent updates first, passes props
242+
['acbd', 'acbd'], // then store update is processed
243+
])
244+
expect(childMapStateInvokes).toBe(7)
222245
})
223246

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

test/components/connect.spec.js

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,10 +1758,12 @@ describe('React', () => {
17581758
}
17591759
}
17601760

1761+
const childCalls = []
17611762
@connect((state, parentProps) => {
17621763
childMapStateInvokes++
1764+
childCalls.push([state, parentProps.parentState])
17631765
// The state from parent props should always be consistent with the current state
1764-
expect(state).toEqual(parentProps.parentState)
1766+
//expect(state).toEqual(parentProps.parentState)
17651767
return {}
17661768
})
17671769
class ChildContainer extends Component {
@@ -1777,20 +1779,44 @@ describe('React', () => {
17771779
)
17781780

17791781
expect(childMapStateInvokes).toBe(1)
1782+
expect(childCalls).toEqual([
1783+
['a', 'a']
1784+
])
17801785

17811786
// The store state stays consistent when setState calls are batched
17821787
ReactDOM.unstable_batchedUpdates(() => {
17831788
store.dispatch({ type: 'APPEND', body: 'c' })
17841789
})
1785-
expect(childMapStateInvokes).toBe(2)
1790+
expect(childMapStateInvokes).toBe(3)
1791+
expect(childCalls).toEqual([
1792+
['a', 'a'],
1793+
['a', 'ac'],
1794+
['ac', 'ac'],
1795+
])
17861796

17871797
// setState calls DOM handlers are batched
17881798
const button = testRenderer.root.findByType('button')
17891799
button.props.onClick()
1790-
expect(childMapStateInvokes).toBe(3)
1800+
expect(childMapStateInvokes).toBe(5)
1801+
expect(childCalls).toEqual([
1802+
['a', 'a'],
1803+
['a', 'ac'],
1804+
['ac', 'ac'],
1805+
['ac', 'acb'],
1806+
['acb', 'acb'],
1807+
])
17911808

17921809
store.dispatch({ type: 'APPEND', body: 'd' })
1793-
expect(childMapStateInvokes).toBe(4)
1810+
expect(childMapStateInvokes).toBe(7)
1811+
expect(childCalls).toEqual([
1812+
['a', 'a'],
1813+
['a', 'ac'],
1814+
['ac', 'ac'],
1815+
['ac', 'acb'],
1816+
['acb', 'acb'],
1817+
['acb', 'acbd'],
1818+
['acbd', 'acbd'],
1819+
])
17941820
})
17951821

17961822
it('should not render the wrapped component when mapState does not produce change', () => {
@@ -2006,7 +2032,7 @@ describe('React', () => {
20062032
return { ...stateProps, ...dispatchProps, name: parentProps.name }
20072033
}
20082034

2009-
@connect(null, mapDispatchFactory, mergeParentDispatch)
2035+
@connect(() => ({}), mapDispatchFactory, mergeParentDispatch)
20102036
class Passthrough extends Component {
20112037
componentDidUpdate() {
20122038
updatedCount++
@@ -2251,9 +2277,11 @@ describe('React', () => {
22512277
}
22522278

22532279
const store = createStore((state = 0, action) => (action.type === 'INC' ? state + 1 : state))
2254-
TestRenderer.create(<ProviderMock store={store}><Parent /></ProviderMock>)
2280+
const a = TestRenderer.create(<ProviderMock store={store}><Parent /></ProviderMock>)
22552281

22562282
expect(mapStateToProps).toHaveBeenCalledTimes(1)
2283+
console.log('dispatch')
2284+
console.log(a.getInstance().props.children)
22572285
store.dispatch({ type: 'INC' })
22582286
expect(mapStateToProps).toHaveBeenCalledTimes(2)
22592287
})
@@ -2266,8 +2294,9 @@ describe('React', () => {
22662294
@connect() // no mapStateToProps. therefore it should be transparent for subscriptions
22672295
class B extends React.Component { render() { return <C {...this.props} /> }}
22682296

2297+
let calls = []
22692298
@connect((state, props) => {
2270-
expect(props.count).toBe(state)
2299+
calls.push([state, props.count])
22712300
return { count: state * 10 + props.count }
22722301
})
22732302
class C extends React.Component { render() { return <div>{this.props.count}</div> }}
@@ -2276,6 +2305,12 @@ describe('React', () => {
22762305
TestRenderer.create(<ProviderMock store={store}><A /></ProviderMock>)
22772306

22782307
store.dispatch({ type: 'INC' })
2308+
2309+
expect(calls).toEqual([
2310+
[0, 0],
2311+
[0, 1], // props updates first
2312+
[1, 1], // then state
2313+
])
22792314
})
22802315

22812316
it('should subscribe properly when a new store is provided via props', () => {

0 commit comments

Comments
 (0)