Include Ownership in the Ship of Theseus

When we determine whether a React component should be updated (as opposed to destroyed or replaced), we currently only look at whether they share the same constructor. This adds a check for whether they share the same owner component.

I've also consolidated this logic (I cannot believe this was not already done).
This commit is contained in:
Tim Yung
2013-11-05 17:14:57 -08:00
committed by Paul O’Shannessy
parent e78d5b5462
commit e78d580c06
5 changed files with 218 additions and 68 deletions
+7 -6
View File
@@ -30,6 +30,7 @@ var keyMirror = require('keyMirror');
var merge = require('merge');
var mixInto = require('mixInto');
var objMap = require('objMap');
var shouldUpdateReactComponent = require('shouldUpdateReactComponent');
/**
* Policies that describe methods in `ReactCompositeComponentInterface`.
@@ -789,15 +790,15 @@ var ReactCompositeComponentMixin = {
'updateComponent',
function(transaction, prevProps, prevState) {
ReactComponent.Mixin.updateComponent.call(this, transaction, prevProps);
var currentComponent = this._renderedComponent;
var prevComponent = this._renderedComponent;
var nextComponent = this._renderValidatedComponent();
if (currentComponent.constructor === nextComponent.constructor) {
currentComponent.receiveProps(nextComponent.props, transaction);
if (shouldUpdateReactComponent(prevComponent, nextComponent)) {
prevComponent.receiveProps(nextComponent.props, transaction);
} else {
// These two IDs are actually the same! But nothing should rely on that.
var thisID = this._rootNodeID;
var currentComponentID = currentComponent._rootNodeID;
currentComponent.unmountComponent();
var prevComponentID = prevComponent._rootNodeID;
prevComponent.unmountComponent();
this._renderedComponent = nextComponent;
var nextMarkup = nextComponent.mountComponent(
thisID,
@@ -805,7 +806,7 @@ var ReactCompositeComponentMixin = {
this._mountDepth + 1
);
ReactComponent.DOMIDOperations.dangerouslyReplaceNodeWithMarkupByID(
currentComponentID,
prevComponentID,
nextMarkup
);
}
+6 -5
View File
@@ -25,6 +25,7 @@ var $ = require('$');
var getReactRootElementInContainer = require('getReactRootElementInContainer');
var invariant = require('invariant');
var nodeContains = require('nodeContains');
var shouldUpdateReactComponent = require('shouldUpdateReactComponent');
var SEPARATOR = ReactInstanceHandles.SEPARATOR;
@@ -304,12 +305,12 @@ var ReactMount = {
* @return {ReactComponent} Component instance rendered in `container`.
*/
renderComponent: function(nextComponent, container, callback) {
var registeredComponent = instancesByReactRootID[getReactRootID(container)];
var prevComponent = instancesByReactRootID[getReactRootID(container)];
if (registeredComponent) {
if (registeredComponent.constructor === nextComponent.constructor) {
if (prevComponent) {
if (shouldUpdateReactComponent(prevComponent, nextComponent)) {
return ReactMount._updateRootComponent(
registeredComponent,
prevComponent,
nextComponent,
container,
callback
@@ -323,7 +324,7 @@ var ReactMount = {
var containerHasReactMarkup =
reactRootElement && ReactMount.isRenderedByReact(reactRootElement);
var shouldReuseMarkup = containerHasReactMarkup && !registeredComponent;
var shouldReuseMarkup = containerHasReactMarkup && !prevComponent;
var component = ReactMount._renderNewRootComponent(
nextComponent,
+2 -14
View File
@@ -23,19 +23,7 @@ var ReactComponent = require('ReactComponent');
var ReactMultiChildUpdateTypes = require('ReactMultiChildUpdateTypes');
var flattenChildren = require('flattenChildren');
/**
* Given a `curChild` and `newChild`, determines if `curChild` should be
* updated as opposed to being destroyed or replaced.
*
* @param {?ReactComponent} curChild
* @param {?ReactComponent} newChild
* @return {boolean} True if `curChild` should be updated with `newChild`.
* @protected
*/
function shouldUpdateChild(curChild, newChild) {
return curChild && newChild && curChild.constructor === newChild.constructor;
}
var shouldUpdateReactComponent = require('shouldUpdateReactComponent');
/**
* Updating children of a component may trigger recursive updates. The depth is
@@ -294,7 +282,7 @@ var ReactMultiChild = {
}
var prevChild = prevChildren && prevChildren[name];
var nextChild = nextChildren[name];
if (shouldUpdateChild(prevChild, nextChild)) {
if (shouldUpdateReactComponent(prevChild, nextChild)) {
this.moveChild(prevChild, nextIndex, lastIndex);
lastIndex = Math.max(prevChild._mountIndex, lastIndex);
prevChild.receiveProps(nextChild.props, transaction);
+150 -43
View File
@@ -17,61 +17,168 @@
* @emails react-core
*/
var mocks = require('mocks');
describe('ReactMultiChild', function() {
var React;
var setInnerHTML;
// Only run this test suite if `Element.prototype.innerHTML` can be spied on.
var innerHTMLDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML'
);
if (!innerHTMLDescriptor) {
return;
}
beforeEach(function() {
require('mock-modules').dumpCache();
React = require('React');
});
Object.defineProperty(Element.prototype, 'innerHTML', {
set: setInnerHTML = jasmine.createSpy().andCallFake(
innerHTMLDescriptor.set
)
describe('reconciliation', function() {
it('should update children when possible', function() {
var container = document.createElement('div');
var mockMount = mocks.getMockFunction();
var mockUpdate = mocks.getMockFunction();
var mockUnmount = mocks.getMockFunction();
var MockComponent = React.createClass({
componentDidMount: mockMount,
componentDidUpdate: mockUpdate,
componentWillUnmount: mockUnmount,
render: function() {
return <span />;
}
});
expect(mockMount.mock.calls.length).toBe(0);
expect(mockUpdate.mock.calls.length).toBe(0);
expect(mockUnmount.mock.calls.length).toBe(0);
React.renderComponent(<div><MockComponent /></div>, container);
expect(mockMount.mock.calls.length).toBe(1);
expect(mockUpdate.mock.calls.length).toBe(0);
expect(mockUnmount.mock.calls.length).toBe(0);
React.renderComponent(<div><MockComponent /></div>, container);
expect(mockMount.mock.calls.length).toBe(1);
expect(mockUpdate.mock.calls.length).toBe(1);
expect(mockUnmount.mock.calls.length).toBe(0);
});
it('should replace children with different constructors', function() {
var container = document.createElement('div');
var mockMount = mocks.getMockFunction();
var mockUnmount = mocks.getMockFunction();
var MockComponent = React.createClass({
componentDidMount: mockMount,
componentWillUnmount: mockUnmount,
render: function() {
return <span />;
}
});
expect(mockMount.mock.calls.length).toBe(0);
expect(mockUnmount.mock.calls.length).toBe(0);
React.renderComponent(<div><MockComponent /></div>, container);
expect(mockMount.mock.calls.length).toBe(1);
expect(mockUnmount.mock.calls.length).toBe(0);
React.renderComponent(<div><span /></div>, container);
expect(mockMount.mock.calls.length).toBe(1);
expect(mockUnmount.mock.calls.length).toBe(1);
});
it('should replace children with different owners', function() {
var container = document.createElement('div');
var mockMount = mocks.getMockFunction();
var mockUnmount = mocks.getMockFunction();
var MockComponent = React.createClass({
componentDidMount: mockMount,
componentWillUnmount: mockUnmount,
render: function() {
return <span />;
}
});
var WrapperComponent = React.createClass({
render: function() {
return this.props.children || <MockComponent />;
}
});
expect(mockMount.mock.calls.length).toBe(0);
expect(mockUnmount.mock.calls.length).toBe(0);
React.renderComponent(<WrapperComponent />, container);
expect(mockMount.mock.calls.length).toBe(1);
expect(mockUnmount.mock.calls.length).toBe(0);
React.renderComponent(
<WrapperComponent><MockComponent /></WrapperComponent>,
container
);
expect(mockMount.mock.calls.length).toBe(2);
expect(mockUnmount.mock.calls.length).toBe(1);
});
});
it('should only set `innerHTML` once on update', function() {
var container = document.createElement('div');
describe('innerHTML', function() {
var setInnerHTML;
React.renderComponent(
<div>
<p><span /></p>
<p><span /></p>
<p><span /></p>
</div>,
container
// Only run this suite if `Element.prototype.innerHTML` can be spied on.
var innerHTMLDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML'
);
// Warm the cache used by `getMarkupWrap`.
React.renderComponent(
<div>
<p><span /><span /></p>
<p><span /><span /></p>
<p><span /><span /></p>
</div>,
container
);
expect(setInnerHTML).toHaveBeenCalled();
var callCountOnMount = setInnerHTML.callCount;
if (!innerHTMLDescriptor) {
return;
}
React.renderComponent(
<div>
<p><span /><span /><span /></p>
<p><span /><span /><span /></p>
<p><span /><span /><span /></p>
</div>,
container
);
expect(setInnerHTML.callCount).toBe(callCountOnMount + 1);
beforeEach(function() {
Object.defineProperty(Element.prototype, 'innerHTML', {
set: setInnerHTML = jasmine.createSpy().andCallFake(
innerHTMLDescriptor.set
)
});
});
it('should only set `innerHTML` once on update', function() {
var container = document.createElement('div');
React.renderComponent(
<div>
<p><span /></p>
<p><span /></p>
<p><span /></p>
</div>,
container
);
// Warm the cache used by `getMarkupWrap`.
React.renderComponent(
<div>
<p><span /><span /></p>
<p><span /><span /></p>
<p><span /><span /></p>
</div>,
container
);
expect(setInnerHTML).toHaveBeenCalled();
var callCountOnMount = setInnerHTML.callCount;
React.renderComponent(
<div>
<p><span /><span /><span /></p>
<p><span /><span /><span /></p>
<p><span /><span /><span /></p>
</div>,
container
);
expect(setInnerHTML.callCount).toBe(callCountOnMount + 1);
});
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Copyright 2013 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @providesModule shouldUpdateReactComponent
* @typechecks static-only
*/
/**
* Given a `prevComponent` and `nextComponent`, determines if `prevComponent`
* should be updated as opposed to being destroyed or replaced.
*
* @param {?object} prevComponent
* @param {?object} nextComponent
* @return {boolean} True if `prevComponent` should be updated.
* @protected
*/
function shouldUpdateReactComponent(prevComponent, nextComponent) {
// TODO: Remove warning after a release.
if (prevComponent && nextComponent &&
prevComponent.constructor === nextComponent.constructor) {
if (prevComponent.props.__owner__ === nextComponent.props.__owner__) {
return true;
} else {
if (__DEV__) {
if (prevComponent.state) {
console.warn(
'A recent change to React has been found to impact your code. ' +
'A mounted component will now be unmounted and replaced by a ' +
'component (of the same class) if their owners are different. ' +
'Previously, ownership was not considered when updating.',
prevComponent,
nextComponent
);
}
}
}
}
return false;
}
module.exports = shouldUpdateReactComponent;