diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 0fd5b0b8baa05..011902b0450d9 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 e1c45fcffad68..01a71bd7cf205 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 653de4526c31b..d103a3feae5ae 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 35932a56c99ba..dd26e7cb42a8c 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 9efc530f38cc3..d3e2e20161010 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 64eb1be345f74..69147a0f71d45 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 40d52147e5b81..12f941ad4f388 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 3ca6d54c48590..ae3d4493395f3 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 fbfac5a277d95..9fd797baa10d2 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 computeMsUntilSuspenseLoadingDelay( + mostRecentEventTime: ExpirationTime, + committedExpirationTime: ExpirationTime, + suspenseConfig: SuspenseConfig, +) { + if (disableYielding) { + // Timeout immediately when yielding is disabled. + return 0; + } + + 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; } - const eventTimeMs: number = inferTimeFromExpirationTime(mostRecentEventTime); + // 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 0000000000000..fe9be71bf8f18 --- /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 1d4a4cbe236eb..4db7a3a76a72f 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 aecbc4f678823..cc3b7037a30cd 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 894f9d5553ad5..1bcce162f95ab 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 8c7ee01d45d9d..7196344adef8e 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 0000000000000..b4aa8b927fde7 --- /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 0000000000000..7fc43dd3b7adc --- /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 1d1e0a0bfc92f..1bc95cffaad4e 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 eaf526199046e..852e31ddf652f 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;