Skip to content

Commit c1e3f7e

Browse files
committed
Fix bug with double updates in a single batch (#6650)
Fixes #2410. Fixes #6371. Fixes #6538. I also manually tested the codepen in #3762 and verified it now works.
1 parent 44f8463 commit c1e3f7e

File tree

4 files changed

+133
-5
lines changed

4 files changed

+133
-5
lines changed

Diff for: src/renderers/shared/reconciler/ReactCompositeComponent.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ var ReactCompositeComponentMixin = {
153153
this._nativeContainerInfo = null;
154154

155155
// See ReactUpdateQueue
156+
this._updateBatchNumber = null;
156157
this._pendingElement = null;
157158
this._pendingStateQueue = null;
158159
this._pendingReplaceState = false;
@@ -765,16 +766,16 @@ var ReactCompositeComponentMixin = {
765766
transaction,
766767
this._context
767768
);
768-
}
769-
770-
if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
769+
} else if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
771770
this.updateComponent(
772771
transaction,
773772
this._currentElement,
774773
this._currentElement,
775774
this._context,
776775
this._context
777776
);
777+
} else {
778+
this._updateBatchNumber = null;
778779
}
779780
},
780781

@@ -878,6 +879,7 @@ var ReactCompositeComponentMixin = {
878879
);
879880
}
880881

