From a87ec42f2ad5cb996cfc92e7023372e667931172 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Oct 2016 02:06:36 -0700 Subject: [PATCH 1/4] Add more life-cycles to Fiber This refactors the initialization process so that we can share it with the "module pattern" initialization. There are a few new interesting scenarios unlocked by this. E.g. constructor -> componentWillMount -> shouldComponentUpdate -> componentDidMount when a render is aborted and resumed. If shouldComponentUpdate returns true then we create a new instance instead of trying componentWillMount again or componentWillReceiveProps without being mounted. Another strange thing is that the "previous props and state" during componentWillReceiveProps, shouldComponentUpdate and componentWillUpdate are all the previous render attempt. However, componentDidMount's previous is the props/state at the previous commit. That is because the first three can execute multiple times before a didMount. --- .../shared/fiber/ReactFiberBeginWork.js | 79 +++------ .../shared/fiber/ReactFiberClassComponent.js | 154 +++++++++++++++++- 2 files changed, 171 insertions(+), 62 deletions(-) diff --git a/src/renderers/shared/fiber/ReactFiberBeginWork.js b/src/renderers/shared/fiber/ReactFiberBeginWork.js index b75fe2e67be4..c199c8c921f7 100644 --- a/src/renderers/shared/fiber/ReactFiberBeginWork.js +++ b/src/renderers/shared/fiber/ReactFiberBeginWork.js @@ -40,9 +40,6 @@ var { NoWork, OffscreenPriority, } = require('ReactPriorityLevel'); -var { - mergeUpdateQueue, -} = require('ReactFiberUpdateQueue'); var { Placement, } = require('ReactTypeOfSideEffect'); @@ -51,7 +48,11 @@ var ReactFiberClassComponent = require('ReactFiberClassComponent'); module.exports = function(config : HostConfig, scheduleUpdate : (fiber: Fiber, priorityLevel : PriorityLevel) => void) { const { - mount, + adoptClassInstance, + constructClassInstance, + mountClassInstance, + resumeMountClassInstance, + updateClassInstance, } = ReactFiberClassComponent(scheduleUpdate); function markChildAsProgressed(current, workInProgress, priorityLevel) { @@ -156,54 +157,27 @@ module.exports = function(config : HostConfig, s } function updateClassComponent(current : ?Fiber, workInProgress : Fiber) { - // A class component update is the result of either new props or new state. - // Account for the possibly of missing pending props by falling back to the - // memoized props. - var props = workInProgress.pendingProps; - if (!props) { - // If there isn't any new props, then we'll reuse the memoized props. - // This could be from already completed work. - props = workInProgress.memoizedProps; - if (!props) { - throw new Error('There should always be pending or memoized props.'); + let shouldUpdate; + if (!current) { + if (!workInProgress.stateNode) { + // In the initial pass we might need to construct the instance. + constructClassInstance(workInProgress); + mountClassInstance(workInProgress); + shouldUpdate = true; + } else { + // In a resume, we'll already have an instance we can reuse. + shouldUpdate = resumeMountClassInstance(workInProgress); } - } - - // Compute the state using the memoized state and the update queue. - var updateQueue = workInProgress.updateQueue; - var previousState = workInProgress.memoizedState; - var state; - if (updateQueue) { - state = mergeUpdateQueue(updateQueue, previousState, props); } else { - state = previousState; + shouldUpdate = updateClassInstance(current, workInProgress); } - - var instance = workInProgress.stateNode; - if (!instance) { - var ctor = workInProgress.type; - workInProgress.stateNode = instance = new ctor(props); - mount(workInProgress, instance); - state = instance.state || null; - } else if (typeof instance.shouldComponentUpdate === 'function' && - !(updateQueue && updateQueue.isForced)) { - if (workInProgress.memoizedProps !== null) { - // Reset the props, in case this is a ping-pong case rather than a - // completed update case. For the completed update case, the instance - // props will already be the memoizedProps. - instance.props = workInProgress.memoizedProps; - instance.state = workInProgress.memoizedState; - if (!instance.shouldComponentUpdate(props, state)) { - return bailoutOnAlreadyFinishedWork(current, workInProgress); - } - } + if (!shouldUpdate) { + return bailoutOnAlreadyFinishedWork(current, workInProgress); } - - instance.props = props; - instance.state = state; - var nextChildren = instance.render(); + // Rerender + const instance = workInProgress.stateNode; + const nextChildren = instance.render(); reconcileChildren(current, workInProgress, nextChildren); - return workInProgress.child; } @@ -258,22 +232,21 @@ module.exports = function(config : HostConfig, s } function mountIndeterminateComponent(current, workInProgress) { + if (current) { + throw new Error('An indeterminate component should never have mounted.'); + } var fn = workInProgress.type; var props = workInProgress.pendingProps; var value = fn(props); if (typeof value === 'object' && value && typeof value.render === 'function') { // Proceed under the assumption that this is a class instance workInProgress.tag = ClassComponent; - if (current) { - current.tag = ClassComponent; - } + adoptClassInstance(workInProgress, value); + mountClassInstance(workInProgress); value = value.render(); } else { // Proceed under the assumption that this is a functional component workInProgress.tag = FunctionalComponent; - if (current) { - current.tag = FunctionalComponent; - } } reconcileChildren(current, workInProgress, value); return workInProgress.child; diff --git a/src/renderers/shared/fiber/ReactFiberClassComponent.js b/src/renderers/shared/fiber/ReactFiberClassComponent.js index 6110057607cb..33a6482f2d66 100644 --- a/src/renderers/shared/fiber/ReactFiberClassComponent.js +++ b/src/renderers/shared/fiber/ReactFiberClassComponent.js @@ -21,6 +21,7 @@ var { createUpdateQueue, addToQueue, addCallbackToQueue, + mergeUpdateQueue, } = require('ReactFiberUpdateQueue'); var ReactInstanceMap = require('ReactInstanceMap'); @@ -70,20 +71,155 @@ module.exports = function(scheduleUpdate : (fiber: Fiber, priorityLevel : Priori }, }; - function mount(workInProgress : Fiber, instance : any) { - const state = instance.state || null; - // The initial state must be added to the update queue in case - // setState is called before the initial render. - if (state !== null) { - workInProgress.updateQueue = createUpdateQueue(state); - } + function adoptClassInstance(workInProgress : Fiber, instance : any) : void { + instance.updater = updater; + workInProgress.stateNode = instance; // The instance needs access to the fiber so that it can schedule updates ReactInstanceMap.set(instance, workInProgress); - instance.updater = updater; + } + + function constructClassInstance(workInProgress : Fiber) : any { + const ctor = workInProgress.type; + const props = workInProgress.pendingProps; + const instance = new ctor(props); + adoptClassInstance(workInProgress, instance); + return instance; + } + + // Invokes the mount life-cycles on a previously never rendered instance. + function mountClassInstance(workInProgress : Fiber) : void { + const instance = workInProgress.stateNode; + + const state = instance.state || null; + + // A class component update is the result of either new props or new state. + // Account for the possibly of missing pending props by falling back to the + // memoized props. + let props = workInProgress.pendingProps; + if (!props) { + throw new Error('There must be pending props for an initial mount.'); + } + + instance.props = props; + instance.state = state; + + if (typeof instance.componentWillMount === 'function') { + instance.componentWillMount(); + // If we had additional state updates during this life-cycle, let's + // process them now. + const updateQueue = workInProgress.updateQueue; + if (updateQueue) { + instance.state = mergeUpdateQueue(updateQueue, state, props); + } + } + } + + // Called on a preexisting class instance. Returns false if a resumed render + // could be reused. + function resumeMountClassInstance(workInProgress : Fiber) : boolean { + const instance = workInProgress.stateNode; + let newState = workInProgress.memoizedState; + let newProps = workInProgress.pendingProps; + if (!newProps) { + // If there isn't any new props, then we'll reuse the memoized props. + // This could be from already completed work. + newProps = workInProgress.memoizedProps; + if (!newProps) { + throw new Error('There should always be pending or memoized props.'); + } + } + + // TODO: Should we deal with a setState that happened after the last + // componentWillMount and before this componentWillMount? Probably + // unsupported anyway. + + const updateQueue = workInProgress.updateQueue; + + // If this completed, we might be able to just reuse this instance. + if (typeof instance.shouldComponentUpdate === 'function' && + !(updateQueue && updateQueue.isForced) && + workInProgress.memoizedProps !== null && + !instance.shouldComponentUpdate(newProps, newState)) { + return false; + } + + // If we didn't bail out we need to construct a new instance. We don't + // want to reuse one that failed to fully mount. + const newInstance = constructClassInstance(workInProgress); + newInstance.props = newProps; + newInstance.state = newState = newInstance.state || null; + + if (typeof newInstance.componentWillMount === 'function') { + newInstance.componentWillMount(); + // If we had additional state updates during this life-cycle, let's + // process them now. + const newUpdateQueue = workInProgress.updateQueue; + if (newUpdateQueue) { + instance.state = mergeUpdateQueue(newUpdateQueue, newState, newProps); + } + } + return true; + } + + // Invokes the update life-cycles and returns false if it shouldn't rerender. + function updateClassInstance(current : Fiber, workInProgress : Fiber) : boolean { + const instance = workInProgress.stateNode; + + const oldProps = current.memoizedProps; + let newProps = workInProgress.pendingProps; + if (!newProps) { + // If there aren't any new props, then we'll reuse the memoized props. + // This could be from already completed work. + newProps = workInProgress.memoizedProps; + if (!newProps) { + throw new Error('There should always be pending or memoized props.'); + } + } + + // Note: During these life-cycles, instance.props/instance.state are what + // ever the previously attempted to render - not the "current". However, + // during componentDidUpdate we pass the "current" props. + + if (oldProps !== newProps) { + if (typeof instance.componentWillReceiveProps === 'function') { + instance.componentWillReceiveProps(newProps); + } + } + + // Compute the next state using the memoized state and the update queue. + const updateQueue = workInProgress.updateQueue; + const previousState = workInProgress.memoizedState; + // TODO: Previous state can be null. + let newState; + if (updateQueue) { + newState = mergeUpdateQueue(updateQueue, previousState, newProps); + } else { + newState = previousState; + } + + if (typeof instance.shouldComponentUpdate === 'function' && + !(updateQueue && updateQueue.isForced) && + workInProgress.memoizedProps !== null && + !instance.shouldComponentUpdate(newProps, newState)) { + // TODO: Should this get the new props/state updated regardless? + return false; + } + + if (typeof instance.componentWillUpdate === 'function') { + instance.componentWillUpdate(newProps, newState); + } + + instance.props = newProps; + instance.state = newState; + return true; } return { - mount, + adoptClassInstance, + constructClassInstance, + mountClassInstance, + resumeMountClassInstance, + updateClassInstance, }; }; From b72c27ce5de4900af544e542774744601f6f1d62 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 20 Oct 2016 11:46:58 -0700 Subject: [PATCH 2/4] Don't schedule NoWork as the next work We currently only filter out "NoWork" in the beginning of this function. If the NoWork root is after the one with work it will show up in the second loop. There's probably a more efficient way of doing this but this works for now. This showed up in this PR because a new unit test gets unblocked which ends up with this case. --- src/renderers/shared/fiber/ReactFiberScheduler.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderers/shared/fiber/ReactFiberScheduler.js b/src/renderers/shared/fiber/ReactFiberScheduler.js index 99c8682a17eb..e29729d8626f 100644 --- a/src/renderers/shared/fiber/ReactFiberScheduler.js +++ b/src/renderers/shared/fiber/ReactFiberScheduler.js @@ -79,14 +79,13 @@ module.exports = function(config : HostConfig) { } nextScheduledRoot = nextScheduledRoot.nextScheduledRoot; } - // TODO: This is scanning one root at a time. It should be scanning all - // roots for high priority work before moving on to lower priorities. let root = nextScheduledRoot; let highestPriorityRoot = null; let highestPriorityLevel = NoWork; while (root) { - if (highestPriorityLevel === NoWork || - highestPriorityLevel > root.current.pendingWorkPriority) { + if (root.current.pendingWorkPriority !== NoWork && ( + highestPriorityLevel === NoWork || + highestPriorityLevel > root.current.pendingWorkPriority)) { highestPriorityLevel = root.current.pendingWorkPriority; highestPriorityRoot = root; } From c862ba719e26764473eb9edd798429600ca746ab Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 20 Oct 2016 18:12:22 -0700 Subject: [PATCH 3/4] Fallback to current props if memoizedProps is null If work has progressed on a state update that gets resumed because of another state up, then we won't have an new pendingProps, and we also won't have any memoizedProps because it got aborted before completing. In that case, we can just fallback to the current props. I think that they can't have diverged because the only way they diverge is if there is new props. This lets us bail out on state only updates in more cases which the unit tests reflect. --- src/renderers/shared/fiber/ReactFiberClassComponent.js | 6 +++--- .../shared/fiber/__tests__/ReactIncremental-test.js | 4 ++-- .../fiber/__tests__/ReactIncrementalSideEffects-test.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderers/shared/fiber/ReactFiberClassComponent.js b/src/renderers/shared/fiber/ReactFiberClassComponent.js index 33a6482f2d66..7392fbb6c90a 100644 --- a/src/renderers/shared/fiber/ReactFiberClassComponent.js +++ b/src/renderers/shared/fiber/ReactFiberClassComponent.js @@ -165,12 +165,12 @@ module.exports = function(scheduleUpdate : (fiber: Fiber, priorityLevel : Priori function updateClassInstance(current : Fiber, workInProgress : Fiber) : boolean { const instance = workInProgress.stateNode; - const oldProps = current.memoizedProps; + const oldProps = workInProgress.memoizedProps || current.memoizedProps; let newProps = workInProgress.pendingProps; if (!newProps) { // If there aren't any new props, then we'll reuse the memoized props. // This could be from already completed work. - newProps = workInProgress.memoizedProps; + newProps = oldProps; if (!newProps) { throw new Error('There should always be pending or memoized props.'); } @@ -199,7 +199,7 @@ module.exports = function(scheduleUpdate : (fiber: Fiber, priorityLevel : Priori if (typeof instance.shouldComponentUpdate === 'function' && !(updateQueue && updateQueue.isForced) && - workInProgress.memoizedProps !== null && + oldProps !== null && !instance.shouldComponentUpdate(newProps, newState)) { // TODO: Should this get the new props/state updated regardless? return false; diff --git a/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js b/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js index aa6cc245541b..f15e12da28f0 100644 --- a/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js +++ b/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js @@ -625,10 +625,10 @@ describe('ReactIncremental', () => { // Normally shouldComponentUpdate->false is not enough to determine that we // can safely reuse the old props, but I think in this case it would be ok, // since it is a resume of already started work. - // Because of the above we can also not reuse the work of Bar because the + // Because of the above we can not reuse the work of Bar because the // rerender of Content will generate a new element which will mean we don't // auto-bail out from Bar. - expect(ops).toEqual(['Content', 'Bar', 'Middle']); + expect(ops).toEqual(['Bar', 'Middle']); }); diff --git a/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js b/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js index 81a1e1c09321..897422d2274d 100644 --- a/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js +++ b/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js @@ -598,7 +598,7 @@ describe('ReactIncrementalSideEffects', () => { ), ]); - expect(ops).toEqual(['Baz', 'Bar', 'Baz', 'Bar', 'Bar']); + expect(ops).toEqual(['Bar', 'Baz', 'Bar', 'Bar']); }); it('deprioritizes setStates that happens within a deprioritized tree', () => { From 37ca3874af3e94f6d108d31779adaee742cb4684 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 20 Oct 2016 18:09:30 -0700 Subject: [PATCH 4/4] Add unit tests for aborted life-cycles This tests the life-cycles when work gets aborted. --- .../fiber/__tests__/ReactIncremental-test.js | 321 +++++++++++++++++- 1 file changed, 314 insertions(+), 7 deletions(-) diff --git a/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js b/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js index f15e12da28f0..467965a8e937 100644 --- a/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js +++ b/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js @@ -839,8 +839,14 @@ describe('ReactIncremental', () => { it('can call sCU while resuming a partly mounted component', () => { var ops = []; + var instances = new Set(); + class Bar extends React.Component { state = { y: 'A' }; + constructor() { + super(); + instances.add(this); + } shouldComponentUpdate(newProps, newState) { return this.props.x !== newProps.x || this.state.y !== newState.y; @@ -855,20 +861,29 @@ describe('ReactIncremental', () => { ops.push('Foo'); return [ , - , + , , + , ]; } - ReactNoop.render(); - ReactNoop.flushDeferredPri(30); - expect(ops).toEqual(['Foo', 'Bar:A', 'Bar:B']); + ReactNoop.render(); + ReactNoop.flushDeferredPri(40); + expect(ops).toEqual(['Foo', 'Bar:A', 'Bar:B', 'Bar:C']); + + expect(instances.size).toBe(3); ops = []; - ReactNoop.render(); - ReactNoop.flushDeferredPri(40); - expect(ops).toEqual(['Foo', 'Bar:B', 'Bar:C']); + ReactNoop.render(); + ReactNoop.flushDeferredPri(50); + // A completed and was reused. B completed but couldn't be reused because + // props differences. C didn't complete and therefore couldn't be reused. + // D never even started so it needed a new instance. + expect(ops).toEqual(['Foo', 'Bar:B2', 'Bar:C', 'Bar:D']); + + // We expect each rerender to correspond to a new instance. + expect(instances.size).toBe(6); }); it('gets new props when setting state on a partly updated component', () => { @@ -927,4 +942,296 @@ describe('ReactIncremental', () => { expect(ops).toEqual(['Bar:A-1', 'Baz', 'Baz']); }); + it('calls componentWillMount twice if the initial render is aborted', () => { + var ops = []; + + class LifeCycle extends React.Component { + state = { x: this.props.x }; + componentWillMount() { + ops.push('componentWillMount:' + this.state.x + '-' + this.props.x); + } + componentDidMount() { + ops.push('componentDidMount:' + this.state.x + '-' + this.props.x); + } + render() { + return ; + } + } + + function Trail() { + ops.push('Trail'); + } + + function App(props) { + ops.push('App'); + return ( +
+ + +
+ ); + } + + ReactNoop.render(); + ReactNoop.flushDeferredPri(30); + + expect(ops).toEqual([ + 'App', + 'componentWillMount:0-0', + ]); + + ops = []; + + ReactNoop.render(); + ReactNoop.flush(); + + expect(ops).toEqual([ + 'App', + 'componentWillMount:1-1', + 'Trail', + 'componentDidMount:1-1', + ]); + }); + + it('calls componentWill* twice if an update render is aborted', () => { + var ops = []; + + class LifeCycle extends React.Component { + componentWillMount() { + ops.push('componentWillMount:' + this.props.x); + } + componentDidMount() { + ops.push('componentDidMount:' + this.props.x); + } + componentWillReceiveProps(nextProps) { + ops.push('componentWillReceiveProps:' + this.props.x + '-' + nextProps.x); + } + shouldComponentUpdate(nextProps) { + ops.push('shouldComponentUpdate:' + this.props.x + '-' + nextProps.x); + return true; + } + componentWillUpdate(nextProps) { + ops.push('componentWillUpdate:' + this.props.x + '-' + nextProps.x); + } + componentDidUpdate(prevProps) { + ops.push('componentDidUpdate:' + this.props.x + '-' + prevProps.x); + } + render() { + ops.push('render:' + this.props.x); + return ; + } + } + + function Sibling() { + // The sibling is used to confirm that we've completed the first child, + // but not yet flushed. + ops.push('Sibling'); + return ; + } + + function App(props) { + ops.push('App'); + + return [ + , + , + ]; + } + + ReactNoop.render(); + ReactNoop.flush(); + + expect(ops).toEqual([ + 'App', + 'componentWillMount:0', + 'render:0', + 'Sibling', + 'componentDidMount:0', + ]); + + ops = []; + + ReactNoop.render(); + ReactNoop.flushDeferredPri(30); + + expect(ops).toEqual([ + 'App', + 'componentWillReceiveProps:0-1', + 'shouldComponentUpdate:0-1', + 'componentWillUpdate:0-1', + 'render:1', + 'Sibling', + // no componentDidUpdate + ]); + + ops = []; + + ReactNoop.render(); + ReactNoop.flush(); + + expect(ops).toEqual([ + 'App', + 'componentWillReceiveProps:1-2', + 'shouldComponentUpdate:1-2', + 'componentWillUpdate:1-2', + 'render:2', + 'Sibling', + // When componentDidUpdate finally gets called, it covers both updates. + 'componentDidUpdate:2-0', + ]); + }); + + it('does not call componentWillReceiveProps for state-only updates', () => { + var ops = []; + + var instances = []; + + class LifeCycle extends React.Component { + state = { x: 0 }; + tick() { + this.setState({ + x: this.state.x + 1, + }); + } + componentWillMount() { + instances.push(this); + ops.push('componentWillMount:' + this.state.x); + } + componentDidMount() { + ops.push('componentDidMount:' + this.state.x); + } + componentWillReceiveProps(nextProps) { + ops.push('componentWillReceiveProps'); + } + shouldComponentUpdate(nextProps, nextState) { + ops.push('shouldComponentUpdate:' + this.state.x + '-' + nextState.x); + return true; + } + componentWillUpdate(nextProps, nextState) { + ops.push('componentWillUpdate:' + this.state.x + '-' + nextState.x); + } + componentDidUpdate(prevProps, prevState) { + ops.push('componentDidUpdate:' + this.state.x + '-' + prevState.x); + } + render() { + ops.push('render:' + this.state.x); + return ; + } + } + + // This wrap is a bit contrived because we can't pause a completed root and + // there is currently an issue where a component can't reuse its render + // output unless it fully completed. + class Wrap extends React.Component { + state = { y: 0 }; + componentWillMount() { + instances.push(this); + } + tick() { + this.setState({ + y: this.state.y + 1, + }); + } + render() { + ops.push('Wrap'); + return ; + } + } + + function Sibling() { + // The sibling is used to confirm that we've completed the first child, + // but not yet flushed. + ops.push('Sibling'); + return ; + } + + function App(props) { + ops.push('App'); + return [ + , + , + ]; + } + + ReactNoop.render(); + ReactNoop.flush(); + + expect(ops).toEqual([ + 'App', + 'Wrap', + 'componentWillMount:0', + 'render:0', + 'Sibling', + 'componentDidMount:0', + ]); + + ops = []; + + // LifeCycle + instances[1].tick(); + + ReactNoop.flushDeferredPri(25); + + expect(ops).toEqual([ + // no componentWillReceiveProps + 'shouldComponentUpdate:0-1', + 'componentWillUpdate:0-1', + 'render:1', + // no componentDidUpdate + ]); + + ops = []; + + // LifeCycle + instances[1].tick(); + + ReactNoop.flush(); + + expect(ops).toEqual([ + // no componentWillReceiveProps + 'shouldComponentUpdate:1-2', + 'componentWillUpdate:1-2', + 'render:2', + // When componentDidUpdate finally gets called, it covers both updates. + 'componentDidUpdate:2-0', + ]); + + ops = []; + + // Next we will update props of LifeCycle by updating its parent. + + instances[0].tick(); + + ReactNoop.flushDeferredPri(30); + + expect(ops).toEqual([ + 'Wrap', + 'componentWillReceiveProps', + 'shouldComponentUpdate:2-2', + 'componentWillUpdate:2-2', + 'render:2', + // no componentDidUpdate + ]); + + ops = []; + + // Next we will update LifeCycle directly but not with new props. + instances[1].tick(); + + ReactNoop.flush(); + + expect(ops).toEqual([ + // This should not trigger another componentWillReceiveProps because + // we never got new props. + 'shouldComponentUpdate:2-3', + 'componentWillUpdate:2-3', + 'render:3', + 'componentDidUpdate:3-2', + ]); + + // TODO: Test that we get the expected values for the same scenario with + // incomplete parents. + + }); + });