Skip to content

Commit 381c063

Browse files
jimbollatimdorr
authored andcommitted
Fixes subscription bug when a new store is passed as a prop. (reduxjs#589)
* Fixes subscription bug when a new store is passed as a prop. Reported here: davidkpiano/react-redux-form#592 * Refactors and adds more comments to connectAdvanced
1 parent fcc6247 commit 381c063

File tree

3 files changed

+120
-58
lines changed

3 files changed

+120
-58
lines changed

src/components/connectAdvanced.js

Lines changed: 76 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ import Subscription from '../utils/Subscription'
66
import storeShape from '../utils/storeShape'
77

88
let hotReloadingVersion = 0
9+
const dummyState = {}
10+
function noop() {}
11+
12+
function makeSelectorStateful(sourceSelector, store) {
13+
// wrap the selector in an object that tracks its results between runs.
14+
const selector = {
15+
run: function runComponentSelector(props) {
16+
try {
17+
const nextProps = sourceSelector(store.getState(), props)
18+
if (nextProps !== selector.props || selector.error) {
19+
selector.shouldComponentUpdate = true
20+
selector.props = nextProps
21+
selector.error = null
22+
}
23+
} catch (error) {
24+
selector.shouldComponentUpdate = true
25+
selector.error = error
26+
}
27+
}
28+
}
29+
30+
return selector
31+
}
32+
933
export default function connectAdvanced(
1034
/*
1135
selectorFactory is a func that is responsible for returning the selector function used to
@@ -96,28 +120,27 @@ export default function connectAdvanced(
96120
this.version = version
97121
this.state = {}
98122
this.renderCount = 0
99-
this.store = this.props[storeKey] || this.context[storeKey]
100-
this.parentSub = props[subscriptionKey] || context[subscriptionKey]
101-
123+
this.store = props[storeKey] || context[storeKey]
124+
this.propsMode = Boolean(props[storeKey])
102125
this.setWrappedInstance = this.setWrappedInstance.bind(this)
103126

104127
invariant(this.store,
105-
`Could not find "${storeKey}" in either the context or ` +
106-
`props of "${displayName}". ` +
107-
`Either wrap the root component in a <Provider>, ` +
128+
`Could not find "${storeKey}" in either the context or props of ` +
129+
`"${displayName}". Either wrap the root component in a <Provider>, ` +
108130
`or explicitly pass "${storeKey}" as a prop to "${displayName}".`
109131
)
110132

111-
// make sure `getState` is properly bound in order to avoid breaking
112-
// custom store implementations that rely on the store's context
113-
this.getState = this.store.getState.bind(this.store);
114-
115133
this.initSelector()
116134
this.initSubscription()
117135
}
118136

119137
getChildContext() {
120-
return { [subscriptionKey]: this.subscription || this.parentSub }
138+
// If this component received store from props, its subscription should be transparent
139+
// to any descendants receiving store+subscription from context; it passes along
140+
// subscription passed to it. Otherwise, it shadows the parent subscription, which allows
141+
// Connect to control ordering of notifications to flow top-down.
142+
const subscription = this.propsMode ? null : this.subscription
143+
return { [subscriptionKey]: subscription || this.context[subscriptionKey] }
121144
}
122145

123146
componentDidMount() {
@@ -144,12 +167,11 @@ export default function connectAdvanced(
144167

145168
componentWillUnmount() {
146169
if (this.subscription) this.subscription.tryUnsubscribe()
147-
// these are just to guard against extra memory leakage if a parent element doesn't
148-
// dereference this instance properly, such as an async callback that never finishes
149170
this.subscription = null
171+
this.notifyNestedSubs = noop
150172
this.store = null
151-
this.parentSub = null
152-
this.selector.run = () => {}
173+
this.selector.run = noop
174+
this.selector.shouldComponentUpdate = false
153175
}
154176

155177
getWrappedInstance() {
@@ -165,65 +187,63 @@ export default function connectAdvanced(
165187
}
166188

167189
initSelector() {
168-
const { dispatch } = this.store
169-
const { getState } = this;
170-
const sourceSelector = selectorFactory(dispatch, selectorFactoryOptions)
171-
172-
// wrap the selector in an object that tracks its results between runs
173-
const selector = this.selector = {
174-
shouldComponentUpdate: true,
175-
props: sourceSelector(getState(), this.props),
176-
run: function runComponentSelector(props) {
177-
try {
178-
const nextProps = sourceSelector(getState(), props)
179-
if (selector.error || nextProps !== selector.props) {
180-
selector.shouldComponentUpdate = true
181-
selector.props = nextProps
182-
selector.error = null
183-
}
184-
} catch (error) {
185-
selector.shouldComponentUpdate = true
186-
selector.error = error
187-
}
188-
}
189-
}
190+
const sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions)
191+
this.selector = makeSelectorStateful(sourceSelector, this.store)
192+
this.selector.run(this.props)
190193
}
191194

192195
initSubscription() {
193-
if (shouldHandleStateChanges) {
194-
const subscription = this.subscription = new Subscription(this.store, this.parentSub)
195-
const dummyState = {}
196-
197-
subscription.onStateChange = function onStateChange() {
198-
this.selector.run(this.props)
199-
200-
if (!this.selector.shouldComponentUpdate) {
201-
subscription.notifyNestedSubs()
202-
} else {
203-
this.componentDidUpdate = function componentDidUpdate() {
204-
this.componentDidUpdate = undefined
205-
subscription.notifyNestedSubs()
206-
}
207-
208-
this.setState(dummyState)
209-
}
210-
}.bind(this)
196+
if (!shouldHandleStateChanges) return
197+
198+
// parentSub's source should match where store came from: props vs. context. A component
199+
// connected to the store via props shouldn't use subscription from context, or vice versa.
200+
const parentSub = (this.propsMode ? this.props : this.context)[subscriptionKey]
201+
this.subscription = new Subscription(this.store, parentSub, this.onStateChange.bind(this))
202+
203+
// `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in
204+
// the middle of the notification loop, where `this.subscription` will then be null. An
205+
// extra null check every change can be avoided by copying the method onto `this` and then
206+
// replacing it with a no-op on unmount. This can probably be avoided if Subscription's
207+
// listeners logic is changed to not call listeners that have been unsubscribed in the
208+
// middle of the notification loop.
209+
this.notifyNestedSubs = this.subscription.notifyNestedSubs.bind(this.subscription)
210+
}
211+
212+
onStateChange() {
213+
this.selector.run(this.props)
214+
215+
if (!this.selector.shouldComponentUpdate) {
216+
this.notifyNestedSubs()
217+
} else {
218+
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
219+
this.setState(dummyState)
211220
}
221+
}
222+
223+
notifyNestedSubsOnComponentDidUpdate() {
224+
// `componentDidUpdate` is conditionally implemented when `onStateChange` determines it
225+
// needs to notify nested subs. Once called, it unimplements itself until further state
226+
// changes occur. Doing it this way vs having a permanent `componentDidMount` that does
227+
// a boolean check every time avoids an extra method call most of the time, resulting
228+
// in some perf boost.
229+
this.componentDidUpdate = undefined
230+
this.notifyNestedSubs()
212231
}
213232

214233
isSubscribed() {
215234
return Boolean(this.subscription) && this.subscription.isSubscribed()
216235
}
217236

218237
addExtraProps(props) {
219-
if (!withRef && !renderCountProp) return props
238+
if (!withRef && !renderCountProp && !(this.propsMode && this.subscription)) return props
220239
// make a shallow copy so that fields added don't leak to the original selector.
221240
// this is especially important for 'ref' since that's a reference back to the component
222241
// instance. a singleton memoized selector would then be holding a reference to the
223242
// instance, preventing the instance from being garbage collected, and that would be bad
224243
const withExtras = { ...props }
225244
if (withRef) withExtras.ref = this.setWrappedInstance
226245
if (renderCountProp) withExtras[renderCountProp] = this.renderCount++
246+
if (this.propsMode && this.subscription) withExtras[subscriptionKey] = this.subscription
227247
return withExtras
228248
}
229249

src/utils/Subscription.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ function createListenerCollection() {
4141
}
4242

4343
export default class Subscription {
44-
constructor(store, parentSub) {
44+
constructor(store, parentSub, onStateChange) {
4545
this.store = store
4646
this.parentSub = parentSub
47+
this.onStateChange = onStateChange
4748
this.unsubscribe = null
4849
this.listeners = nullListeners
4950
}
@@ -63,7 +64,6 @@ export default class Subscription {
6364

6465
trySubscribe() {
6566
if (!this.unsubscribe) {
66-
// this.onStateChange is set by connectAdvanced.initSubscription()
6767
this.unsubscribe = this.parentSub
6868
? this.parentSub.addNestedSub(this.onStateChange)
6969
: this.store.subscribe(this.onStateChange)

test/components/connect.spec.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2207,5 +2207,47 @@ describe('React', () => {
22072207
store.dispatch({ type: 'INC' })
22082208
})
22092209

2210+
it('should subscribe properly when a new store is provided via props', () => {
2211+
const store1 = createStore((state = 0, action) => (action.type === 'INC' ? state + 1 : state))
2212+
const store2 = createStore((state = 0, action) => (action.type === 'INC' ? state + 1 : state))
2213+
2214+
@connect(state => ({ count: state }))
2215+
class A extends Component {
2216+
render() { return <B store={store2} /> }
2217+
}
2218+
2219+
const mapStateToPropsB = expect.createSpy().andCall(state => ({ count: state }))
2220+
@connect(mapStateToPropsB)
2221+
class B extends Component {
2222+
render() { return <C {...this.props} /> }
2223+
}
2224+
2225+
const mapStateToPropsC = expect.createSpy().andCall(state => ({ count: state }))
2226+
@connect(mapStateToPropsC)
2227+
class C extends Component {
2228+
render() { return <D /> }
2229+
}
2230+
2231+
const mapStateToPropsD = expect.createSpy().andCall(state => ({ count: state }))
2232+
@connect(mapStateToPropsD)
2233+
class D extends Component {
2234+
render() { return <div>{this.props.count}</div> }
2235+
}
2236+
2237+
TestUtils.renderIntoDocument(<ProviderMock store={store1}><A /></ProviderMock>)
2238+
expect(mapStateToPropsB.calls.length).toBe(1)
2239+
expect(mapStateToPropsC.calls.length).toBe(1)
2240+
expect(mapStateToPropsD.calls.length).toBe(1)
2241+
2242+
store1.dispatch({ type: 'INC' })
2243+
expect(mapStateToPropsB.calls.length).toBe(1)
2244+
expect(mapStateToPropsC.calls.length).toBe(1)
2245+
expect(mapStateToPropsD.calls.length).toBe(2)
2246+
2247+
store2.dispatch({ type: 'INC' })
2248+
expect(mapStateToPropsB.calls.length).toBe(2)
2249+
expect(mapStateToPropsC.calls.length).toBe(2)
2250+
expect(mapStateToPropsD.calls.length).toBe(2)
2251+
})
22102252
})
22112253
})

0 commit comments

Comments
 (0)