mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
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
This commit is contained in:
committed by
GitHub
parent
1160b37691
commit
9c6de716d0
1
packages/react-dom/src/client/ReactDOM.js
vendored
1
packages/react-dom/src/client/ReactDOM.js
vendored
@@ -220,6 +220,7 @@ ReactBatch.prototype.render = function(children: ReactNodeList) {
|
||||
internalRoot,
|
||||
null,
|
||||
expirationTime,
|
||||
null,
|
||||
work._onCommit,
|
||||
);
|
||||
return work;
|
||||
|
||||
1
packages/react-dom/src/fire/ReactFire.js
vendored
1
packages/react-dom/src/fire/ReactFire.js
vendored
@@ -226,6 +226,7 @@ ReactBatch.prototype.render = function(children: ReactNodeList) {
|
||||
internalRoot,
|
||||
null,
|
||||
expirationTime,
|
||||
null,
|
||||
work._onCommit,
|
||||
);
|
||||
return work;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
19
packages/react-reconciler/src/ReactFiberHooks.js
vendored
19
packages/react-reconciler/src/ReactFiberHooks.js
vendored
@@ -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<S, A> = {
|
||||
expirationTime: ExpirationTime,
|
||||
suspenseConfig: null | SuspenseConfig,
|
||||
action: A,
|
||||
eagerReducer: ((S, A) => S) | null,
|
||||
eagerState: S | null,
|
||||
@@ -728,7 +731,10 @@ function updateReducer<S, I, A>(
|
||||
// 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<S, A>(
|
||||
didScheduleRenderPhaseUpdate = true;
|
||||
const update: Update<S, A> = {
|
||||
expirationTime: renderExpirationTime,
|
||||
suspenseConfig: null,
|
||||
action,
|
||||
eagerReducer: null,
|
||||
eagerState: null,
|
||||
@@ -1114,10 +1121,16 @@ function dispatchAction<S, A>(
|
||||
}
|
||||
|
||||
const currentTime = requestCurrentTime();
|
||||
const expirationTime = computeExpirationForFiber(currentTime, fiber);
|
||||
const suspenseConfig = requestCurrentSuspenseConfig();
|
||||
const expirationTime = computeExpirationForFiber(
|
||||
currentTime,
|
||||
fiber,
|
||||
suspenseConfig,
|
||||
);
|
||||
|
||||
const update: Update<S, A> = {
|
||||
expirationTime,
|
||||
suspenseConfig,
|
||||
action,
|
||||
eagerReducer: null,
|
||||
eagerState: null,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<any, any>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
200
packages/react-reconciler/src/ReactFiberScheduler.js
vendored
200
packages/react-reconciler/src/ReactFiberScheduler.js
vendored
@@ -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.
|
||||
|
||||
22
packages/react-reconciler/src/ReactFiberSuspenseConfig.js
vendored
Normal file
22
packages/react-reconciler/src/ReactFiberSuspenseConfig.js
vendored
Normal file
@@ -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;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ function createRootErrorUpdate(
|
||||
errorInfo: CapturedValue<mixed>,
|
||||
expirationTime: ExpirationTime,
|
||||
): Update<mixed> {
|
||||
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<mixed>,
|
||||
expirationTime: ExpirationTime,
|
||||
): Update<mixed> {
|
||||
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 &&
|
||||
|
||||
@@ -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<State> = {
|
||||
expirationTime: ExpirationTime,
|
||||
suspenseConfig: null | SuspenseConfig,
|
||||
|
||||
tag: 0 | 1 | 2 | 3,
|
||||
payload: any,
|
||||
@@ -191,9 +193,13 @@ function cloneUpdateQueue<State>(
|
||||
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<State>(
|
||||
// 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(
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
{props.text === 'C' ? (
|
||||
<Text text="C" />
|
||||
{props.text === 'C' || props.text === 'S' ? (
|
||||
<Text text={props.text} />
|
||||
) : (
|
||||
<AsyncText text={props.text} ms={10000} />
|
||||
)}
|
||||
@@ -740,30 +739,42 @@ describe('ReactSuspenseWithNoopRenderer', () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Schedule an update
|
||||
ReactNoop.render(<App text="A" />);
|
||||
// First mount without suspending. This ensures we already have content
|
||||
// showing so that subsequent updates will suspend.
|
||||
ReactNoop.render(<App text="S" />);
|
||||
expect(Scheduler).toFlushAndYield(['S']);
|
||||
|
||||
// Schedule an update, and suspend for up to 5 seconds.
|
||||
React.unstable_withSuspenseConfig(
|
||||
() => ReactNoop.render(<App text="A" />),
|
||||
{
|
||||
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(<App text="B" />);
|
||||
React.unstable_withSuspenseConfig(
|
||||
() => ReactNoop.render(<App text="B" />),
|
||||
{
|
||||
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(<App text="C" />);
|
||||
});
|
||||
ReactNoop.render(<App text="C" />);
|
||||
expect(Scheduler).toFlushAndYield(['C']);
|
||||
expect(ReactNoop.getChildren()).toEqual([span('C')]);
|
||||
|
||||
@@ -1660,7 +1671,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
|
||||
ReactNoop.render(<Foo />);
|
||||
|
||||
// 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 (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<AsyncText text={page} ms={5000} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial render.
|
||||
React.unstable_withSuspenseConfig(
|
||||
() => ReactNoop.render(<App page="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.
|
||||
React.unstable_withSuspenseConfig(
|
||||
() => ReactNoop.render(<App page="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('hooks', async () => {
|
||||
let transitionToPage;
|
||||
function App() {
|
||||
let [page, setPage] = React.useState('none');
|
||||
transitionToPage = setPage;
|
||||
if (page === 'none') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<AsyncText text={page} ms={5000} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
ReactNoop.render(<App />);
|
||||
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 (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<AsyncText text={page} ms={5000} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReactNoop.render(<App />);
|
||||
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 (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<AsyncText text={page} ms={2000} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial render.
|
||||
ReactNoop.render(<App page="A" />);
|
||||
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(<App page="B" />),
|
||||
);
|
||||
},
|
||||
{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(<App page="B" unrelated={true} />),
|
||||
);
|
||||
// Then we schedule another transition to a slow page,
|
||||
// but at this scope we should suspend for longer.
|
||||
Scheduler.unstable_next(() => ReactNoop.render(<App page="C" />));
|
||||
},
|
||||
{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 (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<Text text="Hi!" />
|
||||
<Suspense
|
||||
fallback={<Text text={'Loading ' + page + '...'} />}
|
||||
unstable_avoidThisFallback={true}>
|
||||
<AsyncText text={page} ms={3000} />
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial render.
|
||||
ReactNoop.render(<App page="A" />);
|
||||
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(<App page="B" />),
|
||||
{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 (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<Text text="Hi!" />
|
||||
{page === 'A' ? (
|
||||
<Text text="A" />
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={<Text text={'Loading ' + page + '...'} />}
|
||||
unstable_avoidThisFallback={true}>
|
||||
<AsyncText text={page} ms={3000} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial render.
|
||||
ReactNoop.render(<App page="A" />);
|
||||
expect(Scheduler).toFlushAndYield(['Hi!', 'A']);
|
||||
expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]);
|
||||
|
||||
// Start transition.
|
||||
React.unstable_withSuspenseConfig(
|
||||
() => ReactNoop.render(<App page="B" />),
|
||||
{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 (
|
||||
<Fragment>
|
||||
<Text text={page} />
|
||||
{isLoading ? <Text text="L" /> : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial render.
|
||||
ReactNoop.render(<App />);
|
||||
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')]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
23
packages/react/src/ReactBatchConfig.js
Normal file
23
packages/react/src/ReactBatchConfig.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
packages/react/src/ReactCurrentBatchConfig.js
Normal file
20
packages/react/src/ReactCurrentBatchConfig.js
Normal file
@@ -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;
|
||||
@@ -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},
|
||||
|
||||
@@ -18,5 +18,10 @@ if (!ReactSharedInternals.hasOwnProperty('ReactCurrentDispatcher')) {
|
||||
current: null,
|
||||
};
|
||||
}
|
||||
if (!ReactSharedInternals.hasOwnProperty('ReactCurrentBatchConfig')) {
|
||||
ReactSharedInternals.ReactCurrentBatchConfig = {
|
||||
suspense: null,
|
||||
};
|
||||
}
|
||||
|
||||
export default ReactSharedInternals;
|
||||
|
||||
Reference in New Issue
Block a user