@samitouri / QOS-React-2 / commits / 113497cc0e

[Suspense] Change Suspending and Restarting Heuristics (#15769)

* Track most recent commit time of a fallback globally This value is going to be used to avoid committing too many fallback states in quick succession. It doesn't really matter where in the tree that happened. This means that we now don't really need the concept of SuspenseState other than has a flag. It could be made cheaper/simpler. * Change suspense heuristic This now eagerly commits non-delayed suspended trees, unless they're only retries in which case they're throttled to 500ms. * Restart early if we're going to suspend later * Use the local variable where appropriate * Make ReactLazy tests less specific on asserting intermediate states They're not testing the exact states of the suspense boundaries, only the result. I keep assertions that they're not already resolved early. * Adjust Profiler tests to the new heuristics * Update snapshot tests for user timing tests I also added a blank initial render to ensuree that we cover the suspended case. * Adjust Suspense tests to account for new heuristics Mostly this just means render the Suspense boundary first so that it becomes an update instead of initial mount. * Track whether we have a ping on the currently rendering level If we get a ping on this level but have not yet suspended, we might still suspend later. In that case we should still restart. * Add comment about moving markers We should add this to throwException so we get these markers earlier. I've had to rewrite tests that test restarting to account for the delayed restarting heuristic. Ideally, we should also be able to restart from within throwException if we're already ready to restart. Right now we wait until the next yield. * Add test for restarting during throttled retry * Add test that we don't restart for initial render * Add Suspense Heuristics as a comment in Throw

Sebastian Markbåge committed May 30, 2019 at 16:37 UTC 113497cc0e9cebf276a30d916c98c15aa075a225
14 files changed +592 -278
packages/react-reconciler/src/ReactFiberBeginWork.js
+4 -9
@@ -1413,6 +1413,9 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
1413 }
1414 }
1415
1416 +// TODO: This is now an empty object. Should we just make it a boolean?
1417 +const SUSPENDED_MARKER: SuspenseState = ({}: any);
1418 +
1419 function updateSuspenseComponent(
1420 current,
1421 workInProgress,
@@ -1440,17 +1443,9 @@ function updateSuspenseComponent(
1443 (ForceSuspenseFallback: SuspenseContext),
1444 )
1445 ) {
1443 - // This either already captured or is a new mount that was forced into its fallback
1444 - // state by a parent.
1445 - const attemptedState: SuspenseState | null = workInProgress.memoizedState;
1446 // Something in this boundary's subtree already suspended. Switch to
1447 // rendering the fallback children.
1448 - nextState = {
1449 - fallbackExpirationTime:
1450 - attemptedState !== null
1451 - ? attemptedState.fallbackExpirationTime
1452 - : NoWork,
1453 - };
1448 + nextState = SUSPENDED_MARKER;
1449 nextDidTimeout = true;
1450 workInProgress.effectTag &= ~DidCapture;
1451 } else {
packages/react-reconciler/src/ReactFiberCommitWork.js
+2 -15
@@ -63,10 +63,6 @@ import invariant from 'shared/invariant';
63 import warningWithoutStack from 'shared/warningWithoutStack';
64 import warning from 'shared/warning';
65
66 -import {
67 - NoWork,
68 - computeAsyncExpirationNoBucket,
69 -} from './ReactFiberExpirationTime';
66 import {onCommitUnmount} from './ReactFiberDevToolsHook';
67 import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf';
68 import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
@@ -102,8 +98,8 @@ import {
98 } from './ReactFiberHostConfig';
99 import {
100 captureCommitPhaseError,
105 - requestCurrentTime,
101 resolveRetryThenable,
102 + markCommitTimeOfFallback,
103 } from './ReactFiberWorkLoop';
104 import {
105 NoEffect as NoHookEffect,
@@ -1288,16 +1284,7 @@ function commitSuspenseComponent(finishedWork: Fiber) {
1284 } else {
1285 newDidTimeout = true;
1286 primaryChildParent = finishedWork.child;
1291 - if (newState.fallbackExpirationTime === NoWork) {
1292 - // If the children had not already timed out, record the time.
1293 - // This is used to compute the elapsed time during subsequent
1294 - // attempts to render the children.
1295 - // We model this as a normal pri expiration time since that's
1296 - // how we infer start time for updates.
1297 - newState.fallbackExpirationTime = computeAsyncExpirationNoBucket(
1298 - requestCurrentTime(),
1299 - );
1300 - }
1287 + markCommitTimeOfFallback();
1288 }
1289
1290 if (supportsMutation && primaryChildParent !== null) {
packages/react-reconciler/src/ReactFiberCompleteWork.js
+7 -9
@@ -101,7 +101,6 @@ import {
101 enableEventAPI,
102 } from 'shared/ReactFeatureFlags';
103 import {
104 - markRenderEventTimeAndConfig,
104 renderDidSuspend,
105 renderDidSuspendDelayIfPossible,
106 } from './ReactFiberWorkLoop';
@@ -702,14 +701,6 @@ function completeWork(
701 prevDidTimeout = prevState !== null;
702 if (!nextDidTimeout && prevState !== null) {
703 // We just switched from the fallback to the normal children.
705 -
706 - // Mark the event time of the switching from fallback to normal children,
707 - // based on the start of when we first showed the fallback. This time
708 - // was given a normal pri expiration time at the time it was shown.
709 - const fallbackExpirationTime: ExpirationTime =
710 - prevState.fallbackExpirationTime;
711 - markRenderEventTimeAndConfig(fallbackExpirationTime, null);
712 -
704 // Delete the fallback.
705 // TODO: Would it be better to store the fallback fragment on
706 // the stateNode during the begin phase?
@@ -737,6 +728,13 @@ function completeWork(
728 // in the concurrent tree already suspended during this render.
729 // This is a known bug.
730 if ((workInProgress.mode & BatchedMode) !== NoMode) {
731 + // TODO: Move this back to throwException because this is too late
732 + // if this is a large tree which is common for initial loads. We
733 + // don't know if we should restart a render or not until we get
734 + // this marker, and this is too late.
735 + // If this render already had a ping or lower pri updates,
736 + // and this is the first time we know we're going to suspend we
737 + // should be able to immediately restart from within throwException.
738 const hasInvisibleChildContext =
739 current === null &&
740 workInProgress.memoizedProps.unstable_avoidThisFallback !== true;
packages/react-reconciler/src/ReactFiberExpirationTime.js
-8
@@ -83,14 +83,6 @@ export function computeSuspenseExpiration(
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(
89 - currentTime: ExpirationTime,
90 -): ExpirationTime {
91 - return currentTime - LOW_PRIORITY_EXPIRATION / UNIT_SIZE;
92 -}
93 -
86 // We intentionally set a higher expiration time for interactive updates in
87 // dev than in production.
88 //
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+2 -4
@@ -8,11 +8,9 @@
8 */
9
10 import type {Fiber} from './ReactFiber';
11 -import type {ExpirationTime} from './ReactFiberExpirationTime';
11
13 -export type SuspenseState = {|
14 - fallbackExpirationTime: ExpirationTime,
15 -|};
12 +// TODO: This is now an empty object. Should we switch this to a boolean?
13 +export type SuspenseState = {||};
14
15 export function shouldCaptureSuspense(
16 workInProgress: Fiber,
packages/react-reconciler/src/ReactFiberThrow.js
+39
@@ -275,6 +275,45 @@ function throwException(
275
276 // Confirmed that the boundary is in a concurrent mode tree. Continue
277 // with the normal suspend path.
278 + //
279 + // After this we'll use a set of heuristics to determine whether this
280 + // render pass will run to completion or restart or "suspend" the commit.
281 + // The actual logic for this is spread out in different places.
282 + //
283 + // This first principle is that if we're going to suspend when we complete
284 + // a root, then we should also restart if we get an update or ping that
285 + // might unsuspend it, and vice versa. The only reason to suspend is
286 + // because you think you might want to restart before committing. However,
287 + // it doesn't make sense to restart only while in the period we're suspended.
288 + //
289 + // Restarting too aggressively is also not good because it starves out any
290 + // intermediate loading state. So we use heuristics to determine when.
291 +
292 + // Suspense Heuristics
293 + //
294 + // If nothing threw a Promise or all the same fallbacks are already showing,
295 + // then don't suspend/restart.
296 + //
297 + // If this is an initial render of a new tree of Suspense boundaries and
298 + // those trigger a fallback, then don't suspend/restart. We want to ensure
299 + // that we can show the initial loading state as quickly as possible.
300 + //
301 + // If we hit a "Delayed" case, such as when we'd switch from content back into
302 + // a fallback, then we should always suspend/restart. SuspenseConfig applies to
303 + // this case. If none is defined, JND is used instead.
304 + //
305 + // If we're already showing a fallback and it gets "retried", allowing us to show
306 + // another level, but there's still an inner boundary that would show a fallback,
307 + // then we suspend/restart for 500ms since the last time we showed a fallback
308 + // anywhere in the tree. This effectively throttles progressive loading into a
309 + // consistent train of commits. This also gives us an opportunity to restart to
310 + // get to the completed state slightly earlier.
311 + //
312 + // If there's ambiguity due to batching it's resolved in preference of:
313 + // 1) "delayed", 2) "initial render", 3) "retry".
314 + //
315 + // We want to ensure that a "busy" state doesn't get force committed. We want to
316 + // ensure that new initial loading states can commit as soon as possible.
317
318 attachPingListener(root, renderExpirationTime, thenable);
319
packages/react-reconciler/src/ReactFiberWorkLoop.js
+173 -82
@@ -213,6 +213,14 @@ let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
213 let workInProgressRootLatestProcessedExpirationTime: ExpirationTime = Sync;
214 let workInProgressRootLatestSuspenseTimeout: ExpirationTime = Sync;
215 let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
216 +// If we're pinged while rendering we don't always restart immediately.
217 +// This flag determines if it might be worthwhile to restart if an opportunity
218 +// happens latere.
219 +let workInProgressRootHasPendingPing: boolean = false;
220 +// The most recent time we committed a fallback. This lets us ensure a train
221 +// model where we don't commit new loading states in too quick succession.
222 +let globalMostRecentFallbackTime: number = 0;
223 +const FALLBACK_THROTTLE_MS: number = 500;
224
225 let nextEffect: Fiber | null = null;
226 let hasUncaughtError = false;
@@ -753,6 +761,7 @@ function prepareFreshStack(root, expirationTime) {
761 workInProgressRootLatestProcessedExpirationTime = Sync;
762 workInProgressRootLatestSuspenseTimeout = Sync;
763 workInProgressRootCanSuspendUsingConfig = null;
764 + workInProgressRootHasPendingPing = false;
765
766 if (__DEV__) {
767 ReactStrictModeWarnings.discardPendingWarnings();
@@ -794,6 +803,26 @@ function renderRoot(
803 if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
804 prepareFreshStack(root, expirationTime);
805 startWorkOnPendingInteraction(root, expirationTime);
806 + } else if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
807 + // We could've received an update at a lower priority while we yielded.
808 + // We're suspended in a delayed state. Once we complete this render we're
809 + // just going to try to recover at the last pending time anyway so we might
810 + // as well start doing that eagerly.
811 + // Ideally we should be able to do this even for retries but we don't yet
812 + // know if we're going to process an update which wants to commit earlier,
813 + // and this path happens very early so it would happen too often. Instead,
814 + // for that case, we'll wait until we complete.
815 + if (workInProgressRootHasPendingPing) {
816 + // We have a ping at this expiration. Let's restart to see if we get unblocked.
817 + prepareFreshStack(root, expirationTime);
818 + } else {
819 + const lastPendingTime = root.lastPendingTime;
820 + if (lastPendingTime < expirationTime) {
821 + // There's lower priority work. It might be unsuspended. Try rendering
822 + // at that level immediately, while preserving the position in the queue.
823 + return renderRoot.bind(null, root, lastPendingTime);
824 + }
825 + }
826 }
827
828 // If we have a work-in-progress fiber, it means there's still work to do
@@ -932,7 +961,7 @@ function renderRoot(
961 // An error was thrown. First check if there is lower priority work
962 // scheduled on this root.
963 const lastPendingTime = root.lastPendingTime;
935 - if (root.lastPendingTime < expirationTime) {
964 + if (lastPendingTime < expirationTime) {
965 // There's lower priority work. Before raising the error, try rendering
966 // at the lower priority to see if it fixes it. Use a continuation to
967 // maintain the existing priority and position in the queue.
@@ -950,39 +979,114 @@ function renderRoot(
979 // errored state.
980 return commitRoot.bind(null, root);
981 }
953 - case RootSuspended:
982 + case RootSuspended: {
983 + // We have an acceptable loading state. We need to figure out if we should
984 + // immediately commit it or wait a bit.
985 +
986 + // If we have processed new updates during this render, we may now have a
987 + // new loading state ready. We want to ensure that we commit that as soon as
988 + // possible.
989 + const hasNotProcessedNewUpdates =
990 + workInProgressRootLatestProcessedExpirationTime === Sync;
991 + if (hasNotProcessedNewUpdates && !disableYielding && !isSync) {
992 + // If we have not processed any new updates during this pass, then this is
993 + // either a retry of an existing fallback state or a hidden tree.
994 + // Hidden trees shouldn't be batched with other work and after that's
995 + // fixed it can only be a retry.
996 + // We're going to throttle committing retries so that we don't show too
997 + // many loading states too quickly.
998 + let msUntilTimeout =
999 + globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();
1000 + // Don't bother with a very short suspense time.
1001 + if (msUntilTimeout > 10) {
1002 + if (workInProgressRootHasPendingPing) {
1003 + // This render was pinged but we didn't get to restart earlier so try
1004 + // restarting now instead.
1005 + prepareFreshStack(root, expirationTime);
1006 + return renderRoot.bind(null, root, expirationTime);
1007 + }
1008 + const lastPendingTime = root.lastPendingTime;
1009 + if (lastPendingTime < expirationTime) {
1010 + // There's lower priority work. It might be unsuspended. Try rendering
1011 + // at that level.
1012 + return renderRoot.bind(null, root, lastPendingTime);
1013 + }
1014 + // The render is suspended, it hasn't timed out, and there's no lower
1015 + // priority work to do. Instead of committing the fallback
1016 + // immediately, wait for more data to arrive.
1017 + root.timeoutHandle = scheduleTimeout(
1018 + commitRoot.bind(null, root),
1019 + msUntilTimeout,
1020 + );
1021 + return null;
1022 + }
1023 + }
1024 + // The work expired. Commit immediately.
1025 + return commitRoot.bind(null, root);
1026 + }
1027 case RootSuspendedWithDelay: {
955 - if (!isSync) {
1028 + if (!disableYielding && !isSync) {
1029 + // We're suspended in a state that should be avoided. We'll try to avoid committing
1030 + // it for as long as the timeouts let us.
1031 + if (workInProgressRootHasPendingPing) {
1032 + // This render was pinged but we didn't get to restart earlier so try
1033 + // restarting now instead.
1034 + prepareFreshStack(root, expirationTime);
1035 + return renderRoot.bind(null, root, expirationTime);
1036 + }
1037 const lastPendingTime = root.lastPendingTime;
957 - if (root.lastPendingTime < expirationTime) {
1038 + if (lastPendingTime < expirationTime) {
1039 // There's lower priority work. It might be unsuspended. Try rendering
959 - // at that level.
1040 + // at that level immediately.
1041 return renderRoot.bind(null, root, lastPendingTime);
1042 }
962 - // If workInProgressRootLatestProcessedExpirationTime is Sync, that means we didn't
963 - // track any event times. That can happen if we retried but nothing switched
964 - // from fallback to content. There's no reason to delay doing no work.
965 - if (workInProgressRootLatestProcessedExpirationTime !== Sync) {
966 - let shouldDelay =
967 - workInProgressRootExitStatus === RootSuspendedWithDelay;
968 - let msUntilTimeout = computeMsUntilTimeout(
1043 +
1044 + let msUntilTimeout;
1045 + if (workInProgressRootLatestSuspenseTimeout !== Sync) {
1046 + // We have processed a suspense config whose expiration time we can use as
1047 + // the timeout.
1048 + msUntilTimeout =
1049 + expirationTimeToMs(workInProgressRootLatestSuspenseTimeout) - now();
1050 + } else if (workInProgressRootLatestProcessedExpirationTime === Sync) {
1051 + // This should never normally happen because only new updates cause
1052 + // delayed states, so we should have processed something. However,
1053 + // this could also happen in an offscreen tree.
1054 + msUntilTimeout = 0;
1055 + } else {
1056 + // If we don't have a suspense config, we're going to use a heuristic to
1057 + // determine how long we can suspend.
1058 + const eventTimeMs: number = inferTimeFromExpirationTime(
1059 workInProgressRootLatestProcessedExpirationTime,
970 - workInProgressRootLatestSuspenseTimeout,
971 - expirationTime,
972 - workInProgressRootCanSuspendUsingConfig,
973 - shouldDelay,
1060 );
975 - // Don't bother with a very short suspense time.
976 - if (msUntilTimeout > 10) {
977 - // The render is suspended, it hasn't timed out, and there's no lower
978 - // priority work to do. Instead of committing the fallback
979 - // immediately, wait for more data to arrive.
980 - root.timeoutHandle = scheduleTimeout(
981 - commitRoot.bind(null, root),
982 - msUntilTimeout,
983 - );
984 - return null;
1061 + const currentTimeMs = now();
1062 + const timeUntilExpirationMs =
1063 + expirationTimeToMs(expirationTime) - currentTimeMs;
1064 + let timeElapsed = currentTimeMs - eventTimeMs;
1065 + if (timeElapsed < 0) {
1066 + // We get this wrong some time since we estimate the time.
1067 + timeElapsed = 0;
1068 }
1069 +
1070 + msUntilTimeout = jnd(timeElapsed) - timeElapsed;
1071 +
1072 + // Clamp the timeout to the expiration time.
1073 + // TODO: Once the event time is exact instead of inferred from expiration time
1074 + // we don't need this.
1075 + if (timeUntilExpirationMs < msUntilTimeout) {
1076 + msUntilTimeout = timeUntilExpirationMs;
1077 + }
1078 + }
1079 +
1080 + // Don't bother with a very short suspense time.
1081 + if (msUntilTimeout > 10) {
1082 + // The render is suspended, it hasn't timed out, and there's no lower
1083 + // priority work to do. Instead of committing the fallback
1084 + // immediately, wait for more data to arrive.
1085 + root.timeoutHandle = scheduleTimeout(
1086 + commitRoot.bind(null, root),
1087 + msUntilTimeout,
1088 + );
1089 + return null;
1090 }
1091 }
1092 // The work expired. Commit immediately.
@@ -1019,6 +1123,10 @@ function renderRoot(
1123 }
1124 }
1125
1126 +export function markCommitTimeOfFallback() {
1127 + globalMostRecentFallbackTime = now();
1128 +}
1129 +
1130 export function markRenderEventTimeAndConfig(
1131 expirationTime: ExpirationTime,
1132 suspenseConfig: null | SuspenseConfig,
@@ -1062,18 +1170,24 @@ export function renderDidError() {
1170 }
1171 }
1172
1065 -function inferTimeFromExpirationTime(
1173 +function inferTimeFromExpirationTime(expirationTime: ExpirationTime): number {
1174 + // We don't know exactly when the update was scheduled, but we can infer an
1175 + // approximate start time from the expiration time.
1176 + const earliestExpirationTimeMs = expirationTimeToMs(expirationTime);
1177 + return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION;
1178 +}
1179 +
1180 +function inferTimeFromExpirationTimeWithSuspenseConfig(
1181 expirationTime: ExpirationTime,
1067 - suspenseConfig: null | SuspenseConfig,
1182 + suspenseConfig: SuspenseConfig,
1183 ): number {
1184 // We don't know exactly when the update was scheduled, but we can infer an
1070 - // approximate start time from the expiration time.
1185 + // approximate start time from the expiration time by subtracting the timeout
1186 + // that was added to the event time.
1187 const earliestExpirationTimeMs = expirationTimeToMs(expirationTime);
1188 return (
1189 earliestExpirationTimeMs -
1074 - (suspenseConfig !== null
1075 - ? suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION
1076 - : LOW_PRIORITY_EXPIRATION)
1190 + (suspenseConfig.timeoutMs | 0 || LOW_PRIORITY_EXPIRATION)
1191 );
1192 }
1193
@@ -1879,9 +1993,32 @@ export function pingSuspendedRoot(
1993
1994 if (workInProgressRoot === root && renderExpirationTime === suspendedTime) {
1995 // Received a ping at the same priority level at which we're currently
1882 - // rendering. Restart from the root. Don't need to schedule a ping because
1883 - // we're already working on this tree.
1884 - prepareFreshStack(root, renderExpirationTime);
1996 + // rendering. We might want to restart this render. This should mirror
1997 + // the logic of whether or not a root suspends once it completes.
1998 +
1999 + // TODO: If we're rendering sync either due to Sync, Batched or expired,
2000 + // we should probably never restart.
2001 +
2002 + // If we're suspended with delay, we'll always suspend so we can always
2003 + // restart. If we're suspended without any updates, it might be a retry.
2004 + // If it's early in the retry we can restart. We can't know for sure
2005 + // whether we'll eventually process an update during this render pass,
2006 + // but it's somewhat unlikely that we get to a ping before that, since
2007 + // getting to the root most update is usually very fast.
2008 + if (
2009 + workInProgressRootExitStatus === RootSuspendedWithDelay ||
2010 + (workInProgressRootExitStatus === RootSuspended &&
2011 + workInProgressRootLatestProcessedExpirationTime === Sync &&
2012 + now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
2013 + ) {
2014 + // Restart from the root. Don't need to schedule a ping because
2015 + // we're already working on this tree.
2016 + prepareFreshStack(root, renderExpirationTime);
2017 + } else {
2018 + // Even though we can't restart right now, we might get an
2019 + // opportunity later. So we mark this render as having a ping.
2020 + workInProgressRootHasPendingPing = true;
2021 + }
2022 return;
2023 }
2024
@@ -2007,7 +2144,7 @@ function computeMsUntilSuspenseLoadingDelay(
2144
2145 // Compute the time until this render pass would expire.
2146 const currentTimeMs: number = now();
2010 - const eventTimeMs: number = inferTimeFromExpirationTime(
2147 + const eventTimeMs: number = inferTimeFromExpirationTimeWithSuspenseConfig(
2148 mostRecentEventTime,
2149 suspenseConfig,
2150 );
@@ -2022,52 +2159,6 @@ function computeMsUntilSuspenseLoadingDelay(
2159 return msUntilTimeout;
2160 }
2161
2025 -function computeMsUntilTimeout(
2026 - mostRecentEventTime: ExpirationTime,
2027 - suspenseTimeout: ExpirationTime,
2028 - committedExpirationTime: ExpirationTime,
2029 - suspenseConfig: null | SuspenseConfig,
2030 - shouldDelay: boolean,
2031 -) {
2032 - if (disableYielding) {
2033 - // Timeout immediately when yielding is disabled.
2034 - return 0;
2035 - }
2036 -
2037 - // Compute the time until this render pass would expire.
2038 - const currentTimeMs: number = now();
2039 -
2040 - if (suspenseTimeout !== Sync && shouldDelay) {
2041 - const timeUntilTimeoutMs =
2042 - expirationTimeToMs(suspenseTimeout) - currentTimeMs;
2043 - return timeUntilTimeoutMs;
2044 - }
2045 -
2046 - const eventTimeMs: number = inferTimeFromExpirationTime(
2047 - mostRecentEventTime,
2048 - suspenseConfig,
2049 - );
2050 - const timeUntilExpirationMs =
2051 - expirationTimeToMs(committedExpirationTime) - currentTimeMs;
2052 - let timeElapsed = currentTimeMs - eventTimeMs;
2053 - if (timeElapsed < 0) {
2054 - // We get this wrong some time since we estimate the time.
2055 - timeElapsed = 0;
2056 - }
2057 -
2058 - let msUntilTimeout = jnd(timeElapsed) - timeElapsed;
2059 -
2060 - // Clamp the timeout to the expiration time.
2061 - // TODO: Once the event time is exact instead of inferred from expiration time
2062 - // we don't need this.
2063 - if (timeUntilExpirationMs < msUntilTimeout) {
2064 - msUntilTimeout = timeUntilExpirationMs;
2065 - }
2066 -
2067 - // This is the value that is passed to `setTimeout`.
2068 - return msUntilTimeout;
2069 -}
2070 -
2162 function checkForNestedUpdates() {
2163 if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
2164 nestedUpdateCount = 0;
packages/react-reconciler/src/__tests__/ReactIncrementalPerf-test.internal.js
+10
@@ -595,6 +595,16 @@ describe('ReactDebugFiberPerf', () => {
595 }),
596 );
597
598 + // Initial render
599 + ReactNoop.render(
600 + <Parent>
601 + <React.Suspense fallback={<Spinner />} />
602 + </Parent>,
603 + );
604 + expect(Scheduler).toFlushWithoutYielding();
605 + expect(getFlameChart()).toMatchSnapshot();
606 +
607 + // Update that suspends
608 ReactNoop.render(
609 <Parent>
610 <React.Suspense fallback={<Spinner />}>
packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
+19 -21
@@ -46,7 +46,7 @@ describe('ReactLazy', () => {
46 );
47
48 expect(Scheduler).toFlushAndYield(['Loading...']);
49 - expect(root).toMatchRenderedOutput(null);
49 + expect(root).not.toMatchRenderedOutput('Hi');
50
51 await Promise.resolve();
52
@@ -136,13 +136,13 @@ describe('ReactLazy', () => {
136 );
137
138 expect(Scheduler).toFlushAndYield(['Loading...']);
139 - expect(root).toMatchRenderedOutput(null);
139 + expect(root).not.toMatchRenderedOutput('FooBar');
140
141 jest.advanceTimersByTime(100);
142 await promiseForFoo;
143
144 - expect(Scheduler).toFlushAndYield(['Foo', 'Loading...']);
145 - expect(root).toMatchRenderedOutput(null);
144 + expect(Scheduler).toFlushAndYield(['Foo']);
145 + expect(root).not.toMatchRenderedOutput('FooBar');
146
147 jest.advanceTimersByTime(500);
148 await promiseForBar;
@@ -165,7 +165,7 @@ describe('ReactLazy', () => {
165 },
166 );
167 expect(Scheduler).toFlushAndYield(['Loading...']);
168 - expect(root).toMatchRenderedOutput(null);
168 + expect(root).not.toMatchRenderedOutput('Hi');
169
170 await Promise.resolve();
171
@@ -193,7 +193,7 @@ describe('ReactLazy', () => {
193 );
194
195 expect(Scheduler).toFlushAndYield(['Loading...']);
196 - expect(root).toMatchRenderedOutput(null);
196 + expect(root).not.toMatchRenderedOutput('Hi');
197
198 try {
199 await Promise.resolve();
@@ -239,7 +239,7 @@ describe('ReactLazy', () => {
239 });
240
241 expect(Scheduler).toFlushAndYield(['Loading...']);
242 - expect(root).toMatchRenderedOutput(null);
242 + expect(root).not.toMatchRenderedOutput('AB');
243
244 await LazyChildA;
245 await LazyChildB;
@@ -280,7 +280,7 @@ describe('ReactLazy', () => {
280 );
281
282 expect(Scheduler).toFlushAndYield(['Loading...']);
283 - expect(root).toMatchRenderedOutput(null);
283 + expect(root).not.toMatchRenderedOutput('Hi');
284
285 await Promise.resolve();
286
@@ -330,7 +330,7 @@ describe('ReactLazy', () => {
330 },
331 );
332 expect(Scheduler).toFlushAndYield(['Loading...']);
333 - expect(root).toMatchRenderedOutput(null);
333 + expect(root).not.toMatchRenderedOutput('SiblingA');
334
335 await Promise.resolve();
336
@@ -403,7 +403,7 @@ describe('ReactLazy', () => {
403 );
404
405 expect(Scheduler).toFlushAndYield(['Loading...']);
406 - expect(root).toMatchRenderedOutput(null);
406 + expect(root).not.toMatchRenderedOutput('A1');
407
408 await Promise.resolve();
409
@@ -536,7 +536,7 @@ describe('ReactLazy', () => {
536 );
537
538 expect(Scheduler).toFlushAndYield(['Loading...']);
539 - expect(root).toMatchRenderedOutput(null);
539 + expect(root).not.toMatchRenderedOutput('Hi Bye');
540
541 await Promise.resolve();
542 expect(Scheduler).toFlushAndYield(['Hi Bye']);
@@ -572,7 +572,6 @@ describe('ReactLazy', () => {
572 );
573
574 expect(Scheduler).toFlushAndYield(['Loading...']);
575 - expect(root).toMatchRenderedOutput(null);
575
576 await Promise.resolve();
577 root.update(
@@ -600,7 +599,7 @@ describe('ReactLazy', () => {
599 );
600
601 expect(Scheduler).toFlushAndYield(['Loading...']);
603 - expect(root).toMatchRenderedOutput(null);
602 + expect(root).not.toMatchRenderedOutput('Hello');
603
604 await Promise.resolve();
605 root.update(
@@ -650,7 +649,7 @@ describe('ReactLazy', () => {
649 );
650
651 expect(Scheduler).toFlushAndYield(['Loading...']);
653 - expect(root).toMatchRenderedOutput(null);
652 + expect(root).not.toMatchRenderedOutput('22');
653
654 // Mount
655 await Promise.resolve();
@@ -836,7 +835,7 @@ describe('ReactLazy', () => {
835 );
836
837 expect(Scheduler).toFlushAndYield(['Loading...']);
839 - expect(root).toMatchRenderedOutput(null);
838 + expect(root).not.toMatchRenderedOutput('Inner default text');
839
840 // Mount
841 await Promise.resolve();
@@ -878,7 +877,7 @@ describe('ReactLazy', () => {
877 );
878
879 expect(Scheduler).toFlushAndYield(['Started loading', 'Loading...']);
881 - expect(root).toMatchRenderedOutput(null);
880 + expect(root).not.toMatchRenderedOutput(<div>AB</div>);
881
882 await Promise.resolve();
883
@@ -924,7 +923,7 @@ describe('ReactLazy', () => {
923 );
924
925 expect(Scheduler).toFlushAndYield(['Loading...']);
927 - expect(root).toMatchRenderedOutput(null);
926 + expect(root).not.toMatchRenderedOutput('FooBar');
927 expect(ref.current).toBe(null);
928
929 await Promise.resolve();
@@ -952,7 +951,7 @@ describe('ReactLazy', () => {
951 },
952 );
953 expect(Scheduler).toFlushAndYield(['Loading...']);
955 - expect(root).toMatchRenderedOutput(null);
954 + expect(root).not.toMatchRenderedOutput('4');
955
956 // Mount
957 await Promise.resolve();
@@ -1036,7 +1035,7 @@ describe('ReactLazy', () => {
1035 },
1036 );
1037 expect(Scheduler).toFlushAndYield(['Loading...']);
1039 - expect(root).toMatchRenderedOutput(null);
1038 + expect(root).not.toMatchRenderedOutput('4');
1039
1040 // Mount
1041 await Promise.resolve();
@@ -1069,7 +1068,7 @@ describe('ReactLazy', () => {
1068 });
1069
1070 const ref = React.createRef();
1072 - const root = ReactTestRenderer.create(
1071 + ReactTestRenderer.create(
1072 <Suspense fallback={<Text text="Loading..." />}>
1073 <LazyFoo ref={ref} />
1074 </Suspense>,
@@ -1079,7 +1078,6 @@ describe('ReactLazy', () => {
1078 );
1079
1080 expect(Scheduler).toFlushAndYield(['Loading...']);
1082 - expect(root).toMatchRenderedOutput(null);
1081 await Promise.resolve();
1082 expect(() => {
1083 expect(Scheduler).toFlushAndYield([]);
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+42 -17
@@ -98,22 +98,32 @@ describe('ReactSuspense', () => {
98 return props.children;
99 }
100
101 - function Foo() {
101 + function Foo({renderBar}) {
102 Scheduler.yieldValue('Foo');
103 return (
104 <Suspense fallback={<Text text="Loading..." />}>
105 - <Bar>
106 - <AsyncText text="A" ms={100} />
107 - <Text text="B" />
108 - </Bar>
105 + {renderBar ? (
106 + <Bar>
107 + <AsyncText text="A" ms={100} />
108 + <Text text="B" />
109 + </Bar>
110 + ) : null}
111 </Suspense>
112 );
113 }
114
115 + // Render an empty shell
116 const root = ReactTestRenderer.create(<Foo />, {
117 unstable_isConcurrent: true,
118 });
119
120 + expect(Scheduler).toFlushAndYield(['Foo']);
121 + expect(root).toMatchRenderedOutput(null);
122 +
123 + // Navigate the shell to now render the child content.
124 + // This should suspend.
125 + root.update(<Foo renderBar={true} />);
126 +
127 expect(Scheduler).toFlushAndYield([
128 'Foo',
129 'Bar',
@@ -161,17 +171,12 @@ describe('ReactSuspense', () => {
171 'Suspend! [B]',
172 'Loading B...',
173 ]);
164 - expect(root).toMatchRenderedOutput(null);
165 -
166 - // Advance time by enough to timeout both components and commit their placeholders
167 - jest.advanceTimersByTime(4000);
168 - expect(Scheduler).toFlushWithoutYielding();
174 expect(root).toMatchRenderedOutput('Loading A...Loading B...');
175
176 // Advance time by enough that the first Suspense's promise resolves and
177 // switches back to the normal view. The second Suspense should still
178 // show the placeholder
174 - jest.advanceTimersByTime(1000);
179 + jest.advanceTimersByTime(5000);
180 // TODO: Should we throw if you forget to call toHaveYielded?
181 expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
182 expect(Scheduler).toFlushAndYield(['A']);
@@ -214,28 +219,48 @@ describe('ReactSuspense', () => {
219 }
220
221 const root = ReactTestRenderer.create(
217 - <Suspense fallback={<Text text="Loading..." />}>
218 - <Async />
219 - <Text text="Sibling" />
220 - </Suspense>,
222 + <React.Fragment>
223 + <Suspense fallback={<Text text="Loading..." />} />
224 + <Text text="Initial" />
225 + </React.Fragment>,
226 {
227 unstable_isConcurrent: true,
228 },
229 );
230 + expect(Scheduler).toFlushAndYield(['Initial']);
231 + expect(root).toMatchRenderedOutput('Initial');
232
226 - expect(Scheduler).toFlushAndYieldThrough(['Suspend!']);
233 + // The update will suspend.
234 + root.update(
235 + <React.Fragment>
236 + <Suspense fallback={<Text text="Loading..." />}>
237 + <Async />
238 + </Suspense>
239 + <Text text="After Suspense" />
240 + <Text text="Sibling" />
241 + </React.Fragment>,
242 + );
243 +
244 + // Yield past the Suspense boundary but don't complete the last sibling.
245 + expect(Scheduler).toFlushAndYieldThrough([
246 + 'Suspend!',
247 + 'Loading...',
248 + 'After Suspense',
249 + ]);
250
251 // The promise resolves before the current render phase has completed
252 resolveThenable();
253 expect(Scheduler).toHaveYielded([]);
254 + expect(root).toMatchRenderedOutput('Initial');
255
256 // Start over from the root, instead of continuing.
257 expect(Scheduler).toFlushAndYield([
258 // Async renders again *before* Sibling
259 'Async',
260 + 'After Suspense',
261 'Sibling',
262 ]);
238 - expect(root).toMatchRenderedOutput('AsyncSibling');
263 + expect(root).toMatchRenderedOutput('AsyncAfter SuspenseSibling');
264 });
265
266 it('mounts a lazy class component in non-concurrent mode', async () => {
packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
+18 -10
@@ -126,7 +126,7 @@ describe('ReactSuspensePlaceholder', () => {
126 ReactNoop.render(<App middleText="B" />);
127
128 expect(Scheduler).toFlushAndYield(['A', 'Suspend! [B]', 'C', 'Loading...']);
129 - expect(ReactNoop).toMatchRenderedOutput(null);
129 + expect(ReactNoop).toMatchRenderedOutput('Loading...');
130
131 jest.advanceTimersByTime(1000);
132 expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
@@ -189,7 +189,7 @@ describe('ReactSuspensePlaceholder', () => {
189
190 expect(Scheduler).toFlushAndYield(['A', 'Suspend! [B]', 'C', 'Loading...']);
191
192 - expect(ReactNoop).toMatchRenderedOutput(null);
192 + expect(ReactNoop).not.toMatchRenderedOutput('ABC');
193
194 jest.advanceTimersByTime(1000);
195 expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
@@ -239,7 +239,7 @@ describe('ReactSuspensePlaceholder', () => {
239
240 expect(Scheduler).toFlushAndYield(['a', 'Suspend! [b]', 'c', 'Loading...']);
241
242 - expect(ReactNoop).toMatchRenderedOutput(null);
242 + expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
243
244 jest.advanceTimersByTime(1000);
245 expect(Scheduler).toHaveYielded(['Promise resolved [b]']);
@@ -345,10 +345,8 @@ describe('ReactSuspensePlaceholder', () => {
345 'Text',
346 'Fallback',
347 ]);
348 - expect(ReactNoop).toMatchRenderedOutput(null);
349 -
350 - // Show the fallback UI.
351 - jest.advanceTimersByTime(750);
348 + // Since this is initial render we immediately commit the fallback. Another test below
349 + // deals with the update case where this suspends.
350 expect(ReactNoop).toMatchRenderedOutput('Loading...');
351 expect(onRender).toHaveBeenCalledTimes(1);
352
@@ -359,7 +357,7 @@ describe('ReactSuspensePlaceholder', () => {
357 expect(onRender.mock.calls[0][3]).toBe(10);
358
359 // Resolve the pending promise.
362 - jest.advanceTimersByTime(250);
360 + jest.advanceTimersByTime(1000);
361 expect(Scheduler).toHaveYielded(['Promise resolved [Loaded]']);
362 expect(Scheduler).toFlushAndYield(['Suspending', 'Loaded', 'Text']);
363 expect(ReactNoop).toMatchRenderedOutput('LoadedText');
@@ -437,7 +435,12 @@ describe('ReactSuspensePlaceholder', () => {
435 });
436
437 it('properly accounts for base durations when a suspended times out in a concurrent tree', () => {
440 - ReactNoop.render(<App shouldSuspend={false} textRenderDuration={5} />);
438 + ReactNoop.render(
439 + <React.Fragment>
440 + <App shouldSuspend={false} textRenderDuration={5} />
441 + <Suspense fallback={null} />
442 + </React.Fragment>,
443 + );
444
445 expect(Scheduler).toFlushAndYield(['App', 'Text']);
446 expect(ReactNoop).toMatchRenderedOutput('Text');
@@ -448,7 +451,12 @@ describe('ReactSuspensePlaceholder', () => {
451 expect(onRender.mock.calls[0][2]).toBe(5);
452 expect(onRender.mock.calls[0][3]).toBe(5);
453
451 - ReactNoop.render(<App shouldSuspend={true} textRenderDuration={5} />);
454 + ReactNoop.render(
455 + <React.Fragment>
456 + <App shouldSuspend={true} textRenderDuration={5} />
457 + <Suspense fallback={null} />
458 + </React.Fragment>,
459 + );
460 expect(Scheduler).toFlushAndYield([
461 'App',
462 'Suspending',
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+223 -83
@@ -108,25 +108,96 @@ describe('ReactSuspenseWithNoopRenderer', () => {
108 ]);
109 });
110
111 - it('suspends rendering and continues later', async () => {
111 + it('does not restart rendering for initial render', async () => {
112 function Bar(props) {
113 Scheduler.yieldValue('Bar');
114 return props.children;
115 }
116
117 function Foo() {
118 + Scheduler.yieldValue('Foo');
119 + return (
120 + <React.Fragment>
121 + <Suspense fallback={<Text text="Loading..." />}>
122 + <Bar>
123 + <AsyncText text="A" ms={100} />
124 + <Text text="B" />
125 + </Bar>
126 + </Suspense>
127 + <Text text="C" />
128 + <Text text="D" />
129 + </React.Fragment>
130 + );
131 + }
132 +
133 + ReactNoop.render(<Foo />);
134 + expect(Scheduler).toFlushAndYieldThrough([
135 + 'Foo',
136 + 'Bar',
137 + // A suspends
138 + 'Suspend! [A]',
139 + // But we keep rendering the siblings
140 + 'B',
141 + 'Loading...',
142 + 'C',
143 + // We leave D incomplete.
144 + ]);
145 + expect(ReactNoop.getChildren()).toEqual([]);
146 +
147 + // Flush the promise completely
148 + Scheduler.advanceTime(100);
149 + await advanceTimers(100);
150 + expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
151 +
152 + // Even though the promise has resolved, we should now flush
153 + // and commit the in progress render instead of restarting.
154 + expect(Scheduler).toFlushAndYield(['D']);
155 + expect(ReactNoop.getChildren()).toEqual([
156 + span('Loading...'),
157 + span('C'),
158 + span('D'),
159 + ]);
160 +
161 + // Await one micro task to attach the retry listeners.
162 + await null;
163 +
164 + // Next, we'll flush the complete content.
165 + expect(Scheduler).toFlushAndYield(['Bar', 'A', 'B']);
166 +
167 + expect(ReactNoop.getChildren()).toEqual([
168 + span('A'),
169 + span('B'),
170 + span('C'),
171 + span('D'),
172 + ]);
173 + });
174 +
175 + it('suspends rendering and continues later', async () => {
176 + function Bar(props) {
177 + Scheduler.yieldValue('Bar');
178 + return props.children;
179 + }
180 +
181 + function Foo({renderBar}) {
182 Scheduler.yieldValue('Foo');
183 return (
184 <Suspense fallback={<Text text="Loading..." />}>
121 - <Bar>
122 - <AsyncText text="A" ms={100} />
123 - <Text text="B" />
124 - </Bar>
185 + {renderBar ? (
186 + <Bar>
187 + <AsyncText text="A" ms={100} />
188 + <Text text="B" />
189 + </Bar>
190 + ) : null}
191 </Suspense>
192 );
193 }
194
195 + // Render empty shell.
196 ReactNoop.render(<Foo />);
197 + expect(Scheduler).toFlushAndYield(['Foo']);
198 +
199 + // The update will suspend.
200 + ReactNoop.render(<Foo renderBar={true} />);
201 expect(Scheduler).toFlushAndYield([
202 'Foo',
203 'Bar',
@@ -170,13 +241,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
241 'Suspend! [B]',
242 'Loading B...',
243 ]);
173 - expect(ReactNoop.getChildren()).toEqual([]);
174 -
175 - // Advance time by enough to timeout both components and commit their placeholders
176 - ReactNoop.expire(4000);
177 - await advanceTimers(4000);
178 -
179 - expect(Scheduler).toFlushWithoutYielding();
244 expect(ReactNoop.getChildren()).toEqual([
245 span('Loading A...'),
246 span('Loading B...'),
@@ -185,8 +249,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
249 // Advance time by enough that the first Suspense's promise resolves and
250 // switches back to the normal view. The second Suspense should still
251 // show the placeholder
188 - ReactNoop.expire(1000);
189 - await advanceTimers(1000);
252 + ReactNoop.expire(5000);
253 + await advanceTimers(5000);
254
255 expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
256 expect(Scheduler).toFlushAndYield(['A']);
@@ -203,6 +267,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
267 });
268
269 it('continues rendering siblings after suspending', async () => {
270 + // A shell is needed. The update cause it to suspend.
271 + ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
272 + expect(Scheduler).toFlushAndYield([]);
273 + // B suspends. Continue rendering the remaining siblings.
274 ReactNoop.render(
275 <Suspense fallback={<Text text="Loading..." />}>
276 <Text text="A" />
@@ -253,17 +321,23 @@ describe('ReactSuspenseWithNoopRenderer', () => {
321 }
322
323 const errorBoundary = React.createRef();
256 - function App() {
324 + function App({renderContent}) {
325 return (
326 <Suspense fallback={<Text text="Loading..." />}>
259 - <ErrorBoundary ref={errorBoundary}>
260 - <AsyncText text="Result" ms={1000} />
261 - </ErrorBoundary>
327 + {renderContent ? (
328 + <ErrorBoundary ref={errorBoundary}>
329 + <AsyncText text="Result" ms={1000} />
330 + </ErrorBoundary>
331 + ) : null}
332 </Suspense>
333 );
334 }
335
336 ReactNoop.render(<App />);
337 + expect(Scheduler).toFlushAndYield([]);
338 + expect(ReactNoop.getChildren()).toEqual([]);
339 +
340 + ReactNoop.render(<App renderContent={true} />);
341 expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']);
342 expect(ReactNoop.getChildren()).toEqual([]);
343
@@ -318,16 +392,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
392
393 ReactNoop.render(<App />);
394 expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']);
321 - expect(ReactNoop.getChildren()).toEqual([]);
322 -
323 - ReactNoop.expire(2000);
324 - await advanceTimers(2000);
325 - expect(Scheduler).toFlushWithoutYielding();
395 expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
396
397 textResourceShouldFail = true;
329 - ReactNoop.expire(1000);
330 - await advanceTimers(1000);
398 + ReactNoop.expire(3000);
399 + await advanceTimers(3000);
400 textResourceShouldFail = false;
401
402 expect(Scheduler).toHaveYielded(['Promise rejected [Result]']);
@@ -394,20 +463,24 @@ describe('ReactSuspenseWithNoopRenderer', () => {
463 function App(props) {
464 return (
465 <Suspense fallback={<Text text="Loading..." />}>
397 - <AsyncText text="A" />
466 + {props.showA && <AsyncText text="A" />}
467 {props.showB && <Text text="B" />}
468 </Suspense>
469 );
470 }
471
403 - ReactNoop.render(<App showB={false} />);
472 + ReactNoop.render(<App showA={false} showB={false} />);
473 + expect(Scheduler).toFlushAndYield([]);
474 + expect(ReactNoop.getChildren()).toEqual([]);
475 +
476 + ReactNoop.render(<App showA={true} showB={false} />);
477 expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
478 expect(ReactNoop.getChildren()).toEqual([]);
479
480 // Advance React's virtual time by enough to fall into a new async bucket,
481 // but not enough to expire the suspense timeout.
482 ReactNoop.expire(120);
410 - ReactNoop.render(<App showB={true} />);
483 + ReactNoop.render(<App showA={true} showB={true} />);
484 expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'B', 'Loading...']);
485 expect(ReactNoop.getChildren()).toEqual([]);
486
@@ -448,6 +521,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
521 });
522
523 it('forces an expiration after an update times out', async () => {
524 + ReactNoop.render(
525 + <Fragment>
526 + <Suspense fallback={<Text text="Loading..." />} />
527 + </Fragment>,
528 + );
529 + expect(Scheduler).toFlushAndYield([]);
530 +
531 ReactNoop.render(
532 <Fragment>
533 <Suspense fallback={<Text text="Loading..." />}>
@@ -485,7 +565,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
565 expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]);
566 });
567
488 - it('switches to an inner fallback even if it expires later', async () => {
568 + it('switches to an inner fallback after suspending for a while', async () => {
569 // Advance the virtual time so that we're closer to the edge of a bucket.
570 ReactNoop.expire(200);
571
@@ -509,26 +589,15 @@ describe('ReactSuspenseWithNoopRenderer', () => {
589 'Loading inner...',
590 'Loading outer...',
591 ]);
512 - // The update hasn't expired yet, so we commit nothing.
513 - expect(ReactNoop.getChildren()).toEqual([]);
514 -
515 - // Expire the outer timeout, but don't expire the inner one.
516 - // We should see the outer loading placeholder.
517 - ReactNoop.expire(250);
518 - await advanceTimers(250);
519 - expect(Scheduler).toFlushWithoutYielding();
592 + // The outer loading state finishes immediately.
593 expect(ReactNoop.getChildren()).toEqual([
594 span('Sync'),
595 span('Loading outer...'),
596 ]);
597
598 // Resolve the outer promise.
526 - ReactNoop.expire(50);
527 - await advanceTimers(50);
528 - // At this point, 250ms have elapsed total. The outer placeholder
529 - // timed out at around 150-200ms. So, 50-100ms have elapsed since the
530 - // placeholder timed out. That means we still haven't reached the 150ms
531 - // threshold of the inner placeholder.
599 + ReactNoop.expire(300);
600 + await advanceTimers(300);
601 expect(Scheduler).toHaveYielded(['Promise resolved [Outer content]']);
602 expect(Scheduler).toFlushAndYield([
603 'Outer content',
@@ -618,6 +687,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
687 });
688
689 it('expires early by default', async () => {
690 + ReactNoop.render(
691 + <Fragment>
692 + <Suspense fallback={<Text text="Loading..." />} />
693 + </Fragment>,
694 + );
695 + expect(Scheduler).toFlushAndYield([]);
696 +
697 ReactNoop.render(
698 <Fragment>
699 <Suspense fallback={<Text text="Loading..." />}>
@@ -654,17 +730,33 @@ describe('ReactSuspenseWithNoopRenderer', () => {
730
731 it('resolves successfully even if fallback render is pending', async () => {
732 ReactNoop.render(
657 - <Suspense fallback={<Text text="Loading..." />}>
658 - <AsyncText text="Async" ms={3000} />
659 - </Suspense>,
733 + <React.Fragment>
734 + <Suspense fallback={<Text text="Loading..." />} />
735 + </React.Fragment>,
736 + );
737 + expect(Scheduler).toFlushAndYield([]);
738 + expect(ReactNoop.getChildren()).toEqual([]);
739 + ReactNoop.render(
740 + <React.Fragment>
741 + <Suspense fallback={<Text text="Loading..." />}>
742 + <AsyncText text="Async" ms={3000} />
743 + </Suspense>
744 + </React.Fragment>,
745 );
746 expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Async]']);
747 await advanceTimers(1500);
748 expect(Scheduler).toHaveYielded([]);
749 + expect(ReactNoop.getChildren()).toEqual([]);
750 // Before we have a chance to flush, the promise resolves.
751 await advanceTimers(2000);
752 expect(Scheduler).toHaveYielded(['Promise resolved [Async]']);
667 - expect(Scheduler).toFlushAndYield(['Async']);
753 + expect(Scheduler).toFlushAndYield([
754 + // We've now pinged the boundary but we don't know if we should restart yet,
755 + // because we haven't completed the suspense boundary.
756 + 'Loading...',
757 + // Once we've completed the boundary we restarted.
758 + 'Async',
759 + ]);
760 expect(ReactNoop.getChildren()).toEqual([span('Async')]);
761 });
762
@@ -702,6 +794,9 @@ describe('ReactSuspenseWithNoopRenderer', () => {
794 });
795
796 it('can resume rendering earlier than a timeout', async () => {
797 + ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
798 + expect(Scheduler).toFlushAndYield([]);
799 +
800 ReactNoop.render(
801 <Suspense fallback={<Text text="Loading..." />}>
802 <AsyncText text="Async" ms={100} />
@@ -1314,16 +1409,19 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1409 });
1410
1411 it('suspends for longer if something took a long (CPU bound) time to render', async () => {
1317 - function Foo() {
1412 + function Foo({renderContent}) {
1413 Scheduler.yieldValue('Foo');
1414 return (
1415 <Suspense fallback={<Text text="Loading..." />}>
1321 - <AsyncText text="A" ms={5000} />
1416 + {renderContent ? <AsyncText text="A" ms={5000} /> : null}
1417 </Suspense>
1418 );
1419 }
1420
1421 ReactNoop.render(<Foo />);
1422 + expect(Scheduler).toFlushAndYield(['Foo']);
1423 +
1424 + ReactNoop.render(<Foo renderContent={true} />);
1425 Scheduler.advanceTime(100);
1426 await advanceTimers(100);
1427 // Start rendering
@@ -1363,7 +1461,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1461 expect(ReactNoop.getChildren()).toEqual([span('A')]);
1462 });
1463
1366 - it('suspends for longer if a fallback has been shown for a long time', async () => {
1464 + it('does not suspends if a fallback has been shown for a long time', async () => {
1465 function Foo() {
1466 Scheduler.yieldValue('Foo');
1467 return (
@@ -1387,13 +1485,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1485 'Loading more...',
1486 'Loading...',
1487 ]);
1390 - // We're now suspended and we haven't shown anything yet.
1391 - expect(ReactNoop.getChildren()).toEqual([]);
1392 -
1393 - // Show the fallback.
1394 - Scheduler.advanceTime(400);
1395 - await advanceTimers(400);
1396 - expect(Scheduler).toFlushWithoutYielding();
1488 expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1489
1490 // Wait a long time.
@@ -1408,42 +1499,92 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1499 'Suspend! [B]',
1500 'Loading more...',
1501 ]);
1411 - // Because we've already been waiting for so long we can
1412 - // wait a bit longer. Still nothing...
1413 - Scheduler.advanceTime(600);
1414 - await advanceTimers(600);
1415 - expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1416 -
1417 - // Eventually we'll show more content with inner fallback.
1418 - Scheduler.advanceTime(3000);
1419 - await advanceTimers(3000);
1420 - // No need to rerender.
1421 - expect(Scheduler).toFlushWithoutYielding();
1502 + // Because we've already been waiting for so long we've exceeded
1503 + // our threshold and we show the next level immediately.
1504 expect(ReactNoop.getChildren()).toEqual([
1505 span('A'),
1506 span('Loading more...'),
1507 ]);
1508
1509 // Flush the last promise completely
1428 - Scheduler.advanceTime(4500);
1429 - await advanceTimers(4500);
1510 + Scheduler.advanceTime(5000);
1511 + await advanceTimers(5000);
1512 // Renders successfully
1513 expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1514 expect(Scheduler).toFlushAndYield(['B']);
1515 expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
1516 });
1517
1436 - it('does not suspend for very long after a higher priority update', async () => {
1518 + it('does suspend if a fallback has been shown for a short time', async () => {
1519 function Foo() {
1520 Scheduler.yieldValue('Foo');
1521 return (
1522 <Suspense fallback={<Text text="Loading..." />}>
1441 - <AsyncText text="A" ms={5000} />
1523 + <AsyncText text="A" ms={200} />
1524 + <Suspense fallback={<Text text="Loading more..." />}>
1525 + <AsyncText text="B" ms={450} />
1526 + </Suspense>
1527 </Suspense>
1528 );
1529 }
1530
1446 - ReactNoop.discreteUpdates(() => ReactNoop.render(<Foo />));
1531 + ReactNoop.render(<Foo />);
1532 + // Start rendering
1533 + expect(Scheduler).toFlushAndYield([
1534 + 'Foo',
1535 + // A suspends
1536 + 'Suspend! [A]',
1537 + // B suspends
1538 + 'Suspend! [B]',
1539 + 'Loading more...',
1540 + 'Loading...',
1541 + ]);
1542 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1543 +
1544 + // Wait a short time.
1545 + Scheduler.advanceTime(250);
1546 + await advanceTimers(250);
1547 + expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
1548 +
1549 + // Retry with the new content.
1550 + expect(Scheduler).toFlushAndYield([
1551 + 'A',
1552 + // B still suspends
1553 + 'Suspend! [B]',
1554 + 'Loading more...',
1555 + ]);
1556 + // Because we've already been waiting for so long we can
1557 + // wait a bit longer. Still nothing...
1558 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1559 +
1560 + Scheduler.advanceTime(200);
1561 + await advanceTimers(200);
1562 +
1563 + // Before we commit another Promise resolves.
1564 + expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1565 + // We're still showing the first loading state.
1566 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1567 + // Restart and render the complete content.
1568 + expect(Scheduler).toFlushAndYield(['A', 'B']);
1569 + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
1570 + });
1571 +
1572 + it('does not suspend for very long after a higher priority update', async () => {
1573 + function Foo({renderContent}) {
1574 + Scheduler.yieldValue('Foo');
1575 + return (
1576 + <Suspense fallback={<Text text="Loading..." />}>
1577 + {renderContent ? <AsyncText text="A" ms={5000} /> : null}
1578 + </Suspense>
1579 + );
1580 + }
1581 +
1582 + ReactNoop.render(<Foo />);
1583 + expect(Scheduler).toFlushAndYield(['Foo']);
1584 +
1585 + ReactNoop.discreteUpdates(() =>
1586 + ReactNoop.render(<Foo renderContent={true} />),
1587 + );
1588 expect(Scheduler).toFlushAndYieldThrough(['Foo']);
1589
1590 // Advance some time.
@@ -1478,6 +1619,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1619
1620 it('warns when suspending inside discrete update', async () => {
1621 function A() {
1622 + Scheduler.yieldValue('A');
1623 TextResource.read(['A', 1000]);
1624 return 'A';
1625 }
@@ -1505,13 +1647,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1647 }
1648
1649 ReactNoop.discreteUpdates(() => ReactNoop.render(<App />));
1508 - Scheduler.flushAll();
1650 + expect(Scheduler).toFlushAndYieldThrough(['A']);
1651
1652 // Warning is not flushed until the commit phase
1653
1654 // Timeout and commit the fallback
1655 expect(() => {
1514 - jest.advanceTimersByTime(1000);
1656 + Scheduler.flushAll();
1657 }).toWarnDev(
1658 'The following components suspended during a user-blocking update: A, C',
1659 {withoutStack: true},
@@ -1541,11 +1683,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1683 'B',
1684 'Initial load...',
1685 ]);
1544 - // We're still suspended.
1545 - expect(ReactNoop.getChildren()).toEqual([]);
1546 - // Flush to skip suspended time.
1547 - Scheduler.advanceTime(600);
1548 - await advanceTimers(600);
1686 expect(ReactNoop.getChildren()).toEqual([span('Initial load...')]);
1687
1688 // Eventually we resolve and show the data.
@@ -1658,17 +1795,20 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1795 });
1796
1797 it('commits a suspended idle pri render within a reasonable time', async () => {
1661 - function Foo({something}) {
1798 + function Foo({renderContent}) {
1799 return (
1800 <Fragment>
1801 <Suspense fallback={<Text text="Loading A..." />}>
1665 - <AsyncText text="A" ms={10000} />
1802 + {renderContent ? <AsyncText text="A" ms={10000} /> : null}
1803 </Suspense>
1804 </Fragment>
1805 );
1806 }
1807
1808 ReactNoop.render(<Foo />);
1809 + expect(Scheduler).toFlushAndYield([]);
1810 +
1811 + ReactNoop.render(<Foo renderContent={1} />);
1812
1813 // Took a long time to render. This is to ensure we get a long suspense time.
1814 // Could also use something like withSuspenseConfig to simulate this.
@@ -1681,7 +1821,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1821
1822 // Schedule an update at idle pri.
1823 Scheduler.unstable_runWithPriority(Scheduler.unstable_IdlePriority, () =>
1684 - ReactNoop.render(<Foo something={true} />),
1824 + ReactNoop.render(<Foo renderContent={2} />),
1825 );
1826 expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading A...']);
1827
packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap
+41 -5
@@ -363,9 +363,12 @@ exports[`ReactDebugFiberPerf supports Suspense and lazy 1`] = `
363
364 ⚛ (React Tree Reconciliation: Completed Root)
365 ⚛ Parent [mount]
366 - ⛔ Suspense [mount] Warning: Rendering was suspended
366 ⚛ Suspense [mount]
368 - ⚛ Spinner [mount]
367 +
368 +⚛ (Committing Changes)
369 + ⚛ (Committing Snapshot Effects: 0 Total)
370 + ⚛ (Committing Host Effects: 1 Total)
371 + ⚛ (Calling Lifecycle Methods: 0 Total)
372 "
373 `;
374
@@ -374,21 +377,54 @@ exports[`ReactDebugFiberPerf supports Suspense and lazy 2`] = `
377
378 ⚛ (React Tree Reconciliation: Completed Root)
379 ⚛ Parent [mount]
377 - ⛔ Suspense [mount] Warning: Rendering was suspended
380 ⚛ Suspense [mount]
379 - ⚛ Spinner [mount]
381 +
382 +⚛ (Committing Changes)
383 + ⚛ (Committing Snapshot Effects: 0 Total)
384 + ⚛ (Committing Host Effects: 1 Total)
385 + ⚛ (Calling Lifecycle Methods: 0 Total)
386
387 ⚛ (Waiting for async callback...)
388
389 +⚛ (React Tree Reconciliation: Completed Root)
390 + ⚛ Parent [update]
391 + ⛔ Suspense [update] Warning: Rendering was suspended
392 + ⚛ Suspense [update]
393 + ⚛ Spinner [mount]
394 +"
395 +`;
396 +
397 +exports[`ReactDebugFiberPerf supports Suspense and lazy 3`] = `
398 +"⚛ (Waiting for async callback...)
399 +
400 ⚛ (React Tree Reconciliation: Completed Root)
401 ⚛ Parent [mount]
402 ⚛ Suspense [mount]
386 - ⚛ Foo [mount]
403
404 ⚛ (Committing Changes)
405 ⚛ (Committing Snapshot Effects: 0 Total)
406 ⚛ (Committing Host Effects: 1 Total)
407 ⚛ (Calling Lifecycle Methods: 0 Total)
408 +
409 +⚛ (Waiting for async callback...)
410 +
411 +⚛ (React Tree Reconciliation: Completed Root)
412 + ⚛ Parent [update]
413 + ⛔ Suspense [update] Warning: Rendering was suspended
414 + ⚛ Suspense [update]
415 + ⚛ Spinner [mount]
416 +
417 +⚛ (Waiting for async callback...)
418 +
419 +⚛ (React Tree Reconciliation: Completed Root)
420 + ⚛ Parent [update]
421 + ⚛ Suspense [update]
422 + ⚛ Foo [mount]
423 +
424 +⚛ (Committing Changes)
425 + ⚛ (Committing Snapshot Effects: 0 Total)
426 + ⚛ (Committing Host Effects: 2 Total)
427 + ⚛ (Calling Lifecycle Methods: 1 Total)
428 "
429 `;
430
packages/react/src/__tests__/ReactProfiler-test.internal.js
+12 -15
@@ -2303,17 +2303,6 @@ describe('Profiler', () => {
2303 'Text [Sync]',
2304 'Monkey',
2305 ]);
2306 - // The update hasn't expired yet, so we commit nothing.
2307 - expect(ReactNoop.getChildrenAsJSX()).toEqual(null);
2308 - expect(onRender).not.toHaveBeenCalled();
2309 -
2310 - // Advance both React's virtual time and Jest's timers by enough to expire
2311 - // the update, but not by enough to flush the suspending promise.
2312 - ReactNoop.expire(10000);
2313 - await awaitableAdvanceTimers(10000);
2314 - // No additional rendering work is required, since we already prepared
2315 - // the placeholder.
2316 - expect(Scheduler).toHaveYielded([]);
2306 // Should have committed the placeholder.
2307 expect(ReactNoop.getChildrenAsJSX()).toEqual('Loading...Sync');
2308 expect(onRender).toHaveBeenCalledTimes(1);
@@ -2333,7 +2322,7 @@ describe('Profiler', () => {
2322 expect(onRender).toHaveBeenCalledTimes(2);
2323
2324 // Once the promise resolves, we render the suspended view
2336 - await awaitableAdvanceTimers(10000);
2325 + await awaitableAdvanceTimers(20000);
2326 expect(Scheduler).toHaveYielded(['Promise resolved [Async]']);
2327 expect(Scheduler).toFlushAndYield(['AsyncText [Async]']);
2328 expect(ReactNoop.getChildrenAsJSX()).toEqual('AsyncSync');
@@ -2633,6 +2622,16 @@ describe('Profiler', () => {
2622 });
2623
2624 it('handles high-pri renderers between suspended and resolved (async) trees', async () => {
2625 + // Set up an initial shell. We need to set this up before the test sceanrio
2626 + // because we want initial render to suspend on navigation to the initial state.
2627 + let renderer = ReactTestRenderer.create(
2628 + <React.Profiler id="app" onRender={() => {}}>
2629 + <React.Suspense fallback={<Text text="loading" />} />
2630 + </React.Profiler>,
2631 + {unstable_isConcurrent: true},
2632 + );
2633 + expect(Scheduler).toFlushAndYield([]);
2634 +
2635 const initialRenderInteraction = {
2636 id: 0,
2637 name: 'initial render',
@@ -2640,19 +2639,17 @@ describe('Profiler', () => {
2639 };
2640
2641 const onRender = jest.fn();
2643 - let renderer;
2642 SchedulerTracing.unstable_trace(
2643 initialRenderInteraction.name,
2644 initialRenderInteraction.timestamp,
2645 () => {
2648 - renderer = ReactTestRenderer.create(
2646 + renderer.update(
2647 <React.Profiler id="app" onRender={onRender}>
2648 <React.Suspense fallback={<Text text="loading" />}>
2649 <AsyncText text="loaded" ms={100} />
2650 </React.Suspense>
2651 <Text text="initial" />
2652 </React.Profiler>,
2655 - {unstable_isConcurrent: true},
2653 );
2654 },
2655 );