diff --git a/src/renderers/noop/ReactNoop.js b/src/renderers/noop/ReactNoop.js index 390c4f6ac38..4d1bfb46b83 100644 --- a/src/renderers/noop/ReactNoop.js +++ b/src/renderers/noop/ReactNoop.js @@ -20,6 +20,7 @@ 'use strict'; import type { Fiber } from 'ReactFiber'; +import type { UpdateQueue } from 'ReactFiberUpdateQueue'; import type { HostChildren } from 'ReactFiberReconciler'; var ReactFiberReconciler = require('ReactFiberReconciler'); @@ -153,30 +154,61 @@ var ReactNoop = { return; } + var bufferedLog = []; + function log(...args) { + bufferedLog.push(...args, '\n'); + } + function logHostInstances(children: Array, depth) { for (var i = 0; i < children.length; i++) { var child = children[i]; - console.log(' '.repeat(depth) + '- ' + child.type + '#' + child.id); + log(' '.repeat(depth) + '- ' + child.type + '#' + child.id); logHostInstances(child.children, depth + 1); } } function logContainer(container : Container, depth) { - console.log(' '.repeat(depth) + '- [root#' + container.rootID + ']'); + log(' '.repeat(depth) + '- [root#' + container.rootID + ']'); logHostInstances(container.children, depth + 1); } + function logUpdateQueue(updateQueue : UpdateQueue, depth) { + log( + ' '.repeat(depth + 1) + 'QUEUED UPDATES', + updateQueue.isReplace ? 'is replace' : '', + updateQueue.isForced ? 'is forced' : '' + ); + log( + ' '.repeat(depth + 1) + '~', + updateQueue.partialState, + updateQueue.callback ? 'with callback' : '' + ); + var next; + while (next = updateQueue.next) { + log( + ' '.repeat(depth + 1) + '~', + next.partialState, + next.callback ? 'with callback' : '' + ); + } + } + function logFiber(fiber : Fiber, depth) { - console.log( + log( ' '.repeat(depth) + '- ' + (fiber.type ? fiber.type.name || fiber.type : '[root]'), '[' + fiber.pendingWorkPriority + (fiber.pendingProps ? '*' : '') + ']' ); + if (fiber.updateQueue) { + logUpdateQueue(fiber.updateQueue, depth); + } const childInProgress = fiber.progressedChild; if (childInProgress && childInProgress !== fiber.child) { - console.log(' '.repeat(depth + 1) + 'IN PROGRESS: ' + fiber.progressedPriority); + log(' '.repeat(depth + 1) + 'IN PROGRESS: ' + fiber.progressedPriority); logFiber(childInProgress, depth + 1); if (fiber.child) { - console.log(' '.repeat(depth + 1) + 'CURRENT'); + log(' '.repeat(depth + 1) + 'CURRENT'); } + } else if (fiber.child && fiber.updateQueue) { + log(' '.repeat(depth + 1) + 'CHILDREN'); } if (fiber.child) { logFiber(fiber.child, depth + 1); @@ -186,10 +218,12 @@ var ReactNoop = { } } - console.log('HOST INSTANCES:'); + log('HOST INSTANCES:'); logContainer(rootContainer, 0); - console.log('FIBERS:'); + log('FIBERS:'); logFiber((root.stateNode : any).current, 0); + + console.log(...bufferedLog); }, }; diff --git a/src/renderers/shared/fiber/ReactFiber.js b/src/renderers/shared/fiber/ReactFiber.js index 967c6f78b07..7dadfbe47a9 100644 --- a/src/renderers/shared/fiber/ReactFiber.js +++ b/src/renderers/shared/fiber/ReactFiber.js @@ -15,6 +15,7 @@ import type { ReactCoroutine, ReactYield } from 'ReactCoroutine'; import type { TypeOfWork } from 'ReactTypeOfWork'; import type { PriorityLevel } from 'ReactPriorityLevel'; +import type { UpdateQueue } from 'ReactFiberUpdateQueue'; var ReactTypeOfWork = require('ReactTypeOfWork'); var { @@ -76,6 +77,12 @@ export type Fiber = Instance & { pendingProps: any, // This type will be more specific once we overload the tag. // TODO: I think that there is a way to merge pendingProps and memoizedProps. memoizedProps: any, // The props used to create the output. + // A queue of local state updates. + updateQueue: ?UpdateQueue, + // The state used to create the output. This is a full state object. + memoizedState: any, + // Linked list of callbacks to call after updates are committed. + callbackList: ?UpdateQueue, // Output is the return value of this fiber, or a linked list of return values // if this returns multiple values. Such as a fragment. output: any, // This type will be more specific once we overload the tag. @@ -151,6 +158,9 @@ var createFiber = function(tag : TypeOfWork, key : null | string) : Fiber { pendingProps: null, memoizedProps: null, + updateQueue: null, + memoizedState: null, + callbackList: null, output: null, nextEffect: null, @@ -192,6 +202,8 @@ exports.cloneFiber = function(fiber : Fiber, priorityLevel : PriorityLevel) : Fi alt.sibling = fiber.sibling; // This should always be overridden. TODO: null alt.ref = fiber.ref; alt.pendingProps = fiber.pendingProps; // TODO: Pass as argument. + alt.updateQueue = fiber.updateQueue; + alt.callbackList = fiber.callbackList; alt.pendingWorkPriority = priorityLevel; alt.child = fiber.child; @@ -217,6 +229,8 @@ exports.cloneFiber = function(fiber : Fiber, priorityLevel : PriorityLevel) : Fi // pendingProps is here for symmetry but is unnecessary in practice for now. // TODO: Pass in the new pendingProps as an argument maybe? alt.pendingProps = fiber.pendingProps; + alt.updateQueue = fiber.updateQueue; + alt.callbackList = fiber.callbackList; alt.pendingWorkPriority = priorityLevel; alt.memoizedProps = fiber.memoizedProps; diff --git a/src/renderers/shared/fiber/ReactFiberBeginWork.js b/src/renderers/shared/fiber/ReactFiberBeginWork.js index a21bd4059a2..80b22b0dd55 100644 --- a/src/renderers/shared/fiber/ReactFiberBeginWork.js +++ b/src/renderers/shared/fiber/ReactFiberBeginWork.js @@ -14,14 +14,18 @@ import type { ReactCoroutine } from 'ReactCoroutine'; import type { Fiber } from 'ReactFiber'; +import type { FiberRoot } from 'ReactFiberRoot'; import type { HostConfig } from 'ReactFiberReconciler'; +import type { Scheduler } from 'ReactFiberScheduler'; import type { PriorityLevel } from 'ReactPriorityLevel'; +import type { UpdateQueue } from 'ReactFiberUpdateQueue'; var { reconcileChildFibers, reconcileChildFibersInPlace, cloneChildFibers, } = require('ReactChildFiber'); +var { LowPriority } = require('ReactPriorityLevel'); var ReactTypeOfWork = require('ReactTypeOfWork'); var { IndeterminateComponent, @@ -37,8 +41,15 @@ var { NoWork, OffscreenPriority, } = require('ReactPriorityLevel'); +var { + createUpdateQueue, + addToQueue, + addCallbackToQueue, + mergeUpdateQueue, +} = require('ReactFiberUpdateQueue'); +var ReactInstanceMap = require('ReactInstanceMap'); -module.exports = function(config : HostConfig) { +module.exports = function(config : HostConfig, getScheduler : () => Scheduler) { function markChildAsProgressed(current, workInProgress, priorityLevel) { // We now have clones. Let's store them as the currently progressed work. @@ -105,25 +116,116 @@ module.exports = function(config : HostConfig) { return workInProgress.child; } + function scheduleUpdate(fiber: Fiber, updateQueue: UpdateQueue, priorityLevel : PriorityLevel): void { + const { scheduleLowPriWork } = getScheduler(); + fiber.updateQueue = updateQueue; + // Schedule update on the alternate as well, since we don't know which tree + // is current. + if (fiber.alternate) { + fiber.alternate.updateQueue = updateQueue; + } + while (true) { + if (fiber.pendingWorkPriority === NoWork || + fiber.pendingWorkPriority >= priorityLevel) { + fiber.pendingWorkPriority = priorityLevel; + } + if (fiber.alternate) { + if (fiber.alternate.pendingWorkPriority === NoWork || + fiber.alternate.pendingWorkPriority >= priorityLevel) { + fiber.alternate.pendingWorkPriority = priorityLevel; + } + } + // Duck type root + if (fiber.stateNode && fiber.stateNode.containerInfo) { + const root : FiberRoot = (fiber.stateNode : any); + scheduleLowPriWork(root, priorityLevel); + return; + } + if (!fiber.return) { + throw new Error('No root!'); + } + fiber = fiber.return; + } + } + + // Class component state updater + const updater = { + enqueueSetState(instance, partialState) { + const fiber = ReactInstanceMap.get(instance); + const updateQueue = fiber.updateQueue ? + addToQueue(fiber.updateQueue, partialState) : + createUpdateQueue(partialState); + scheduleUpdate(fiber, updateQueue, LowPriority); + }, + enqueueReplaceState(instance, state) { + const fiber = ReactInstanceMap.get(instance); + const updateQueue = createUpdateQueue(state); + updateQueue.isReplace = true; + scheduleUpdate(fiber, updateQueue, LowPriority); + }, + enqueueForceUpdate(instance) { + const fiber = ReactInstanceMap.get(instance); + const updateQueue = fiber.updateQueue || createUpdateQueue(null); + updateQueue.isForced = true; + scheduleUpdate(fiber, updateQueue, LowPriority); + }, + enqueueCallback(instance, callback) { + const fiber = ReactInstanceMap.get(instance); + let updateQueue = fiber.updateQueue ? + fiber.updateQueue : + createUpdateQueue(null); + addCallbackToQueue(updateQueue, callback); + fiber.updateQueue = updateQueue; + if (fiber.alternate) { + fiber.alternate.updateQueue = updateQueue; + } + }, + }; + 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 && current) { + props = current.memoizedProps; + } + // Compute the state using the memoized state and the update queue. + var updateQueue = workInProgress.updateQueue; + var previousState = current ? current.memoizedState : null; + var state = updateQueue ? + mergeUpdateQueue(updateQueue, previousState, props) : + previousState; + var instance = workInProgress.stateNode; if (!instance) { var ctor = workInProgress.type; workInProgress.stateNode = instance = new ctor(props); - } else if (typeof instance.shouldComponentUpdate === 'function') { + 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); + } + // The instance needs access to the fiber so that it can schedule updates + ReactInstanceMap.set(instance, workInProgress); + instance.updater = updater; + } 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; - if (!instance.shouldComponentUpdate(props)) { + instance.state = workInProgress.memoizedState; + if (!instance.shouldComponentUpdate(props, state)) { return bailoutOnAlreadyFinishedWork(current, workInProgress); } } } instance.props = props; + instance.state = state; var nextChildren = instance.render(); reconcileChildren(current, workInProgress, nextChildren); @@ -251,10 +353,11 @@ module.exports = function(config : HostConfig) { workInProgress.child = workInProgress.progressedChild; } - if (workInProgress.pendingProps === null || ( + if ((workInProgress.pendingProps === null || ( workInProgress.memoizedProps !== null && workInProgress.pendingProps === workInProgress.memoizedProps - )) { + )) && + workInProgress.updateQueue === null) { return bailoutOnAlreadyFinishedWork(current, workInProgress); } diff --git a/src/renderers/shared/fiber/ReactFiberCommitWork.js b/src/renderers/shared/fiber/ReactFiberCommitWork.js index 2e3e8ae3d02..beb773a2e77 100644 --- a/src/renderers/shared/fiber/ReactFiberCommitWork.js +++ b/src/renderers/shared/fiber/ReactFiberCommitWork.js @@ -22,6 +22,7 @@ var { HostContainer, HostComponent, } = ReactTypeOfWork; +var { callCallbacks } = require('ReactFiberUpdateQueue'); module.exports = function(config : HostConfig) { @@ -31,6 +32,18 @@ module.exports = function(config : HostConfig) { function commitWork(current : ?Fiber, finishedWork : Fiber) : void { switch (finishedWork.tag) { case ClassComponent: { + // Clear updates from current fiber. This must go before the callbacks + // are reset, in case an update is triggered from inside a callback. Is + // this safe? Relies on the assumption that work is only committed if + // the update queue is empty. + if (finishedWork.alternate) { + finishedWork.alternate.updateQueue = null; + } + if (finishedWork.callbackList) { + const { callbackList } = finishedWork; + finishedWork.callbackList = null; + callCallbacks(callbackList, finishedWork.stateNode); + } // TODO: Fire componentDidMount/componentDidUpdate, update refs return; } diff --git a/src/renderers/shared/fiber/ReactFiberCompleteWork.js b/src/renderers/shared/fiber/ReactFiberCompleteWork.js index 14e95a0d735..d4687f45745 100644 --- a/src/renderers/shared/fiber/ReactFiberCompleteWork.js +++ b/src/renderers/shared/fiber/ReactFiberCompleteWork.js @@ -46,7 +46,6 @@ module.exports = function(config : HostConfig) { } } - /* // TODO: It's possible this will create layout thrash issues because mutations // of the DOM and life-cycles are interleaved. E.g. if a componentDidMount // of a sibling reads, then the next sibling updates and reads etc. @@ -59,7 +58,6 @@ module.exports = function(config : HostConfig) { } workInProgress.lastEffect = workInProgress; } - */ function transferOutput(child : ?Fiber, returnFiber : Fiber) { // If we have a single result, we just pass that through as the output to @@ -132,6 +130,17 @@ module.exports = function(config : HostConfig) { return null; case ClassComponent: transferOutput(workInProgress.child, workInProgress); + // Don't use the state queue to compute the memoized state. We already + // merged it and assigned it to the instance. Transfer it from there. + // Also need to transfer the props, because pendingProps will be null + // in the case of an update + const { state, props } = workInProgress.stateNode; + workInProgress.memoizedState = state; + workInProgress.memoizedProps = props; + // Transfer update queue to callbackList field so callbacks can be + // called during commit phase. + workInProgress.callbackList = workInProgress.updateQueue; + markForPostEffect(workInProgress); return null; case HostContainer: transferOutput(workInProgress.child, workInProgress); diff --git a/src/renderers/shared/fiber/ReactFiberScheduler.js b/src/renderers/shared/fiber/ReactFiberScheduler.js index e04c846f206..874c24928cc 100644 --- a/src/renderers/shared/fiber/ReactFiberScheduler.js +++ b/src/renderers/shared/fiber/ReactFiberScheduler.js @@ -29,9 +29,19 @@ var { var timeHeuristicForUnitOfWork = 1; +export type Scheduler = { + scheduleLowPriWork: (root : FiberRoot, priority : PriorityLevel) => void +}; + module.exports = function(config : HostConfig) { + // Use a closure to circumvent the circular dependency between the scheduler + // and ReactFiberBeginWork. Don't know if there's a better way to do this. + let scheduler; + function getScheduler(): Scheduler { + return scheduler; + } - const { beginWork } = ReactFiberBeginWork(config); + const { beginWork } = ReactFiberBeginWork(config, getScheduler); const { completeWork } = ReactFiberCompleteWork(config); const { commitWork } = ReactFiberCommitWork(config); @@ -133,6 +143,7 @@ module.exports = function(config : HostConfig) { // The work is now done. We don't need this anymore. This flags // to the system not to redo any work here. workInProgress.pendingProps = null; + workInProgress.updateQueue = null; const returnFiber = workInProgress.return; @@ -259,7 +270,8 @@ module.exports = function(config : HostConfig) { } */ - return { + scheduler = { scheduleLowPriWork: scheduleLowPriWork, }; + return scheduler; }; diff --git a/src/renderers/shared/fiber/ReactFiberUpdateQueue.js b/src/renderers/shared/fiber/ReactFiberUpdateQueue.js new file mode 100644 index 00000000000..aab252b6558 --- /dev/null +++ b/src/renderers/shared/fiber/ReactFiberUpdateQueue.js @@ -0,0 +1,89 @@ +/** + * Copyright 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactFiberUpdateQueue + * @flow + */ + +'use strict'; + +type UpdateQueueNode = { + partialState: any, + callback: ?Function, + callbackWasCalled: boolean, + next: ?UpdateQueueNode, +}; + +export type UpdateQueue = UpdateQueueNode & { + isReplace: boolean, + isForced: boolean, + tail: UpdateQueueNode +}; + +exports.createUpdateQueue = function(partialState : mixed) : UpdateQueue { + const queue = { + partialState, + callback: null, + callbackWasCalled: false, + next: null, + isReplace: false, + isForced: false, + tail: (null : any), + }; + queue.tail = queue; + return queue; +}; + +exports.addToQueue = function(queue : UpdateQueue, partialState : mixed) : UpdateQueue { + const node = { + partialState, + callback: null, + callbackWasCalled: false, + next: null, + }; + queue.tail.next = node; + queue.tail = node; + return queue; +}; + +exports.addCallbackToQueue = function(queue : UpdateQueue, callback: Function) : UpdateQueue { + if (queue.tail.callback) { + // If the tail already as a callback, add an empty node to queue + exports.addToQueue(queue, null); + } + queue.tail.callback = callback; + return queue; +}; + +exports.callCallbacks = function(queue : UpdateQueue, context : any) { + let node : ?UpdateQueueNode = queue; + while (node) { + if (node.callback && !node.callbackWasCalled) { + node.callbackWasCalled = true; + node.callback.call(context); + } + node = node.next; + } +}; + +exports.mergeUpdateQueue = function(queue : UpdateQueue, prevState : any, props : any) : any { + let node : ?UpdateQueueNode = queue; + let state = queue.isReplace ? null : Object.assign({}, prevState); + while (node) { + let partialState; + if (typeof node.partialState === 'function') { + const updateFn = node.partialState; + partialState = updateFn(state, props); + } else { + partialState = node.partialState; + } + state = Object.assign(state || {}, partialState); + node = node.next; + } + return state; +}; diff --git a/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js b/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js index 608a95ea4ff..c543de45499 100644 --- a/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js +++ b/src/renderers/shared/fiber/__tests__/ReactIncremental-test.js @@ -556,4 +556,208 @@ describe('ReactIncremental', () => { expect(ops).toEqual(['Content', 'Bar', 'Middle']); }); + + it('can update in the middle of a tree using setState', () => { + let instance; + class Bar extends React.Component { + constructor() { + super(); + this.state = { a: 'a' }; + instance = this; + } + render() { + return
{this.props.children}
; + } + } + + function Foo() { + return ( +
+ +
+ ); + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(instance.state).toEqual({ a: 'a' }); + instance.setState({ b: 'b' }); + ReactNoop.flush(); + expect(instance.state).toEqual({ a: 'a', b: 'b' }); + }); + + it('can queue multiple state updates', () => { + let instance; + class Bar extends React.Component { + constructor() { + super(); + this.state = { a: 'a' }; + instance = this; + } + render() { + return
{this.props.children}
; + } + } + + function Foo() { + return ( +
+ +
+ ); + } + + ReactNoop.render(); + ReactNoop.flush(); + // Call setState multiple times before flushing + instance.setState({ b: 'b' }); + instance.setState({ c: 'c' }); + instance.setState({ d: 'd' }); + ReactNoop.flush(); + expect(instance.state).toEqual({ a: 'a', b: 'b', c: 'c', d: 'd' }); + }); + + it('can use updater form of setState', () => { + let instance; + class Bar extends React.Component { + constructor() { + super(); + this.state = { num: 1 }; + instance = this; + } + render() { + return
{this.props.children}
; + } + } + + function Foo({ multiplier }) { + return ( +
+ +
+ ); + } + + function updater(state, props) { + return { num: state.num * props.multiplier }; + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(instance.state.num).toEqual(1); + instance.setState(updater); + ReactNoop.flush(); + expect(instance.state.num).toEqual(2); + instance.setState(updater); + ReactNoop.render(); + ReactNoop.flush(); + expect(instance.state.num).toEqual(6); + }); + + it('can call setState inside update callback', () => { + let instance; + class Bar extends React.Component { + constructor() { + super(); + this.state = { num: 1 }; + instance = this; + } + render() { + return
{this.props.children}
; + } + } + + function Foo({ multiplier }) { + return ( +
+ +
+ ); + } + + function updater(state, props) { + return { num: state.num * props.multiplier }; + } + + function callback() { + this.setState({ called: true }); + } + + ReactNoop.render(); + ReactNoop.flush(); + instance.setState(updater); + instance.setState(updater, callback); + ReactNoop.flush(); + expect(instance.state.num).toEqual(4); + expect(instance.state.called).toEqual(true); + }); + + it('can replaceState', () => { + let instance; + const Bar = React.createClass({ + getInitialState() { + instance = this; + return { a: 'a' }; + }, + render() { + return
{this.props.children}
; + }, + }); + + function Foo() { + return ( +
+ +
+ ); + } + + ReactNoop.render(); + ReactNoop.flush(); + instance.setState({ b: 'b' }); + instance.setState({ c: 'c' }); + instance.replaceState({ d: 'd' }); + ReactNoop.flush(); + expect(instance.state).toEqual({ d: 'd' }); + }); + + it('can forceUpdate', () => { + const ops = []; + + function Baz() { + ops.push('Baz'); + return
; + } + + let instance; + class Bar extends React.Component { + constructor() { + super(); + instance = this; + } + shouldComponentUpdate() { + return false; + } + render() { + ops.push('Bar'); + return ; + } + } + + function Foo() { + ops.push('Foo'); + return ( +
+ +
+ ); + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(ops).toEqual(['Foo', 'Bar', 'Baz']); + instance.forceUpdate(); + ReactNoop.flush(); + expect(ops).toEqual(['Foo', 'Bar', 'Baz', 'Bar', 'Baz']); + }); }); diff --git a/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js b/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js index 63983720df8..f49f09592ee 100644 --- a/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js +++ b/src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js @@ -354,4 +354,34 @@ describe('ReactIncrementalSideEffects', () => { // moves to "current" without flushing due to having lower priority. Does this // even happen? Maybe a child doesn't get processed because it is lower prio? + it('calls callback after update is flushed', () => { + let instance; + class Foo extends React.Component { + constructor() { + super(); + instance = this; + this.state = { text: 'foo' }; + } + render() { + return ; + } + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.root.children).toEqual([ + span('foo'), + ]); + let called = false; + instance.setState({ text: 'bar' }, () => { + expect(ReactNoop.root.children).toEqual([ + span('bar'), + ]); + called = true; + }); + ReactNoop.flush(); + expect(called).toBe(true); + }); + + // TODO: Test that callbacks are not lost if an update is preempted. }); diff --git a/src/renderers/shared/stack/reconciler/ReactInstanceMap.js b/src/renderers/shared/shared/ReactInstanceMap.js similarity index 100% rename from src/renderers/shared/stack/reconciler/ReactInstanceMap.js rename to src/renderers/shared/shared/ReactInstanceMap.js