Skip to content

Commit 113497c

Browse files
authored
[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
1 parent 3b23022 commit 113497c

14 files changed

+592
-278
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

+4-9
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,9 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
14131413
}
14141414
}
14151415

1416+
// TODO: This is now an empty object. Should we just make it a boolean?
1417+
const SUSPENDED_MARKER: SuspenseState = ({}: any);
1418+
14161419
function updateSuspenseComponent(
14171420
current,
14181421
workInProgress,
@@ -1440,17 +1443,9 @@ function updateSuspenseComponent(
14401443
(ForceSuspenseFallback: SuspenseContext),
14411444
)
14421445
) {
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;
14461446
// Something in this boundary's subtree already suspended. Switch to
14471447
// rendering the fallback children.
1448-
nextState = {
1449-
fallbackExpirationTime:
1450-
attemptedState !== null
1451-
? attemptedState.fallbackExpirationTime
1452-
: NoWork,
1453-
};
1448+
nextState = SUSPENDED_MARKER;
14541449
nextDidTimeout = true;
14551450
workInProgress.effectTag &= ~DidCapture;
14561451
} else {

packages/react-reconciler/src/ReactFiberCommitWork.js

+2-15
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,6 @@ import invariant from 'shared/invariant';
6363
import warningWithoutStack from 'shared/warningWithoutStack';
6464
import warning from 'shared/warning';
6565

66-
import {
67-
NoWork,
68-
computeAsyncExpirationNoBucket,
69-
} from './ReactFiberExpirationTime';
7066
import {onCommitUnmount} from './ReactFiberDevToolsHook';
7167
import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf';
7268
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
@@ -102,8 +98,8 @@ import {
10298
} from './ReactFiberHostConfig';
10399
import {
104100
captureCommitPhaseError,
105-
requestCurrentTime,
106101
resolveRetryThenable,
102+
markCommitTimeOfFallback,
107103
} from './ReactFiberWorkLoop';
108104
import {
109105
NoEffect as NoHookEffect,
@@ -1288,16 +1284,7 @@ function commitSuspenseComponent(finishedWork: Fiber) {
12881284
} else {
12891285
newDidTimeout = true;
12901286
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();
13011288
}
13021289

13031290
if (supportsMutation && primaryChildParent !== null) {

packages/react-reconciler/src/ReactFiberCompleteWork.js

+7-9
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ import {
101101
enableEventAPI,
102102
} from 'shared/ReactFeatureFlags';
103103
import {
104-
markRenderEventTimeAndConfig,
105104
renderDidSuspend,
106105
renderDidSuspendDelayIfPossible,
107106
} from './ReactFiberWorkLoop';
@@ -702,14 +701,6 @@ function completeWork(
702701
prevDidTimeout = prevState !== null;
703702
if (!nextDidTimeout && prevState !== null) {
704703
// 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-
713704
// Delete the fallback.
714705
// TODO: Would it be better to store the fallback fragment on
715706
// the stateNode during the begin phase?
@@ -737,6 +728,13 @@ function completeWork(
737728
// in the concurrent tree already suspended during this render.
738729
// This is a known bug.
739730
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.
740738
const hasInvisibleChildContext =
741739
current === null &&
742740
workInProgress.memoizedProps.unstable_avoidThisFallback !== true;

packages/react-reconciler/src/ReactFiberExpirationTime.js

-8
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,6 @@ export function computeSuspenseExpiration(
8383
);
8484
}
8585

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-
9486
// We intentionally set a higher expiration time for interactive updates in
9587
// dev than in production.
9688
//

packages/react-reconciler/src/ReactFiberSuspenseComponent.js

+2-4
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@
88
*/
99

1010
import type {Fiber} from './ReactFiber';
11-
import type {ExpirationTime} from './ReactFiberExpirationTime';
1211

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 = {||};
1614

1715
export function shouldCaptureSuspense(
1816
workInProgress: Fiber,

packages/react-reconciler/src/ReactFiberThrow.js

+39
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,45 @@ function throwException(
275275

276276
// Confirmed that the boundary is in a concurrent mode tree. Continue
277277
// 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.
278317

279318
attachPingListener(root, renderExpirationTime, thenable);
280319

0 commit comments

Comments
 (0)