From 7166ce6d9b7973ddd5e06be9effdfaaeeff57ed6 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 26 Feb 2018 19:26:40 -0800 Subject: [PATCH 1/2] [WIP] This will all make sense, soon --- packages/react-reconciler/src/ReactFiber.js | 5 + .../src/ReactFiberBeginWork.js | 73 +- .../src/ReactFiberClassComponent.js | 27 +- .../src/ReactFiberCommitWork.js | 33 + .../src/ReactFiberCompleteWork.js | 6 + .../src/ReactFiberPendingWork.js | 243 +++++++ .../src/ReactFiberReconciler.js | 4 +- .../react-reconciler/src/ReactFiberRoot.js | 3 + .../src/ReactFiberScheduler.js | 192 +++++- .../src/ReactFiberUnwindWork.js | 201 +++++- .../src/__tests__/ReactSuspense-test.js | 636 ++++++++++++++++++ packages/react/src/React.js | 2 + packages/react/src/ReactElementValidator.js | 2 + packages/shared/ReactSymbols.js | 3 + packages/shared/ReactTypeOfWork.js | 1 + 15 files changed, 1398 insertions(+), 33 deletions(-) create mode 100644 packages/react-reconciler/src/ReactFiberPendingWork.js create mode 100644 packages/react-reconciler/src/__tests__/ReactSuspense-test.js diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 578986fcf229a..b4538a79c36db 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -29,6 +29,7 @@ import { Mode, ContextProvider, ContextConsumer, + TimeoutComponent, } from 'shared/ReactTypeOfWork'; import getComponentName from 'shared/getComponentName'; @@ -42,6 +43,7 @@ import { REACT_PROVIDER_TYPE, REACT_CONTEXT_TYPE, REACT_ASYNC_MODE_TYPE, + REACT_TIMEOUT_TYPE, } from 'shared/ReactSymbols'; let hasBadMapPolyfill; @@ -347,6 +349,9 @@ export function createFiberFromElement( case REACT_RETURN_TYPE: fiberTag = ReturnComponent; break; + case REACT_TIMEOUT_TYPE: + fiberTag = TimeoutComponent; + break; default: { if (typeof type === 'object' && type !== null) { switch (type.$$typeof) { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index a5197b32025fd..963a06a4ea61f 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -30,11 +30,14 @@ import { Mode, ContextProvider, ContextConsumer, + TimeoutComponent, } from 'shared/ReactTypeOfWork'; import { + NoEffect, PerformedWork, Placement, ContentReset, + DidCapture, Ref, } from 'shared/ReactTypeOfSideEffect'; import {ReactCurrentOwner} from 'shared/ReactGlobalSharedState'; @@ -83,8 +86,16 @@ export default function( config: HostConfig, hostContext: HostContext, hydrationContext: HydrationContext, - scheduleWork: (fiber: Fiber, expirationTime: ExpirationTime) => void, - computeExpirationForFiber: (fiber: Fiber) => ExpirationTime, + scheduleWork: ( + fiber: Fiber, + startTime: ExpirationTime, + expirationTime: ExpirationTime, + ) => void, + computeExpirationForFiber: ( + startTime: ExpirationTime, + fiber: Fiber, + ) => ExpirationTime, + recalculateCurrentTime: () => ExpirationTime, ) { const {shouldSetTextContent, shouldDeprioritizeSubtree} = config; @@ -108,6 +119,7 @@ export default function( computeExpirationForFiber, memoizeProps, memoizeState, + recalculateCurrentTime, ); // TODO: Remove this and use reconcileChildrenAtExpirationTime directly. @@ -716,6 +728,57 @@ export default function( return workInProgress.stateNode; } + function updateTimeoutComponent( + current, + workInProgress, + renderExpirationTime, + ) { + const nextProps = workInProgress.pendingProps; + const prevProps = workInProgress.memoizedProps; + + let nextState = workInProgress.memoizedState; + if (nextState === null) { + nextState = workInProgress.memoizedState = false; + } + const prevState = current === null ? nextState : current.memoizedState; + + const updateQueue = workInProgress.updateQueue; + if (updateQueue !== null) { + nextState = workInProgress.memoizedState = processUpdateQueue( + current, + workInProgress, + updateQueue, + null, + null, + renderExpirationTime, + ); + } + + if (hasLegacyContextChanged()) { + // Normally we can bail out on props equality but if context has changed + // we don't do the bailout and we have to reuse existing props instead. + } else if ( + // Don't bail out if this is a restart + (workInProgress.effectTag & DidCapture) === NoEffect && + prevProps === nextProps && + prevState === nextState + ) { + return bailoutOnAlreadyFinishedWork(current, workInProgress); + } + + if ((workInProgress.effectTag & DidCapture) !== NoEffect) { + nextState = workInProgress.memoizedState = true; + } + + const isExpired = nextState; + const render = nextProps.children; + const nextChildren = render(isExpired); + workInProgress.memoizedProps = nextProps; + workInProgress.memoizedState = nextState; + reconcileChildren(current, workInProgress, nextChildren); + return workInProgress.child; + } + function updatePortalComponent( current, workInProgress, @@ -1092,6 +1155,12 @@ export default function( // A return component is just a placeholder, we can just run through the // next one immediately. return null; + case TimeoutComponent: + return updateTimeoutComponent( + current, + workInProgress, + renderExpirationTime, + ); case HostPortal: return updatePortalComponent( current, diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.js b/packages/react-reconciler/src/ReactFiberClassComponent.js index 6d682cd2329a6..1deca2aac337d 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.js @@ -110,10 +110,18 @@ function callGetDerivedStateFromCatch(ctor: any, capturedValues: Array) { } export default function( - scheduleWork: (fiber: Fiber, expirationTime: ExpirationTime) => void, - computeExpirationForFiber: (fiber: Fiber) => ExpirationTime, + scheduleWork: ( + fiber: Fiber, + startTime: ExpirationTime, + expirationTime: ExpirationTime, + ) => void, + computeExpirationForFiber: ( + startTime: ExpirationTime, + fiber: Fiber, + ) => ExpirationTime, memoizeProps: (workInProgress: Fiber, props: any) => void, memoizeState: (workInProgress: Fiber, state: any) => void, + recalculateCurrentTime: () => ExpirationTime, ) { // Class component state updater const updater = { @@ -124,7 +132,8 @@ export default function( if (__DEV__) { warnOnInvalidCallback(callback, 'setState'); } - const expirationTime = computeExpirationForFiber(fiber); + const currentTime = recalculateCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); const update = { expirationTime, partialState, @@ -135,7 +144,7 @@ export default function( next: null, }; insertUpdateIntoFiber(fiber, update); - scheduleWork(fiber, expirationTime); + scheduleWork(fiber, currentTime, expirationTime); }, enqueueReplaceState(instance, state, callback) { const fiber = ReactInstanceMap.get(instance); @@ -143,7 +152,8 @@ export default function( if (__DEV__) { warnOnInvalidCallback(callback, 'replaceState'); } - const expirationTime = computeExpirationForFiber(fiber); + const currentTime = recalculateCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); const update = { expirationTime, partialState: state, @@ -154,7 +164,7 @@ export default function( next: null, }; insertUpdateIntoFiber(fiber, update); - scheduleWork(fiber, expirationTime); + scheduleWork(fiber, currentTime, expirationTime); }, enqueueForceUpdate(instance, callback) { const fiber = ReactInstanceMap.get(instance); @@ -162,7 +172,8 @@ export default function( if (__DEV__) { warnOnInvalidCallback(callback, 'forceUpdate'); } - const expirationTime = computeExpirationForFiber(fiber); + const currentTime = recalculateCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); const update = { expirationTime, partialState: null, @@ -173,7 +184,7 @@ export default function( next: null, }; insertUpdateIntoFiber(fiber, update); - scheduleWork(fiber, expirationTime); + scheduleWork(fiber, currentTime, expirationTime); }, }; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 831a391d42ded..54031ea40e518 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -25,6 +25,7 @@ import { HostText, HostPortal, CallComponent, + TimeoutComponent, } from 'shared/ReactTypeOfWork'; import ReactErrorUtils from 'shared/ReactErrorUtils'; import {Placement, Update, ContentReset} from 'shared/ReactTypeOfSideEffect'; @@ -33,6 +34,7 @@ import invariant from 'fbjs/lib/invariant'; import {commitCallbacks} from './ReactFiberUpdateQueue'; import {onCommitUnmount} from './ReactFiberDevToolsHook'; import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf'; +import {insertUpdateIntoFiber} from './ReactFiberUpdateQueue'; import {logCapturedError} from './ReactFiberErrorLogger'; import getComponentName from 'shared/getComponentName'; import {getStackAddendumByWorkInProgressFiber} from 'shared/ReactFiberComponentTreeHook'; @@ -152,6 +154,22 @@ export default function( } } + function scheduleExpirationBoundaryRecovery(fiber) { + const currentTime = recalculateCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); + const update = { + expirationTime, + partialState: false, + callback: null, + isReplace: true, + isForced: false, + capturedValue: null, + next: null, + }; + insertUpdateIntoFiber(fiber, update); + scheduleWork(fiber, currentTime, expirationTime); + } + function commitLifeCycles( finishedRoot: FiberRoot, current: Fiber | null, @@ -226,6 +244,18 @@ export default function( // We have no life-cycles associated with portals. return; } + case TimeoutComponent: { + const updateQueue = finishedWork.updateQueue; + if (updateQueue !== null) { + const promises = updateQueue.capturedValues; + if (promises !== null) { + Promise.race(promises).then(() => + scheduleExpirationBoundaryRecovery(finishedWork), + ); + } + } + return; + } default: { invariant( false, @@ -784,6 +814,9 @@ export default function( case HostRoot: { return; } + case TimeoutComponent: { + return; + } default: { invariant( false, diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 650ec2989b1fe..21aea96615d7e 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -34,6 +34,7 @@ import { ContextConsumer, Fragment, Mode, + TimeoutComponent, } from 'shared/ReactTypeOfWork'; import { Placement, @@ -605,6 +606,11 @@ export default function( case ReturnComponent: // Does nothing. return null; + case TimeoutComponent: + if (workInProgress.effectTag & DidCapture) { + workInProgress.effectTag |= Update; + } + return null; case Fragment: return null; case Mode: diff --git a/packages/react-reconciler/src/ReactFiberPendingWork.js b/packages/react-reconciler/src/ReactFiberPendingWork.js new file mode 100644 index 0000000000000..825316ce060cb --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberPendingWork.js @@ -0,0 +1,243 @@ +/** + * 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. + * + * @flow + */ + +import type {FiberRoot} from './ReactFiberRoot'; +import type {ExpirationTime} from './ReactFiberExpirationTime'; + +import {NoWork} from './ReactFiberExpirationTime'; + +// Because we don't have a global queue of updates, we use this module to keep +// track of the pending levels of work that have yet to be flushed. You can +// think of a PendingWork object as representing a batch of work that will +// all flush at the same time. The actual updates are spread throughout the +// update queues of all the fibers in the tree, but those updates have +// priorities that correspond to a PendingWork batch. + +export type PendingWork = { + // We use `expirationTime` to represent both a priority and a timeout. There's + // no inherent reason why they need to be the same, and we may split them + // in the future. + startTime: ExpirationTime, + expirationTime: ExpirationTime, + isSuspended: boolean, + shouldTryResuming: boolean, + isRenderPhaseWork: boolean, + next: PendingWork | null, +}; + +function insertPendingWorkAtPosition(root, work, insertAfter, insertBefore) { + work.next = insertBefore; + if (insertAfter === null) { + root.firstPendingWork = work; + } else { + insertAfter.next = work; + } +} + +export function addPendingWork( + root: FiberRoot, + startTime: ExpirationTime, + expirationTime: ExpirationTime, +): void { + let match = null; + let insertAfter = null; + let insertBefore = root.firstPendingWork; + while (insertBefore !== null) { + if (insertBefore.expirationTime >= expirationTime) { + // Retry anything with an equal or lower expiration time + insertBefore.shouldTryResuming = true; + } + if (insertBefore.expirationTime === expirationTime) { + // Found a matching bucket. But we'll keep iterating so we can set + // `shouldTryResuming` as needed. + match = insertBefore; + // Update the start time. We always measure from the most recently + // added update. + match.startTime = startTime; + } + if (match === null && insertBefore.expirationTime > expirationTime) { + // Found the insertion position + break; + } + insertAfter = insertBefore; + insertBefore = insertBefore.next; + } + if (match === null) { + const work: PendingWork = { + startTime, + expirationTime, + isSuspended: false, + shouldTryResuming: false, + isRenderPhaseWork: false, + next: null, + }; + insertPendingWorkAtPosition(root, work, insertAfter, insertBefore); + } +} +export function addRenderPhasePendingWork( + root: FiberRoot, + startTime: ExpirationTime, + expirationTime: ExpirationTime, +): void { + // Render-phase updates are treated differently because, while they + // could potentially unblock earlier pending work, we assume that they won't. + // They are also coalesced differently (see findNextExpirationTimeToWorkOn). + let insertAfter = null; + let insertBefore = root.firstPendingWork; + while (insertBefore !== null) { + if (insertBefore.expirationTime === expirationTime) { + // Found a matching bucket + return; + } + if (insertBefore.expirationTime > expirationTime) { + // Found the insertion position + break; + } + insertAfter = insertBefore; + insertBefore = insertBefore.next; + } + // No matching level found. Create a new one. + const work: PendingWork = { + startTime, + expirationTime, + isSuspended: false, + shouldTryResuming: false, + isRenderPhaseWork: true, + next: null, + }; + insertPendingWorkAtPosition(root, work, insertAfter, insertBefore); +} + +export function flushPendingWork( + root: FiberRoot, + currentTime: ExpirationTime, + remainingExpirationTime: ExpirationTime, +) { + // Pop all work that has higher priority than the remaining priority. + let firstUnflushedWork = root.firstPendingWork; + while (firstUnflushedWork !== null) { + if ( + remainingExpirationTime !== NoWork && + firstUnflushedWork.expirationTime >= remainingExpirationTime + ) { + break; + } + firstUnflushedWork = firstUnflushedWork.next; + } + root.firstPendingWork = firstUnflushedWork; + + if (firstUnflushedWork === null) { + if (remainingExpirationTime !== NoWork) { + // There was an update during the render phase that wasn't flushed. + addRenderPhasePendingWork(root, currentTime, remainingExpirationTime); + } + } else if ( + remainingExpirationTime !== NoWork && + firstUnflushedWork.expirationTime > remainingExpirationTime + ) { + // There was an update during the render phase that wasn't flushed. + addRenderPhasePendingWork(root, currentTime, remainingExpirationTime); + } +} + +export function suspendPendingWork( + root: FiberRoot, + expirationTime: ExpirationTime, +): void { + let work = root.firstPendingWork; + while (work !== null) { + if (work.expirationTime === expirationTime) { + work.isSuspended = true; + work.shouldTryResuming = false; + return; + } + if (work.expirationTime > expirationTime) { + return; + } + work = work.next; + } +} + +export function resumePendingWork( + root: FiberRoot, + expirationTime: ExpirationTime, +): void { + // Called when a promise resolves + let work = root.firstPendingWork; + while (work !== null) { + if (work.expirationTime === expirationTime) { + work.shouldTryResuming = true; + } + if (work.expirationTime > expirationTime) { + return; + } + work = work.next; + } +} + +export function findNextExpirationTimeToWorkOn( + root: FiberRoot, +): ExpirationTime { + // If there's a non-suspended interactive expiration time, return the first + // one. If everything is suspended, return the last retry time that's either + // a) a render phase update + // b) later or equal to the last suspended time + let lastSuspendedTime = NoWork; + let lastRenderPhaseTime = NoWork; + let lastRetryTime = NoWork; + let work = root.firstPendingWork; + while (work !== null) { + if ( + !work.isSuspended && + (!work.isRenderPhaseWork || lastSuspendedTime === NoWork) + ) { + return work.expirationTime; + } + if ( + lastSuspendedTime === NoWork || + lastSuspendedTime < work.expirationTime + ) { + lastSuspendedTime = work.expirationTime; + } + if (work.shouldTryResuming) { + if (lastRetryTime === NoWork || lastRetryTime < work.expirationTime) { + lastRetryTime = work.expirationTime; + } + if ( + work.isRenderPhaseWork && + (lastRenderPhaseTime === NoWork || + lastRenderPhaseTime < work.expirationTime) + ) { + lastRenderPhaseTime = work.expirationTime; + } + } + work = work.next; + } + // This has the effect of coalescing all async updates that occur while we're + // in a suspended state. This prevents us from rendering an intermediate state + // that is no longer valid. An example is a tab switching interface: if + // switching to a new tab is suspended, we should only switch to the last + // tab that was clicked. If the user switches to tab A and then tab B, we + // should continue suspending until B is ready. + if (lastRetryTime >= lastSuspendedTime) { + return lastRetryTime; + } + return lastRenderPhaseTime; +} + +export function findStartTime(root: FiberRoot, expirationTime: ExpirationTime) { + let match = root.firstPendingWork; + while (match !== null) { + if (match.expirationTime === expirationTime) { + return match.startTime; + } + match = match.next; + } + return NoWork; +} diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 949e806779418..91cf36f9e077d 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -355,7 +355,7 @@ export default function( next: null, }; insertUpdateIntoFiber(current, update); - scheduleWork(current, expirationTime); + scheduleWork(current, currentTime, expirationTime); return expirationTime; } @@ -424,7 +424,7 @@ export default function( ): ExpirationTime { const current = container.current; const currentTime = recalculateCurrentTime(); - const expirationTime = computeExpirationForFiber(current); + const expirationTime = computeExpirationForFiber(currentTime, current); return updateContainerAtExpirationTime( element, container, diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 50bcafbd3902e..963dfcdaa0aef 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -9,6 +9,7 @@ import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; +import type {PendingWork} from './ReactFiberPendingWork'; import {createHostRootFiber} from './ReactFiber'; import {NoWork} from './ReactFiberExpirationTime'; @@ -28,6 +29,7 @@ export type FiberRoot = { pendingChildren: any, // The currently active root fiber. This is the mutable root of the tree. current: Fiber, + firstPendingWork: PendingWork | null, pendingCommitExpirationTime: ExpirationTime, // A finished work-in-progress HostRoot that's ready to be committed. // TODO: The reason this is separate from isReadyForCommit is because the @@ -63,6 +65,7 @@ export function createFiberRoot( containerInfo: containerInfo, pendingChildren: null, pendingCommitExpirationTime: NoWork, + firstPendingWork: null, finishedWork: null, context: null, pendingContext: null, diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 09fb2679e4e79..232ab4815fcfd 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -55,6 +55,14 @@ import ReactFiberHostContext from './ReactFiberHostContext'; import ReactFiberHydrationContext from './ReactFiberHydrationContext'; import ReactFiberInstrumentation from './ReactFiberInstrumentation'; import ReactDebugCurrentFiber from './ReactDebugCurrentFiber'; +import { + addPendingWork, + addRenderPhasePendingWork, + flushPendingWork, + findStartTime, + findNextExpirationTimeToWorkOn, + resumePendingWork, +} from './ReactFiberPendingWork'; import { recordEffect, recordScheduleUpdate, @@ -173,6 +181,7 @@ export default function( hydrationContext, scheduleWork, computeExpirationForFiber, + recalculateCurrentTime, ); const {completeWork} = ReactFiberCompleteWork( config, @@ -181,8 +190,10 @@ export default function( ); const {throwException, unwindWork} = ReactFiberUnwindWork( hostContext, + retryOnPromiseResolution, scheduleWork, isAlreadyFailedLegacyErrorBoundary, + markTimeout, ); const { commitResetTextContent, @@ -229,6 +240,12 @@ export default function( let nextRoot: FiberRoot | null = null; // The time at which we're currently rendering work. let nextRenderExpirationTime: ExpirationTime = NoWork; + let nextStartTime: ExpirationTime = NoWork; + let nextStartTimeMs: number = -1; + let nextElapsedTimeMs: number = -1; + let nextRemainingTimeMs: number = -1; + let nextEarliestTimeoutMs: number = -1; + let nextRenderIsExpired: boolean = false; // The next fiber with an effect that we're currently committing. let nextEffect: Fiber | null = null; @@ -246,7 +263,22 @@ export default function( let replayUnitOfWork; if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { stashedWorkInProgressProperties = null; - replayUnitOfWork = (failedUnitOfWork: Fiber, isAsync: boolean) => { + replayUnitOfWork = ( + thrownValue: mixed, + failedUnitOfWork: Fiber, + isAsync: boolean, + ) => { + if ( + thrownValue !== null && + typeof thrownValue === 'object' && + typeof thrownValue.then === 'function' + ) { + // Don't replay promises. Treat everything else like an error. + // TODO: Need to figure out a different strategy if/when we add + // support for catching other types. + return; + } + // Retore the original state of the work-in-progress Object.assign(failedUnitOfWork, stashedWorkInProgressProperties); switch (failedUnitOfWork.tag) { @@ -294,6 +326,12 @@ export default function( nextRoot = null; nextRenderExpirationTime = NoWork; + nextStartTime = NoWork; + nextStartTimeMs = -1; + nextElapsedTimeMs = -1; + nextRemainingTimeMs = -1; + nextEarliestTimeoutMs = -1; + nextRenderIsExpired = false; nextUnitOfWork = null; isRootReadyForCommit = false; @@ -570,7 +608,8 @@ export default function( ReactFiberInstrumentation.debugTool.onCommitWork(finishedWork); } - const remainingTime = root.current.expirationTime; + flushPendingWork(root, currentTime, root.current.expirationTime); + const remainingTime = findNextExpirationTimeToWorkOn(root); if (remainingTime === NoWork) { // If there's no remaining work, we can clear the set of already failed // error boundaries. @@ -706,7 +745,14 @@ export default function( // This fiber did not complete because something threw. Pop values off // the stack without entering the complete phase. If this is a boundary, // capture values if possible. - const next = unwindWork(workInProgress); + const next = unwindWork( + workInProgress, + nextElapsedTimeMs, + nextRenderIsExpired, + nextRemainingTimeMs, + nextStartTime, + nextRenderExpirationTime, + ); // Because this fiber did not complete, don't reset its expiration time. if (workInProgress.effectTag & DidCapture) { // Restarting an error boundary @@ -833,6 +879,18 @@ export default function( resetContextStack(); nextRoot = root; nextRenderExpirationTime = expirationTime; + nextStartTime = findStartTime(nextRoot, nextRenderExpirationTime); + recalculateCurrentTime(); + if (nextStartTime === NoWork) { + nextStartTime = mostRecentCurrentTime; + nextStartTimeMs = mostRecentCurrentTimeMs; + } else { + nextStartTimeMs = expirationTimeToMs(nextStartTime); + } + nextElapsedTimeMs = mostRecentCurrentTimeMs - nextStartTimeMs; + nextRemainingTimeMs = + expirationTimeToMs(nextRenderExpirationTime) - mostRecentCurrentTimeMs; + nextEarliestTimeoutMs = nextRemainingTimeMs; nextUnitOfWork = createWorkInProgress( nextRoot.current, null, @@ -843,6 +901,9 @@ export default function( let didFatal = false; + nextRenderIsExpired = + !isAsync || nextRenderExpirationTime <= mostRecentCurrentTime; + startWorkLoopTimer(nextUnitOfWork); do { @@ -858,7 +919,7 @@ export default function( if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { const failedUnitOfWork = nextUnitOfWork; - replayUnitOfWork(failedUnitOfWork, isAsync); + replayUnitOfWork(thrownValue, failedUnitOfWork, isAsync); } const sourceFiber: Fiber = nextUnitOfWork; @@ -869,7 +930,16 @@ export default function( onUncaughtError(thrownValue); break; } - throwException(returnFiber, sourceFiber, thrownValue); + throwException( + returnFiber, + sourceFiber, + thrownValue, + nextRenderIsExpired, + nextRemainingTimeMs, + nextElapsedTimeMs, + nextStartTime, + nextRenderExpirationTime, + ); nextUnitOfWork = completeUnitOfWork(sourceFiber); } break; @@ -894,10 +964,20 @@ export default function( } else { // The root did not complete. invariant( - false, + !nextRenderIsExpired, 'Expired work should have completed. This error is likely caused ' + 'by a bug in React. Please file an issue.', ); + if (nextEarliestTimeoutMs >= 0) { + const ms = + nextStartTimeMs + nextEarliestTimeoutMs - mostRecentCurrentTimeMs; + waitForTimeout(root, ms, expirationTime); + } + const firstUnblockedExpirationTime = findNextExpirationTimeToWorkOn( + root, + ); + onBlock(firstUnblockedExpirationTime); + return null; } } else { // There's more work to do, but we ran out of time. Yield back to @@ -906,7 +986,13 @@ export default function( } } - function scheduleCapture(sourceFiber, boundaryFiber, value, expirationTime) { + function scheduleCapture( + sourceFiber, + boundaryFiber, + value, + startTime, + expirationTime, + ) { // TODO: We only support dispatching errors. const capturedValue = createCapturedValue(value, sourceFiber); const update = { @@ -919,12 +1005,13 @@ export default function( next: null, }; insertUpdateIntoFiber(boundaryFiber, update); - scheduleWork(boundaryFiber, expirationTime); + scheduleWork(boundaryFiber, startTime, expirationTime); } function dispatch( sourceFiber: Fiber, value: mixed, + startTime: ExpirationTime, expirationTime: ExpirationTime, ) { invariant( @@ -945,13 +1032,19 @@ export default function( (typeof instance.componentDidCatch === 'function' && !isAlreadyFailedLegacyErrorBoundary(instance)) ) { - scheduleCapture(sourceFiber, fiber, value, expirationTime); + scheduleCapture( + sourceFiber, + fiber, + value, + startTime, + expirationTime, + ); return; } break; // TODO: Handle async boundaries case HostRoot: - scheduleCapture(sourceFiber, fiber, value, expirationTime); + scheduleCapture(sourceFiber, fiber, value, startTime, expirationTime); return; } fiber = fiber.return; @@ -960,12 +1053,19 @@ export default function( if (sourceFiber.tag === HostRoot) { // Error was thrown at the root. There is no parent, so the root // itself should capture it. - scheduleCapture(sourceFiber, sourceFiber, value, expirationTime); + scheduleCapture( + sourceFiber, + sourceFiber, + value, + startTime, + expirationTime, + ); } } function onCommitPhaseError(fiber: Fiber, error: mixed) { - return dispatch(fiber, error, Sync); + const startTime = recalculateCurrentTime(); + return dispatch(fiber, error, startTime, Sync); } function computeAsyncExpiration(currentTime: ExpirationTime) { @@ -998,7 +1098,10 @@ export default function( return lastUniqueAsyncExpiration; } - function computeExpirationForFiber(fiber: Fiber) { + function computeExpirationForFiber( + currentTime: ExpirationTime, + fiber: Fiber, + ) { let expirationTime; if (expirationContext !== NoWork) { // An explicit expiration context was set; @@ -1019,11 +1122,9 @@ export default function( if (fiber.mode & AsyncMode) { if (isBatchingInteractiveUpdates) { // This is an interactive update - const currentTime = recalculateCurrentTime(); expirationTime = computeInteractiveExpiration(currentTime); } else { // This is an async update - const currentTime = recalculateCurrentTime(); expirationTime = computeAsyncExpiration(currentTime); } } else { @@ -1045,12 +1146,41 @@ export default function( return expirationTime; } - function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) { - return scheduleWorkImpl(fiber, expirationTime, false); + function retryOnPromiseResolution( + root: FiberRoot, + suspendedTime: ExpirationTime, + ) { + resumePendingWork(root, suspendedTime); + const retryTime = findNextExpirationTimeToWorkOn(root); + + if (retryTime !== NoWork) { + requestRetry(root, retryTime); + } + } + + function markTimeout(timeoutMs: number) { + if (timeoutMs >= 0 && timeoutMs < nextEarliestTimeoutMs) { + nextEarliestTimeoutMs = timeoutMs; + } + } + + function waitForTimeout(root, ms, suspendedTime) { + setTimeout(() => { + retryOnPromiseResolution(root, suspendedTime); + }, ms); + } + + function scheduleWork( + fiber: Fiber, + startTime: ExpirationTime, + expirationTime: ExpirationTime, + ) { + return scheduleWorkImpl(fiber, startTime, expirationTime, false); } function scheduleWorkImpl( fiber: Fiber, + startTime: ExpirationTime, expirationTime: ExpirationTime, isErrorRecovery: boolean, ) { @@ -1093,6 +1223,12 @@ export default function( interruptedBy = fiber; resetContextStack(); } + if (!isWorking || isCommitting) { + addPendingWork(root, startTime, expirationTime); + } else { + // We're in the render phase. + addRenderPhasePendingWork(root, startTime, expirationTime); + } if (nextRoot !== root || !isWorking) { requestWork(root, expirationTime); } @@ -1206,6 +1342,18 @@ export default function( callbackID = scheduleDeferredCallback(performAsyncWork, {timeout}); } + function requestRetry(root: FiberRoot, expirationTime: ExpirationTime) { + if ( + root.remainingExpirationTime === NoWork || + root.remainingExpirationTime < expirationTime + ) { + // For a retry, only update the remaining expiration time if it has a + // *lower priority* than the existing value. This is because, on a retry, + // we should attempt to coalesce as much as possible. + requestWork(root, expirationTime); + } + } + // requestWork is called by the scheduler whenever a root receives an update. // It's up to the renderer to call renderRoot at some point in the future. function requestWork(root: FiberRoot, expirationTime: ExpirationTime) { @@ -1564,6 +1712,16 @@ export default function( } } + function onBlock(remainingExpirationTime: ExpirationTime) { + invariant( + nextFlushedRoot !== null, + 'Should be working on a root. This error is likely caused by a bug in ' + + 'React. Please file an issue.', + ); + // This root was blocked. Unschedule it until there's another update. + nextFlushedRoot.remainingExpirationTime = remainingExpirationTime; + } + // TODO: Batching should be implemented at the renderer level, not inside // the reconciler. function batchedUpdates(fn: (a: A) => R, a: A): R { diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index bb3ce84945d2b..2941c86fd4e4c 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -7,7 +7,11 @@ */ import {createCapturedValue} from './ReactCapturedValue'; -import {ensureUpdateQueues} from './ReactFiberUpdateQueue'; +import {suspendPendingWork} from './ReactFiberPendingWork'; +import { + ensureUpdateQueues, + insertUpdateIntoFiber, +} from './ReactFiberUpdateQueue'; import { ClassComponent, @@ -15,6 +19,8 @@ import { HostComponent, HostPortal, ContextProvider, + LoadingComponent, + TimeoutComponent, } from 'shared/ReactTypeOfWork'; import { NoEffect, @@ -22,6 +28,7 @@ import { Incomplete, ShouldCapture, } from 'shared/ReactTypeOfSideEffect'; +import {Sync} from './ReactFiberExpirationTime'; import {enableGetDerivedStateFromCatch} from 'shared/ReactFeatureFlags'; @@ -31,29 +38,200 @@ import { } from './ReactFiberContext'; import {popProvider} from './ReactFiberNewContext'; +import invariant from 'fbjs/lib/invariant'; + +const SuspendException = 1; +const SuspendAndLoadingException = 2; + +function createRootExpirationError(sourceFiber, renderExpirationTime) { + try { + // TODO: Better error messages. + invariant( + renderExpirationTime !== Sync, + 'A synchronous update was suspended, but no fallback UI was provided.', + ); + invariant( + false, + 'An update was suspended for longer than the timeout, but no fallback ' + + 'UI was provided.', + ); + } catch (error) { + return error; + } +} + export default function( hostContext: HostContext, + retryOnPromiseResolution: ( + root: FiberRoot, + blockedTime: ExpirationTime, + ) => void, scheduleWork: ( fiber: Fiber, startTime: ExpirationTime, expirationTime: ExpirationTime, ) => void, isAlreadyFailedLegacyErrorBoundary: (instance: mixed) => boolean, + markTimeout: (timeoutMs: number) => void, ) { const {popHostContainer, popHostContext} = hostContext; + function waitForPromise(root, promise, suspendedTime) { + promise.then(() => retryOnPromiseResolution(root, suspendedTime)); + } + + function scheduleLoadingState( + workInProgress, + renderStartTime, + renderExpirationTime, + ) { + const slightlyHigherPriority = renderExpirationTime - 1; + const loadingUpdate = { + expirationTime: slightlyHigherPriority, + partialState: true, + callback: null, + isReplace: true, + isForced: false, + capturedValue: null, + next: null, + }; + insertUpdateIntoFiber(workInProgress, loadingUpdate); + + const revertUpdate = { + expirationTime: renderExpirationTime, + partialState: false, + callback: null, + isReplace: true, + isForced: false, + capturedValue: null, + next: null, + }; + insertUpdateIntoFiber(workInProgress, revertUpdate); + scheduleWork(workInProgress, renderStartTime, slightlyHigherPriority); + return false; + } + function throwException( returnFiber: Fiber, sourceFiber: Fiber, - rawValue: mixed, + value: mixed, + renderIsExpired: boolean, + remainingTimeMs: number, + elapsedMs: number, + renderStartTime: number, + renderExpirationTime: ExpirationTime, ) { // The source fiber did not complete. sourceFiber.effectTag |= Incomplete; // Its effect list is no longer valid. sourceFiber.firstEffect = sourceFiber.lastEffect = null; - const value = createCapturedValue(rawValue, sourceFiber); + if ( + value !== null && + typeof value === 'object' && + typeof value.then === 'function' + ) { + // This is a thenable. + let typeOfException = SuspendAndLoadingException; + let workInProgress = returnFiber; + do { + switch (workInProgress.tag) { + case HostRoot: { + const root: FiberRoot = workInProgress.stateNode; + switch (typeOfException) { + case SuspendAndLoadingException: + case SuspendException: { + if (!renderIsExpired) { + // Set-up timer using render expiration time + const suspendedTime = renderExpirationTime; + const promise = value; + suspendPendingWork(root, suspendedTime); + waitForPromise(root, promise, suspendedTime); + return; + } + // The root expired, but no fallback was provided. Throw a + // helpful error. + value = createRootExpirationError( + sourceFiber, + renderExpirationTime, + ); + break; + } + } + break; + } + case TimeoutComponent: + switch (typeOfException) { + case SuspendAndLoadingException: + case SuspendException: { + const didExpire = workInProgress.memoizedState; + const timeout = workInProgress.pendingProps.ms; + // Check if the boundary should capture promises that threw. + let shouldCapture; + if (workInProgress.effectTag & DidCapture) { + // Already captured during this render. Can't capture again. + shouldCapture = false; + } else if (didExpire || renderIsExpired) { + // Render is expired. + shouldCapture = true; + } else if ( + typeof timeout === 'number' && + elapsedMs >= timeout + ) { + // The elapsed time exceeds the provided timeout. + shouldCapture = true; + } else { + // There's still time left. Bubble to the next boundary. + shouldCapture = false; + } + if (shouldCapture) { + workInProgress.effectTag |= ShouldCapture; + ensureUpdateQueues(workInProgress); + const updateQueue: UpdateQueue = (workInProgress.updateQueue: any); + const capturedValues = updateQueue.capturedValues; + if (capturedValues === null) { + updateQueue.capturedValues = [value]; + } else { + capturedValues.push(value); + } + return workInProgress; + } else { + if (typeof timeout === 'number') { + markTimeout(timeout); + } + } + } + } + break; + case LoadingComponent: + switch (typeOfException) { + case SuspendAndLoadingException: { + const current = workInProgress.alternate; + const isLoading = workInProgress.memoizedState; + if (current !== null && !isLoading && !renderIsExpired) { + // Schedule loading update + scheduleLoadingState( + workInProgress, + renderStartTime, + renderExpirationTime, + ); + typeOfException = SuspendException; + break; + } + } + } + break; + default: + break; + } + workInProgress = workInProgress.return; + } while (workInProgress !== null); + } + // We didn't find a boundary that could handle this type of exception. Start + // over and traverse parent path again, this time treating the exception + // as an error. + value = createCapturedValue(value, sourceFiber); let workInProgress = returnFiber; do { switch (workInProgress.tag) { @@ -97,7 +275,14 @@ export default function( } while (workInProgress !== null); } - function unwindWork(workInProgress) { + function unwindWork( + workInProgress, + elapsedMs, + renderIsExpired, + remainingTimeMs, + renderStartTime, + renderExpirationTime, + ) { switch (workInProgress.tag) { case ClassComponent: { popLegacyContextProvider(workInProgress); @@ -122,6 +307,14 @@ export default function( popHostContext(workInProgress); return null; } + case TimeoutComponent: { + const effectTag = workInProgress.effectTag; + if (effectTag & ShouldCapture) { + workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture; + return workInProgress; + } + return null; + } case HostPortal: popHostContainer(workInProgress); return null; diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.js new file mode 100644 index 0000000000000..1fbaac197b07a --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.js @@ -0,0 +1,636 @@ +let React; +let Fragment; +let ReactNoop; +let SimpleCacheProvider; +let Timeout; + +let cache; +let readText; + +describe('ReactSuspense', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + Fragment = React.Fragment; + ReactNoop = require('react-noop-renderer'); + SimpleCacheProvider = require('simple-cache-provider'); + Timeout = React.Timeout; + + cache = SimpleCacheProvider.createCache(); + readText = SimpleCacheProvider.createResource(([text, ms = 0]) => { + return new Promise(resolve => + setTimeout(() => { + ReactNoop.yield(`Promise resolved [${text}]`); + resolve(text); + }, ms), + ); + }, ([text, ms]) => text); + }); + + // function div(...children) { + // children = children.map(c => (typeof c === 'string' ? {text: c} : c)); + // return {type: 'div', children, prop: undefined}; + // } + + function span(prop) { + return {type: 'span', children: [], prop}; + } + + function advanceTimers(ms) { + // Note: This advances Jest's virtual time but not React's. Use + // ReactNoop.expire for that. + if (typeof ms !== 'number') { + throw new Error('Must specify ms'); + } + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + return new Promise(resolve => { + setImmediate(resolve); + }); + } + + function Text(props) { + ReactNoop.yield(props.text); + return ; + } + + function AsyncText(props) { + const text = props.text; + try { + readText(cache, [props.text, props.ms]); + ReactNoop.yield(text); + return ; + } catch (promise) { + ReactNoop.yield(`Suspend! [${text}]`); + throw promise; + } + } + + function Fallback(props) { + return ( + + {didExpire => (didExpire ? props.placeholder : props.children)} + + ); + } + it('suspends rendering and continues later', async () => { + function Bar(props) { + ReactNoop.yield('Bar'); + return props.children; + } + + function Foo() { + ReactNoop.yield('Foo'); + return ( + + + + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Foo', + 'Bar', + // A suspends + 'Suspend! [A]', + // But we keep rendering the siblings + 'B', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush some of the time + await advanceTimers(50); + // Still nothing... + expect(ReactNoop.flush()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush the promise completely + await advanceTimers(50); + // Renders successfully + expect(ReactNoop.flush()).toEqual([ + 'Promise resolved [A]', + 'Foo', + 'Bar', + 'A', + 'B', + ]); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('continues rendering siblings after suspending', async () => { + ReactNoop.render( + + + + + + , + ); + // B suspends. Continue rendering the remaining siblings. + expect(ReactNoop.flush()).toEqual(['A', 'Suspend! [B]', 'C', 'D']); + // Did not commit yet. + expect(ReactNoop.getChildren()).toEqual([]); + + // Wait for data to resolve + await advanceTimers(100); + // Renders successfully + expect(ReactNoop.flush()).toEqual([ + 'Promise resolved [B]', + 'A', + 'B', + 'C', + 'D', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('B'), + span('C'), + span('D'), + ]); + }); + + it('can update at a higher priority while in a suspended state', async () => { + function App(props) { + return ( + + + + + ); + } + + // Initial mount + ReactNoop.render(); + ReactNoop.flush(); + await advanceTimers(0); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('1')]); + + // Update the low-pri text + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'A', + // Suspends + 'Suspend! [2]', + ]); + + // While we're still waiting for the low-pri update to complete, update the + // high-pri text at high priority. + ReactNoop.flushSync(() => { + ReactNoop.render(); + }); + expect(ReactNoop.flush()).toEqual(['B', '1']); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]); + + // Unblock the low-pri text and finish + await advanceTimers(0); + expect(ReactNoop.flush()).toEqual(['Promise resolved [2]']); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]); + }); + + it('keeps working on lower priority work after being unblocked', async () => { + function App(props) { + return ( + + + {props.showB && } + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Suspend! [A]']); + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance React's virtual time by enough to fall into a new async bucket. + ReactNoop.expire(1200); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'B']); + expect(ReactNoop.getChildren()).toEqual([]); + + await advanceTimers(0); + expect(ReactNoop.flush()).toEqual(['Promise resolved [A]', 'A', 'B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('coalesces all async updates when in a suspended state', async () => { + ReactNoop.render(); + ReactNoop.flush(); + await advanceTimers(0); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Suspend! [B]']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Advance React's virtual time so that C falls into a new expiration bucket + ReactNoop.expire(1000); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + // Tries C first, since it has a later expiration time + 'Suspend! [C]', + // Does not retry B, because its promise has not resolved yet. + ]); + + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Unblock B + await advanceTimers(90); + // Even though B's promise resolved, the view is still suspended because it + // coalesced with C. + expect(ReactNoop.flush()).toEqual(['Promise resolved [B]']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Unblock C + await advanceTimers(50); + expect(ReactNoop.flush()).toEqual(['Promise resolved [C]', 'C']); + expect(ReactNoop.getChildren()).toEqual([span('C')]); + }); + + it('forces an expiration after an update times out', async () => { + ReactNoop.render( + + }> + + + + , + ); + + expect(ReactNoop.flush()).toEqual([ + // The async child suspends + 'Suspend! [Async]', + // Continue on the sibling + 'Sync', + ]); + // The update hasn't expired yet, so we commit nothing. + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance both React's virtual time and Jest's timers by enough to expire + // the update, but not by enough to flush the suspending promise. + ReactNoop.expire(10000); + await advanceTimers(10000); + expect(ReactNoop.flushExpired()).toEqual([ + // Still suspended. + 'Suspend! [Async]', + // Now that the update has expired, we render the fallback UI + 'Loading...', + 'Sync', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); + + // Once the promise resolves, we render the suspended view + await advanceTimers(10000); + expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + + it('renders an expiration boundary synchronously', async () => { + // Synchronously render a tree that suspends + ReactNoop.flushSync(() => + ReactNoop.render( + + }> + + + + , + ), + ); + expect(ReactNoop.clearYields()).toEqual([ + // The async child suspends + 'Suspend! [Async]', + // We immediately render the fallback UI + 'Loading...', + // Continue on the sibling + 'Sync', + ]); + // The tree commits synchronously + expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); + + // Once the promise resolves, we render the suspended view + await advanceTimers(0); + expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + + it('suspending inside an expired expiration boundary will bubble to the next one', async () => { + ReactNoop.flushSync(() => + ReactNoop.render( + + }> + }> + + + + + , + ), + ); + expect(ReactNoop.clearYields()).toEqual([ + 'Suspend! [Async]', + 'Suspend! [Loading (inner)...]', + 'Sync', + 'Loading (outer)...', + ]); + // The tree commits synchronously + expect(ReactNoop.getChildren()).toEqual([span('Loading (outer)...')]); + }); + + it('expires early with a `timeout` option', async () => { + ReactNoop.render( + + }> + + + + , + ); + + expect(ReactNoop.flush()).toEqual([ + // The async child suspends + 'Suspend! [Async]', + // Continue on the sibling + 'Sync', + ]); + // The update hasn't expired yet, so we commit nothing. + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance both React's virtual time and Jest's timers by enough to trigger + // the timeout, but not by enough to flush the promise or reach the true + // expiration time. + ReactNoop.expire(120); + await advanceTimers(120); + expect(ReactNoop.flush()).toEqual([ + // Still suspended. + 'Suspend! [Async]', + // Now that the expiration view has timed out, we render the fallback UI + 'Loading...', + 'Sync', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); + + // Once the promise resolves, we render the suspended view + await advanceTimers(1000); + expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + + it('throws a helpful error when a synchronous update is suspended', () => { + expect(() => { + ReactNoop.flushSync(() => ReactNoop.render()); + }).toThrow( + 'A synchronous update was suspended, but no fallback UI was provided.', + ); + }); + + it('throws a helpful error when an expired update is suspended', async () => { + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']); + await advanceTimers(10000); + ReactNoop.expire(10000); + expect(() => { + expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']); + }).toThrow( + 'An update was suspended for longer than the timeout, but no fallback ' + + 'UI was provided.', + ); + }); + + it('a Timeout component correctly handles more than one suspended child', async () => { + ReactNoop.render( + + + + , + ); + ReactNoop.expire(10000); + expect(ReactNoop.flush()).toEqual(['Suspend! [A]', 'Suspend! [B]']); + expect(ReactNoop.getChildren()).toEqual([]); + + await advanceTimers(100); + + expect(ReactNoop.flush()).toEqual([ + 'Promise resolved [A]', + 'Promise resolved [B]', + 'A', + 'B', + ]); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('can resume rendering earlier than a timeout', async () => { + ReactNoop.render( + }> + + , + ); + expect(ReactNoop.flush()).toEqual(['Suspend! [Async]']); + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance time by an amount slightly smaller than what's necessary to + // resolve the promise + await advanceTimers(99); + + // Nothing has rendered yet + expect(ReactNoop.flush()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Resolve the promise + await advanceTimers(1); + // We can now resume rendering + expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + }); + + describe('splitting a high-pri update into high and low', () => { + React = require('react'); + + class AsyncValue extends React.Component { + state = {asyncValue: this.props.defaultValue}; + componentDidMount() { + ReactNoop.deferredUpdates(() => { + this.setState((state, props) => ({asyncValue: props.value})); + }); + } + componentDidUpdate() { + if (this.props.value !== this.state.asyncValue) { + ReactNoop.deferredUpdates(() => { + this.setState((state, props) => ({asyncValue: props.value})); + }); + } + } + render() { + return this.props.children(this.state.asyncValue); + } + } + + it('coalesces async values when in a suspended state', async () => { + function App(props) { + const highPriText = props.text; + return ( + + {lowPriText => ( + + + {lowPriText && ( + + )} + + )} + + ); + } + + function renderAppSync(props) { + ReactNoop.flushSync(() => ReactNoop.render()); + } + + // Initial mount + renderAppSync({text: 'A'}); + expect(ReactNoop.flush()).toEqual([ + // First we render at high priority + 'High-pri: A', + // Then we come back later to render a low priority + 'High-pri: A', + // The low-pri view suspends + 'Suspend! [Low-pri: A]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('High-pri: A')]); + + // Partially flush the promise for 'A', not by enough to resolve it. + await advanceTimers(99); + + // Advance React's virtual time so that the next update falls into a new + // expiration bucket + ReactNoop.expire(2000); + // Update to B. At this point, the low-pri view still hasn't updated + // to 'A'. + renderAppSync({text: 'B'}); + expect(ReactNoop.flush()).toEqual([ + // First we render at high priority + 'High-pri: B', + // Then we come back later to render a low priority + 'High-pri: B', + // The low-pri view suspends + 'Suspend! [Low-pri: B]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('High-pri: B')]); + + // Flush the rest of the promise for 'A', without flushing the one + // for 'B'. + await advanceTimers(1); + expect(ReactNoop.flush()).toEqual([ + // A is unblocked + 'Promise resolved [Low-pri: A]', + // But we don't try to render it, because there's a lower priority + // update that is also suspended. + ]); + expect(ReactNoop.getChildren()).toEqual([span('High-pri: B')]); + + // Flush the remaining work. + await advanceTimers(99); + expect(ReactNoop.flush()).toEqual([ + // B is unblocked + 'Promise resolved [Low-pri: B]', + // Now we can continue rendering the async view + 'High-pri: B', + 'Low-pri: B', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('High-pri: B'), + span('Low-pri: B'), + ]); + }); + }); + + describe('a Delay component', () => { + function Never() { + // Throws a promise that resolves after some arbitrarily large + // number of seconds. The idea is that this component will never + // resolve. It's always wrapped by a Timeout. + throw new Promise(resolve => setTimeout(() => resolve(), 10000)); + } + + function Delay({ms}) { + return ( + + {didTimeout => { + if (didTimeout) { + // Once ms has elapsed, render null. This allows the rest of the + // tree to resume rendering. + return null; + } + return ; + }} + + ); + } + + function DebouncedText({text, ms}) { + return ( + + + + + ); + } + + it('works', async () => { + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([]); + + await advanceTimers(999); + ReactNoop.expire(999); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([]); + + await advanceTimers(1); + ReactNoop.expire(1); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + }); + + it('uses the most recent update as its start time', async () => { + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance time by a little, but not by enough to move this into a new + // expiration bucket. + await advanceTimers(10); + ReactNoop.expire(10); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([]); + + // Schedule an update. It should have the same expiration as the first one. + ReactNoop.render(); + + // Advance time by enough that it would have timed-out the first update, + // but not enough that it times out the second one. + await advanceTimers(999); + ReactNoop.expire(999); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance time by just a bit more to trigger the timeout. + await advanceTimers(1); + ReactNoop.expire(1); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + }); + }); + + // TODO: + // Timeout inside an async boundary + // Start time of expiration bucket is time of most recent update + // Promise rejection + // Warns if promise reaches the root + // Multiple timeouts with different values + // Suspending inside an offscreen tree + // Timeout for CPU-bound work +}); diff --git a/packages/react/src/React.js b/packages/react/src/React.js index a2feefed097f8..d9805b24bd420 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -11,6 +11,7 @@ import { REACT_FRAGMENT_TYPE, REACT_STRICT_MODE_TYPE, REACT_ASYNC_MODE_TYPE, + REACT_TIMEOUT_TYPE, } from 'shared/ReactSymbols'; import {Component, PureComponent} from './ReactBaseClasses'; @@ -49,6 +50,7 @@ const React = { Fragment: REACT_FRAGMENT_TYPE, StrictMode: REACT_STRICT_MODE_TYPE, unstable_AsyncMode: REACT_ASYNC_MODE_TYPE, + Timeout: REACT_TIMEOUT_TYPE, createElement: __DEV__ ? createElementWithValidation : createElement, cloneElement: __DEV__ ? cloneElementWithValidation : cloneElement, diff --git a/packages/react/src/ReactElementValidator.js b/packages/react/src/ReactElementValidator.js index 471e6d6f8b677..004e4b6deaf36 100644 --- a/packages/react/src/ReactElementValidator.js +++ b/packages/react/src/ReactElementValidator.js @@ -22,6 +22,7 @@ import { REACT_ASYNC_MODE_TYPE, REACT_PROVIDER_TYPE, REACT_CONTEXT_TYPE, + REACT_TIMEOUT_TYPE, } from 'shared/ReactSymbols'; import checkPropTypes from 'prop-types/checkPropTypes'; import warning from 'fbjs/lib/warning'; @@ -294,6 +295,7 @@ export function createElementWithValidation(type, props, children) { type === REACT_FRAGMENT_TYPE || type === REACT_ASYNC_MODE_TYPE || type === REACT_STRICT_MODE_TYPE || + type === REACT_TIMEOUT_TYPE || (typeof type === 'object' && type !== null && (type.$$typeof === REACT_PROVIDER_TYPE || diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index cc60e34e4c5b1..f01c91899f9a3 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -36,6 +36,9 @@ export const REACT_CONTEXT_TYPE = hasSymbol export const REACT_ASYNC_MODE_TYPE = hasSymbol ? Symbol.for('react.async_mode') : 0xeacf; +export const REACT_TIMEOUT_TYPE = hasSymbol + ? Symbol.for('react.timeout') + : 0xeada; const MAYBE_ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; const FAUX_ITERATOR_SYMBOL = '@@iterator'; diff --git a/packages/shared/ReactTypeOfWork.js b/packages/shared/ReactTypeOfWork.js index 3d1bfc3f37584..6abcca4217507 100644 --- a/packages/shared/ReactTypeOfWork.js +++ b/packages/shared/ReactTypeOfWork.js @@ -37,3 +37,4 @@ export const Fragment = 10; export const Mode = 11; export const ContextConsumer = 12; export const ContextProvider = 13; +export const TimeoutComponent = 15; From fd5aaa4ac998fe9f09c4ba5542bf3d2cd33405e0 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Mon, 5 Mar 2018 23:27:23 +0000 Subject: [PATCH 2/2] Add a failing test for nested fallbacks --- .../src/__tests__/ReactSuspense-test.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.js index 1fbaac197b07a..a30c4af81a108 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.js @@ -288,6 +288,60 @@ describe('ReactSuspense', () => { expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); }); + it('switches to an inner fallback even if it expires later', async () => { + ReactNoop.render( + }> + }> + + + + , + ); + + expect(ReactNoop.flush()).toEqual([ + // The async child suspends + 'Suspend! [Async]', + // Continue on the sibling + 'Sync', + ]); + // The update hasn't expired yet, so we commit nothing. + expect(ReactNoop.getChildren()).toEqual([]); + + // Expire the outer timeout, but don't expire the inner one. + // We should see the outer loading placeholder. + ReactNoop.expire(1500); + await advanceTimers(1500); + expect(ReactNoop.flush()).toEqual([ + // Still suspended. + 'Suspend! [Async]', + 'Sync', + // Now that the outer update has expired, we render the fallback UI + 'Loading outer...', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Loading outer...')]); + + // Advance just enough to also expire the inner timeout. + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(ReactNoop.flush()).toEqual([ + // Still suspended. + 'Suspend! [Async]', + 'Loading inner...', + 'Sync', + ]); + // We should now see the inner fallback UI. + expect(ReactNoop.getChildren()).toEqual([ + span('Loading inner...'), + span('Sync'), + ]); + + // Finally, we should see the complete screen. + ReactNoop.expire(20000); + await advanceTimers(20000); + expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + it('renders an expiration boundary synchronously', async () => { // Synchronously render a tree that suspends ReactNoop.flushSync(() =>