882+
this._updateBatchNumber = null;
881883
if (shouldUpdate) {
882884
this._pendingForceUpdate = false;
883885
// Will set `this.props`, `this.state` and `this.context`.

Diff for: src/renderers/shared/reconciler/ReactReconciler.js

+17-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
var ReactRef = require('ReactRef');
1515
var ReactInstrumentation = require('ReactInstrumentation');
1616

17+
var invariant = require('invariant');
18+
1719
/**
1820
* Helper to call ReactRef.attachRefs with this composite component, split out
1921
* to avoid allocations in the transaction mount-ready queue.
@@ -190,8 +192,22 @@ var ReactReconciler = {
190192
*/
191193
performUpdateIfNecessary: function(
192194
internalInstance,
193-
transaction
195+
transaction,
196+
updateBatchNumber
194197
) {
198+
if (internalInstance._updateBatchNumber !== updateBatchNumber) {
199+
// The component's enqueued batch number should always be the current
200+
// batch or the following one.
201+
invariant(
202+
internalInstance._updateBatchNumber == null ||
203+
internalInstance._updateBatchNumber === updateBatchNumber + 1,
204+
'performUpdateIfNecessary: Unexpected batch number (current %s, ' +
205+
'pending %s)',
206+
updateBatchNumber,
207+
internalInstance._updateBatchNumber
208+
);
209+
return;
210+
}
195211
if (__DEV__) {
196212
if (internalInstance._debugID !== 0) {
197213
ReactInstrumentation.debugTool.onBeginReconcilerTimer(

Diff for: src/renderers/shared/reconciler/ReactUpdates.js

+13-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ var Transaction = require('Transaction');
2222
var invariant = require('invariant');
2323

2424
var dirtyComponents = [];
25+
var updateBatchNumber = 0;
2526
var asapCallbackQueue = CallbackQueue.getPooled();
2627
var asapEnqueued = false;
2728

@@ -138,6 +139,13 @@ function runBatchedUpdates(transaction) {
138139
// them before their children by sorting the array.
139140
dirtyComponents.sort(mountOrderComparator);
140141

142+
// Any updates enqueued while reconciling must be performed after this entire
143+
// batch. Otherwise, if dirtyComponents is [A, B] where A has children B and
144+
// C, B could update twice in a single batch if C's render enqueues an update
145+
// to B (since B would have already updated, we should skip it, and the only
146+
// way we can know to do so is by checking the batch counter).
147+
updateBatchNumber++;
148+
141149
for (var i = 0; i < len; i++) {
142150
// If a component is unmounted before pending changes apply, it will still
143151
// be here, but we assume that it has cleared its _pendingCallbacks and
@@ -166,7 +174,8 @@ function runBatchedUpdates(transaction) {
166174

167175
ReactReconciler.performUpdateIfNecessary(
168176
component,
169-
transaction.reconcileTransaction
177+
transaction.reconcileTransaction,
178+
updateBatchNumber
170179
);
171180

172181
if (markerName) {
@@ -238,6 +247,9 @@ function enqueueUpdate(component) {
238247
}
239248

240249
dirtyComponents.push(component);
250+
if (component._updateBatchNumber == null) {
251+
component._updateBatchNumber = updateBatchNumber + 1;
252+
}
241253
}
242254

243255
/**

Diff for: src/renderers/shared/reconciler/__tests__/ReactUpdates-test.js

+98
Original file line numberDiff line numberDiff line change
@@ -1024,4 +1024,102 @@ describe('ReactUpdates', function() {
10241024
'to be a function. Instead received: Foo (keys: a, b).'
10251025
);
10261026
});
1027+
1028+
it('does not update one component twice in a batch (#2410)', function() {
1029+
var Parent = React.createClass({
1030+
getChild: function() {
1031+
return this.refs.child;
1032+
},
1033+
render: function() {
1034+
return <Child ref="child" />;
1035+
},
1036+
});
1037+
1038+
var renderCount = 0;
1039+
var postRenderCount = 0;
1040+
var once = false;
1041+
var Child = React.createClass({
1042+
getInitialState: function() {
1043+
return {updated: false};
1044+
},
1045+
componentWillUpdate: function() {
1046+
if (!once) {
1047+
once = true;
1048+
this.setState({updated: true});
1049+
}
1050+
},
1051+
componentDidMount: function() {
1052+
expect(renderCount).toBe(postRenderCount + 1);
1053+
postRenderCount++;
1054+
},
1055+
componentDidUpdate: function() {
1056+
expect(renderCount).toBe(postRenderCount + 1);
1057+
postRenderCount++;
1058+
},
1059+
render: function() {
1060+
expect(renderCount).toBe(postRenderCount);
1061+
renderCount++;
1062+
return <div />;
1063+
},
1064+
});
1065+
1066+
var parent = ReactTestUtils.renderIntoDocument(<Parent />);
1067+
var child = parent.getChild();
1068+
ReactDOM.unstable_batchedUpdates(function() {
1069+
parent.forceUpdate();
1070+
child.forceUpdate();
1071+
});
1072+
});
1073+
1074+
it('does not update one component twice in a batch (#6371)', function() {
1075+
var callbacks = [];
1076+
function emitChange() {
1077+
callbacks.forEach(c => c());
1078+
}
1079+
1080+
class App extends React.Component {
1081+
constructor(props) {
1082+
super(props);
1083+
this.state = { showChild: true };
1084+
}
1085+
componentDidMount() {
1086+
console.log('about to remove child via set state');
1087+
this.setState({ showChild: false });
1088+
}
1089+
render() {
1090+
return (
1091+
<div>
1092+
<ForceUpdatesOnChange />
1093+
{this.state.showChild && <EmitsChangeOnUnmount />}
1094+
</div>
1095+
);
1096+
}
1097+
}
1098+
1099+
class EmitsChangeOnUnmount extends React.Component {
1100+
componentWillUnmount() {
1101+
emitChange();
1102+
}
1103+
render() {
1104+
return null;
1105+
}
1106+
}
1107+
1108+
class ForceUpdatesOnChange extends React.Component {
1109+
componentDidMount() {
1110+
this.onChange = () => this.forceUpdate();
1111+
this.onChange();
1112+
callbacks.push(this.onChange);
1113+
}
1114+
componentWillUnmount() {
1115+
callbacks = callbacks.filter((c) => c !== this.onChange);
1116+
}
1117+
render() {
1118+
return <div key={Math.random()} onClick={function() {}} />;
1119+
}
1120+
}
1121+
1122+
ReactDOM.render(<App />, document.createElement('div'));
1123+
});
1124+
10271125
});

0 commit comments

Comments
 (0)