Skip to content

Commit 0e52cfc

Browse files
committed
Unify use and renderDidSuspendDelayIfPossible implementations (#25922)
When unwrapping a promise with `use`, we sometimes suspend the work loop from rendering anything else until the data has resolved. This is different from how Suspense works in the old throw-a-promise world, where rather than suspend rendering midway through the render phase, we prepare a fallback and block the commit at the end, if necessary; however, the logic for determining whether it's OK to block is the same. The implementation is only incidentally different because it happens in two different parts of the code. This means for `use`, we end up doing the same checks twice, which is wasteful in terms of computation, but also introduces a risk that the logic will accidentally diverge. This unifies the implementation by moving it into the SuspenseContext module. Most of the logic for deciding whether to suspend is already performed in the begin phase of SuspenseComponent, so it makes sense to store that information on the stack rather than recompute it on demand. The way I've chosen to model this is to track whether the work loop is rendering inside the "shell" of the tree. The shell is defined as the part of the tree that's visible in the current UI. Once we enter a new Suspense boundary (or a hidden Offscreen boundary, which acts a Suspense boundary), we're no longer in the shell. This is already how Suspense behavior was modeled in terms of UX, so using this concept directly in the implementation turns out to result in less code than before. For the most part, this is purely an internal refactor, though it does fix a bug in the `use` implementation related to nested Suspense boundaries. I wouldn't be surprised if it happens to fix other bugs that we haven't yet discovered, especially around Offscreen. I'll add more tests as I think of them. DiffTrain build for [c2d6552](c2d6552) [View git log for this commit](https://github.com/facebook/react/commits/c2d6552079178b36619f5dfd1ea39ae80b1d38b5)
1 parent f71e333 commit 0e52cfc

28 files changed

+2171
-2145
lines changed

compiled/facebook-www/REVISION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
48274a43aa708f63a7580142a4c1c1a47f31c1ac
1+
c2d6552079178b36619f5dfd1ea39ae80b1d38b5
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
48274a43aa708f63a7580142a4c1c1a47f31c1ac
1+
c2d6552079178b36619f5dfd1ea39ae80b1d38b5

compiled/facebook-www/React-dev.classic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ if (
2727
}
2828
"use strict";
2929

30-
var ReactVersion = "18.3.0-www-classic-48274a43a-20230104";
30+
var ReactVersion = "18.3.0-www-classic-c2d655207-20230104";
3131

3232
// ATTENTION
3333
// When adding new symbols to this file,

compiled/facebook-www/React-dev.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ if (
2727
}
2828
"use strict";
2929

30-
var ReactVersion = "18.3.0-www-modern-48274a43a-20230104";
30+
var ReactVersion = "18.3.0-www-modern-c2d655207-20230104";
3131

3232
// ATTENTION
3333
// When adding new symbols to this file,

compiled/facebook-www/React-prod.classic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,4 +643,4 @@ exports.useSyncExternalStore = function(
643643
);
644644
};
645645
exports.useTransition = useTransition;
646-
exports.version = "18.3.0-www-classic-48274a43a-20230104";
646+
exports.version = "18.3.0-www-classic-c2d655207-20230104";

compiled/facebook-www/React-prod.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,4 +635,4 @@ exports.useSyncExternalStore = function(
635635
);
636636
};
637637
exports.useTransition = useTransition;
638-
exports.version = "18.3.0-www-modern-48274a43a-20230104";
638+
exports.version = "18.3.0-www-modern-c2d655207-20230104";

compiled/facebook-www/React-profiling.classic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ exports.useSyncExternalStore = function(
654654
);
655655
};
656656
exports.useTransition = useTransition;
657-
exports.version = "18.3.0-www-classic-48274a43a-20230104";
657+
exports.version = "18.3.0-www-classic-c2d655207-20230104";
658658

659659
/* global __REACT_DEVTOOLS_GLOBAL_HOOK__ */
660660
if (

compiled/facebook-www/React-profiling.modern.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ exports.useSyncExternalStore = function(
646646
);
647647
};
648648
exports.useTransition = useTransition;
649-
exports.version = "18.3.0-www-modern-48274a43a-20230104";
649+
exports.version = "18.3.0-www-modern-c2d655207-20230104";
650650

