@samitouri / QOS-React-2 / commits / 9c6de716d0

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

Sebastian Markbåge committed May 16, 2019 at 16:51 UTC 9c6de716d028f17736d0892d8a3d8f3ac2cb62bd
18 files changed +831 -86
packages/react-dom/src/client/ReactDOM.js
+1
@@ -220,6 +220,7 @@ ReactBatch.prototype.render = function(children: ReactNodeList) {
220 internalRoot,
221 null,
222 expirationTime,
223 + null,
224 work._onCommit,
225 );
226 return work;
packages/react-dom/src/fire/ReactFire.js
+1
@@ -226,6 +226,7 @@ ReactBatch.prototype.render = function(children: ReactNodeList) {
226 internalRoot,
227 null,
228 expirationTime,
229 + null,
230 work._onCommit,
231 );
232 return work;
packages/react-reconciler/src/ReactFiberClassComponent.js
+22 -6
@@ -55,6 +55,7 @@ import {
55 flushPassiveEffects,
56 } from './ReactFiberScheduler';
57 import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags';
58 +import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
59
60 const fakeInternalInstance = {};
61 const isArray = Array.isArray;
@@ -184,9 +185,14 @@ const classComponentUpdater = {
185 enqueueSetState(inst, payload, callback) {
186 const fiber = getInstance(inst);
187 const currentTime = requestCurrentTime();
187 - const expirationTime = computeExpirationForFiber(currentTime, fiber);
188 + const suspenseConfig = requestCurrentSuspenseConfig();
189 + const expirationTime = computeExpirationForFiber(
190 + currentTime,
191 + fiber,
192 + suspenseConfig,
193 + );
194
189 - const update = createUpdate(expirationTime);
195 + const update = createUpdate(expirationTime, suspenseConfig);
196 update.payload = payload;
197 if (callback !== undefined && callback !== null) {
198 if (__DEV__) {
@@ -204,9 +210,14 @@ const classComponentUpdater = {
210 enqueueReplaceState(inst, payload, callback) {
211 const fiber = getInstance(inst);
212 const currentTime = requestCurrentTime();
207 - const expirationTime = computeExpirationForFiber(currentTime, fiber);
213 + const suspenseConfig = requestCurrentSuspenseConfig();
214 + const expirationTime = computeExpirationForFiber(
215 + currentTime,
216 + fiber,
217 + suspenseConfig,
218 + );
219
209 - const update = createUpdate(expirationTime);
220 + const update = createUpdate(expirationTime, suspenseConfig);
221 update.tag = ReplaceState;
222 update.payload = payload;
223
@@ -226,9 +237,14 @@ const classComponentUpdater = {
237 enqueueForceUpdate(inst, callback) {
238 const fiber = getInstance(inst);
239 const currentTime = requestCurrentTime();
229 - const expirationTime = computeExpirationForFiber(currentTime, fiber);
240 + const suspenseConfig = requestCurrentSuspenseConfig();
241 + const expirationTime = computeExpirationForFiber(
242 + currentTime,
243 + fiber,
244 + suspenseConfig,
245 + );
246
231 - const update = createUpdate(expirationTime);
247 + const update = createUpdate(expirationTime, suspenseConfig);
248 update.tag = ForceUpdate;
249
250 if (callback !== undefined && callback !== null) {
packages/react-reconciler/src/ReactFiberCompleteWork.js
+31 -4
@@ -19,6 +19,7 @@ import type {
19 } from './ReactFiberHostConfig';
20 import type {ReactEventComponentInstance} from 'shared/ReactTypes';
21 import type {SuspenseState} from './ReactFiberSuspenseComponent';
22 +import type {SuspenseContext} from './ReactFiberSuspenseContext';
23
24 import {
25 IndeterminateComponent,
@@ -77,7 +78,12 @@ import {
78 getHostContext,
79 popHostContainer,
80 } from './ReactFiberHostContext';
80 -import {popSuspenseContext} from './ReactFiberSuspenseContext';
81 +import {
82 + suspenseStackCursor,
83 + InvisibleParentSuspenseContext,
84 + hasSuspenseContext,
85 + popSuspenseContext,
86 +} from './ReactFiberSuspenseContext';
87 import {
88 isContextProvider as isLegacyContextProvider,
89 popContext as popLegacyContext,
@@ -94,7 +100,11 @@ import {
100 enableSuspenseServerRenderer,
101 enableEventAPI,
102 } from 'shared/ReactFeatureFlags';
97 -import {markRenderEventTime, renderDidSuspend} from './ReactFiberScheduler';
103 +import {
104 + markRenderEventTimeAndConfig,
105 + renderDidSuspend,
106 + renderDidSuspendDelayIfPossible,
107 +} from './ReactFiberScheduler';
108 import {getEventComponentHostChildrenCount} from './ReactFiberEvents';
109 import getComponentName from 'shared/getComponentName';
110 import warning from 'shared/warning';
@@ -698,7 +708,7 @@ function completeWork(
708 // was given a normal pri expiration time at the time it was shown.
709 const fallbackExpirationTime: ExpirationTime =
710 prevState.fallbackExpirationTime;
701 - markRenderEventTime(fallbackExpirationTime);
711 + markRenderEventTimeAndConfig(fallbackExpirationTime, null);
712
713 // Delete the fallback.
714 // TODO: Would it be better to store the fallback fragment on
@@ -727,7 +737,24 @@ function completeWork(
737 // in the concurrent tree already suspended during this render.
738 // This is a known bug.
739 if ((workInProgress.mode & BatchedMode) !== NoMode) {
730 - renderDidSuspend();
740 + const hasInvisibleChildContext =
741 + current === null &&
742 + workInProgress.memoizedProps.unstable_avoidThisFallback !== true;
743 + if (
744 + hasInvisibleChildContext ||
745 + hasSuspenseContext(
746 + suspenseStackCursor.current,
747 + (InvisibleParentSuspenseContext: SuspenseContext),
748 + )
749 + ) {
750 + // If this was in an invisible tree or a new render, then showing
751 + // this boundary is ok.
752 + renderDidSuspend();
753 + } else {
754 + // Otherwise, we're going to have to hide content so we should
755 + // suspend for longer if possible.
756 + renderDidSuspendDelayIfPossible();
757 + }
758 }
759 }
760
packages/react-reconciler/src/ReactFiberExpirationTime.js
+12
@@ -71,6 +71,18 @@ export function computeAsyncExpiration(
71 );
72 }
73
74 +export function computeSuspenseExpiration(
75 + currentTime: ExpirationTime,
76 + timeoutMs: number,
77 +): ExpirationTime {
78 + // TODO: Should we warn if timeoutMs is lower than the normal pri expiration time?
79 + return computeExpirationBucket(
80 + currentTime,
81 + timeoutMs,
82 + LOW_PRIORITY_BATCH_SIZE,
83 + );
84 +}
85 +
86 // Same as computeAsyncExpiration but without the bucketing logic. This is
87 // used to compute timestamps instead of actual expiration times.
88 export function computeAsyncExpirationNoBucket(
packages/react-reconciler/src/ReactFiberHooks.js
+16 -3
@@ -12,6 +12,7 @@ import type {SideEffectTag} from 'shared/ReactSideEffectTags';
12 import type {Fiber} from './ReactFiber';
13 import type {ExpirationTime} from './ReactFiberExpirationTime';
14 import type {HookEffectTag} from './ReactHookEffectTags';
15 +import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
16
17 import ReactSharedInternals from 'shared/ReactSharedInternals';
18
@@ -34,7 +35,7 @@ import {
35 flushPassiveEffects,
36 requestCurrentTime,
37 warnIfNotCurrentlyActingUpdatesInDev,
37 - markRenderEventTime,
38 + markRenderEventTimeAndConfig,
39 } from './ReactFiberScheduler';
40
41 import invariant from 'shared/invariant';
@@ -43,6 +44,7 @@ import getComponentName from 'shared/getComponentName';
44 import is from 'shared/objectIs';
45 import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
46 import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags';
47 +import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
48
49 const {ReactCurrentDispatcher} = ReactSharedInternals;
50
@@ -82,6 +84,7 @@ export type Dispatcher = {
84
85 type Update<S, A> = {
86 expirationTime: ExpirationTime,
87 + suspenseConfig: null | SuspenseConfig,
88 action: A,
89 eagerReducer: ((S, A) => S) | null,
90 eagerState: S | null,
@@ -728,7 +731,10 @@ function updateReducer<S, I, A>(
731 // TODO: We should skip this update if it was already committed but currently
732 // we have no way of detecting the difference between a committed and suspended
733 // update here.
731 - markRenderEventTime(updateExpirationTime);
734 + markRenderEventTimeAndConfig(
735 + updateExpirationTime,
736 + update.suspenseConfig,
737 + );
738
739 // Process this update.
740 if (update.eagerReducer === reducer) {
@@ -1089,6 +1095,7 @@ function dispatchAction<S, A>(
1095 didScheduleRenderPhaseUpdate = true;
1096 const update: Update<S, A> = {
1097 expirationTime: renderExpirationTime,
1098 + suspenseConfig: null,
1099 action,
1100 eagerReducer: null,
1101 eagerState: null,
@@ -1114,10 +1121,16 @@ function dispatchAction<S, A>(
1121 }
1122
1123 const currentTime = requestCurrentTime();
1117 - const expirationTime = computeExpirationForFiber(currentTime, fiber);
1124 + const suspenseConfig = requestCurrentSuspenseConfig();
1125 + const expirationTime = computeExpirationForFiber(
1126 + currentTime,
1127 + fiber,
1128 + suspenseConfig,
1129 + );
1130
1131 const update: Update<S, A> = {
1132 expirationTime,
1133 + suspenseConfig,
1134 action,
1135 eagerReducer: null,
1136 eagerState: null,
packages/react-reconciler/src/ReactFiberNewContext.js
+1 -1
@@ -216,7 +216,7 @@ export function propagateContextChange(
216
217 if (fiber.tag === ClassComponent) {
218 // Schedule a force update on the work-in-progress.
219 - const update = createUpdate(renderExpirationTime);
219 + const update = createUpdate(renderExpirationTime, null);
220 update.tag = ForceUpdate;
221 // TODO: Because we don't have a work-in-progress, this will add the
222 // update to the current fiber, too, which means it will persist even if
packages/react-reconciler/src/ReactFiberReconciler.js
+19 -3
@@ -18,6 +18,7 @@ import type {
18 } from './ReactFiberHostConfig';
19 import type {ReactNodeList} from 'shared/ReactTypes';
20 import type {ExpirationTime} from './ReactFiberExpirationTime';
21 +import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
22
23 import {
24 findCurrentHostFiber,
@@ -65,6 +66,7 @@ import {
66 import {StrictMode} from './ReactTypeOfMode';
67 import {Sync} from './ReactFiberExpirationTime';
68 import {revertPassiveEffectsChange} from 'shared/ReactFeatureFlags';
69 +import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
70
71 type OpaqueRoot = FiberRoot;
72
@@ -117,6 +119,7 @@ function scheduleRootUpdate(
119 current: Fiber,
120 element: ReactNodeList,
121 expirationTime: ExpirationTime,
122 + suspenseConfig: null | SuspenseConfig,
123 callback: ?Function,
124 ) {
125 if (__DEV__) {
@@ -137,7 +140,7 @@ function scheduleRootUpdate(
140 }
141 }
142
140 - const update = createUpdate(expirationTime);
143 + const update = createUpdate(expirationTime, suspenseConfig);
144 // Caution: React DevTools currently depends on this property
145 // being called "element".
146 update.payload = {element};
@@ -167,6 +170,7 @@ export function updateContainerAtExpirationTime(
170 container: OpaqueRoot,
171 parentComponent: ?React$Component<any, any>,
172 expirationTime: ExpirationTime,
173 + suspenseConfig: null | SuspenseConfig,
174 callback: ?Function,
175 ) {
176 // TODO: If this is a nested container, this won't be the root.
@@ -191,7 +195,13 @@ export function updateContainerAtExpirationTime(
195 container.pendingContext = context;
196 }
197
194 - return scheduleRootUpdate(current, element, expirationTime, callback);
198 + return scheduleRootUpdate(
199 + current,
200 + element,
201 + expirationTime,
202 + suspenseConfig,
203 + callback,
204 + );
205 }
206
207 function findHostInstance(component: Object): PublicInstance | null {
@@ -291,12 +301,18 @@ export function updateContainer(
301 ): ExpirationTime {
302 const current = container.current;
303 const currentTime = requestCurrentTime();
294 - const expirationTime = computeExpirationForFiber(currentTime, current);
304 + const suspenseConfig = requestCurrentSuspenseConfig();
305 + const expirationTime = computeExpirationForFiber(
306 + currentTime,
307 + current,
308 + suspenseConfig,
309 + );
310 return updateContainerAtExpirationTime(
311 element,
312 container,
313 parentComponent,
314 expirationTime,
315 + suspenseConfig,
316 callback,
317 );
318 }
packages/react-reconciler/src/ReactFiberScheduler.js
+159 -39
@@ -15,6 +15,7 @@ import type {
15 SchedulerCallback,
16 } from './SchedulerWithReactIntegration';
17 import type {Interaction} from 'scheduler/src/Tracing';
18 +import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
19
20 import {
21 warnAboutDeprecatedLifecycles,
@@ -96,6 +97,7 @@ import {
97 expirationTimeToMs,
98 computeInteractiveExpiration,
99 computeAsyncExpiration,
100 + computeSuspenseExpiration,
101 inferPriorityFromExpirationTime,
102 LOW_PRIORITY_EXPIRATION,
103 Batched,
@@ -184,11 +186,12 @@ const FlushSyncPhase = 3;
186 const RenderPhase = 4;
187 const CommitPhase = 5;
188
187 -type RootExitStatus = 0 | 1 | 2 | 3;
189 +type RootExitStatus = 0 | 1 | 2 | 3 | 4;
190 const RootIncomplete = 0;
191 const RootErrored = 1;
192 const RootSuspended = 2;
191 -const RootCompleted = 3;
193 +const RootSuspendedWithDelay = 3;
194 +const RootCompleted = 4;
195
196 export type Thenable = {
197 then(resolve: () => mixed, reject?: () => mixed): Thenable | void,
@@ -208,7 +211,9 @@ let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
211 // This is conceptually a time stamp but expressed in terms of an ExpirationTime
212 // because we deal mostly with expiration times in the hot path, so this avoids
213 // the conversion happening in the hot path.
211 -let workInProgressRootMostRecentEventTime: ExpirationTime = Sync;
214 +let workInProgressRootLatestProcessedExpirationTime: ExpirationTime = Sync;
215 +let workInProgressRootLatestSuspenseTimeout: ExpirationTime = Sync;
216 +let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
217
218 let nextEffect: Fiber | null = null;
219 let hasUncaughtError = false;
@@ -262,6 +267,7 @@ export function requestCurrentTime() {
267 export function computeExpirationForFiber(
268 currentTime: ExpirationTime,
269 fiber: Fiber,
270 + suspenseConfig: null | SuspenseConfig,
271 ): ExpirationTime {
272 const mode = fiber.mode;
273 if ((mode & BatchedMode) === NoMode) {
@@ -278,26 +284,34 @@ export function computeExpirationForFiber(
284 return renderExpirationTime;
285 }
286
281 - // Compute an expiration time based on the Scheduler priority.
287 let expirationTime;
283 - switch (priorityLevel) {
284 - case ImmediatePriority:
285 - expirationTime = Sync;
286 - break;
287 - case UserBlockingPriority:
288 - // TODO: Rename this to computeUserBlockingExpiration
289 - expirationTime = computeInteractiveExpiration(currentTime);
290 - break;
291 - case NormalPriority:
292 - case LowPriority: // TODO: Handle LowPriority
293 - // TODO: Rename this to... something better.
294 - expirationTime = computeAsyncExpiration(currentTime);
295 - break;
296 - case IdlePriority:
297 - expirationTime = Never;
298 - break;
299 - default:
300 - invariant(false, 'Expected a valid priority level');
288 + if (suspenseConfig !== null) {
289 + // Compute an expiration time based on the Suspense timeout.
290 + expirationTime = computeSuspenseExpiration(
291 + currentTime,
292 + suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION,
293 + );
294 + } else {
295 + // Compute an expiration time based on the Scheduler priority.
296 + switch (priorityLevel) {
297 + case ImmediatePriority:
298 + expirationTime = Sync;
299 + break;
300 + case UserBlockingPriority:
301 + // TODO: Rename this to computeUserBlockingExpiration
302 + expirationTime = computeInteractiveExpiration(currentTime);
303 + break;
304 + case NormalPriority:
305 + case LowPriority: // TODO: Handle LowPriority
306 + // TODO: Rename this to... something better.
307 + expirationTime = computeAsyncExpiration(currentTime);
308 + break;
309 + case IdlePriority:
310 + expirationTime = Never;
311 + break;
312 + default:
313 + invariant(false, 'Expected a valid priority level');
314 + }
315 }
316
317 // If we're in the middle of rendering a tree, do not update at the same
@@ -720,7 +734,9 @@ function prepareFreshStack(root, expirationTime) {
734 workInProgress = createWorkInProgress(root.current, null, expirationTime);
735 renderExpirationTime = expirationTime;
736 workInProgressRootExitStatus = RootIncomplete;
723 - workInProgressRootMostRecentEventTime = Sync;
737 + workInProgressRootLatestProcessedExpirationTime = Sync;
738 + workInProgressRootLatestSuspenseTimeout = Sync;
739 + workInProgressRootCanSuspendUsingConfig = null;
740
741 if (__DEV__) {
742 ReactStrictModeWarnings.discardPendingWarnings();
@@ -918,7 +934,8 @@ function renderRoot(
934 // errored state.
935 return commitRoot.bind(null, root);
936 }
921 - case RootSuspended: {
937 + case RootSuspended:
938 + case RootSuspendedWithDelay: {
939 if (!isSync) {
940 const lastPendingTime = root.lastPendingTime;
941 if (root.lastPendingTime < expirationTime) {
@@ -926,13 +943,18 @@ function renderRoot(
943 // at that level.
944 return renderRoot.bind(null, root, lastPendingTime);
945 }
929 - // If workInProgressRootMostRecentEventTime is Sync, that means we didn't
946 + // If workInProgressRootLatestProcessedExpirationTime is Sync, that means we didn't
947 // track any event times. That can happen if we retried but nothing switched
948 // from fallback to content. There's no reason to delay doing no work.
932 - if (workInProgressRootMostRecentEventTime !== Sync) {
949 + if (workInProgressRootLatestProcessedExpirationTime !== Sync) {
950 + let shouldDelay =
951 + workInProgressRootExitStatus === RootSuspendedWithDelay;
952 let msUntilTimeout = computeMsUntilTimeout(
934 - workInProgressRootMostRecentEventTime,
953 + workInProgressRootLatestProcessedExpirationTime,
954 + workInProgressRootLatestSuspenseTimeout,
955 expirationTime,
956 + workInProgressRootCanSuspendUsingConfig,
957 + shouldDelay,
958 );
959 // Don't bother with a very short suspense time.
960 if (msUntilTimeout > 10) {
@@ -952,6 +974,27 @@ function renderRoot(
974 }
975 case RootCompleted: {
976 // The work completed. Ready to commit.
977 + if (
978 + !isSync &&
979 + workInProgressRootLatestProcessedExpirationTime !== Sync &&
980 + workInProgressRootCanSuspendUsingConfig !== null
981 + ) {
982 + // If we have exceeded the minimum loading delay, which probably
983 + // means we have shown a spinner already, we might have to suspend
984 + // a bit longer to ensure that the spinner is shown for enough time.
985 + const msUntilTimeout = computeMsUntilSuspenseLoadingDelay(
986 + workInProgressRootLatestProcessedExpirationTime,
987 + expirationTime,
988 + workInProgressRootCanSuspendUsingConfig,
989 + );
990 + if (msUntilTimeout > 10) {
991 + root.timeoutHandle = scheduleTimeout(
992 + commitRoot.bind(null, root),
993 + msUntilTimeout,
994 + );
995 + return null;
996 + }
997 + }
998 return commitRoot.bind(null, root);
999 }
1000 default: {
@@ -960,12 +1003,25 @@ function renderRoot(
1003 }
1004 }
1005
963 -export function markRenderEventTime(expirationTime: ExpirationTime): void {
1006 +export function markRenderEventTimeAndConfig(
1007 + expirationTime: ExpirationTime,
1008 + suspenseConfig: null | SuspenseConfig,
1009 +): void {
1010 if (
965 - expirationTime < workInProgressRootMostRecentEventTime &&
1011 + expirationTime < workInProgressRootLatestProcessedExpirationTime &&
1012 expirationTime > Never
1013 ) {
968 - workInProgressRootMostRecentEventTime = expirationTime;
1014 + workInProgressRootLatestProcessedExpirationTime = expirationTime;
1015 + }
1016 + if (suspenseConfig !== null) {
1017 + if (
1018 + expirationTime < workInProgressRootLatestSuspenseTimeout &&
1019 + expirationTime > Never
1020 + ) {
1021 + workInProgressRootLatestSuspenseTimeout = expirationTime;
1022 + // Most of the time we only have one config and getting wrong is not bad.
1023 + workInProgressRootCanSuspendUsingConfig = suspenseConfig;
1024 + }
1025 }
1026 }
1027
@@ -975,20 +1031,34 @@ export function renderDidSuspend(): void {
1031 }
1032 }
1033
978 -export function renderDidError() {
1034 +export function renderDidSuspendDelayIfPossible(): void {
1035 if (
1036 workInProgressRootExitStatus === RootIncomplete ||
1037 workInProgressRootExitStatus === RootSuspended
1038 ) {
1039 + workInProgressRootExitStatus = RootSuspendedWithDelay;
1040 + }
1041 +}
1042 +
1043 +export function renderDidError() {
1044 + if (workInProgressRootExitStatus !== RootCompleted) {
1045 workInProgressRootExitStatus = RootErrored;
1046 }
1047 }
1048
987 -function inferTimeFromExpirationTime(expirationTime: ExpirationTime): number {
1049 +function inferTimeFromExpirationTime(
1050 + expirationTime: ExpirationTime,
1051 + suspenseConfig: null | SuspenseConfig,
1052 +): number {
1053 // We don't know exactly when the update was scheduled, but we can infer an
1054 // approximate start time from the expiration time.
1055 const earliestExpirationTimeMs = expirationTimeToMs(expirationTime);
991 - return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION;
1056 + return (
1057 + earliestExpirationTimeMs -
1058 + (suspenseConfig !== null
1059 + ? suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION
1060 + : LOW_PRIORITY_EXPIRATION)
1061 + );
1062 }
1063
1064 function workLoopSync() {
@@ -1834,7 +1904,12 @@ export function retryTimedOutBoundary(boundaryFiber: Fiber) {
1904 // resolved, which means at least part of the tree was likely unblocked. Try
1905 // rendering again, at a new expiration time.
1906 const currentTime = requestCurrentTime();
1837 - const retryTime = computeExpirationForFiber(currentTime, boundaryFiber);
1907 + const suspenseConfig = null; // Retries don't carry over the already committed update.
1908 + const retryTime = computeExpirationForFiber(
1909 + currentTime,
1910 + boundaryFiber,
1911 + suspenseConfig,
1912 + );
1913 // TODO: Special case idle priority?
1914 const priorityLevel = inferPriorityFromExpirationTime(currentTime, retryTime);
1915 const root = markUpdateTimeFromFiberToRoot(boundaryFiber, retryTime);
@@ -1898,17 +1973,66 @@ function jnd(timeElapsed: number) {
1973 : ceil(timeElapsed / 1960) * 1960;
1974 }
1975
1976 +function computeMsUntilSuspenseLoadingDelay(
1977 + mostRecentEventTime: ExpirationTime,
1978 + committedExpirationTime: ExpirationTime,
1979 + suspenseConfig: SuspenseConfig,
1980 +) {
1981 + if (disableYielding) {
1982 + // Timeout immediately when yielding is disabled.
1983 + return 0;
1984 + }
1985 +
1986 + const minLoadingDurationMs = (suspenseConfig.minLoadingDurationMs: any) | 0;
1987 + if (minLoadingDurationMs <= 0) {
1988 + return 0;
1989 + }
1990 + const loadingDelayMs = (suspenseConfig.loadingDelayMs: any) | 0;
1991 +
1992 + // Compute the time until this render pass would expire.
1993 + const currentTimeMs: number = now();
1994 + const eventTimeMs: number = inferTimeFromExpirationTime(
1995 + mostRecentEventTime,
1996 + suspenseConfig,
1997 + );
1998 + const timeElapsed = currentTimeMs - eventTimeMs;
1999 + if (timeElapsed <= loadingDelayMs) {
2000 + // If we haven't yet waited longer than the initial delay, we don't
2001 + // have to wait any additional time.
2002 + return 0;
2003 + }
2004 + const msUntilTimeout = loadingDelayMs + minLoadingDurationMs - timeElapsed;
2005 + // This is the value that is passed to `setTimeout`.
2006 + return msUntilTimeout;
2007 +}
2008 +
2009 function computeMsUntilTimeout(
2010 mostRecentEventTime: ExpirationTime,
2011 + suspenseTimeout: ExpirationTime,
2012 committedExpirationTime: ExpirationTime,
2013 + suspenseConfig: null | SuspenseConfig,
2014 + shouldDelay: boolean,
2015 ) {
2016 if (disableYielding) {
2017 // Timeout immediately when yielding is disabled.
2018 return 0;
2019 }
2020
1910 - const eventTimeMs: number = inferTimeFromExpirationTime(mostRecentEventTime);
2021 + // Compute the time until this render pass would expire.
2022 const currentTimeMs: number = now();
2023 +
2024 + if (suspenseTimeout !== Sync && shouldDelay) {
2025 + const timeUntilTimeoutMs =
2026 + expirationTimeToMs(suspenseTimeout) - currentTimeMs;
2027 + return timeUntilTimeoutMs;
2028 + }
2029 +
2030 + const eventTimeMs: number = inferTimeFromExpirationTime(
2031 + mostRecentEventTime,
2032 + suspenseConfig,
2033 + );
2034 + const timeUntilExpirationMs =
2035 + expirationTimeToMs(committedExpirationTime) - currentTimeMs;
2036 let timeElapsed = currentTimeMs - eventTimeMs;
2037 if (timeElapsed < 0) {
2038 // We get this wrong some time since we estimate the time.
@@ -1917,10 +2041,6 @@ function computeMsUntilTimeout(
2041
2042 let msUntilTimeout = jnd(timeElapsed) - timeElapsed;
2043
1920 - // Compute the time until this render pass would expire.
1921 - const timeUntilExpirationMs =
1922 - expirationTimeToMs(committedExpirationTime) - currentTimeMs;
1923 -
2044 // Clamp the timeout to the expiration time.
2045 // TODO: Once the event time is exact instead of inferred from expiration time
2046 // we don't need this.
packages/react-reconciler/src/ReactFiberSuspenseConfig.js new
+22
@@ -0,0 +1,22 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import ReactSharedInternals from 'shared/ReactSharedInternals';
11 +
12 +const {ReactCurrentBatchConfig} = ReactSharedInternals;
13 +
14 +export type SuspenseConfig = {|
15 + timeoutMs: number,
16 + loadingDelayMs?: number,
17 + minLoadingDurationMs?: number,
18 +|};
19 +
20 +export function requestCurrentSuspenseConfig(): null | SuspenseConfig {
21 + return ReactCurrentBatchConfig.suspense;
22 +}
packages/react-reconciler/src/ReactFiberUnwindWork.js
+3 -9
@@ -90,7 +90,7 @@ function createRootErrorUpdate(
90 errorInfo: CapturedValue<mixed>,
91 expirationTime: ExpirationTime,
92 ): Update<mixed> {
93 - const update = createUpdate(expirationTime);
93 + const update = createUpdate(expirationTime, null);
94 // Unmount the root by rendering null.
95 update.tag = CaptureUpdate;
96 // Caution: React DevTools currently depends on this property
@@ -109,7 +109,7 @@ function createClassErrorUpdate(
109 errorInfo: CapturedValue<mixed>,
110 expirationTime: ExpirationTime,
111 ): Update<mixed> {
112 - const update = createUpdate(expirationTime);
112 + const update = createUpdate(expirationTime, null);
113 update.tag = CaptureUpdate;
114 const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
115 if (typeof getDerivedStateFromError === 'function') {
@@ -265,7 +265,7 @@ function throwException(
265 // When we try rendering again, we should not reuse the current fiber,
266 // since it's known to be in an inconsistent state. Use a force updte to
267 // prevent a bail out.
268 - const update = createUpdate(Sync);
268 + const update = createUpdate(Sync, null);
269 update.tag = ForceUpdate;
270 enqueueUpdate(sourceFiber, update);
271 }
@@ -287,12 +287,6 @@ function throwException(
287 workInProgress.effectTag |= ShouldCapture;
288 workInProgress.expirationTime = renderExpirationTime;
289
290 - if (!hasInvisibleParentBoundary) {
291 - // TODO: If we're not in an invisible subtree, then we need to mark this render
292 - // pass as needing to suspend for longer to avoid showing this fallback state.
293 - // We could do it here or when we render the fallback.
294 - }
295 -
290 return;
291 } else if (
292 enableSuspenseServerRenderer &&
packages/react-reconciler/src/ReactUpdateQueue.js
+10 -4
@@ -86,6 +86,7 @@
86
87 import type {Fiber} from './ReactFiber';
88 import type {ExpirationTime} from './ReactFiberExpirationTime';
89 +import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
90
91 import {NoWork} from './ReactFiberExpirationTime';
92 import {
@@ -101,13 +102,14 @@ import {
102 } from 'shared/ReactFeatureFlags';
103
104 import {StrictMode} from './ReactTypeOfMode';
104 -import {markRenderEventTime} from './ReactFiberScheduler';
105 +import {markRenderEventTimeAndConfig} from './ReactFiberScheduler';
106
107 import invariant from 'shared/invariant';
108 import warningWithoutStack from 'shared/warningWithoutStack';
109
110 export type Update<State> = {
111 expirationTime: ExpirationTime,
112 + suspenseConfig: null | SuspenseConfig,
113
114 tag: 0 | 1 | 2 | 3,
115 payload: any,
@@ -191,9 +193,13 @@ function cloneUpdateQueue<State>(
193 return queue;
194 }
195
194 -export function createUpdate(expirationTime: ExpirationTime): Update<*> {
196 +export function createUpdate(
197 + expirationTime: ExpirationTime,
198 + suspenseConfig: null | SuspenseConfig,
199 +): Update<*> {
200 return {
196 - expirationTime: expirationTime,
201 + expirationTime,
202 + suspenseConfig,
203
204 tag: UpdateState,
205 payload: null,
@@ -463,7 +469,7 @@ export function processUpdateQueue<State>(
469 // TODO: We should skip this update if it was already committed but currently
470 // we have no way of detecting the difference between a committed and suspended
471 // update here.
466 - markRenderEventTime(updateExpirationTime);
472 + markRenderEventTimeAndConfig(updateExpirationTime, update.suspenseConfig);
473
474 // Process it and compute a new result.
475 resultState = getStateFromUpdate(
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+481 -17
@@ -726,13 +726,12 @@ describe('ReactSuspenseWithNoopRenderer', () => {
726 expect(ReactNoop.getChildren()).toEqual([span('Async')]);
727 });
728
729 - // TODO: This cannot be tested until we have a way to long-suspend navigations.
730 - it.skip('starts working on an update even if its priority falls between two suspended levels', async () => {
729 + it('starts working on an update even if its priority falls between two suspended levels', async () => {
730 function App(props) {
731 return (
732 <Suspense fallback={<Text text="Loading..." />}>
734 - {props.text === 'C' ? (
735 - <Text text="C" />
733 + {props.text === 'C' || props.text === 'S' ? (
734 + <Text text={props.text} />
735 ) : (
736 <AsyncText text={props.text} ms={10000} />
737 )}
@@ -740,30 +739,42 @@ describe('ReactSuspenseWithNoopRenderer', () => {
739 );
740 }
741
743 - // Schedule an update
744 - ReactNoop.render(<App text="A" />);
742 + // First mount without suspending. This ensures we already have content
743 + // showing so that subsequent updates will suspend.
744 + ReactNoop.render(<App text="S" />);
745 + expect(Scheduler).toFlushAndYield(['S']);
746 +
747 + // Schedule an update, and suspend for up to 5 seconds.
748 + React.unstable_withSuspenseConfig(
749 + () => ReactNoop.render(<App text="A" />),
750 + {
751 + timeoutMs: 5000,
752 + },
753 + );
754 // The update should suspend.
755 expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
747 - expect(ReactNoop.getChildren()).toEqual([]);
756 + expect(ReactNoop.getChildren()).toEqual([span('S')]);
757
749 - // Advance time until right before it expires. This number may need to
750 - // change if the default expiration for low priority updates is adjusted.
758 + // Advance time until right before it expires.
759 await advanceTimers(4999);
760 ReactNoop.expire(4999);
761 expect(Scheduler).toFlushWithoutYielding();
754 - expect(ReactNoop.getChildren()).toEqual([]);
762 + expect(ReactNoop.getChildren()).toEqual([span('S')]);
763
764 // Schedule another low priority update.
757 - ReactNoop.render(<App text="B" />);
765 + React.unstable_withSuspenseConfig(
766 + () => ReactNoop.render(<App text="B" />),
767 + {
768 + timeoutMs: 10000,
769 + },
770 + );
771 // This update should also suspend.
772 expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']);
760 - expect(ReactNoop.getChildren()).toEqual([]);
773 + expect(ReactNoop.getChildren()).toEqual([span('S')]);
774
762 - // Schedule a high priority update. Its expiration time will fall between
775 + // Schedule a regular update. Its expiration time will fall between
776 // the expiration times of the previous two updates.
764 - ReactNoop.interactiveUpdates(() => {
765 - ReactNoop.render(<App text="C" />);
766 - });
777 + ReactNoop.render(<App text="C" />);
778 expect(Scheduler).toFlushAndYield(['C']);
779 expect(ReactNoop.getChildren()).toEqual([span('C')]);
780
@@ -1660,7 +1671,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1671 ReactNoop.render(<Foo />);
1672
1673 // Took a long time to render. This is to ensure we get a long suspense time.
1663 - // Could also use something like suspendIfNeeded to simulate this.
1674 + // Could also use something like withSuspenseConfig to simulate this.
1675 Scheduler.advanceTime(1500);
1676 await advanceTimers(1500);
1677
@@ -1690,4 +1701,457 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1701
1702 expect(ReactNoop.getChildren()).toEqual([span('Loading A...')]);
1703 });
1704 +
1705 + describe('delays transitions when there a suspense config is supplied', () => {
1706 + const SUSPENSE_CONFIG = {
1707 + timeoutMs: 2000,
1708 + };
1709 +
1710 + it('top level render', async () => {
1711 + function App({page}) {
1712 + return (
1713 + <Suspense fallback={<Text text="Loading..." />}>
1714 + <AsyncText text={page} ms={5000} />
1715 + </Suspense>
1716 + );
1717 + }
1718 +
1719 + // Initial render.
1720 + React.unstable_withSuspenseConfig(
1721 + () => ReactNoop.render(<App page="A" />),
1722 + SUSPENSE_CONFIG,
1723 + );
1724 +
1725 + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
1726 + // Only a short time is needed to unsuspend the initial loading state.
1727 + Scheduler.advanceTime(400);
1728 + await advanceTimers(400);
1729 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1730 +
1731 + // Later we load the data.
1732 + Scheduler.advanceTime(5000);
1733 + await advanceTimers(5000);
1734 + expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
1735 + expect(Scheduler).toFlushAndYield(['A']);
1736 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
1737 +
1738 + // Start transition.
1739 + React.unstable_withSuspenseConfig(
1740 + () => ReactNoop.render(<App page="B" />),
1741 + SUSPENSE_CONFIG,
1742 + );
1743 +
1744 + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']);
1745 + Scheduler.advanceTime(1000);
1746 + await advanceTimers(1000);
1747 + // Even after a second, we have still not yet flushed the loading state.
1748 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
1749 + Scheduler.advanceTime(1100);
1750 + await advanceTimers(1100);
1751 + // After the timeout, we do show the loading state.
1752 + expect(ReactNoop.getChildren()).toEqual([
1753 + hiddenSpan('A'),
1754 + span('Loading...'),
1755 + ]);
1756 + // Later we load the data.
1757 + Scheduler.advanceTime(3000);
1758 + await advanceTimers(3000);
1759 + expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1760 + expect(Scheduler).toFlushAndYield(['B']);
1761 + expect(ReactNoop.getChildren()).toEqual([span('B')]);
1762 + });
1763 +
1764 + it('hooks', async () => {
1765 + let transitionToPage;
1766 + function App() {
1767 + let [page, setPage] = React.useState('none');
1768 + transitionToPage = setPage;
1769 + if (page === 'none') {
1770 + return null;
1771 + }
1772 + return (
1773 + <Suspense fallback={<Text text="Loading..." />}>
1774 + <AsyncText text={page} ms={5000} />
1775 + </Suspense>
1776 + );
1777 + }
1778 +
1779 + ReactNoop.render(<App />);
1780 + expect(Scheduler).toFlushAndYield([]);
1781 +
1782 + // Initial render.
1783 + await ReactNoop.act(async () => {
1784 + React.unstable_withSuspenseConfig(
1785 + () => transitionToPage('A'),
1786 + SUSPENSE_CONFIG,
1787 + );
1788 +
1789 + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
1790 + // Only a short time is needed to unsuspend the initial loading state.
1791 + Scheduler.advanceTime(400);
1792 + await advanceTimers(400);
1793 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1794 + });
1795 +
1796 + // Later we load the data.
1797 + Scheduler.advanceTime(5000);
1798 + await advanceTimers(5000);
1799 + expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
1800 + expect(Scheduler).toFlushAndYield(['A']);
1801 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
1802 +
1803 + // Start transition.
1804 + await ReactNoop.act(async () => {
1805 + React.unstable_withSuspenseConfig(
1806 + () => transitionToPage('B'),
1807 + SUSPENSE_CONFIG,
1808 + );
1809 +
1810 + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']);
1811 + Scheduler.advanceTime(1000);
1812 + await advanceTimers(1000);
1813 + // Even after a second, we have still not yet flushed the loading state.
1814 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
1815 + Scheduler.advanceTime(1100);
1816 + await advanceTimers(1100);
1817 + // After the timeout, we do show the loading state.
1818 + expect(ReactNoop.getChildren()).toEqual([
1819 + hiddenSpan('A'),
1820 + span('Loading...'),
1821 + ]);
1822 + });
1823 + // Later we load the data.
1824 + Scheduler.advanceTime(3000);
1825 + await advanceTimers(3000);
1826 + expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1827 + expect(Scheduler).toFlushAndYield(['B']);
1828 + expect(ReactNoop.getChildren()).toEqual([span('B')]);
1829 + });
1830 +
1831 + it('classes', async () => {
1832 + let transitionToPage;
1833 + class App extends React.Component {
1834 + state = {page: 'none'};
1835 + render() {
1836 + transitionToPage = page => this.setState({page});
1837 + let page = this.state.page;
1838 + if (page === 'none') {
1839 + return null;
1840 + }
1841 + return (
1842 + <Suspense fallback={<Text text="Loading..." />}>
1843 + <AsyncText text={page} ms={5000} />
1844 + </Suspense>
1845 + );
1846 + }
1847 + }
1848 +
1849 + ReactNoop.render(<App />);
1850 + expect(Scheduler).toFlushAndYield([]);
1851 +
1852 + // Initial render.
1853 + await ReactNoop.act(async () => {
1854 + React.unstable_withSuspenseConfig(
1855 + () => transitionToPage('A'),
1856 + SUSPENSE_CONFIG,
1857 + );
1858 +
1859 + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
1860 + // Only a short time is needed to unsuspend the initial loading state.
1861 + Scheduler.advanceTime(400);
1862 + await advanceTimers(400);
1863 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1864 + });
1865 +
1866 + // Later we load the data.
1867 + Scheduler.advanceTime(5000);
1868 + await advanceTimers(5000);
1869 + expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
1870 + expect(Scheduler).toFlushAndYield(['A']);
1871 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
1872 +
1873 + // Start transition.
1874 + await ReactNoop.act(async () => {
1875 + React.unstable_withSuspenseConfig(
1876 + () => transitionToPage('B'),
1877 + SUSPENSE_CONFIG,
1878 + );
1879 +
1880 + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']);
1881 + Scheduler.advanceTime(1000);
1882 + await advanceTimers(1000);
1883 + // Even after a second, we have still not yet flushed the loading state.
1884 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
1885 + Scheduler.advanceTime(1100);
1886 + await advanceTimers(1100);
1887 + // After the timeout, we do show the loading state.
1888 + expect(ReactNoop.getChildren()).toEqual([
1889 + hiddenSpan('A'),
1890 + span('Loading...'),
1891 + ]);
1892 + });
1893 + // Later we load the data.
1894 + Scheduler.advanceTime(3000);
1895 + await advanceTimers(3000);
1896 + expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1897 + expect(Scheduler).toFlushAndYield(['B']);
1898 + expect(ReactNoop.getChildren()).toEqual([span('B')]);
1899 + });
1900 + });
1901 +
1902 + it('disables suspense config when nothing is passed to withSuspenseConfig', async () => {
1903 + function App({page}) {
1904 + return (
1905 + <Suspense fallback={<Text text="Loading..." />}>
1906 + <AsyncText text={page} ms={2000} />
1907 + </Suspense>
1908 + );
1909 + }
1910 +
1911 + // Initial render.
1912 + ReactNoop.render(<App page="A" />);
1913 + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
1914 + Scheduler.advanceTime(2000);
1915 + await advanceTimers(2000);
1916 + expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
1917 + expect(Scheduler).toFlushAndYield(['A']);
1918 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
1919 +
1920 + // Start transition.
1921 + React.unstable_withSuspenseConfig(
1922 + () => {
1923 + // When we schedule an inner transition without a suspense config
1924 + // so it should only suspend for a short time.
1925 + React.unstable_withSuspenseConfig(() =>
1926 + ReactNoop.render(<App page="B" />),
1927 + );
1928 + },
1929 + {timeoutMs: 2000},
1930 + );
1931 +
1932 + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']);
1933 + // Suspended
1934 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
1935 + Scheduler.advanceTime(500);
1936 + await advanceTimers(500);
1937 + // Committed loading state.
1938 + expect(ReactNoop.getChildren()).toEqual([
1939 + hiddenSpan('A'),
1940 + span('Loading...'),
1941 + ]);
1942 +
1943 + Scheduler.advanceTime(2000);
1944 + await advanceTimers(2000);
1945 + expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1946 + expect(Scheduler).toFlushAndYield(['B']);
1947 + expect(ReactNoop.getChildren()).toEqual([span('B')]);
1948 +
1949 + React.unstable_withSuspenseConfig(
1950 + () => {
1951 + // First we schedule an inner unrelated update.
1952 + React.unstable_withSuspenseConfig(() =>
1953 + ReactNoop.render(<App page="B" unrelated={true} />),
1954 + );
1955 + // Then we schedule another transition to a slow page,
1956 + // but at this scope we should suspend for longer.
1957 + Scheduler.unstable_next(() => ReactNoop.render(<App page="C" />));
1958 + },
1959 + {timeoutMs: 2000},
1960 + );
1961 + expect(Scheduler).toFlushAndYield([
1962 + 'Suspend! [C]',
1963 + 'Loading...',
1964 + 'Suspend! [C]',
1965 + 'Loading...',
1966 + ]);
1967 + expect(ReactNoop.getChildren()).toEqual([span('B')]);
1968 + Scheduler.advanceTime(1200);
1969 + await advanceTimers(1200);
1970 + // Even after a second, we have still not yet flushed the loading state.
1971 + expect(ReactNoop.getChildren()).toEqual([span('B')]);
1972 + Scheduler.advanceTime(1200);
1973 + await advanceTimers(1200);
1974 + // After the two second timeout we show the loading state.
1975 + expect(ReactNoop.getChildren()).toEqual([
1976 + hiddenSpan('B'),
1977 + span('Loading...'),
1978 + ]);
1979 + });
1980 +
1981 + it('withSuspenseConfig timeout applies when we use an updated avoided boundary', async () => {
1982 + function App({page}) {
1983 + return (
1984 + <Suspense fallback={<Text text="Loading..." />}>
1985 + <Text text="Hi!" />
1986 + <Suspense
1987 + fallback={<Text text={'Loading ' + page + '...'} />}
1988 + unstable_avoidThisFallback={true}>
1989 + <AsyncText text={page} ms={3000} />
1990 + </Suspense>
1991 + </Suspense>
1992 + );
1993 + }
1994 +
1995 + // Initial render.
1996 + ReactNoop.render(<App page="A" />);
1997 + expect(Scheduler).toFlushAndYield(['Hi!', 'Suspend! [A]', 'Loading...']);
1998 + Scheduler.advanceTime(3000);
1999 + await advanceTimers(3000);
2000 + expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
2001 + expect(Scheduler).toFlushAndYield(['Hi!', 'A']);
2002 + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]);
2003 +
2004 + // Start transition.
2005 + React.unstable_withSuspenseConfig(
2006 + () => ReactNoop.render(<App page="B" />),
2007 + {timeoutMs: 2000},
2008 + );
2009 +
2010 + expect(Scheduler).toFlushAndYield(['Hi!', 'Suspend! [B]', 'Loading B...']);
2011 +
2012 + // Suspended
2013 + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]);
2014 + Scheduler.advanceTime(1800);
2015 + await advanceTimers(1800);
2016 + expect(Scheduler).toFlushAndYield([]);
2017 + // We should still be suspended here because this loading state should be avoided.
2018 + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]);
2019 + Scheduler.advanceTime(1500);
2020 + await advanceTimers(1500);
2021 + expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
2022 + expect(ReactNoop.getChildren()).toEqual([
2023 + span('Hi!'),
2024 + hiddenSpan('A'),
2025 + span('Loading B...'),
2026 + ]);
2027 + });
2028 +
2029 + it('withSuspenseConfig timeout applies when we use a newly created avoided boundary', async () => {
2030 + function App({page}) {
2031 + return (
2032 + <Suspense fallback={<Text text="Loading..." />}>
2033 + <Text text="Hi!" />
2034 + {page === 'A' ? (
2035 + <Text text="A" />
2036 + ) : (
2037 + <Suspense
2038 + fallback={<Text text={'Loading ' + page + '...'} />}
2039 + unstable_avoidThisFallback={true}>
2040 + <AsyncText text={page} ms={3000} />
2041 + </Suspense>
2042 + )}
2043 + </Suspense>
2044 + );
2045 + }
2046 +
2047 + // Initial render.
2048 + ReactNoop.render(<App page="A" />);
2049 + expect(Scheduler).toFlushAndYield(['Hi!', 'A']);
2050 + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]);
2051 +
2052 + // Start transition.
2053 + React.unstable_withSuspenseConfig(
2054 + () => ReactNoop.render(<App page="B" />),
2055 + {timeoutMs: 2000},
2056 + );
2057 +
2058 + expect(Scheduler).toFlushAndYield(['Hi!', 'Suspend! [B]', 'Loading B...']);
2059 +
2060 + // Suspended
2061 + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]);
2062 + Scheduler.advanceTime(1800);
2063 + await advanceTimers(1800);
2064 + expect(Scheduler).toFlushAndYield([]);
2065 + // We should still be suspended here because this loading state should be avoided.
2066 + expect(ReactNoop.getChildren()).toEqual([span('Hi!'), span('A')]);
2067 + Scheduler.advanceTime(1500);
2068 + await advanceTimers(1500);
2069 + expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
2070 + expect(ReactNoop.getChildren()).toEqual([
2071 + span('Hi!'),
2072 + span('Loading B...'),
2073 + ]);
2074 + });
2075 +
2076 + it('supports delaying a busy spinner from disappearing', async () => {
2077 + function useLoadingIndicator(config) {
2078 + let [isLoading, setLoading] = React.useState(false);
2079 + let start = React.useCallback(
2080 + cb => {
2081 + setLoading(true);
2082 + Scheduler.unstable_next(() =>
2083 + React.unstable_withSuspenseConfig(() => {
2084 + setLoading(false);
2085 + cb();
2086 + }, config),
2087 + );
2088 + },
2089 + [setLoading, config],
2090 + );
2091 + return [isLoading, start];
2092 + }
2093 +
2094 + const SUSPENSE_CONFIG = {
2095 + timeoutMs: 10000,
2096 + loadingDelayMs: 500,
2097 + minLoadingDurationMs: 400,
2098 + };
2099 +
2100 + let transitionToPage;
2101 +
2102 + function App() {
2103 + let [page, setPage] = React.useState('A');
2104 + let [isLoading, startLoading] = useLoadingIndicator(SUSPENSE_CONFIG);
2105 + transitionToPage = nextPage => startLoading(() => setPage(nextPage));
2106 + return (
2107 + <Fragment>
2108 + <Text text={page} />
2109 + {isLoading ? <Text text="L" /> : null}
2110 + </Fragment>
2111 + );
2112 + }
2113 +
2114 + // Initial render.
2115 + ReactNoop.render(<App />);
2116 + expect(Scheduler).toFlushAndYield(['A']);
2117 + expect(ReactNoop.getChildren()).toEqual([span('A')]);
2118 +
2119 + await ReactNoop.act(async () => {
2120 + transitionToPage('B');
2121 + // Rendering B is quick and we didn't have enough
2122 + // time to show the loading indicator.
2123 + Scheduler.advanceTime(200);
2124 + await advanceTimers(200);
2125 + expect(Scheduler).toFlushAndYield(['A', 'L', 'B']);
2126 + expect(ReactNoop.getChildren()).toEqual([span('B')]);
2127 + });
2128 +
2129 + await ReactNoop.act(async () => {
2130 + transitionToPage('C');
2131 + // Rendering C is a bit slower so we've already showed
2132 + // the loading indicator.
2133 + Scheduler.advanceTime(600);
2134 + await advanceTimers(600);
2135 + expect(Scheduler).toFlushAndYield(['B', 'L', 'C']);
2136 + // We're technically done now but we haven't shown the
2137 + // loading indicator for long enough yet so we'll suspend
2138 + // while we keep it on the screen a bit longer.
2139 + expect(ReactNoop.getChildren()).toEqual([span('B'), span('L')]);
2140 + Scheduler.advanceTime(400);
2141 + await advanceTimers(400);
2142 + expect(ReactNoop.getChildren()).toEqual([span('C')]);
2143 + });
2144 +
2145 + await ReactNoop.act(async () => {
2146 + transitionToPage('D');
2147 + // Rendering D is very slow so we've already showed
2148 + // the loading indicator.
2149 + Scheduler.advanceTime(1000);
2150 + await advanceTimers(1000);
2151 + expect(Scheduler).toFlushAndYield(['C', 'L', 'D']);
2152 + // However, since we exceeded the minimum time to show
2153 + // the loading indicator, we commit immediately.
2154 + expect(ReactNoop.getChildren()).toEqual([span('D')]);
2155 + });
2156 + });
2157 });
packages/react/src/React.js
+3
@@ -40,6 +40,7 @@ import {
40 useRef,
41 useState,
42 } from './ReactHooks';
43 +import {withSuspenseConfig} from './ReactBatchConfig';
44 import {
45 createElementWithValidation,
46 createFactoryWithValidation,
@@ -95,6 +96,8 @@ const React = {
96
97 version: ReactVersion,
98
99 + unstable_withSuspenseConfig: withSuspenseConfig,
100 +
101 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactSharedInternals,
102 };
103
packages/react/src/ReactBatchConfig.js new
+23
@@ -0,0 +1,23 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {SuspenseConfig} from 'react-reconciler/src/ReactFiberSuspenseConfig';
11 +
12 +import ReactCurrentBatchConfig from './ReactCurrentBatchConfig';
13 +
14 +// Within the scope of the callback, mark all updates as being allowed to suspend.
15 +export function withSuspenseConfig(scope: () => void, config?: SuspenseConfig) {
16 + const previousConfig = ReactCurrentBatchConfig.suspense;
17 + ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
18 + try {
19 + scope();
20 + } finally {
21 + ReactCurrentBatchConfig.suspense = previousConfig;
22 + }
23 +}
packages/react/src/ReactCurrentBatchConfig.js new
+20
@@ -0,0 +1,20 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {SuspenseConfig} from 'react-reconciler/src/ReactFiberSuspenseConfig';
11 +
12 +/**
13 + * Keeps track of the current batch's configuration such as how long an update
14 + * should suspend for if it needs to.
15 + */
16 +const ReactCurrentBatchConfig = {
17 + suspense: (null: null | SuspenseConfig),
18 +};
19 +
20 +export default ReactCurrentBatchConfig;
packages/react/src/ReactSharedInternals.js
+2
@@ -7,11 +7,13 @@
7
8 import assign from 'object-assign';
9 import ReactCurrentDispatcher from './ReactCurrentDispatcher';
10 +import ReactCurrentBatchConfig from './ReactCurrentBatchConfig';
11 import ReactCurrentOwner from './ReactCurrentOwner';
12 import ReactDebugCurrentFrame from './ReactDebugCurrentFrame';
13
14 const ReactSharedInternals = {
15 ReactCurrentDispatcher,
16 + ReactCurrentBatchConfig,
17 ReactCurrentOwner,
18 // used by act()
19 ReactShouldWarnActingUpdates: {current: false},
packages/shared/ReactSharedInternals.js
+5
@@ -18,5 +18,10 @@ if (!ReactSharedInternals.hasOwnProperty('ReactCurrentDispatcher')) {
18 current: null,
19 };
20 }
21 +if (!ReactSharedInternals.hasOwnProperty('ReactCurrentBatchConfig')) {
22 + ReactSharedInternals.ReactCurrentBatchConfig = {
23 + suspense: null,
24 + };
25 +}
26
27 export default ReactSharedInternals;