Skip to content

Commit 6390ec7

Browse files
authored
Merge pull request reduxjs#472 from jimbolla/fix-457
tests and fixes reduxjs#457
2 parents 84c7acd + c684b15 commit 6390ec7

File tree

2 files changed

+124
-25
lines changed

2 files changed

+124
-25
lines changed

src/utils/Subscription.js

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,61 @@
11
// encapsulates the subscription logic for connecting a component to the redux store, as
22
// well as nesting subscriptions of descendant components, so that we can ensure the
33
// ancestor components re-render before descendants
4+
5+
const CLEARED = null
6+
7+
function createListenerCollection() {
8+
// the current/next pattern is copied from redux's createStore code.
9+
// TODO: refactor+expose that code to be reusable here?
10+
let current = []
11+
let next = []
12+
13+
return {
14+
clear() {
15+
next = CLEARED
16+
current = CLEARED
17+
},
18+
19+
notify() {
20+
current = next
21+
for (let i = 0; i < current.length; i++) {
22+
current[i]()
23+
}
24+
},
25+
26+
subscribe(listener) {
27+
let isSubscribed = true
28+
if (next === current) next = current.slice()
29+
next.push(listener)
30+
31+
return function unsubscribe() {
32+
if (!isSubscribed || current === CLEARED) return
33+
isSubscribed = false
34+
35+
if (next === current) next = current.slice()
36+
next.splice(next.indexOf(listener), 1)
37+
}
38+
}
39+
}
40+
}
41+
442
export default class Subscription {
543
constructor(store, parentSub) {
644
this.subscribe = parentSub
745
? parentSub.addNestedSub.bind(parentSub)
846
: store.subscribe.bind(store)
947

1048
this.unsubscribe = null
11-
this.nextListeners = this.currentListeners = []
12-
}
13-
14-
ensureCanMutateNextListeners() {
15-
if (this.nextListeners === this.currentListeners) {
16-
this.nextListeners = this.currentListeners.slice()
17-
}
49+
this.listeners = createListenerCollection()
1850
}
1951

2052
addNestedSub(listener) {
2153
this.trySubscribe()
22-
23-
let isSubscribed = true
24-
this.ensureCanMutateNextListeners()
25-
this.nextListeners.push(listener)
26-
27-
return function unsubscribe() {
28-
if (!isSubscribed) return
29-
isSubscribed = false
30-
31-
this.ensureCanMutateNextListeners()
32-
const index = this.nextListeners.indexOf(listener)
33-
this.nextListeners.splice(index, 1)
34-
}
54+
return this.listeners.subscribe(listener)
3555
}
3656

3757
notifyNestedSubs() {
38-
const listeners = this.currentListeners = this.nextListeners
39-
const length = listeners.length
40-
for (let i = 0; i < length; i++) {
41-
listeners[i]()
42-
}
58+
this.listeners.notify()
4359
}
4460

4561
isSubscribed() {
@@ -55,7 +71,10 @@ export default class Subscription {
5571
tryUnsubscribe() {
5672
if (this.unsubscribe) {
5773
this.unsubscribe()
74+
this.listeners.clear()
5875
}
5976
this.unsubscribe = null
77+
this.subscribe = null
78+
this.listeners = { notify() {} }
6079
}
6180
}

test/components/connect.spec.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,86 @@ describe('React', () => {
910910
expect(mapStateToPropsCalls).toBe(1)
911911
})
912912

913+
it('should not attempt to set state after unmounting nested components', () => {
914+
const store = createStore(() => ({}))
915+
let mapStateToPropsCalls = 0
916+
917+
let linkA, linkB
918+
919+
let App = ({ children, setLocation }) => {
920+
const onClick = to => event => {
921+
event.preventDefault()
922+
setLocation(to)
923+
}
924+
/* eslint-disable react/jsx-no-bind */
925+
return (
926+
<div>
927+
<a href="#" onClick={onClick('a')} ref={c => { linkA = c }}>A</a>
928+
<a href="#" onClick={onClick('b')} ref={c => { linkB = c }}>B</a>
929+
{children}
930+
</div>
931+
)
932+
/* eslint-enable react/jsx-no-bind */
933+
}
934+
App = connect(() => ({}))(App)
935+
936+
937+
let A = () => (<h1>A</h1>)
938+
A = connect(() => ({ calls: ++mapStateToPropsCalls }))(A)
939+
940+
941+
const B = () => (<h1>B</h1>)
942+
943+
944+
class RouterMock extends React.Component {
945+
constructor(...args) {
946+
super(...args)
947+
this.state = { location: { pathname: 'a' } }
948+
this.setLocation = this.setLocation.bind(this)
949+
}
950+
951+
setLocation(pathname) {
952+
this.setState({ location: { pathname } })
953+
store.dispatch({ type: 'TEST' })
954+
}
955+
956+
getChildComponent(location) {
957+
switch (location) {
958+
case 'a': return <A />
959+
case 'b': return <B />
960+
default: throw new Error('Unknown location: ' + location)
961+
}
962+
}
963+
964+
render() {
965+
return (<App setLocation={this.setLocation}>
966+
{this.getChildComponent(this.state.location.pathname)}
967+
</App>)
968+
}
969+
}
970+
971+
972+
const div = document.createElement('div')
973+
document.body.appendChild(div)
974+
ReactDOM.render(
975+
(<ProviderMock store={store}>
976+
<RouterMock />
977+
</ProviderMock>),
978+
div
979+
)
980+
981+
const spy = expect.spyOn(console, 'error')
982+
983+
linkA.click()
984+
linkB.click()
985+
linkB.click()
986+
987+
spy.destroy()
988+
document.body.removeChild(div)
989+
expect(mapStateToPropsCalls).toBe(3)
990+
expect(spy.calls.length).toBe(0)
991+
})
992+
913993
it('should not attempt to set state when dispatching in componentWillUnmount', () => {
914994
const store = createStore(stringBuilder)
915995
let mapStateToPropsCalls = 0

0 commit comments

Comments
 (0)