Prepare new composite child before removing old (#8572)

This matches what we do in Fiber -- and doing it this way is the only way we can prepare new views in the background before unmounting old ones.

In particular, this breaks this pattern:

```js
class Child1 extends React.Component {
  render() { ... }
  componentWillMount() {
    this.props.registerChild(this);
  }
  componentWillUnmount() {
    this.props.unregisterChild();
  }
}

class Child2 extends React.Component {
  render() { ... }
  componentWillMount() {
    this.props.registerChild(this);
  }
  componentWillUnmount() {
    this.props.unregisterChild();
  }
}

class Parent extends React.Component {
  render() {
    return (
      showChild1 ?
        <Child1
          registerChild={(child) => this.registered = child}
          unregisterChild={() => this.registered = null}
        /> :
        <Child2
          registerChild={(child) => this.registered = child}
          unregisterChild={() => this.registered = null}
        />
    );
  }
}
```

Previously, `this.registered` would always be set -- now, after a rerender, `this.registered` gets stuck at null because the old child's componentWillUnmount runs *after* the new child's componentWillMount.

A correct fix here is to use componentDidMount rather than componentWillMount. (In general, componentWillMount should not have side effects.) If Parent stored a list or set of registered children instead, there would also be no issue.
This commit is contained in:
Ben Alpert
2016-12-14 11:14:50 -08:00
committed by GitHub
parent 931cad5aae
commit ba8f24ba99
7 changed files with 155 additions and 8 deletions
+2
View File
@@ -1318,6 +1318,7 @@ src/renderers/shared/shared/__tests__/ReactCompositeComponent-test.js
* should support objects with prototypes as state
* should not warn about unmounting during unmounting
* should only call componentWillUnmount once
* prepares new child before unmounting old
src/renderers/shared/shared/__tests__/ReactCompositeComponentDOMMinimalism-test.js
* should not render extra nodes for non-interpolated text
@@ -1405,6 +1406,7 @@ src/renderers/shared/shared/__tests__/ReactMultiChild-test.js
* should NOT replace children with different owners
* should replace children with different keys
* should reorder bailed-out children
* prepares new children before unmounting old
src/renderers/shared/shared/__tests__/ReactMultiChildReconcile-test.js
* should reset internal state if removed then readded in an array
@@ -1358,4 +1358,45 @@ describe('ReactCompositeComponent', () => {
expect(count).toBe(1);
});
it('prepares new child before unmounting old', () => {
var log = [];
class Spy extends React.Component {
componentWillMount() {
log.push(this.props.name + ' componentWillMount');
}
render() {
log.push(this.props.name + ' render');
return <div />;
}
componentDidMount() {
log.push(this.props.name + ' componentDidMount');
}
componentWillUnmount() {
log.push(this.props.name + ' componentWillUnmount');
}
}
class Wrapper extends React.Component {
render() {
return <Spy key={this.props.name} name={this.props.name} />;
}
}
var container = document.createElement('div');
ReactDOM.render(<Wrapper name="A" />, container);
ReactDOM.render(<Wrapper name="B" />, container);
expect(log).toEqual([
'A componentWillMount',
'A render',
'A componentDidMount',
'B componentWillMount',
'B render',
'A componentWillUnmount',
'B componentDidMount',
]);
});
});
@@ -18,11 +18,13 @@ describe('ReactMultiChild', () => {
var React;
var ReactDOM;
var ReactDOMFeatureFlags;
beforeEach(() => {
jest.resetModuleRegistry();
React = require('React');
ReactDOM = require('ReactDOM');
ReactDOMFeatureFlags = require('ReactDOMFeatureFlags');
});
describe('reconciliation', () => {
@@ -254,4 +256,78 @@ describe('ReactMultiChild', () => {
ReactDOM.render(<Letters letters="EHCjpdTUuiybDvhRJwZt" />, container);
expect(container.textContent).toBe('EHCjpdTUuiybDvhRJwZt');
});
it('prepares new children before unmounting old', () => {
var log = [];
class Spy extends React.Component {
componentWillMount() {
log.push(this.props.name + ' componentWillMount');
}
render() {
log.push(this.props.name + ' render');
return <div />;
}
componentDidMount() {
log.push(this.props.name + ' componentDidMount');
}
componentWillUnmount() {
log.push(this.props.name + ' componentWillUnmount');
}
}
// These are reference-unequal so they will be swapped even if they have
// matching keys
var SpyA = (props) => <Spy {...props} />;
var SpyB = (props) => <Spy {...props} />;
var container = document.createElement('div');
ReactDOM.render(
<div>
<SpyA key="one" name="oneA" />
<SpyA key="two" name="twoA" />
</div>,
container
);
ReactDOM.render(
<div>
<SpyB key="one" name="oneB" />
<SpyB key="two" name="twoB" />
</div>,
container
);
expect(log).toEqual([
'oneA componentWillMount',
'oneA render',
'twoA componentWillMount',
'twoA render',
'oneA componentDidMount',
'twoA componentDidMount',
...(
ReactDOMFeatureFlags.useFiber ?
[
'oneB componentWillMount',
'oneB render',
'twoB componentWillMount',
'twoB render',
'oneA componentWillUnmount',
'twoA componentWillUnmount',
] :
[
'oneB componentWillMount',
'oneB render',
'oneA componentWillUnmount',
'twoB componentWillMount',
'twoB render',
'twoA componentWillUnmount',
]
),
'oneB componentDidMount',
'twoB componentDidMount',
]);
});
});
@@ -11,10 +11,11 @@
'use strict';
var KeyEscapeUtils = require('KeyEscapeUtils');
var ReactFeatureFlags = require('ReactFeatureFlags');
var ReactReconciler = require('ReactReconciler');
var instantiateReactComponent = require('instantiateReactComponent');
var KeyEscapeUtils = require('KeyEscapeUtils');
var shouldUpdateReactComponent = require('shouldUpdateReactComponent');
var traverseAllChildren = require('traverseAllChildren');
var warning = require('warning');
@@ -144,7 +145,10 @@ var ReactChildReconciler = {
);
nextChildren[name] = prevChild;
} else {
if (prevChild) {
if (
!ReactFeatureFlags.prepareNewChildrenBeforeUnmountInStack &&
prevChild
) {
removedNodes[name] = ReactReconciler.getHostNode(prevChild);
ReactReconciler.unmountComponent(
prevChild,
@@ -166,6 +170,17 @@ var ReactChildReconciler = {
selfDebugID
);
mountImages.push(nextChildMountImage);
if (
ReactFeatureFlags.prepareNewChildrenBeforeUnmountInStack &&
prevChild
) {
removedNodes[name] = ReactReconciler.getHostNode(prevChild);
ReactReconciler.unmountComponent(
prevChild,
false, /* safely */
false /* skipLifecycle */
);
}
}
}
// Unmount children that are no longer present.
@@ -15,6 +15,7 @@ var React = require('React');
var ReactComponentEnvironment = require('ReactComponentEnvironment');
var ReactCurrentOwner = require('ReactCurrentOwner');
var ReactErrorUtils = require('ReactErrorUtils');
var ReactFeatureFlags = require('ReactFeatureFlags');
var ReactInstanceMap = require('ReactInstanceMap');
var ReactInstrumentation = require('ReactInstrumentation');
var ReactNodeTypes = require('ReactNodeTypes');
@@ -1109,11 +1110,14 @@ var ReactCompositeComponent = {
);
} else {
var oldHostNode = ReactReconciler.getHostNode(prevComponentInstance);
ReactReconciler.unmountComponent(
prevComponentInstance,
safely,
false /* skipLifecycle */
);
if (!ReactFeatureFlags.prepareNewChildrenBeforeUnmountInStack) {
ReactReconciler.unmountComponent(
prevComponentInstance,
safely,
false /* skipLifecycle */
);
}
var nodeType = ReactNodeTypes.getType(nextRenderedElement);
this._renderedNodeType = nodeType;
@@ -1132,6 +1136,14 @@ var ReactCompositeComponent = {
debugID
);
if (ReactFeatureFlags.prepareNewChildrenBeforeUnmountInStack) {
ReactReconciler.unmountComponent(
prevComponentInstance,
safely,
false /* skipLifecycle */
);
}
if (__DEV__) {
if (debugID !== 0) {
var childDebugIDs = child._debugID !== 0 ? [child._debugID] : [];
@@ -17,6 +17,7 @@ var ReactFeatureFlags = {
// render (both initial renders and updates). Useful when looking at prod-mode
// timeline profiles in Chrome, for example.
logTopLevelRenders: false,
prepareNewChildrenBeforeUnmountInStack: true,
};
module.exports = ReactFeatureFlags;
@@ -200,8 +200,8 @@ describe('ReactTestRenderer', () => {
expect(log).toEqual([
'render Foo',
'mount Foo',
'unmount Foo',
'render Bar',
'unmount Foo',
'mount Bar',
'unmount Bar',
]);