Skip to content

Commit ca09d0c

Browse files
committed
Move renderDidSuspend logic to throwException
This is part of a larger refactor I'm doing to simplify how this logic works, since it's currently spread out and duplicated across several different parts of the work loop. When a Suspense boundary shows a fallback, and it wasn't already showing a fallback, we mark the render as suspended. Knowing whether the in-progress tree includes a new fallback is useful for several reasons: we can choose to interrupt the current render and switch to a different task, we can suspend the work loop until more data becomes available, we can throttle the appearance of multiple fallback states, and so on. Currently this logic happens in the complete phase, but because we use renderDidSuspendWithDelay as a signal to interrupt the work loop, it's best to do it early as we possibly can. A subsequent step will move the logic even earlier, as soon as we attempt to unwrap an unresolved promise, so that `use` can determine whether it's OK to pause the entire work loop and wait for the promise. There's some existing code that attempts to do this but it doesn't have parity with how `renderDidSuspend` works, which is the main motivation for this series of refactors.
1 parent 9659788 commit ca09d0c

File tree

5 files changed

+66
-33
lines changed

5 files changed

+66
-33
lines changed

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,6 @@ import {
126126
setShallowSuspenseListContext,
127127
ForceSuspenseFallback,
128128
setDefaultShallowSuspenseListContext,
129-
isBadSuspenseFallback,
130129
} from './ReactFiberSuspenseContext';
131130
import {popHiddenContext} from './ReactFiberHiddenContext';
132131
import {findFirstSuspended} from './ReactFiberSuspenseComponent';
@@ -148,8 +147,6 @@ import {
148147
upgradeHydrationErrorsToRecoverable,
149148
} from './ReactFiberHydrationContext';
150149
import {
151-
renderDidSuspend,
152-
renderDidSuspendDelayIfPossible,
153150
renderHasNotSuspendedYet,
154151
getRenderTargetTime,
155152
getWorkInProgressTransitions,
@@ -1257,24 +1254,6 @@ function completeWork(
12571254
if (nextDidTimeout) {
12581255
const offscreenFiber: Fiber = (workInProgress.child: any);
12591256
offscreenFiber.flags |= Visibility;
1260-
1261-
// TODO: This will still suspend a synchronous tree if anything
1262-
// in the concurrent tree already suspended during this render.
1263-
// This is a known bug.
1264-
if ((workInProgress.mode & ConcurrentMode) !== NoMode) {
1265-
// TODO: Move this back to throwException because this is too late
1266-
// if this is a large tree which is common for initial loads. We
1267-
// don't know if we should restart a render or not until we get
1268-
// this marker, and this is too late.
1269-
// If this render already had a ping or lower pri updates,
1270-
// and this is the first time we know we're going to suspend we
1271-
// should be able to immediately restart from within throwException.
1272-
if (isBadSuspenseFallback(current, newProps)) {
1273-
renderDidSuspendDelayIfPossible();
1274-
} else {
1275-
renderDidSuspend();
1276-
}
1277-
}
12781257
}
12791258
}
12801259

packages/react-reconciler/src/ReactFiberSuspenseContext.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ export function isBadSuspenseFallback(
6262
// Check if this is a "bad" fallback state or a good one. A bad fallback state
6363
// is one that we only show as a last resort; if this is a transition, we'll
6464
// block it from displaying, and wait for more data to arrive.
65+
// TODO: This is function is only used by the `use` implementation. There's
66+
// identical logic in `throwException`, and also in the begin phase of the
67+
// Suspense component. Since we're already computing this in the begin phase,
68+
// track it on stack instead of re-computing it on demand. Less code, less
69+
// duplicated logic, less computation.
6570
if (current !== null) {
6671
const prevState: SuspenseState = current.memoizedState;
6772
const isShowingFallback = prevState !== null;

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {CapturedValue} from './ReactCapturedValue';
1313
import type {Update} from './ReactFiberClassUpdateQueue';
1414
import type {Wakeable} from 'shared/ReactTypes';
1515
import type {OffscreenQueue} from './ReactFiberOffscreenComponent';
16+
import type {SuspenseState} from './ReactFiberSuspenseComponent';
1617

1718
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
1819
import {
@@ -39,6 +40,7 @@ import {
3940
enableDebugTracing,
4041
enableLazyContextPropagation,
4142
enableUpdaterTracking,
43+
enableSuspenseAvoidThisFallback,
4244
} from 'shared/ReactFeatureFlags';
4345
import {createCapturedValueAtFiber} from './ReactCapturedValue';
4446
import {
@@ -58,6 +60,7 @@ import {
5860
isAlreadyFailedLegacyErrorBoundary,
5961
attachPingListener,
6062
restorePendingUpdaters,
63+
renderDidSuspend,
6164
} from './ReactFiberWorkLoop';
6265
import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext';
6366
import {logCapturedError} from './ReactFiberErrorLogger';
@@ -349,11 +352,60 @@ function throwException(
349352
}
350353
}
351354

352-
// Schedule the nearest Suspense to re-render the timed out view.
355+
// Mark the nearest Suspense boundary to switch to rendering a fallback.
353356
const suspenseBoundary = getSuspenseHandler();
354357
if (suspenseBoundary !== null) {
355358
switch (suspenseBoundary.tag) {
356359
case SuspenseComponent: {
360+
// If this suspense boundary is not already showing a fallback, mark
361+
// the in-progress render as suspended. We try to perform this logic
362+
// as soon as soon as possible during the render phase, so the work
363+
// loop can know things like whether it's OK to switch to other tasks,
364+
// or whether it can wait for data to resolve before continuing.
365+
// TODO: Most of these checks are already performed when entering a
366+
// Suspense boundary. We should track the information on the stack so
367+
// we don't have to recompute it on demand. This would also allow us
368+
// to unify with `use` which needs to perform this logic even sooner,
369+
// before `throwException` is called.
370+
if (sourceFiber.mode & ConcurrentMode) {
371+
if (getIsHydrating()) {
372+
// A dehydrated boundary is considered a fallback state. We don't
373+
// have to suspend.
374+
} else {
375+
const current = suspenseBoundary.alternate;
376+
if (current === null) {
377+
// This is a new mount. Unless this is an "avoided" fallback
378+
// (experimental feature) this should not delay the tree
379+
// from appearing.
380+
const nextProps = suspenseBoundary.pendingProps;
381+
if (
382+
enableSuspenseAvoidThisFallback &&
383+
nextProps.unstable_avoidThisFallback === true
384+
) {
385+
// Experimental feature: Some fallbacks are always bad
386+
renderDidSuspendDelayIfPossible();
387+
} else {
388+
// Show a fallback without delaying. The only reason we mark
389+
// this case at all is so we can throttle the appearance of
390+
// new fallbacks. If we did nothing here, all other behavior
391+
// would be the same, except it wouldn't throttle.
392+
renderDidSuspend();
393+
}
394+
} else {
395+
const prevState: SuspenseState = current.memoizedState;
396+
if (prevState !== null) {
397+
// This boundary is currently showing a fallback. Don't need
398+
// to suspend.
399+
} else {
400+
// This boundary is currently showing content. Switching to a
401+
// fallback will cause that content to disappear. Tell the
402+
// work loop to delay the commit, if possible.
403+
renderDidSuspendDelayIfPossible();
404+
}
405+
}
406+
}
407+
}
408+
357409
suspenseBoundary.flags &= ~ForceClientRender;
358410
markSuspenseBoundaryShouldCapture(
359411
suspenseBoundary,

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1851,9 +1851,10 @@ function handleThrow(root, thrownValue): void {
18511851
}
18521852

18531853
function shouldAttemptToSuspendUntilDataResolves() {
1854-
// TODO: We should be able to move the
1855-
// renderDidSuspend/renderDidSuspendDelayIfPossible logic into this function,
1856-
// instead of repeating it in the complete phase. Or something to that effect.
1854+
// TODO: This function needs to have parity with
1855+
// renderDidSuspendDelayIfPossible, but it currently doesn't. (It only affects
1856+
// the `use` API.) Fix by unifying the logic here with the equivalent checks
1857+
// in `throwException` and in the begin phase of Suspense.
18571858

18581859
if (includesOnlyRetries(workInProgressRootRenderLanes)) {
18591860
// We can always wait during a retry.

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -995,14 +995,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
995995
expect(Scheduler).toFlushAndYieldThrough(['Suspend! [Async]', 'Sibling']);
996996

997997
await resolveText('Async');
998-
expect(Scheduler).toFlushAndYield([
999-
// We've now pinged the boundary but we don't know if we should restart yet,
1000-
// because we haven't completed the suspense boundary.
1001-
'Loading...',
1002-
// Once we've completed the boundary we restarted.
1003-
'Async',
1004-
'Sibling',
1005-
]);
998+
999+
// Because we're already showing a fallback, interrupt the current render
1000+
// and restart immediately.
1001+
expect(Scheduler).toFlushAndYield(['Async', 'Sibling']);
10061002
expect(root).toMatchRenderedOutput(
10071003
<>
10081004
<span prop="Async" />

0 commit comments

Comments
 (0)