Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow suspending outside a Suspense boundary #23267

Merged
merged 1 commit into from Feb 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
95 changes: 60 additions & 35 deletions packages/react-reconciler/src/ReactFiberThrow.new.js
Expand Up @@ -62,6 +62,7 @@ import {
} from './ReactFiberSuspenseContext.new';
import {
renderDidError,
renderDidSuspendDelayIfPossible,
onUncaughtError,
markLegacyErrorBoundaryAsFailed,
isAlreadyFailedLegacyErrorBoundary,
Expand All @@ -78,6 +79,7 @@ import {
includesSomeLane,
mergeLanes,
pickArbitraryLane,
includesOnlyTransitions,
} from './ReactFiberLane.new';
import {
getIsHydrating,
Expand Down Expand Up @@ -165,12 +167,7 @@ function createClassErrorUpdate(
return update;
}

function attachWakeableListeners(
suspenseBoundary: Fiber,
root: FiberRoot,
wakeable: Wakeable,
lanes: Lanes,
) {
function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
// Attach a ping listener
//
// The data might resolve before we have a chance to commit the fallback. Or,
Expand All @@ -183,34 +180,39 @@ function attachWakeableListeners(
//
// We only need to do this in concurrent mode. Legacy Suspense always
// commits fallbacks synchronously, so there are no pings.
if (suspenseBoundary.mode & ConcurrentMode) {
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
} else {
threadIDs = pingCache.get(wakeable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
} else {
threadIDs = pingCache.get(wakeable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
}
}
if (!threadIDs.has(lanes)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(lanes);
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
if (enableUpdaterTracking) {
if (isDevToolsPresent) {
// If we have pending work still, restore the original updaters
restorePendingUpdaters(root, lanes);
}
}
if (!threadIDs.has(lanes)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(lanes);
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
if (enableUpdaterTracking) {
if (isDevToolsPresent) {
// If we have pending work still, restore the original updaters
restorePendingUpdaters(root, lanes);
}
wakeable.then(ping, ping);
}
wakeable.then(ping, ping);
}
}

function attachRetryListener(
suspenseBoundary: Fiber,
root: FiberRoot,
wakeable: Wakeable,
lanes: Lanes,
) {
// Retry listener
//
// If the fallback does commit, we need to attach a different type of
Expand Down Expand Up @@ -470,24 +472,47 @@ function throwException(
root,
rootRenderLanes,
);
attachWakeableListeners(
suspenseBoundary,
root,
wakeable,
rootRenderLanes,
);
// We only attach ping listeners in concurrent mode. Legacy Suspense always
// commits fallbacks synchronously, so there are no pings.
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
return;
} else {
// No boundary was found. Fallthrough to error mode.
// No boundary was found. If we're inside startTransition, this is OK.
// We can suspend and wait for more data to arrive.

if (includesOnlyTransitions(rootRenderLanes)) {
// This is a transition. Suspend. Since we're not activating a Suspense
// boundary, this will unwind all the way to the root without performing
// a second pass to render a fallback. (This is arguably how refresh
// transitions should work, too, since we're not going to commit the
// fallbacks anyway.)
attachPingListener(root, wakeable, rootRenderLanes);
renderDidSuspendDelayIfPossible();
Comment on lines +487 to +493
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the key change. Because we exit throwException without marking a boundary, the root will unwind all the way to the root without completing.

return;
}

// We're not in a transition. We treat this case like an error because
// discrete renders are expected to finish synchronously to maintain
// consistency with external state.
// TODO: This will error during non-transition concurrent renders, too.
// But maybe it shouldn't?

// TODO: We should never call getComponentNameFromFiber in production.
// Log a warning or something to prevent us from accidentally bundling it.
value = new Error(
const uncaughtSuspenseError = new Error(
(getComponentNameFromFiber(sourceFiber) || 'A React component') +
' suspended while rendering, but no fallback UI was specified.\n' +
'\n' +
'Add a <Suspense fallback=...> component higher in the tree to ' +
'provide a loading indicator or placeholder to display.',
);

// If we're outside a transition, fall through to the regular error path.
// The error will be caught by the nearest suspense boundary.
value = uncaughtSuspenseError;
}
} else {
// This is a regular error, not a Suspense wakeable.
Expand Down
95 changes: 60 additions & 35 deletions packages/react-reconciler/src/ReactFiberThrow.old.js
Expand Up @@ -62,6 +62,7 @@ import {
} from './ReactFiberSuspenseContext.old';
import {
renderDidError,
renderDidSuspendDelayIfPossible,
onUncaughtError,
markLegacyErrorBoundaryAsFailed,
isAlreadyFailedLegacyErrorBoundary,
Expand All @@ -78,6 +79,7 @@ import {
includesSomeLane,
mergeLanes,
pickArbitraryLane,
includesOnlyTransitions,
} from './ReactFiberLane.old';
import {
getIsHydrating,
Expand Down Expand Up @@ -165,12 +167,7 @@ function createClassErrorUpdate(
return update;
}

function attachWakeableListeners(
suspenseBoundary: Fiber,
root: FiberRoot,
wakeable: Wakeable,
lanes: Lanes,
) {
function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
// Attach a ping listener
//
// The data might resolve before we have a chance to commit the fallback. Or,
Expand All @@ -183,34 +180,39 @@ function attachWakeableListeners(
//
// We only need to do this in concurrent mode. Legacy Suspense always
// commits fallbacks synchronously, so there are no pings.
if (suspenseBoundary.mode & ConcurrentMode) {
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
} else {
threadIDs = pingCache.get(wakeable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
} else {
threadIDs = pingCache.get(wakeable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
}
}
if (!threadIDs.has(lanes)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(lanes);
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
if (enableUpdaterTracking) {
if (isDevToolsPresent) {
// If we have pending work still, restore the original updaters
restorePendingUpdaters(root, lanes);
}
}
if (!threadIDs.has(lanes)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(lanes);
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
if (enableUpdaterTracking) {
if (isDevToolsPresent) {
// If we have pending work still, restore the original updaters
restorePendingUpdaters(root, lanes);
}
wakeable.then(ping, ping);
}
wakeable.then(ping, ping);
}
}

function attachRetryListener(
suspenseBoundary: Fiber,
root: FiberRoot,
wakeable: Wakeable,
lanes: Lanes,
) {
// Retry listener
//
// If the fallback does commit, we need to attach a different type of
Expand Down Expand Up @@ -470,24 +472,47 @@ function throwException(
root,
rootRenderLanes,
);
attachWakeableListeners(
suspenseBoundary,
root,
wakeable,
rootRenderLanes,
);
// We only attach ping listeners in concurrent mode. Legacy Suspense always
// commits fallbacks synchronously, so there are no pings.
if (suspenseBoundary.mode & ConcurrentMode) {
attachPingListener(root, wakeable, rootRenderLanes);
}
attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
return;
} else {
// No boundary was found. Fallthrough to error mode.
// No boundary was found. If we're inside startTransition, this is OK.
// We can suspend and wait for more data to arrive.

if (includesOnlyTransitions(rootRenderLanes)) {
// This is a transition. Suspend. Since we're not activating a Suspense
// boundary, this will unwind all the way to the root without performing
// a second pass to render a fallback. (This is arguably how refresh
// transitions should work, too, since we're not going to commit the
// fallbacks anyway.)
attachPingListener(root, wakeable, rootRenderLanes);
renderDidSuspendDelayIfPossible();
return;
}

// We're not in a transition. We treat this case like an error because
// discrete renders are expected to finish synchronously to maintain
// consistency with external state.
// TODO: This will error during non-transition concurrent renders, too.
// But maybe it shouldn't?

// TODO: We should never call getComponentNameFromFiber in production.
// Log a warning or something to prevent us from accidentally bundling it.
value = new Error(
const uncaughtSuspenseError = new Error(
(getComponentNameFromFiber(sourceFiber) || 'A React component') +
' suspended while rendering, but no fallback UI was specified.\n' +
'\n' +
'Add a <Suspense fallback=...> component higher in the tree to ' +
'provide a loading indicator or placeholder to display.',
);

// If we're outside a transition, fall through to the regular error path.
// The error will be caught by the nearest suspense boundary.
value = uncaughtSuspenseError;
}
} else {
// This is a regular error, not a Suspense wakeable.
Expand Down
19 changes: 10 additions & 9 deletions packages/react-reconciler/src/ReactFiberUnwindWork.new.js
Expand Up @@ -89,16 +89,17 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
popTopLevelLegacyContextObject(workInProgress);
resetMutableSourceWorkInProgressVersions();
const flags = workInProgress.flags;

if ((flags & DidCapture) !== NoFlags) {
throw new Error(
'The root failed to unmount after an error. This is likely a bug in ' +
'React. Please file an issue.',
);
if (
(flags & ShouldCapture) !== NoFlags &&
(flags & DidCapture) === NoFlags
) {
// There was an error during render that wasn't captured by a suspense
// boundary. Do a second pass on the root to unmount the children.
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
return workInProgress;
}

workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
return workInProgress;
// We unwound to the root without completing it. Exit.
return null;
}
case HostComponent: {
// TODO: popHydrationState
Expand Down
19 changes: 10 additions & 9 deletions packages/react-reconciler/src/ReactFiberUnwindWork.old.js
Expand Up @@ -89,16 +89,17 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
popTopLevelLegacyContextObject(workInProgress);
resetMutableSourceWorkInProgressVersions();
const flags = workInProgress.flags;

if ((flags & DidCapture) !== NoFlags) {
throw new Error(
'The root failed to unmount after an error. This is likely a bug in ' +
'React. Please file an issue.',
);
if (
(flags & ShouldCapture) !== NoFlags &&
(flags & DidCapture) === NoFlags
) {
// There was an error during render that wasn't captured by a suspense
// boundary. Do a second pass on the root to unmount the children.
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
return workInProgress;
}

workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
return workInProgress;
// We unwound to the root without completing it. Exit.
return null;
}
case HostComponent: {
// TODO: popHydrationState
Expand Down