From 9c6de716d028f17736d0892d8a3d8f3ac2cb62bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 16 May 2019 16:51:18 -0700 Subject: [PATCH] Add withSuspenseConfig API (#15593) * Add suspendIfNeeded API and a global scope to track it Adds a "current" suspense config that gets applied to all updates scheduled during the current scope. I suspect we might want to add other types of configurations to the "batch" so I called it the "batch config". This works across renderers/roots but they won't actually necessarily go into the same batch. * Add the suspenseConfig to all updates created during this scope * Compute expiration time based on the timeout of the suspense config * Track if there was a processed suspenseConfig this render pass We'll use this info to suspend a commit for longer when necessary. * Mark suspended states that should be avoided as a separate flag This lets us track which renders we want to suspend for a short time vs a longer time if possible. * Suspend until the full expiration time if something asked to suspend * Reenable an old test that we can now repro again * Suspend the commit even if it is complete if there is a minimum delay This can be used to implement spinners that don't flicker if the data and rendering is really fast. * Default timeoutMs to low pri expiration if not provided This is a required argument in the type signature but people may not supply it and this is a user facing object. * Rename to withSuspenseConfig and drop the default config This allow opting out of suspending in some nested scope. A lot of time when you use this function you'll use it with high level helpers. Those helpers often want to accept some additional configuration for suspense and if it should suspend at all. The easiest way is to just have the api accept null or a suspense config and pass it through. However, then you have to remember that calling suspendIfNeeded has a default. It gets simpler by just saying tat you can pass the config. You can have your own default in user space. * Track the largest suspense config expiration separately This ensures that if we've scheduled lower pri work that doesn't have a suspenseConfig, we don't consider its expiration as the timeout. * Add basic tests for functionality using each update mechanism * Fix issue when newly created avoided boundary doesn't suspend with delay * Add test for loading indicator with minLoadingDurationMs option --- packages/react-dom/src/client/ReactDOM.js | 1 + packages/react-dom/src/fire/ReactFire.js | 1 + .../src/ReactFiberClassComponent.js | 28 +- .../src/ReactFiberCompleteWork.js | 35 +- .../src/ReactFiberExpirationTime.js | 12 + .../react-reconciler/src/ReactFiberHooks.js | 19 +- .../src/ReactFiberNewContext.js | 2 +- .../src/ReactFiberReconciler.js | 22 +- .../src/ReactFiberScheduler.js | 200 +++++-- .../src/ReactFiberSuspenseConfig.js | 22 + .../src/ReactFiberUnwindWork.js | 12 +- .../react-reconciler/src/ReactUpdateQueue.js | 14 +- ...tSuspenseWithNoopRenderer-test.internal.js | 498 +++++++++++++++++- packages/react/src/React.js | 3 + packages/react/src/ReactBatchConfig.js | 23 + packages/react/src/ReactCurrentBatchConfig.js | 20 + packages/react/src/ReactSharedInternals.js | 2 + packages/shared/ReactSharedInternals.js | 5 + 18 files changed, 832 insertions(+), 87 deletions(-) create mode 100644 packages/react-reconciler/src/ReactFiberSuspenseConfig.js create mode 100644 packages/react/src/ReactBatchConfig.js create mode 100644 packages/react/src/ReactCurrentBatchConfig.js diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 0fd5b0b8ba..011902b045 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -220,6 +220,7 @@ ReactBatch.prototype.render = function(children: ReactNodeList) { internalRoot, null, expirationTime, + null, work._onCommit, ); return work; diff --git a/packages/react-dom/src/fire/ReactFire.js b/packages/react-dom/src/fire/ReactFire.js index e1c45fcffa..01a71bd7cf 100644 --- a/packages/react-dom/src/fire/ReactFire.js +++ b/packages/react-dom/src/fire/ReactFire.js @@ -226,6 +226,7 @@ ReactBatch.prototype.render = function(children: ReactNodeList) { internalRoot, null, expirationTime, + null, work._onCommit, ); return work; diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.js b/packages/react-reconciler/src/ReactFiberClassComponent.js index 653de4526c..d103a3feae 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.js @@ -55,6 +55,7 @@ import { flushPassiveEffects, } from './ReactFiberScheduler'; import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags'; +import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; const fakeInternalInstance = {}; const isArray = Array.isArray; @@ -184,9 +185,14 @@ const classComponentUpdater = { enqueueSetState(inst, payload, callback) { const fiber = getInstance(inst); const currentTime = requestCurrentTime(); - const expirationTime = computeExpirationForFiber(currentTime, fiber); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + fiber, + suspenseConfig, + ); - const update = createUpdate(expirationTime); + const update = createUpdate(expirationTime, suspenseConfig); update.payload = payload; if (callback !== undefined && callback !== null) { if (__DEV__) { @@ -204,9 +210,14 @@ const classComponentUpdater = { enqueueReplaceState(inst, payload, callback) { const fiber = getInstance(inst); const currentTime = requestCurrentTime(); - const expirationTime = computeExpirationForFiber(currentTime, fiber); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + fiber, + suspenseConfig, + ); - const update = createUpdate(expirationTime); + const update = createUpdate(expirationTime, suspenseConfig); update.tag = ReplaceState; update.payload = payload; @@ -226,9 +237,14 @@ const classComponentUpdater = { enqueueForceUpdate(inst, callback) { const fiber = getInstance(inst); const currentTime = requestCurrentTime(); - const expirationTime = computeExpirationForFiber(currentTime, fiber); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + fiber, + suspenseConfig, + ); - const update = createUpdate(expirationTime); + const update = createUpdate(expirationTime, suspenseConfig); update.tag = ForceUpdate; if (callback !== undefined && callback !== null) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 35932a56c9..dd26e7cb42 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -19,6 +19,7 @@ import type { } from './ReactFiberHostConfig'; import type {ReactEventComponentInstance} from 'shared/ReactTypes'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; +import type {SuspenseContext} from './ReactFiberSuspenseContext'; import { IndeterminateComponent, @@ -77,7 +78,12 @@ import { getHostContext, popHostContainer, } from './ReactFiberHostContext'; -import {popSuspenseContext} from './ReactFiberSuspenseContext'; +import { + suspenseStackCursor, + InvisibleParentSuspenseContext, + hasSuspenseContext, + popSuspenseContext, +} from './ReactFiberSuspenseContext'; import { isContextProvider as isLegacyContextProvider, popContext as popLegacyContext, @@ -94,7 +100,11 @@ import { enableSuspenseServerRenderer, enableEventAPI, } from 'shared/ReactFeatureFlags'; -import {markRenderEventTime, renderDidSuspend} from './ReactFiberScheduler'; +import { + markRenderEventTimeAndConfig, + renderDidSuspend, + renderDidSuspendDelayIfPossible, +} from './ReactFiberScheduler'; import {getEventComponentHostChildrenCount} from './ReactFiberEvents'; import getComponentName from 'shared/getComponentName'; import warning from 'shared/warning'; @@ -698,7 +708,7 @@ function completeWork( // was given a normal pri expiration time at the time it was shown. const fallbackExpirationTime: ExpirationTime = prevState.fallbackExpirationTime; - markRenderEventTime(fallbackExpirationTime); + markRenderEventTimeAndConfig(fallbackExpirationTime, null); // Delete the fallback. // TODO: Would it be better to store the fallback fragment on @@ -727,7 +737,24 @@ function completeWork( // in the concurrent tree already suspended during this render. // This is a known bug. if ((workInProgress.mode & BatchedMode) !== NoMode) { - renderDidSuspend(); + const hasInvisibleChildContext = + current === null && + workInProgress.memoizedProps.unstable_avoidThisFallback !== true; + if ( + hasInvisibleChildContext || + hasSuspenseContext( + suspenseStackCursor.current, + (InvisibleParentSuspenseContext: SuspenseContext), + ) + ) { + // If this was in an invisible tree or a new render, then showing + // this boundary is ok. + renderDidSuspend(); + } else { + // Otherwise, we're going to have to hide content so we should + // suspend for longer if possible. + renderDidSuspendDelayIfPossible(); + } } } diff --git a/packages/react-reconciler/src/ReactFiberExpirationTime.js b/packages/react-reconciler/src/ReactFiberExpirationTime.js index 9efc530f38..d3e2e20161 100644 --- a/packages/react-reconciler/src/ReactFiberExpirationTime.js +++ b/packages/react-reconciler/src/ReactFiberExpirationTime.js @@ -71,6 +71,18 @@ export function computeAsyncExpiration( ); } +export function computeSuspenseExpiration( + currentTime: ExpirationTime, + timeoutMs: number, +): ExpirationTime { + // TODO: Should we warn if timeoutMs is lower than the normal pri expiration time? + return computeExpirationBucket( + currentTime, + timeoutMs, + LOW_PRIORITY_BATCH_SIZE, + ); +} + // Same as computeAsyncExpiration but without the bucketing logic. This is // used to compute timestamps instead of actual expiration times. export function computeAsyncExpirationNoBucket( diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 64eb1be345..69147a0f71 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -12,6 +12,7 @@ import type {SideEffectTag} from 'shared/ReactSideEffectTags'; import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {HookEffectTag} from './ReactHookEffectTags'; +import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; @@ -34,7 +35,7 @@ import { flushPassiveEffects, requestCurrentTime, warnIfNotCurrentlyActingUpdatesInDev, - markRenderEventTime, + markRenderEventTimeAndConfig, } from './ReactFiberScheduler'; import invariant from 'shared/invariant'; @@ -43,6 +44,7 @@ import getComponentName from 'shared/getComponentName'; import is from 'shared/objectIs'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags'; +import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; const {ReactCurrentDispatcher} = ReactSharedInternals; @@ -82,6 +84,7 @@ export type Dispatcher = { type Update = { expirationTime: ExpirationTime, + suspenseConfig: null | SuspenseConfig, action: A, eagerReducer: ((S, A) => S) | null, eagerState: S | null, @@ -728,7 +731,10 @@ function updateReducer( // TODO: We should skip this update if it was already committed but currently // we have no way of detecting the difference between a committed and suspended // update here. - markRenderEventTime(updateExpirationTime); + markRenderEventTimeAndConfig( + updateExpirationTime, + update.suspenseConfig, + ); // Process this update. if (update.eagerReducer === reducer) { @@ -1089,6 +1095,7 @@ function dispatchAction( didScheduleRenderPhaseUpdate = true; const update: Update = { expirationTime: renderExpirationTime, + suspenseConfig: null, action, eagerReducer: null, eagerState: null, @@ -1114,10 +1121,16 @@ function dispatchAction( } const currentTime = requestCurrentTime(); - const expirationTime = computeExpirationForFiber(currentTime, fiber); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + fiber, + suspenseConfig, + ); const update: Update = { expirationTime, + suspenseConfig, action, eagerReducer: null, eagerState: null, diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index 40d52147e5..12f941ad4f 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -216,7 +216,7 @@ export function propagateContextChange( if (fiber.tag === ClassComponent) { // Schedule a force update on the work-in-progress. - const update = createUpdate(renderExpirationTime); + const update = createUpdate(renderExpirationTime, null); update.tag = ForceUpdate; // TODO: Because we don't have a work-in-progress, this will add the // update to the current fiber, too, which means it will persist even if diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 3ca6d54c48..ae3d449339 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -18,6 +18,7 @@ import type { } from './ReactFiberHostConfig'; import type {ReactNodeList} from 'shared/ReactTypes'; import type {ExpirationTime} from './ReactFiberExpirationTime'; +import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import { findCurrentHostFiber, @@ -65,6 +66,7 @@ import { import {StrictMode} from './ReactTypeOfMode'; import {Sync} from './ReactFiberExpirationTime'; import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags'; +import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; type OpaqueRoot = FiberRoot; @@ -117,6 +119,7 @@ function scheduleRootUpdate( current: Fiber, element: ReactNodeList, expirationTime: ExpirationTime, + suspenseConfig: null | SuspenseConfig, callback: ?Function, ) { if (__DEV__) { @@ -137,7 +140,7 @@ function scheduleRootUpdate( } } - const update = createUpdate(expirationTime); + const update = createUpdate(expirationTime, suspenseConfig); // Caution: React DevTools currently depends on this property // being called "element". update.payload = {element}; @@ -167,6 +170,7 @@ export function updateContainerAtExpirationTime( container: OpaqueRoot, parentComponent: ?React$Component, expirationTime: ExpirationTime, + suspenseConfig: null | SuspenseConfig, callback: ?Function, ) { // TODO: If this is a nested container, this won't be the root. @@ -191,7 +195,13 @@ export function updateContainerAtExpirationTime( container.pendingContext = context; } - return scheduleRootUpdate(current, element, expirationTime, callback); + return scheduleRootUpdate( + current, + element, + expirationTime, + suspenseConfig, + callback, + ); } function findHostInstance(component: Object): PublicInstance | null { @@ -291,12 +301,18 @@ export function updateContainer( ): ExpirationTime { const current = container.current; const currentTime = requestCurrentTime(); - const expirationTime = computeExpirationForFiber(currentTime, current); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + currentTime, + current, + suspenseConfig, + ); return updateContainerAtExpirationTime( element, container, parentComponent, expirationTime, + suspenseConfig, callback, ); } diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index fbfac5a277..9fd797baa1 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -15,6 +15,7 @@ import type { SchedulerCallback, } from './SchedulerWithReactIntegration'; import type {Interaction} from 'scheduler/src/Tracing'; +import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import { warnAboutDeprecatedLifecycles, @@ -96,6 +97,7 @@ import { expirationTimeToMs, computeInteractiveExpiration, computeAsyncExpiration, + computeSuspenseExpiration, inferPriorityFromExpirationTime, LOW_PRIORITY_EXPIRATION, Batched, @@ -184,11 +186,12 @@ const FlushSyncPhase = 3; const RenderPhase = 4; const CommitPhase = 5; -type RootExitStatus = 0 | 1 | 2 | 3; +type RootExitStatus = 0 | 1 | 2 | 3 | 4; const RootIncomplete = 0; const RootErrored = 1; const RootSuspended = 2; -const RootCompleted = 3; +const RootSuspendedWithDelay = 3; +const RootCompleted = 4; export type Thenable = { then(resolve: () => mixed, reject?: () => mixed): Thenable | void, @@ -208,7 +211,9 @@ let workInProgressRootExitStatus: RootExitStatus = RootIncomplete; // This is conceptually a time stamp but expressed in terms of an ExpirationTime // because we deal mostly with expiration times in the hot path, so this avoids // the conversion happening in the hot path. -let workInProgressRootMostRecentEventTime: ExpirationTime = Sync; +let workInProgressRootLatestProcessedExpirationTime: ExpirationTime = Sync; +let workInProgressRootLatestSuspenseTimeout: ExpirationTime = Sync; +let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null; let nextEffect: Fiber | null = null; let hasUncaughtError = false; @@ -262,6 +267,7 @@ export function requestCurrentTime() { export function computeExpirationForFiber( currentTime: ExpirationTime, fiber: Fiber, + suspenseConfig: null | SuspenseConfig, ): ExpirationTime { const mode = fiber.mode; if ((mode & BatchedMode) === NoMode) { @@ -278,26 +284,34 @@ export function computeExpirationForFiber( return renderExpirationTime; } - // Compute an expiration time based on the Scheduler priority. let expirationTime; - switch (priorityLevel) { - case ImmediatePriority: - expirationTime = Sync; - break; - case UserBlockingPriority: - // TODO: Rename this to computeUserBlockingExpiration - expirationTime = computeInteractiveExpiration(currentTime); - break; - case NormalPriority: - case LowPriority: // TODO: Handle LowPriority - // TODO: Rename this to... something better. - expirationTime = computeAsyncExpiration(currentTime); - break; - case IdlePriority: - expirationTime = Never; - break; - default: - invariant(false, 'Expected a valid priority level'); + if (suspenseConfig !== null) { + // Compute an expiration time based on the Suspense timeout. + expirationTime = computeSuspenseExpiration( + currentTime, + suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION, + ); + } else { + // Compute an expiration time based on the Scheduler priority. + switch (priorityLevel) { + case ImmediatePriority: + expirationTime = Sync; + break; + case UserBlockingPriority: + // TODO: Rename this to computeUserBlockingExpiration + expirationTime = computeInteractiveExpiration(currentTime); + break; + case NormalPriority: + case LowPriority: // TODO: Handle LowPriority + // TODO: Rename this to... something better. + expirationTime = computeAsyncExpiration(currentTime); + break; + case IdlePriority: + expirationTime = Never; + break; + default: + invariant(false, 'Expected a valid priority level'); + } } // If we're in the middle of rendering a tree, do not update at the same @@ -720,7 +734,9 @@ function prepareFreshStack(root, expirationTime) { workInProgress = createWorkInProgress(root.current, null, expirationTime); renderExpirationTime = expirationTime; workInProgressRootExitStatus = RootIncomplete; - workInProgressRootMostRecentEventTime = Sync; + workInProgressRootLatestProcessedExpirationTime = Sync; + workInProgressRootLatestSuspenseTimeout = Sync; + workInProgressRootCanSuspendUsingConfig = null; if (__DEV__) { ReactStrictModeWarnings.discardPendingWarnings(); @@ -918,7 +934,8 @@ function renderRoot( // errored state. return commitRoot.bind(null, root); } - case RootSuspended: { + case RootSuspended: + case RootSuspendedWithDelay: { if (!isSync) { const lastPendingTime = root.lastPendingTime; if (root.lastPendingTime < expirationTime) { @@ -926,13 +943,18 @@ function renderRoot( // at that level. return renderRoot.bind(null, root, lastPendingTime); } - // If workInProgressRootMostRecentEventTime is Sync, that means we didn't + // If workInProgressRootLatestProcessedExpirationTime is Sync, that means we didn't // track any event times. That can happen if we retried but nothing switched // from fallback to content. There's no reason to delay doing no work. - if (workInProgressRootMostRecentEventTime !== Sync) { + if (workInProgressRootLatestProcessedExpirationTime !== Sync) { + let shouldDelay = + workInProgressRootExitStatus === RootSuspendedWithDelay; let msUntilTimeout = computeMsUntilTimeout( - workInProgressRootMostRecentEventTime, + workInProgressRootLatestProcessedExpirationTime, + workInProgressRootLatestSuspenseTimeout, expirationTime, + workInProgressRootCanSuspendUsingConfig, + shouldDelay, ); // Don't bother with a very short suspense time. if (msUntilTimeout > 10) { @@ -952,6 +974,27 @@ function renderRoot( } case RootCompleted: { // The work completed. Ready to commit. + if ( + !isSync && + workInProgressRootLatestProcessedExpirationTime !== Sync && + workInProgressRootCanSuspendUsingConfig !== null + ) { + // If we have exceeded the minimum loading delay, which probably + // means we have shown a spinner already, we might have to suspend + // a bit longer to ensure that the spinner is shown for enough time. + const msUntilTimeout = computeMsUntilSuspenseLoadingDelay( + workInProgressRootLatestProcessedExpirationTime, + expirationTime, + workInProgressRootCanSuspendUsingConfig, + ); + if (msUntilTimeout > 10) { + root.timeoutHandle = scheduleTimeout( + commitRoot.bind(null, root), + msUntilTimeout, + ); + return null; + } + } return commitRoot.bind(null, root); } default: { @@ -960,12 +1003,25 @@ function renderRoot( } } -export function markRenderEventTime(expirationTime: ExpirationTime): void { +export function markRenderEventTimeAndConfig( + expirationTime: ExpirationTime, + suspenseConfig: null | SuspenseConfig, +): void { if ( - expirationTime < workInProgressRootMostRecentEventTime && + expirationTime < workInProgressRootLatestProcessedExpirationTime && expirationTime > Never ) { - workInProgressRootMostRecentEventTime = expirationTime; + workInProgressRootLatestProcessedExpirationTime = expirationTime; + } + if (suspenseConfig !== null) { + if ( + expirationTime < workInProgressRootLatestSuspenseTimeout && + expirationTime > Never + ) { + workInProgressRootLatestSuspenseTimeout = expirationTime; + // Most of the time we only have one config and getting wrong is not bad. + workInProgressRootCanSuspendUsingConfig = suspenseConfig; + } } } @@ -975,20 +1031,34 @@ export function renderDidSuspend(): void { } } -export function renderDidError() { +export function renderDidSuspendDelayIfPossible(): void { if ( workInProgressRootExitStatus === RootIncomplete || workInProgressRootExitStatus === RootSuspended ) { + workInProgressRootExitStatus = RootSuspendedWithDelay; + } +} + +export function renderDidError() { + if (workInProgressRootExitStatus !== RootCompleted) { workInProgressRootExitStatus = RootErrored; } } -function inferTimeFromExpirationTime(expirationTime: ExpirationTime): number { +function inferTimeFromExpirationTime( + expirationTime: ExpirationTime, + suspenseConfig: null | SuspenseConfig, +): number { // We don't know exactly when the update was scheduled, but we can infer an // approximate start time from the expiration time. const earliestExpirationTimeMs = expirationTimeToMs(expirationTime); - return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION; + return ( + earliestExpirationTimeMs - + (suspenseConfig !== null + ? suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION + : LOW_PRIORITY_EXPIRATION) + ); } function workLoopSync() { @@ -1834,7 +1904,12 @@ export function retryTimedOutBoundary(boundaryFiber: Fiber) { // resolved, which means at least part of the tree was likely unblocked. Try // rendering again, at a new expiration time. const currentTime = requestCurrentTime(); - const retryTime = computeExpirationForFiber(currentTime, boundaryFiber); + const suspenseConfig = null; // Retries don't carry over the already committed update. + const retryTime = computeExpirationForFiber( + currentTime, + boundaryFiber, + suspenseConfig, + ); // TODO: Special case idle priority? const priorityLevel = inferPriorityFromExpirationTime(currentTime, retryTime); const root = markUpdateTimeFromFiberToRoot(boundaryFiber, retryTime); @@ -1898,17 +1973,66 @@ function jnd(timeElapsed: number) { : ceil(timeElapsed / 1960) * 1960; } -function computeMsUntilTimeout( +function computeMsUntilSuspenseLoadingDelay( mostRecentEventTime: ExpirationTime, committedExpirationTime: ExpirationTime, + suspenseConfig: SuspenseConfig, ) { if (disableYielding) { // Timeout immediately when yielding is disabled. return 0; } - const eventTimeMs: number = inferTimeFromExpirationTime(mostRecentEventTime); + const minLoadingDurationMs = (suspenseConfig.minLoadingDurationMs: any) | 0; + if (minLoadingDurationMs <= 0) { + return 0; + } + const loadingDelayMs = (suspenseConfig.loadingDelayMs: any) | 0; + + // Compute the time until this render pass would expire. const currentTimeMs: number = now(); + const eventTimeMs: number = inferTimeFromExpirationTime( + mostRecentEventTime, + suspenseConfig, + ); + const timeElapsed = currentTimeMs - eventTimeMs; + if (timeElapsed <= loadingDelayMs) { + // If we haven't yet waited longer than the initial delay, we don't + // have to wait any additional time. + return 0; + } + const msUntilTimeout = loadingDelayMs + minLoadingDurationMs - timeElapsed; + // This is the value that is passed to `setTimeout`. + return msUntilTimeout; +} + +function computeMsUntilTimeout( + mostRecentEventTime: ExpirationTime, + suspenseTimeout: ExpirationTime, + committedExpirationTime: ExpirationTime, + suspenseConfig: null | SuspenseConfig, + shouldDelay: boolean, +) { + if (disableYielding) { + // Timeout immediately when yielding is disabled. + return 0; + } + + // Compute the time until this render pass would expire. + const currentTimeMs: number = now(); + + if (suspenseTimeout !== Sync && shouldDelay) { + const timeUntilTimeoutMs = + expirationTimeToMs(suspenseTimeout) - currentTimeMs; + return timeUntilTimeoutMs; + } + + const eventTimeMs: number = inferTimeFromExpirationTime( + mostRecentEventTime, + suspenseConfig, + ); + const timeUntilExpirationMs = + expirationTimeToMs(committedExpirationTime) - currentTimeMs; let timeElapsed = currentTimeMs - eventTimeMs; if (timeElapsed < 0) { // We get this wrong some time since we estimate the time. @@ -1917,10 +2041,6 @@ function computeMsUntilTimeout( let msUntilTimeout = jnd(timeElapsed) - timeElapsed; - // Compute the time until this render pass would expire. - const timeUntilExpirationMs = - expirationTimeToMs(committedExpirationTime) - currentTimeMs; - // Clamp the timeout to the expiration time. // TODO: Once the event time is exact instead of inferred from expiration time // we don't need this. diff --git a/packages/react-reconciler/src/ReactFiberSuspenseConfig.js b/packages/react-reconciler/src/ReactFiberSuspenseConfig.js new file mode 100644 index 0000000000..fe9be71bf8 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberSuspenseConfig.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import ReactSharedInternals from 'shared/ReactSharedInternals'; + +const {ReactCurrentBatchConfig} = ReactSharedInternals; + +export type SuspenseConfig = {| + timeoutMs: number, + loadingDelayMs?: number, + minLoadingDurationMs?: number, +|}; + +export function requestCurrentSuspenseConfig(): null | SuspenseConfig { + return ReactCurrentBatchConfig.suspense; +} diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 1d4a4cbe23..4db7a3a76a 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -90,7 +90,7 @@ function createRootErrorUpdate( errorInfo: CapturedValue, expirationTime: ExpirationTime, ): Update { - const update = createUpdate(expirationTime); + const update = createUpdate(expirationTime, null); // Unmount the root by rendering null. update.tag = CaptureUpdate; // Caution: React DevTools currently depends on this property @@ -109,7 +109,7 @@ function createClassErrorUpdate( errorInfo: CapturedValue, expirationTime: ExpirationTime, ): Update { - const update = createUpdate(expirationTime); + const update = createUpdate(expirationTime, null); update.tag = CaptureUpdate; const getDerivedStateFromError = fiber.type.getDerivedStateFromError; if (typeof getDerivedStateFromError === 'function') { @@ -265,7 +265,7 @@ function throwException( // When we try rendering again, we should not reuse the current fiber, // since it's known to be in an inconsistent state. Use a force updte to // prevent a bail out. - const update = createUpdate(Sync); + const update = createUpdate(Sync, null); update.tag = ForceUpdate; enqueueUpdate(sourceFiber, update); } @@ -287,12 +287,6 @@ function throwException( workInProgress.effectTag |= ShouldCapture; workInProgress.expirationTime = renderExpirationTime; - if (!hasInvisibleParentBoundary) { - // TODO: If we're not in an invisible subtree, then we need to mark this render - // pass as needing to suspend for longer to avoid showing this fallback state. - // We could do it here or when we render the fallback. - } - return; } else if ( enableSuspenseServerRenderer && diff --git a/packages/react-reconciler/src/ReactUpdateQueue.js b/packages/react-reconciler/src/ReactUpdateQueue.js index aecbc4f678..cc3b7037a3 100644 --- a/packages/react-reconciler/src/ReactUpdateQueue.js +++ b/packages/react-reconciler/src/ReactUpdateQueue.js @@ -86,6 +86,7 @@ import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; +import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import {NoWork} from './ReactFiberExpirationTime'; import { @@ -101,13 +102,14 @@ import { } from 'shared/ReactFeatureFlags'; import {StrictMode} from './ReactTypeOfMode'; -import {markRenderEventTime} from './ReactFiberScheduler'; +import {markRenderEventTimeAndConfig} from './ReactFiberScheduler'; import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; export type Update = { expirationTime: ExpirationTime, + suspenseConfig: null | SuspenseConfig, tag: 0 | 1 | 2 | 3, payload: any, @@ -191,9 +193,13 @@ function cloneUpdateQueue( return queue; } -export function createUpdate(expirationTime: ExpirationTime): Update<*> { +export function createUpdate( + expirationTime: ExpirationTime, + suspenseConfig: null | SuspenseConfig, +): Update<*> { return { - expirationTime: expirationTime, + expirationTime, + suspenseConfig, tag: UpdateState, payload: null, @@ -463,7 +469,7 @@ export function processUpdateQueue( // TODO: We should skip this update if it was already committed but currently // we have no way of detecting the difference between a committed and suspended // update here. - markRenderEventTime(updateExpirationTime); + markRenderEventTimeAndConfig(updateExpirationTime, update.suspenseConfig); // Process it and compute a new result. resultState = getStateFromUpdate( diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index 894f9d5553..1bcce162f9 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -726,13 +726,12 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(ReactNoop.getChildren()).toEqual([span('Async')]); }); - // TODO: This cannot be tested until we have a way to long-suspend navigations. - it.skip('starts working on an update even if its priority falls between two suspended levels', async () => { + it('starts working on an update even if its priority falls between two suspended levels', async () => { function App(props) { return ( }> - {props.text === 'C' ? ( - + {props.text === 'C' || props.text === 'S' ? ( + ) : ( )} @@ -740,30 +739,42 @@ describe('ReactSuspenseWithNoopRenderer', () => { ); } - // Schedule an update - ReactNoop.render(); + // First mount without suspending. This ensures we already have content + // showing so that subsequent updates will suspend. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['S']); + + // Schedule an update, and suspend for up to 5 seconds. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + { + timeoutMs: 5000, + }, + ); // The update should suspend. expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([span('S')]); - // Advance time until right before it expires. This number may need to - // change if the default expiration for low priority updates is adjusted. + // Advance time until right before it expires. await advanceTimers(4999); ReactNoop.expire(4999); expect(Scheduler).toFlushWithoutYielding(); - expect(ReactNoop.getChildren()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([span('S')]); // Schedule another low priority update. - ReactNoop.render(); + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + { + timeoutMs: 10000, + }, + ); // This update should also suspend. expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([]); + expect(ReactNoop.getChildren()).toEqual([span('S')]); - // Schedule a high priority update. Its expiration time will fall between + // Schedule a regular update. Its expiration time will fall between // the expiration times of the previous two updates. - ReactNoop.interactiveUpdates(() => { - ReactNoop.render(); - }); + ReactNoop.render(); expect(Scheduler).toFlushAndYield(['C']); expect(ReactNoop.getChildren()).toEqual([span('C')]); @@ -1660,7 +1671,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { ReactNoop.render(); // Took a long time to render. This is to ensure we get a long suspense time. - // Could also use something like suspendIfNeeded to simulate this. + // Could also use something like withSuspenseConfig to simulate this. Scheduler.advanceTime(1500); await advanceTimers(1500); @@ -1690,4 +1701,457 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(ReactNoop.getChildren()).toEqual([span('Loading A...')]); }); + + describe('delays transitions when there a suspense config is supplied', () => { + const SUSPENSE_CONFIG = { + timeoutMs: 2000, + }; + + it('top level render', async () => { + function App({page}) { + return ( + }> + + + ); + } + + // Initial render. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + SUSPENSE_CONFIG, + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + // Only a short time is needed to unsuspend the initial loading state. + Scheduler.advanceTime(400); + await advanceTimers(400); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + // Later we load the data. + Scheduler.advanceTime(5000); + await advanceTimers(5000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Start transition. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + SUSPENSE_CONFIG, + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + Scheduler.advanceTime(1000); + await advanceTimers(1000); + // Even after a second, we have still not yet flushed the loading state. + expect(ReactNoop.getChildren()).toEqual([span('A')]); + Scheduler.advanceTime(1100); + await advanceTimers(1100); + // After the timeout, we do show the loading state. + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('A'), + span('Loading...'), + ]); + // Later we load the data. + Scheduler.advanceTime(3000); + await advanceTimers(3000); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + }); + + it('hooks', async () => { + let transitionToPage; + function App() { + let [page, setPage] = React.useState('none'); + transitionToPage = setPage; + if (page === 'none') { + return null; + } + return ( + }> + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + + // Initial render. + await ReactNoop.act(async () => { + React.unstable_withSuspenseConfig( + () => transitionToPage('A'), + SUSPENSE_CONFIG, + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + // Only a short time is needed to unsuspend the initial loading state. + Scheduler.advanceTime(400); + await advanceTimers(400); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + }); + + // Later we load the data. + Scheduler.advanceTime(5000); + await advanceTimers(5000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Start transition. + await ReactNoop.act(async () => { + React.unstable_withSuspenseConfig( + () => transitionToPage('B'), + SUSPENSE_CONFIG, + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + Scheduler.advanceTime(1000); + await advanceTimers(1000); + // Even after a second, we have still not yet flushed the loading state. + expect(ReactNoop.getChildren()).toEqual([span('A')]); + Scheduler.advanceTime(1100); + await advanceTimers(1100); + // After the timeout, we do show the loading state. + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('A'), + span('Loading...'), + ]); + }); + // Later we load the data. + Scheduler.advanceTime(3000); + await advanceTimers(3000); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + }); + + it('classes', async () => { + let transitionToPage; + class App extends React.Component { + state = {page: 'none'}; + render() { + transitionToPage = page => this.setState({page}); + let page = this.state.page; + if (page === 'none') { + return null; + } + return ( + }> + + + ); + } + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + + // Initial render. + await ReactNoop.act(async () => { + React.unstable_withSuspenseConfig( + () => transitionToPage('A'), + SUSPENSE_CONFIG, + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + // Only a short time is needed to unsuspend the initial loading state. + Scheduler.advanceTime(400); + await advanceTimers(400); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + }); + + // Later we load the data. + Scheduler.advanceTime(5000); + await advanceTimers(5000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Start transition. + await ReactNoop.act(async () => { + React.unstable_withSuspenseConfig( + () => transitionToPage('B'), + SUSPENSE_CONFIG, + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + Scheduler.advanceTime(1000); + await advanceTimers(1000); + // Even after a second, we have still not yet flushed the loading state. + expect(ReactNoop.getChildren()).toEqual([span('A')]); + Scheduler.advanceTime(1100); + await advanceTimers(1100); + // After the timeout, we do show the loading state. + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('A'), + span('Loading...'), + ]); + }); + // Later we load the data. + Scheduler.advanceTime(3000); + await advanceTimers(3000); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + }); + }); + + it('disables suspense config when nothing is passed to withSuspenseConfig', async () => { + function App({page}) { + return ( + }> + + + ); + } + + // Initial render. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + Scheduler.advanceTime(2000); + await advanceTimers(2000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + // Start transition. + React.unstable_withSuspenseConfig( + () => { + // When we schedule an inner transition without a suspense config + // so it should only suspend for a short time. + React.unstable_withSuspenseConfig(() => + ReactNoop.render(), + ); + }, + {timeoutMs: 2000}, + ); + + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + // Suspended + expect(ReactNoop.getChildren()).toEqual([span('A')]); + Scheduler.advanceTime(500); + await advanceTimers(500); + // Committed loading state. + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('A'), + span('Loading...'), + ]); + + Scheduler.advanceTime(2000); + await advanceTimers(2000); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + + React.unstable_withSuspenseConfig( + () => { + // First we schedule an inner unrelated update. + React.unstable_withSuspenseConfig(() => + ReactNoop.render(), + ); + // Then we schedule another transition to a slow page, + // but at this scope we should suspend for longer. + Scheduler.unstable_next(() => ReactNoop.render()); + }, + {timeoutMs: 2000}, + ); + expect(Scheduler).toFlushAndYield([ + 'Suspend! [C]', + 'Loading...', + 'Suspend! [C]', + 'Loading...', + ]); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + Scheduler.advanceTime(1200); + await advanceTimers(1200); + // Even after a second, we have still not yet flushed the loading state. + expect(ReactNoop.getChildren()).toEqual([span('B')]); + Scheduler.advanceTime(1200); + await advanceTimers(1200); + // After the two second timeout we show the loading state. + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('B'), + span('Loading...'), + ]); + }); + + it('withSuspenseConfig timeout applies when we use an updated avoided boundary', async () => { + function App({page}) { + return ( + }> + + } + unstable_avoidThisFallback={true}> + + + + ); + } + + // Initial render. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Hi!', 'Suspend! [A]', 'Loading...']); + Scheduler.advanceTime(3000); + await advanceTimers(3000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['Hi!', 'A']); + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]); + + // Start transition. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + {timeoutMs: 2000}, + ); + + expect(Scheduler).toFlushAndYield(['Hi!', 'Suspend! [B]', 'Loading B...']); + + // Suspended + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]); + Scheduler.advanceTime(1800); + await advanceTimers(1800); + expect(Scheduler).toFlushAndYield([]); + // We should still be suspended here because this loading state should be avoided. + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]); + Scheduler.advanceTime(1500); + await advanceTimers(1500); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(ReactNoop.getChildren()).toEqual([ + span('Hi!'), + hiddenSpan('A'), + span('Loading B...'), + ]); + }); + + it('withSuspenseConfig timeout applies when we use a newly created avoided boundary', async () => { + function App({page}) { + return ( + }> + + {page === 'A' ? ( + + ) : ( + } + unstable_avoidThisFallback={true}> + + + )} + + ); + } + + // Initial render. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Hi!', 'A']); + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]); + + // Start transition. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + {timeoutMs: 2000}, + ); + + expect(Scheduler).toFlushAndYield(['Hi!', 'Suspend! [B]', 'Loading B...']); + + // Suspended + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]); + Scheduler.advanceTime(1800); + await advanceTimers(1800); + expect(Scheduler).toFlushAndYield([]); + // We should still be suspended here because this loading state should be avoided. + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]); + Scheduler.advanceTime(1500); + await advanceTimers(1500); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(ReactNoop.getChildren()).toEqual([ + span('Hi!'), + span('Loading B...'), + ]); + }); + + it('supports delaying a busy spinner from disappearing', async () => { + function useLoadingIndicator(config) { + let [isLoading, setLoading] = React.useState(false); + let start = React.useCallback( + cb => { + setLoading(true); + Scheduler.unstable_next(() => + React.unstable_withSuspenseConfig(() => { + setLoading(false); + cb(); + }, config), + ); + }, + [setLoading, config], + ); + return [isLoading, start]; + } + + const SUSPENSE_CONFIG = { + timeoutMs: 10000, + loadingDelayMs: 500, + minLoadingDurationMs: 400, + }; + + let transitionToPage; + + function App() { + let [page, setPage] = React.useState('A'); + let [isLoading, startLoading] = useLoadingIndicator(SUSPENSE_CONFIG); + transitionToPage = nextPage => startLoading(() => setPage(nextPage)); + return ( + + + {isLoading ? : null} + + ); + } + + // Initial render. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + + await ReactNoop.act(async () => { + transitionToPage('B'); + // Rendering B is quick and we didn't have enough + // time to show the loading indicator. + Scheduler.advanceTime(200); + await advanceTimers(200); + expect(Scheduler).toFlushAndYield(['A', 'L', 'B']); + expect(ReactNoop.getChildren()).toEqual([span('B')]); + }); + + await ReactNoop.act(async () => { + transitionToPage('C'); + // Rendering C is a bit slower so we've already showed + // the loading indicator. + Scheduler.advanceTime(600); + await advanceTimers(600); + expect(Scheduler).toFlushAndYield(['B', 'L', 'C']); + // We're technically done now but we haven't shown the + // loading indicator for long enough yet so we'll suspend + // while we keep it on the screen a bit longer. + expect(ReactNoop.getChildren()).toEqual([span('B'), span('L')]); + Scheduler.advanceTime(400); + await advanceTimers(400); + expect(ReactNoop.getChildren()).toEqual([span('C')]); + }); + + await ReactNoop.act(async () => { + transitionToPage('D'); + // Rendering D is very slow so we've already showed + // the loading indicator. + Scheduler.advanceTime(1000); + await advanceTimers(1000); + expect(Scheduler).toFlushAndYield(['C', 'L', 'D']); + // However, since we exceeded the minimum time to show + // the loading indicator, we commit immediately. + expect(ReactNoop.getChildren()).toEqual([span('D')]); + }); + }); }); diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 8c7ee01d45..7196344ade 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -40,6 +40,7 @@ import { useRef, useState, } from './ReactHooks'; +import {withSuspenseConfig} from './ReactBatchConfig'; import { createElementWithValidation, createFactoryWithValidation, @@ -95,6 +96,8 @@ const React = { version: ReactVersion, + unstable_withSuspenseConfig: withSuspenseConfig, + __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactSharedInternals, }; diff --git a/packages/react/src/ReactBatchConfig.js b/packages/react/src/ReactBatchConfig.js new file mode 100644 index 0000000000..b4aa8b927f --- /dev/null +++ b/packages/react/src/ReactBatchConfig.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * 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 {SuspenseConfig} from 'react-reconciler/src/ReactFiberSuspenseConfig'; + +import ReactCurrentBatchConfig from './ReactCurrentBatchConfig'; + +// Within the scope of the callback, mark all updates as being allowed to suspend. +export function withSuspenseConfig(scope: () => void, config?: SuspenseConfig) { + const previousConfig = ReactCurrentBatchConfig.suspense; + ReactCurrentBatchConfig.suspense = config === undefined ? null : config; + try { + scope(); + } finally { + ReactCurrentBatchConfig.suspense = previousConfig; + } +} diff --git a/packages/react/src/ReactCurrentBatchConfig.js b/packages/react/src/ReactCurrentBatchConfig.js new file mode 100644 index 0000000000..7fc43dd3b7 --- /dev/null +++ b/packages/react/src/ReactCurrentBatchConfig.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * 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 {SuspenseConfig} from 'react-reconciler/src/ReactFiberSuspenseConfig'; + +/** + * Keeps track of the current batch's configuration such as how long an update + * should suspend for if it needs to. + */ +const ReactCurrentBatchConfig = { + suspense: (null: null | SuspenseConfig), +}; + +export default ReactCurrentBatchConfig; diff --git a/packages/react/src/ReactSharedInternals.js b/packages/react/src/ReactSharedInternals.js index 1d1e0a0bfc..1bc95cffaa 100644 --- a/packages/react/src/ReactSharedInternals.js +++ b/packages/react/src/ReactSharedInternals.js @@ -7,11 +7,13 @@ import assign from 'object-assign'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; +import ReactCurrentBatchConfig from './ReactCurrentBatchConfig'; import ReactCurrentOwner from './ReactCurrentOwner'; import ReactDebugCurrentFrame from './ReactDebugCurrentFrame'; const ReactSharedInternals = { ReactCurrentDispatcher, + ReactCurrentBatchConfig, ReactCurrentOwner, // used by act() ReactShouldWarnActingUpdates: {current: false}, diff --git a/packages/shared/ReactSharedInternals.js b/packages/shared/ReactSharedInternals.js index eaf5261990..852e31ddf6 100644 --- a/packages/shared/ReactSharedInternals.js +++ b/packages/shared/ReactSharedInternals.js @@ -18,5 +18,10 @@ if (!ReactSharedInternals.hasOwnProperty('ReactCurrentDispatcher')) { current: null, }; } +if (!ReactSharedInternals.hasOwnProperty('ReactCurrentBatchConfig')) { + ReactSharedInternals.ReactCurrentBatchConfig = { + suspense: null, + }; +} export default ReactSharedInternals;