651651
/* global __REACT_DEVTOOLS_GLOBAL_HOOK__ */
652652
if (

compiled/facebook-www/ReactART-dev.classic.js

Lines changed: 138 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function _assertThisInitialized(self) {
6969
return self;
7070
}
7171

72-
var ReactVersion = "18.3.0-www-classic-48274a43a-20230104";
72+
var ReactVersion = "18.3.0-www-classic-c2d655207-20230104";
7373

7474
var LegacyRoot = 0;
7575
var ConcurrentRoot = 1;
@@ -6864,71 +6864,68 @@ function isCurrentTreeHidden() {
68646864

68656865
// suspends, i.e. it's the nearest `catch` block on the stack.
68666866

6867-
var suspenseHandlerStackCursor = createCursor(null);
6868-
6869-
function shouldAvoidedBoundaryCapture(workInProgress, handlerOnStack, props) {
6870-
{
6871-
// If the parent is already showing content, and we're not inside a hidden
6872-
// tree, then we should show the avoided fallback.
6873-
if (handlerOnStack.alternate !== null && !isCurrentTreeHidden()) {
6874-
return true;
6875-
} // If the handler on the stack is also an avoided boundary, then we should
6876-
// favor this inner one.
6877-
6878-
if (
6879-
handlerOnStack.tag === SuspenseComponent &&
6880-
handlerOnStack.memoizedProps.unstable_avoidThisFallback === true
6881-
) {
6882-
return true;
6883-
} // If this avoided boundary is dehydrated, then it should capture.
6884-
6885-
var suspenseState = workInProgress.memoizedState;
6886-
6887-
if (suspenseState !== null && suspenseState.dehydrated !== null) {
6888-
return true;
6889-
}
6890-
} // If none of those cases apply, then we should avoid this fallback and show
6891-
// the outer one instead.
6892-
6893-
return false;
6867+
var suspenseHandlerStackCursor = createCursor(null); // Represents the outermost boundary that is not visible in the current tree.
6868+
// Everything above this is the "shell". When this is null, it means we're
6869+
// rendering in the shell of the app. If it's non-null, it means we're rendering
6870+
// deeper than the shell, inside a new tree that wasn't already visible.
6871+
//
6872+
// The main way we use this concept is to determine whether showing a fallback
6873+
// would result in a desirable or undesirable loading state. Activing a fallback
6874+
// in the shell is considered an undersirable loading state, because it would
6875+
// mean hiding visible (albeit stale) content in the current tree — we prefer to
6876+
// show the stale content, rather than switch to a fallback. But showing a
6877+
// fallback in a new tree is fine, because there's no stale content to
6878+
// prefer instead.
6879+
6880+
var shellBoundary = null;
6881+
function getShellBoundary() {
6882+
return shellBoundary;
68946883
}
6884+
function pushPrimaryTreeSuspenseHandler(handler) {
6885+
// TODO: Pass as argument
6886+
var current = handler.alternate;
6887+
var props = handler.pendingProps; // Experimental feature: Some Suspense boundaries are marked as having an
6888+
// undesirable fallback state. These have special behavior where we only
6889+
// activate the fallback if there's no other boundary on the stack that we can
6890+
// use instead.
68956891

6896-
function isBadSuspenseFallback(current, nextProps) {
6897-
// Check if this is a "bad" fallback state or a good one. A bad fallback state
6898-
// is one that we only show as a last resort; if this is a transition, we'll
6899-
// block it from displaying, and wait for more data to arrive.
6900-
if (current !== null) {
6901-
var prevState = current.memoizedState;
6902-
var isShowingFallback = prevState !== null;
6903-
6904-
if (!isShowingFallback && !isCurrentTreeHidden()) {
6905-
// It's bad to switch to a fallback if content is already visible
6906-
return true;
6892+
if (
6893+
props.unstable_avoidThisFallback === true && // If an avoided boundary is already visible, it behaves identically to
6894+
// a regular Suspense boundary.
6895+
(current === null || isCurrentTreeHidden())
6896+
) {
6897+
if (shellBoundary === null) {
6898+
// We're rendering in the shell. There's no parent Suspense boundary that
6899+
// can provide a desirable fallback state. We'll use this boundary.
6900+
push(suspenseHandlerStackCursor, handler, handler); // However, because this is not a desirable fallback, the children are
6901+
// still considered part of the shell. So we intentionally don't assign
6902+
// to `shellBoundary`.
6903+
} else {
6904+
// There's already a parent Suspense boundary that can provide a desirable
6905+
// fallback state. Prefer that one.
6906+
var handlerOnStack = suspenseHandlerStackCursor.current;
6907+
push(suspenseHandlerStackCursor, handlerOnStack, handler);
69076908
}
6908-
}
69096909

6910-
if (nextProps.unstable_avoidThisFallback === true) {
6911-
// Experimental: Some fallbacks are always bad
6912-
return true;
6913-
}
6910+
return;
6911+
} // TODO: If the parent Suspense handler already suspended, there's no reason
6912+
// to push a nested Suspense handler, because it will get replaced by the
6913+
// outer fallback, anyway. Consider this as a future optimization.
69146914

6915-
return false;
6916-
}
6917-
function pushPrimaryTreeSuspenseHandler(handler) {
6918-
var props = handler.pendingProps;
6919-
var handlerOnStack = suspenseHandlerStackCursor.current;
6915+
push(suspenseHandlerStackCursor, handler, handler);
69206916

6921-
if (
6922-
props.unstable_avoidThisFallback === true &&
6923-
handlerOnStack !== null &&
6924-
!shouldAvoidedBoundaryCapture(handler, handlerOnStack)
6925-
) {
6926-
// This boundary should not capture if something suspends. Reuse the
6927-
// existing handler on the stack.
6928-
push(suspenseHandlerStackCursor, handlerOnStack, handler);
6929-
} else {
6930-
// Push this handler onto the stack.
6931-
push(suspenseHandlerStackCursor, handler, handler);
6917+
if (shellBoundary === null) {
6918+
if (current === null || isCurrentTreeHidden()) {
6919+
// This boundary is not visible in the current UI.
6920+
shellBoundary = handler;
6921+
} else {
6922+
var prevState = current.memoizedState;
6923+
6924+
if (prevState !== null) {
6925+
// This boundary is showing a fallback in the current UI.
6926+
shellBoundary = handler;
6927+
}
6928+
}
69326929
}
69336930
}
69346931
function pushFallbackTreeSuspenseHandler(fiber) {
@@ -6940,6 +6937,21 @@ function pushFallbackTreeSuspenseHandler(fiber) {
69406937
function pushOffscreenSuspenseHandler(fiber) {
69416938
if (fiber.tag === OffscreenComponent) {
69426939
push(suspenseHandlerStackCursor, fiber, fiber);
6940+
6941+
if (shellBoundary !== null);
6942+
else {
6943+
var current = fiber.alternate;
6944+
6945+
if (current !== null) {
6946+
var prevState = current.memoizedState;
6947+
6948+
if (prevState !== null) {
6949+
// This is the first boundary in the stack that's already showing
6950+
// a fallback. So everything outside is considered the shell.
6951+
shellBoundary = fiber;
6952+
}
6953+
}
6954+
}
69436955
} else {
69446956
// This is a LegacyHidden component.
69456957
reuseSuspenseHandlerOnStack(fiber);
@@ -6953,6 +6965,11 @@ function getSuspenseHandler() {
69536965
}
69546966
function popSuspenseHandler(fiber) {
69556967
pop(suspenseHandlerStackCursor, fiber);
6968+
6969+
if (shellBoundary === fiber) {
6970+
// Popping back into the shell.
6971+
shellBoundary = null;
6972+
}
69566973
} // SuspenseList context
69576974
// TODO: Move to a separate module? We may change the SuspenseList
69586975
// implementation to hide/show in the commit phase, anyway.
@@ -12368,13 +12385,49 @@ function throwException(
1236812385
logComponentSuspended(name, wakeable);
1236912386
}
1237012387
}
12371-
} // Schedule the nearest Suspense to re-render the timed out view.
12388+
} // Mark the nearest Suspense boundary to switch to rendering a fallback.
1237212389

1237312390
var suspenseBoundary = getSuspenseHandler();
1237412391

1237512392
if (suspenseBoundary !== null) {
1237612393
switch (suspenseBoundary.tag) {
1237712394
case SuspenseComponent: {
12395+
// If this suspense boundary is not already showing a fallback, mark
12396+
// the in-progress render as suspended. We try to perform this logic
12397+
// as soon as soon as possible during the render phase, so the work
12398+
// loop can know things like whether it's OK to switch to other tasks,
12399+
// or whether it can wait for data to resolve before continuing.
12400+
// TODO: Most of these checks are already performed when entering a
12401+
// Suspense boundary. We should track the information on the stack so
12402+
// we don't have to recompute it on demand. This would also allow us
12403+
// to unify with `use` which needs to perform this logic even sooner,
12404+
// before `throwException` is called.
12405+
if (sourceFiber.mode & ConcurrentMode) {
12406+
if (getShellBoundary() === null) {
12407+
// Suspended in the "shell" of the app. This is an undesirable
12408+
// loading state. We should avoid committing this tree.
12409+
renderDidSuspendDelayIfPossible();
12410+
} else {
12411+
// If we suspended deeper than the shell, we don't need to delay
12412+
// the commmit. However, we still call renderDidSuspend if this is
12413+
// a new boundary, to tell the work loop that a new fallback has
12414+
// appeared during this render.
12415+
// TODO: Theoretically we should be able to delete this branch.
12416+
// It's currently used for two things: 1) to throttle the
12417+
// appearance of successive loading states, and 2) in
12418+
// SuspenseList, to determine whether the children include any
12419+
// pending fallbacks. For 1, we should apply throttling to all
12420+
// retries, not just ones that render an additional fallback. For
12421+
// 2, we should check subtreeFlags instead. Then we can delete
12422+
// this branch.
12423+
var current = suspenseBoundary.alternate;
12424+
12425+
if (current === null) {
12426+
renderDidSuspend();
12427+
}
12428+
}
12429+
}
12430+
1237812431
suspenseBoundary.flags &= ~ForceClientRender;
1237912432
markSuspenseBoundaryShouldCapture(
1238012433
suspenseBoundary,
@@ -18001,24 +18054,7 @@ function completeWork(current, workInProgress, renderLanes) {
1800118054

1800218055
if (nextDidTimeout) {
1800318056
var _offscreenFiber2 = workInProgress.child;
18004-
_offscreenFiber2.flags |= Visibility; // TODO: This will still suspend a synchronous tree if anything
18005-
// in the concurrent tree already suspended during this render.
18006-
// This is a known bug.
18007-
18008-
if ((workInProgress.mode & ConcurrentMode) !== NoMode) {
18009-
// TODO: Move this back to throwException because this is too late
18010-
// if this is a large tree which is common for initial loads. We
18011-
// don't know if we should restart a render or not until we get
18012-
// this marker, and this is too late.
18013-
// If this render already had a ping or lower pri updates,
18014-
// and this is the first time we know we're going to suspend we
18015-
// should be able to immediately restart from within throwException.
18016-
if (isBadSuspenseFallback(current, newProps)) {
18017-
renderDidSuspendDelayIfPossible();
18018-
} else {
18019-
renderDidSuspend();
18020-
}
18021-
}
18057+
_offscreenFiber2.flags |= Visibility;
1802218058
}
1802318059
}
1802418060

@@ -24294,16 +24330,11 @@ function handleThrow(root, thrownValue) {
2429424330
}
2429524331

2429624332
function shouldAttemptToSuspendUntilDataResolves() {
24297-
// TODO: We should be able to move the
24298-
// renderDidSuspend/renderDidSuspendDelayIfPossible logic into this function,
24299-
// instead of repeating it in the complete phase. Or something to that effect.
24300-
if (includesOnlyRetries(workInProgressRootRenderLanes)) {
24301-
// We can always wait during a retry.
24302-
return true;
24303-
} // Check if there are other pending updates that might possibly unblock this
24333+
// Check if there are other pending updates that might possibly unblock this
2430424334
// component from suspending. This mirrors the check in
2430524335
// renderDidSuspendDelayIfPossible. We should attempt to unify them somehow.
24306-
24336+
// TODO: Consider unwinding immediately, using the
24337+
// SuspendedOnHydration mechanism.
2430724338
if (
2430824339
includesNonIdleWork(workInProgressRootSkippedLanes) ||
2430924340
includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
@@ -24315,28 +24346,22 @@ function shouldAttemptToSuspendUntilDataResolves() {
2431524346
// finishConcurrentRender, and rely just on this one.
2431624347

2431724348
if (includesOnlyTransitions(workInProgressRootRenderLanes)) {
24318-
var suspenseHandler = getSuspenseHandler();
24319-
24320-
if (suspenseHandler !== null && suspenseHandler.tag === SuspenseComponent) {
24321-
var currentSuspenseHandler = suspenseHandler.alternate;
24322-
var nextProps = suspenseHandler.memoizedProps;
24349+
// If we're rendering inside the "shell" of the app, it's better to suspend
24350+
// rendering and wait for the data to resolve. Otherwise, we should switch
24351+
// to a fallback and continue rendering.
24352+
return getShellBoundary() === null;
24353+
}
2432324354

24324-
if (isBadSuspenseFallback(currentSuspenseHandler, nextProps)) {
24325-
// The nearest Suspense boundary is already showing content. We should
24326-
// avoid replacing it with a fallback, and instead wait until the
24327-
// data finishes loading.
24328-
return true;
24329-
} else {
24330-
// This is not a bad fallback condition. We should show a fallback
24331-
// immediately instead of waiting for the data to resolve. This includes
24332-
// when suspending inside new trees.
24333-
return false;
24334-
}
24335-
} // During a transition, if there is no Suspense boundary (i.e. suspending in
24336-
// the "shell" of an application), or if we're inside a hidden tree, then
24337-
// we should wait until the data finishes loading.
24355+
var handler = getSuspenseHandler();
2433824356

24339-
return true;
24357+
if (handler === null);
24358+
else {
24359+
if (includesOnlyRetries(workInProgressRootRenderLanes)) {
24360+
// During a retry, we can suspend rendering if the nearest Suspense boundary
24361+
// is the boundary of the "shell", because we're guaranteed not to block
24362+
// any new content from appearing.
24363+
return handler === getShellBoundary();
24364+
}
2434024365
} // For all other Lanes besides Transitions and Retries, we should not wait
2434124366
// for the data to load.
2434224367
// TODO: We should wait during Offscreen prerendering, too.
@@ -24406,6 +24431,8 @@ function renderDidSuspendDelayIfPossible() {
2440624431
// (inside this function), since by suspending at the end of the render
2440724432
// phase introduces a potential mistake where we suspend lanes that were
2440824433
// pinged or updated while we were rendering.
24434+
// TODO: Consider unwinding immediately, using the
24435+
// SuspendedOnHydration mechanism.
2440924436
markRootSuspended$1(workInProgressRoot, workInProgressRootRenderLanes);
2441024437
}
2441124438
}
@@ -24621,6 +24648,10 @@ function renderRootConcurrent(root, lanes) {
2462124648
break;
2462224649
} // The work loop is suspended on data. We should wait for it to
2462324650
// resolve before continuing to render.
24651+
// TODO: Handle the case where the promise resolves synchronously.
24652+
// Usually this is handled when we instrument the promise to add a
24653+
// `status` field, but if the promise already has a status, we won't
24654+
// have added a listener until right here.
2462424655

2462524656
var onResolution = function() {
2462624657
ensureRootIsScheduled(root, now());

0 commit comments

Comments
 (0)