Don't diff memoized host components in completion phase (#13423)

* Add a regression test for 12643#issuecomment-413727104

* Don't diff memoized host components

* Add regression tests for noop renderer

* No early return

* Strengthen the test for host siblings

* Flow types
This commit is contained in:
Dan Abramov
2018-08-17 18:13:46 +01:00
committed by GitHub
parent 5e0f073d50
commit d2f5c3fbc2
5 changed files with 356 additions and 115 deletions
+136 -78
View File
@@ -18,6 +18,12 @@ describe('ReactDOMFiber', () => {
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
container = null;
});
it('should render strings as children', () => {
@@ -205,12 +211,12 @@ describe('ReactDOMFiber', () => {
};
const assertNamespacesMatch = function(tree) {
container = document.createElement('div');
let testContainer = document.createElement('div');
svgEls = [];
htmlEls = [];
mathEls = [];
ReactDOM.render(tree, container);
ReactDOM.render(tree, testContainer);
svgEls.forEach(el => {
expect(el.namespaceURI).toBe('http://www.w3.org/2000/svg');
});
@@ -221,8 +227,8 @@ describe('ReactDOMFiber', () => {
expect(el.namespaceURI).toBe('http://www.w3.org/1998/Math/MathML');
});
ReactDOM.unmountComponentAtNode(container);
expect(container.innerHTML).toBe('');
ReactDOM.unmountComponentAtNode(testContainer);
expect(testContainer.innerHTML).toBe('');
};
it('should render one portal', () => {
@@ -874,7 +880,6 @@ describe('ReactDOMFiber', () => {
it('should not onMouseLeave when staying in the portal', () => {
const portalContainer = document.createElement('div');
document.body.appendChild(container);
document.body.appendChild(portalContainer);
let ops = [];
@@ -944,7 +949,6 @@ describe('ReactDOMFiber', () => {
'leave parent', // Only when we leave the portal does onMouseLeave fire.
]);
} finally {
document.body.removeChild(container);
document.body.removeChild(portalContainer);
}
});
@@ -987,82 +991,77 @@ describe('ReactDOMFiber', () => {
});
it('should not update event handlers until commit', () => {
document.body.appendChild(container);
try {
let ops = [];
const handlerA = () => ops.push('A');
const handlerB = () => ops.push('B');
let ops = [];
const handlerA = () => ops.push('A');
const handlerB = () => ops.push('B');
class Example extends React.Component {
state = {flip: false, count: 0};
flip() {
this.setState({flip: true, count: this.state.count + 1});
}
tick() {
this.setState({count: this.state.count + 1});
}
render() {
const useB = !this.props.forceA && this.state.flip;
return <div onClick={useB ? handlerB : handlerA} />;
}
class Example extends React.Component {
state = {flip: false, count: 0};
flip() {
this.setState({flip: true, count: this.state.count + 1});
}
class Click extends React.Component {
constructor() {
super();
node.click();
}
render() {
return null;
}
tick() {
this.setState({count: this.state.count + 1});
}
render() {
const useB = !this.props.forceA && this.state.flip;
return <div onClick={useB ? handlerB : handlerA} />;
}
let inst;
ReactDOM.render([<Example key="a" ref={n => (inst = n)} />], container);
const node = container.firstChild;
expect(node.tagName).toEqual('DIV');
node.click();
expect(ops).toEqual(['A']);
ops = [];
// Render with the other event handler.
inst.flip();
node.click();
expect(ops).toEqual(['B']);
ops = [];
// Rerender without changing any props.
inst.tick();
node.click();
expect(ops).toEqual(['B']);
ops = [];
// Render a flip back to the A handler. The second component invokes the
// click handler during render to simulate a click during an aborted
// render. I use this hack because at current time we don't have a way to
// test aborted ReactDOM renders.
ReactDOM.render(
[<Example key="a" forceA={true} />, <Click key="b" />],
container,
);
// Because the new click handler has not yet committed, we should still
// invoke B.
expect(ops).toEqual(['B']);
ops = [];
// Any click that happens after commit, should invoke A.
node.click();
expect(ops).toEqual(['A']);
} finally {
document.body.removeChild(container);
}
class Click extends React.Component {
constructor() {
super();
node.click();
}
render() {
return null;
}
}
let inst;
ReactDOM.render([<Example key="a" ref={n => (inst = n)} />], container);
const node = container.firstChild;
expect(node.tagName).toEqual('DIV');
node.click();
expect(ops).toEqual(['A']);
ops = [];
// Render with the other event handler.
inst.flip();
node.click();
expect(ops).toEqual(['B']);
ops = [];
// Rerender without changing any props.
inst.tick();
node.click();
expect(ops).toEqual(['B']);
ops = [];
// Render a flip back to the A handler. The second component invokes the
// click handler during render to simulate a click during an aborted
// render. I use this hack because at current time we don't have a way to
// test aborted ReactDOM renders.
ReactDOM.render(
[<Example key="a" forceA={true} />, <Click key="b" />],
container,
);
// Because the new click handler has not yet committed, we should still
// invoke B.
expect(ops).toEqual(['B']);
ops = [];
// Any click that happens after commit, should invoke A.
node.click();
expect(ops).toEqual(['A']);
});
it('should not crash encountering low-priority tree', () => {
@@ -1178,4 +1177,63 @@ describe('ReactDOMFiber', () => {
container.appendChild(fragment);
expect(container.innerHTML).toBe('<div>foo</div>');
});
// Regression test for https://github.com/facebook/react/issues/12643#issuecomment-413727104
it('should not diff memoized host components', () => {
let inputRef = React.createRef();
let didCallOnChange = false;
class Child extends React.Component {
state = {};
componentDidMount() {
document.addEventListener('click', this.update, true);
}
componentWillUnmount() {
document.removeEventListener('click', this.update, true);
}
update = () => {
// We're testing that this setState()
// doesn't cause React to commit updates
// to the input outside (which would itself
// prevent the parent's onChange parent handler
// from firing).
this.setState({});
// Note that onChange was always broken when there was an
// earlier setState() in a manual document capture phase
// listener *in the same component*. But that's very rare.
// Here we're testing that a *child* component doesn't break
// the parent if this happens.
};
render() {
return <div />;
}
}
class Parent extends React.Component {
handleChange = val => {
didCallOnChange = true;
};
render() {
return (
<div>
<Child />
<input
ref={inputRef}
type="checkbox"
checked={true}
onChange={this.handleChange}
/>
</div>
);
}
}
ReactDOM.render(<Parent />, container);
inputRef.current.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
}),
);
expect(didCallOnChange).toBe(true);
});
});
+25
View File
@@ -41,6 +41,8 @@ if (__DEV__) {
function createReactNoop(reconciler: Function, useMutation: boolean) {
let scheduledCallback = null;
let instanceCounter = 0;
let hostDiffCounter = 0;
let hostUpdateCounter = 0;
function appendChildToContainerOrInstance(
parentInstance: Container | Instance,
@@ -220,6 +222,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
if (newProps === null) {
throw new Error('Should have new props');
}
hostDiffCounter++;
return UPDATE_SIGNAL;
},
@@ -303,6 +306,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
if (oldProps === null) {
throw new Error('Should have old props');
}
hostUpdateCounter++;
instance.prop = newProps.prop;
},
@@ -311,6 +315,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
oldText: string,
newText: string,
): void {
hostUpdateCounter++;
textInstance.text = newText;
},
@@ -556,6 +561,26 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return actual !== null ? actual : [];
},
flushWithHostCounters(
fn: () => void,
): {|
hostDiffCounter: number,
hostUpdateCounter: number,
|} {
hostDiffCounter = 0;
hostUpdateCounter = 0;
try {
ReactNoop.flush();
return {
hostDiffCounter,
hostUpdateCounter,
};
} finally {
hostDiffCounter = 0;
hostUpdateCounter = 0;
}
},
expire(ms: number): Array<mixed> {
ReactNoop.advanceTime(ms);
return ReactNoop.flushExpired();
+29 -27
View File
@@ -361,34 +361,36 @@ function completeWork(
// If we have an alternate, that means this is an update and we need to
// schedule a side-effect to do the updates.
const oldProps = current.memoizedProps;
// If we get updated because one of our children updated, we don't
// have newProps so we'll have to reuse them.
// TODO: Split the update API as separate for the props vs. children.
// Even better would be if children weren't special cased at all tho.
const instance: Instance = workInProgress.stateNode;
const currentHostContext = getHostContext();
// TODO: Experiencing an error where oldProps is null. Suggests a host
// component is hitting the resume path. Figure out why. Possibly
// related to `hidden`.
const updatePayload = prepareUpdate(
instance,
type,
oldProps,
newProps,
rootContainerInstance,
currentHostContext,
);
if (oldProps !== newProps) {
// If we get updated because one of our children updated, we don't
// have newProps so we'll have to reuse them.
// TODO: Split the update API as separate for the props vs. children.
// Even better would be if children weren't special cased at all tho.
const instance: Instance = workInProgress.stateNode;
const currentHostContext = getHostContext();
// TODO: Experiencing an error where oldProps is null. Suggests a host
// component is hitting the resume path. Figure out why. Possibly
// related to `hidden`.
const updatePayload = prepareUpdate(
instance,
type,
oldProps,
newProps,
rootContainerInstance,
currentHostContext,
);
updateHostComponent(
current,
workInProgress,
updatePayload,
type,
oldProps,
newProps,
rootContainerInstance,
currentHostContext,
);
updateHostComponent(
current,
workInProgress,
updatePayload,
type,
oldProps,
newProps,
rootContainerInstance,
currentHostContext,
);
}
if (current.ref !== workInProgress.ref) {
markRef(workInProgress);
@@ -0,0 +1,156 @@
/**
* Copyright (c) 2013-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment node
*/
'use strict';
let React;
let ReactNoop;
describe('ReactIncrementalUpdatesMinimalism', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
});
it('should render a simple component', () => {
function Child() {
return <div>Hello World</div>;
}
function Parent() {
return <Child />;
}
ReactNoop.render(<Parent />);
expect(ReactNoop.flushWithHostCounters()).toEqual({
hostDiffCounter: 0,
hostUpdateCounter: 0,
});
ReactNoop.render(<Parent />);
expect(ReactNoop.flushWithHostCounters()).toEqual({
hostDiffCounter: 1,
hostUpdateCounter: 1,
});
});
it('should not diff referentially equal host elements', () => {
function Leaf(props) {
return (
<span>
hello
<b />
{props.name}
</span>
);
}
const constEl = (
<div>
<Leaf name="world" />
</div>
);
function Child() {
return constEl;
}
function Parent() {
return <Child />;
}
ReactNoop.render(<Parent />);
expect(ReactNoop.flushWithHostCounters()).toEqual({
hostDiffCounter: 0,
hostUpdateCounter: 0,
});
ReactNoop.render(<Parent />);
expect(ReactNoop.flushWithHostCounters()).toEqual({
hostDiffCounter: 0,
hostUpdateCounter: 0,
});
});
it('should not diff parents of setState targets', () => {
let childInst;
function Leaf(props) {
return (
<span>
hello
<b />
{props.name}
</span>
);
}
class Child extends React.Component {
state = {name: 'Batman'};
render() {
childInst = this;
return (
<div>
<Leaf name={this.state.name} />
</div>
);
}
}
function Parent() {
return (
<section>
<div>
<Leaf name="world" />
<Child />
<hr />
<Leaf name="world" />
</div>
</section>
);
}
ReactNoop.render(<Parent />);
expect(ReactNoop.flushWithHostCounters()).toEqual({
hostDiffCounter: 0,
hostUpdateCounter: 0,
});
childInst.setState({name: 'Robin'});
expect(ReactNoop.flushWithHostCounters()).toEqual({
// Child > div
// Child > Leaf > span
// Child > Leaf > span > b
hostDiffCounter: 3,
// Child > div
// Child > Leaf > span
// Child > Leaf > span > b
// Child > Leaf > span > #text
hostUpdateCounter: 4,
});
ReactNoop.render(<Parent />);
expect(ReactNoop.flushWithHostCounters()).toEqual({
// Parent > section
// Parent > section > div
// Parent > section > div > Leaf > span
// Parent > section > div > Leaf > span > b
// Parent > section > div > Child > div
// Parent > section > div > Child > div > Leaf > span
// Parent > section > div > Child > div > Leaf > span > b
// Parent > section > div > hr
// Parent > section > div > Leaf > span
// Parent > section > div > Leaf > span > b
hostDiffCounter: 10,
hostUpdateCounter: 10,
});
});
});
@@ -85,8 +85,8 @@ exports[`ReactDebugFiberPerf deduplicates lifecycle names during commit to reduc
⚛ (Committing Changes)
⚛ (Committing Snapshot Effects: 0 Total)
⚛ (Committing Host Effects: 3 Total)
⚛ (Calling Lifecycle Methods: 3 Total)
⚛ (Committing Host Effects: 2 Total)
⚛ (Calling Lifecycle Methods: 2 Total)
⚛ B.componentDidUpdate
"
`;
@@ -257,8 +257,8 @@ exports[`ReactDebugFiberPerf measures deprioritized work 1`] = `
⚛ (Committing Changes)
⚛ (Committing Snapshot Effects: 0 Total)
⚛ (Committing Host Effects: 3 Total)
⚛ (Calling Lifecycle Methods: 2 Total)
⚛ (Committing Host Effects: 1 Total)
⚛ (Calling Lifecycle Methods: 0 Total)
"
`;
@@ -311,8 +311,8 @@ exports[`ReactDebugFiberPerf recovers from caught errors 1`] = `
⚛ (Committing Changes)
⚛ (Committing Snapshot Effects: 0 Total)
⚛ (Committing Host Effects: 2 Total)
⚛ (Calling Lifecycle Methods: 1 Total)
⚛ (Committing Host Effects: 1 Total)
⚛ (Calling Lifecycle Methods: 0 Total)
"
`;
@@ -357,8 +357,8 @@ exports[`ReactDebugFiberPerf skips parents during setState 1`] = `
⚛ (Committing Changes)
⚛ (Committing Snapshot Effects: 0 Total)
⚛ (Committing Host Effects: 6 Total)
⚛ (Calling Lifecycle Methods: 6 Total)
⚛ (Committing Host Effects: 2 Total)
⚛ (Calling Lifecycle Methods: 2 Total)
"
`;
@@ -431,8 +431,8 @@ exports[`ReactDebugFiberPerf warns on cascading renders from setState 1`] = `
⚛ (Committing Changes)
⚛ (Committing Snapshot Effects: 0 Total)
⚛ (Committing Host Effects: 2 Total)
⚛ (Calling Lifecycle Methods: 2 Total)
⚛ (Committing Host Effects: 1 Total)
⚛ (Calling Lifecycle Methods: 1 Total)
"
`;