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..7392fbb6c90a 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 = 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 = oldProps; + 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) && + oldProps !== 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, }; }; 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; } diff --git a/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js b/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js index aa6cc245541b..467965a8e937 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']); }); @@ -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. + + }); + }); 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', () => {