diff --git a/src/renderers/dom/fiber/ReactDOMFiberEntry.js b/src/renderers/dom/fiber/ReactDOMFiberEntry.js index d319b9d2d87..c86a616db61 100644 --- a/src/renderers/dom/fiber/ReactDOMFiberEntry.js +++ b/src/renderers/dom/fiber/ReactDOMFiberEntry.js @@ -11,6 +11,7 @@ 'use strict'; import type {ReactNodeList} from 'ReactTypes'; +import type {ExpirationTime} from 'ReactFiberExpirationTime'; require('checkReact'); var DOMNamespaces = require('DOMNamespaces'); @@ -757,8 +758,27 @@ function createPortal( return ReactPortal.createPortal(children, container, null, key); } +type WorkNode = { + commit(): void, + + _reactRootContainer: *, + _expirationTime: ExpirationTime, +}; + +function Work(root: *, expirationTime: ExpirationTime) { + this._reactRootContainer = root; + this._expirationTime = expirationTime; +} +Work.prototype.commit = function() { + const root = this._reactRootContainer; + const expirationTime = this._expirationTime; + DOMRenderer.unblockRoot(root, expirationTime); + DOMRenderer.flushRoot(root, expirationTime); +}; + type ReactRootNode = { render(children: ReactNodeList, callback: ?() => mixed): void, + prerender(children: ReactNodeList): WorkNode, unmount(callback: ?() => mixed): void, _reactRootContainer: *, @@ -779,6 +799,11 @@ ReactRoot.prototype.render = function( const root = this._reactRootContainer; DOMRenderer.updateContainer(children, root, null, callback); }; +ReactRoot.prototype.prerender = function(children: ReactNodeList): WorkNode { + const root = this._reactRootContainer; + const expirationTime = DOMRenderer.updateRoot(children, root, null, null); + return new Work(root, expirationTime); +}; ReactRoot.prototype.unmount = function(callback) { const root = this._reactRootContainer; DOMRenderer.updateContainer(null, root, null, callback); diff --git a/src/renderers/dom/shared/__tests__/ReactDOMRoot-test.js b/src/renderers/dom/shared/__tests__/ReactDOMRoot-test.js index c4a327b116e..498be74e378 100644 --- a/src/renderers/dom/shared/__tests__/ReactDOMRoot-test.js +++ b/src/renderers/dom/shared/__tests__/ReactDOMRoot-test.js @@ -68,4 +68,34 @@ describe('ReactDOMRoot', () => { root.render(
dc
); expect(container.textContent).toEqual('abdc'); }); + + it('can defer commit using prerender', () => { + const root = ReactDOM.createRoot(container); + const work = root.prerender(
Hi
); + // Hasn't updated yet + expect(container.textContent).toEqual(''); + // Flush work + work.commit(); + expect(container.textContent).toEqual('Hi'); + }); + + it("does not restart a blocked root that wasn't updated", () => { + let ops = []; + function Foo(props) { + ops.push('Foo'); + return props.children; + } + const root = ReactDOM.createRoot(container); + const work = root.prerender(Hi); + expect(ops).toEqual(['Foo']); + // Hasn't updated yet + expect(container.textContent).toEqual(''); + + ops = []; + + // Flush work. Shouldn't re-render Foo. + work.commit(); + expect(ops).toEqual([]); + expect(container.textContent).toEqual('Hi'); + }); }); diff --git a/src/renderers/shared/fiber/ReactFiberBeginWork.js b/src/renderers/shared/fiber/ReactFiberBeginWork.js index 508a41a8622..347116ceafe 100644 --- a/src/renderers/shared/fiber/ReactFiberBeginWork.js +++ b/src/renderers/shared/fiber/ReactFiberBeginWork.js @@ -319,6 +319,7 @@ module.exports = function( } function updateHostRoot(current, workInProgress, renderExpirationTime) { + const root: FiberRoot = workInProgress.stateNode; pushHostRootContext(workInProgress); const updateQueue = workInProgress.updateQueue; if (updateQueue !== null) { @@ -331,6 +332,16 @@ module.exports = function( null, renderExpirationTime, ); + memoizeState(workInProgress, state); + if (root.completedAt === renderExpirationTime) { + // The root is already complete. Bail out and commit. + // TODO: This is a limited version of resuming that only applies to + // the root, to account for the pathological case where a completed + // root must be completely restarted before it can commit. Once we + // implement resuming for real, this special branch shouldn't + // be neccessary. + return null; + } if (prevState === state) { // If the state is the same as before, that's a bailout because we had // no work that expires at this time. @@ -338,7 +349,6 @@ module.exports = function( return bailoutOnAlreadyFinishedWork(current, workInProgress); } const element = state.element; - const root: FiberRoot = workInProgress.stateNode; if ( (current === null || current.child === null) && root.hydrate && @@ -370,7 +380,6 @@ module.exports = function( resetHydrationState(); reconcileChildren(current, workInProgress, element); } - memoizeState(workInProgress, state); return workInProgress.child; } resetHydrationState(); diff --git a/src/renderers/shared/fiber/ReactFiberCompleteWork.js b/src/renderers/shared/fiber/ReactFiberCompleteWork.js index edb6048e913..8299b959d71 100644 --- a/src/renderers/shared/fiber/ReactFiberCompleteWork.js +++ b/src/renderers/shared/fiber/ReactFiberCompleteWork.js @@ -48,6 +48,7 @@ module.exports = function( config: HostConfig, hostContext: HostContext, hydrationContext: HydrationContext, + blockCurrentlyRenderingWork: () => void, ) { const { createInstance, @@ -219,6 +220,12 @@ module.exports = function( // TODO: Delete this when we delete isMounted and findDOMNode. workInProgress.effectTag &= ~Placement; } + + const memoizedState = workInProgress.memoizedState; + if (memoizedState !== null && memoizedState.isBlocked) { + // Root is blocked by a top-level update. + blockCurrentlyRenderingWork(); + } return null; } case HostComponent: { diff --git a/src/renderers/shared/fiber/ReactFiberReconciler.js b/src/renderers/shared/fiber/ReactFiberReconciler.js index 7f6a7c3017c..0629d7bc9dd 100644 --- a/src/renderers/shared/fiber/ReactFiberReconciler.js +++ b/src/renderers/shared/fiber/ReactFiberReconciler.js @@ -13,6 +13,7 @@ import type {Fiber} from 'ReactFiber'; import type {FiberRoot} from 'ReactFiberRoot'; import type {ReactNodeList} from 'ReactTypes'; +import type {ExpirationTime} from 'ReactFiberExpirationTime'; var ReactFeatureFlags = require('ReactFeatureFlags'); var { @@ -226,6 +227,14 @@ export type Reconciler = { parentComponent: ?React$Component, callback: ?Function, ): void, + updateRoot( + element: ReactNodeList, + container: OpaqueRoot, + parentComponent: ?React$Component, + callback: ?Function, + ): ExpirationTime, + unblockRoot(root: OpaqueRoot, expirationTime: ExpirationTime): void, + flushRoot(root: OpaqueRoot, expirationTime: ExpirationTime): void, batchedUpdates(fn: () => A): A, unbatchedUpdates(fn: () => A): A, flushSync(fn: () => A): A, @@ -266,6 +275,7 @@ module.exports = function( computeAsyncExpiration, computeExpirationForFiber, scheduleWork, + expireWork, batchedUpdates, unbatchedUpdates, flushSync, @@ -275,8 +285,9 @@ module.exports = function( function scheduleTopLevelUpdate( current: Fiber, element: ReactNodeList, + isBlocked: boolean, callback: ?Function, - ) { + ): ExpirationTime { if (__DEV__) { if ( ReactDebugCurrentFiber.phase === 'render' && @@ -323,15 +334,15 @@ module.exports = function( const update = { expirationTime, - partialState: {element}, + partialState: {element, isBlocked}, callback, isReplace: false, isForced: false, - nextCallback: null, next: null, }; insertUpdateIntoFiber(current, update); scheduleWork(current, expirationTime); + return expirationTime; } return { @@ -367,7 +378,61 @@ module.exports = function( container.pendingContext = context; } - scheduleTopLevelUpdate(current, element, callback); + scheduleTopLevelUpdate(current, element, false, callback); + }, + + // Like updateContainer, but blocks the root from committing. Returns an + // expiration time. + // TODO: Can this be unified with updateContainer? Or is it incompatible + // with the existing semantics of ReactDOM.render? + updateRoot( + element: ReactNodeList, + container: OpaqueRoot, + parentComponent: ?React$Component, + callback: ?Function, + ): ExpirationTime { + // TODO: If this is a nested container, this won't be the root. + const current = container.current; + + if (__DEV__) { + if (ReactFiberInstrumentation.debugTool) { + if (current.alternate === null) { + ReactFiberInstrumentation.debugTool.onMountContainer(container); + } else if (element === null) { + ReactFiberInstrumentation.debugTool.onUnmountContainer(container); + } else { + ReactFiberInstrumentation.debugTool.onUpdateContainer(container); + } + } + } + + const context = getContextForSubtree(parentComponent); + if (container.context === null) { + container.context = context; + } else { + container.pendingContext = context; + } + + return scheduleTopLevelUpdate(current, element, true, callback); + }, + + unblockRoot(root: OpaqueRoot, expirationTime: ExpirationTime) { + const current = root.current; + const partialState = {isBlocked: false}; + const update = { + expirationTime, + partialState, + callback: null, + isReplace: false, + isForced: false, + next: null, + }; + insertUpdateIntoFiber(current, update); + scheduleWork(current, expirationTime); + }, + + flushRoot(root: OpaqueRoot, expirationTime: ExpirationTime) { + expireWork(root, expirationTime); }, batchedUpdates, diff --git a/src/renderers/shared/fiber/ReactFiberRoot.js b/src/renderers/shared/fiber/ReactFiberRoot.js index 419e842e08b..3045d2eacb1 100644 --- a/src/renderers/shared/fiber/ReactFiberRoot.js +++ b/src/renderers/shared/fiber/ReactFiberRoot.js @@ -11,8 +11,10 @@ 'use strict'; import type {Fiber} from 'ReactFiber'; +import type {ExpirationTime} from 'ReactFiberExpirationTime'; const {createHostRootFiber} = require('ReactFiber'); +const {NoWork} = require('ReactFiberExpirationTime'); export type FiberRoot = { // Any additional information from the host associated with this root. @@ -21,6 +23,12 @@ export type FiberRoot = { current: Fiber, // Determines if this root has already been added to the schedule for work. isScheduled: boolean, + // Determines if this root was blocked from committing. + isBlocked: boolean, + // The time at which this root completed. + // TODO: Remove once we add back resuming. + completedAt: ExpirationTime, + forceExpire: ExpirationTime, // The work schedule is a linked list. nextScheduledRoot: FiberRoot | null, // Top context object, used by renderSubtreeIntoContainer @@ -41,6 +49,9 @@ exports.createFiberRoot = function( current: uninitializedFiber, containerInfo: containerInfo, isScheduled: false, + isBlocked: false, + completedAt: NoWork, + forceExpire: NoWork, nextScheduledRoot: null, context: null, pendingContext: null, diff --git a/src/renderers/shared/fiber/ReactFiberScheduler.js b/src/renderers/shared/fiber/ReactFiberScheduler.js index 50f16402d06..6d0c8b9cb38 100644 --- a/src/renderers/shared/fiber/ReactFiberScheduler.js +++ b/src/renderers/shared/fiber/ReactFiberScheduler.js @@ -172,6 +172,7 @@ module.exports = function( config, hostContext, hydrationContext, + blockCurrentlyRenderingRoot, ); const { commitResetTextContent, @@ -216,6 +217,10 @@ module.exports = function( let nextUnitOfWork: Fiber | null = null; // The time at which we're currently rendering work. let nextRenderExpirationTime: ExpirationTime = NoWork; + // The root that we're currently working on. + let nextRenderedTree: FiberRoot | null = null; + // Whether the root we're currently working on is blocked from committing. + let nextCommitIsBlocked: boolean = false; // The next fiber with an effect that we're currently committing. let nextEffect: Fiber | null = null; @@ -248,7 +253,6 @@ module.exports = function( // Use these to prevent an infinite loop of nested updates const NESTED_UPDATE_LIMIT = 1000; let nestedUpdateCount: number = 0; - let nextRenderedTree: FiberRoot | null = null; function resetContextStack() { // Reset the stack @@ -286,13 +290,17 @@ module.exports = function( let earliestExpirationRoot = null; let earliestExpirationTime = NoWork; while (root !== null) { - if ( - root.current.expirationTime !== NoWork && - (earliestExpirationTime === NoWork || - earliestExpirationTime > root.current.expirationTime) - ) { - earliestExpirationTime = root.current.expirationTime; - earliestExpirationRoot = root; + if (root.isBlocked) { + // TODO: Process completion callbacks + } else { + if ( + root.current.expirationTime !== NoWork && + (earliestExpirationTime === NoWork || + earliestExpirationTime > root.current.expirationTime) + ) { + earliestExpirationTime = root.current.expirationTime; + earliestExpirationRoot = root; + } } // We didn't find anything to do in this root, so let's try the next one. root = root.nextScheduledRoot; @@ -306,24 +314,46 @@ module.exports = function( // unfortunately this is it. resetContextStack(); - nextUnitOfWork = createWorkInProgress( - earliestExpirationRoot.current, - earliestExpirationTime, - ); + if (earliestExpirationRoot.completedAt === nextRenderExpirationTime) { + // If the root is already complete, reuse the existing work-in-progress. + // TODO: This is a limited version of resuming that only applies to + // the root, to account for the pathological case where a completed + // root must be completely restarted before it can commit. Once we + // implement resuming for real, this special branch shouldn't + // be neccessary. + nextUnitOfWork = earliestExpirationRoot.current.alternate; + invariant( + nextUnitOfWork !== null, + 'Expected a completed root to have a work-in-progress. This error ' + + 'is likely caused by a bug in React. Please file an issue.', + ); + } else { + nextUnitOfWork = createWorkInProgress( + earliestExpirationRoot.current, + earliestExpirationTime, + ); + } + if (earliestExpirationRoot !== nextRenderedTree) { // We've switched trees. Reset the nested update counter. nestedUpdateCount = 0; nextRenderedTree = earliestExpirationRoot; } + nextCommitIsBlocked = false; return; } nextRenderExpirationTime = NoWork; nextUnitOfWork = null; nextRenderedTree = null; + nextCommitIsBlocked = false; return; } + function blockCurrentlyRenderingRoot() { + nextCommitIsBlocked = true; + } + function commitAllHostEffects() { while (nextEffect !== null) { if (__DEV__) { @@ -448,6 +478,7 @@ module.exports = function( 'related to the return field. This error is likely caused by a bug ' + 'in React. Please file an issue.', ); + root.completedAt = NoWork; if (nextRenderExpirationTime <= mostRecentCurrentTime) { // Keep track of the number of iterations to prevent an infinite @@ -702,10 +733,14 @@ module.exports = function( workInProgress = returnFiber; continue; } else { - // We've reached the root. Mark the root as pending commit. Depending - // on how much time we have left, we'll either commit it now or in - // the next frame. - pendingCommit = workInProgress; + // We've reached the root. + const root: FiberRoot = workInProgress.stateNode; + root.completedAt = nextRenderExpirationTime; + if (nextCommitIsBlocked) { + root.isBlocked = true; + } else { + pendingCommit = workInProgress; + } return null; } } @@ -808,13 +843,10 @@ module.exports = function( nextUnitOfWork = performUnitOfWork(nextUnitOfWork); } if (nextUnitOfWork === null) { - invariant( - pendingCommit !== null, - 'Should have a pending commit. This error is likely caused by ' + - 'a bug in React. Please file an issue.', - ); - // We just completed a root. Commit it now. - commitAllWork(pendingCommit); + if (pendingCommit !== null) { + // We just completed a root. Commit it now. + commitAllWork(pendingCommit); + } if ( capturedErrors === null || capturedErrors.size === 0 || @@ -856,15 +888,12 @@ module.exports = function( while (nextUnitOfWork !== null) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork); if (nextUnitOfWork === null) { - invariant( - pendingCommit !== null, - 'Should have a pending commit. This error is likely caused by ' + - 'a bug in React. Please file an issue.', - ); - // We just completed a root. Commit it now. - commitAllWork(pendingCommit); - // Clear any errors that were scheduled during the commit phase. - handleCommitPhaseErrors(); + if (pendingCommit !== null) { + // We just completed a root. Commit it now. + commitAllWork(pendingCommit); + // Clear any errors that were scheduled during the commit phase. + handleCommitPhaseErrors(); + } // The render time may have changed. Check again. if ( nextRenderExpirationTime === NoWork || @@ -886,28 +915,26 @@ module.exports = function( // omit either of the checks in the following condition, but we need // both to satisfy Flow. if (nextUnitOfWork === null) { - invariant( - pendingCommit !== null, - 'Should have a pending commit. This error is likely caused by ' + - 'a bug in React. Please file an issue.', - ); - // We just completed a root. If we have time, commit it now. - // Otherwise, we'll commit it in the next frame. - if (deadline.timeRemaining() > timeHeuristicForUnitOfWork) { - commitAllWork(pendingCommit); - // Clear any errors that were scheduled during the commit phase. - handleCommitPhaseErrors(); - // The render time may have changed. Check again. - if ( - nextRenderExpirationTime === NoWork || - nextRenderExpirationTime > minExpirationTime || - nextRenderExpirationTime <= mostRecentCurrentTime - ) { - // We've completed all the async work. - break; + if (pendingCommit !== null) { + // We just completed a root. If we have time, commit it now. + // Otherwise, we'll commit it in the next frame. + if (deadline.timeRemaining() > timeHeuristicForUnitOfWork) { + commitAllWork(pendingCommit); + // Clear any errors that were scheduled during the + // commit phase. + handleCommitPhaseErrors(); + } else { + deadlineHasExpired = true; } - } else { - deadlineHasExpired = true; + } + // The render time may have changed. Check again. + if ( + nextRenderExpirationTime === NoWork || + nextRenderExpirationTime > minExpirationTime || + nextRenderExpirationTime <= mostRecentCurrentTime + ) { + // We've completed all the async work. + break; } } } else { @@ -1354,25 +1381,6 @@ module.exports = function( } } - function scheduleRoot(root: FiberRoot, expirationTime: ExpirationTime) { - if (expirationTime === NoWork) { - return; - } - - if (!root.isScheduled) { - root.isScheduled = true; - if (lastScheduledRoot) { - // Schedule ourselves to the end. - lastScheduledRoot.nextScheduledRoot = root; - lastScheduledRoot = root; - } else { - // We're the only work scheduled. - nextScheduledRoot = root; - lastScheduledRoot = root; - } - } - } - function computeAsyncExpiration() { // Given the current clock time, returns an expiration time. We use rounding // to batch like updates together. @@ -1461,17 +1469,12 @@ module.exports = function( let node = fiber; let shouldContinue = true; while (node !== null && shouldContinue) { - // Walk the parent path to the root and update each node's expiration - // time. Once we reach a node whose expiration matches (and whose - // alternate's expiration matches) we can exit safely knowing that the - // rest of the path is correct. - shouldContinue = false; + // Walk the parent path to the root and update each node's + // expiration time. if ( node.expirationTime === NoWork || node.expirationTime > expirationTime ) { - // Expiration time did not match. Update and keep going. - shouldContinue = true; node.expirationTime = expirationTime; } if (node.alternate !== null) { @@ -1479,15 +1482,29 @@ module.exports = function( node.alternate.expirationTime === NoWork || node.alternate.expirationTime > expirationTime ) { - // Expiration time did not match. Update and keep going. - shouldContinue = true; node.alternate.expirationTime = expirationTime; } } if (node.return === null) { if (node.tag === HostRoot) { const root: FiberRoot = (node.stateNode: any); - scheduleRoot(root, expirationTime); + + // Set this to false in case the root has been unblocked. + root.isBlocked = false; + + if (!root.isScheduled) { + root.isScheduled = true; + if (lastScheduledRoot) { + // Schedule ourselves to the end. + lastScheduledRoot.nextScheduledRoot = root; + lastScheduledRoot = root; + } else { + // We're the only work scheduled. + nextScheduledRoot = root; + lastScheduledRoot = root; + } + } + if (!isPerformingWork) { switch (expirationTime) { case Sync: @@ -1535,12 +1552,37 @@ module.exports = function( } function recalculateCurrentTime(): ExpirationTime { + if (nextRenderedTree !== null) { + // Check if the current root is being force expired. + const forceExpire = nextRenderedTree.forceExpire; + if (forceExpire !== NoWork) { + // Override the current time with the `forceExpire` time. This has the + // effect of expiring all work up to and including that time. + mostRecentCurrentTime = forceExpire; + return forceExpire; + } + } // Subtract initial time so it fits inside 32bits const ms = now() - startTime; mostRecentCurrentTime = msToExpirationTime(ms); return mostRecentCurrentTime; } + function expireWork(root: FiberRoot, expirationTime: ExpirationTime): void { + invariant( + !isPerformingWork, + 'Cannot commit while already performing work.', + ); + root.forceExpire = expirationTime; + root.isBlocked = false; + try { + performWork(expirationTime, null); + } finally { + root.forceExpire = NoWork; + recalculateCurrentTime(); + } + } + function batchedUpdates(fn: (a: A) => R, a: A): R { const previousIsBatchingUpdates = isBatchingUpdates; isBatchingUpdates = true; @@ -1604,6 +1646,7 @@ module.exports = function( computeAsyncExpiration: computeAsyncExpiration, computeExpirationForFiber: computeExpirationForFiber, scheduleWork: scheduleWork, + expireWork: expireWork, batchedUpdates: batchedUpdates, unbatchedUpdates: unbatchedUpdates, flushSync: flushSync,