Skip to content

Commit b7029ce

Browse files
committed
Allow suspending outside a Suspense boundary
(If the update is wrapped in startTransition) Currently you're not allowed to suspend outside of a Suspense boundary. We throw an error: > A React component suspended while rendering, but no fallback UI was specified We treat this case like an error because discrete renders are expected to finish synchronously to maintain consistency with external state. However, during a concurrent transition (startTransition), what we can do instead is treat this case like a refresh transition: suspend the commit without showing a fallback. The behavior is roughly as if there were a built-in Suspense boundary at the root of the app with unstable_avoidThisFallback enabled. Conceptually it's very similar because during hydration you're already showing server-rendered UI; there's no need to replace that with a fallback when something suspends.
1 parent d5ae0f8 commit b7029ce

File tree

7 files changed

+414
-182
lines changed

7 files changed

+414
-182
lines changed

packages/react-reconciler/src/ReactFiberThrow.new.js

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
import {
6464
renderDidError,
6565
renderDidErrorUncaught,
66+
renderDidSuspendDelayIfPossible,
6667
queueConcurrentError,
6768
onUncaughtError,
6869
markLegacyErrorBoundaryAsFailed,
@@ -80,6 +81,7 @@ import {
8081
includesSomeLane,
8182
mergeLanes,
8283
pickArbitraryLane,
84+
includesOnlyTransitions,
8385
} from './ReactFiberLane.new';
8486
import {
8587
getIsHydrating,
@@ -167,12 +169,7 @@ function createClassErrorUpdate(
167169
return update;
168170
}
169171

170-
function attachWakeableListeners(
171-
suspenseBoundary: Fiber,
172-
root: FiberRoot,
173-
wakeable: Wakeable,
174-
lanes: Lanes,
175-
) {
172+
function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
176173
// Attach a ping listener
177174
//
178175
// The data might resolve before we have a chance to commit the fallback. Or,
@@ -185,34 +182,39 @@ function attachWakeableListeners(
185182
//
186183
// We only need to do this in concurrent mode. Legacy Suspense always
187184
// commits fallbacks synchronously, so there are no pings.
188-
if (suspenseBoundary.mode & ConcurrentMode) {
189-
let pingCache = root.pingCache;
190-
let threadIDs;
191-
if (pingCache === null) {
192-
pingCache = root.pingCache = new PossiblyWeakMap();
185+
let pingCache = root.pingCache;
186+
let threadIDs;
187+
if (pingCache === null) {
188+
pingCache = root.pingCache = new PossiblyWeakMap();
189+
threadIDs = new Set();
190+
pingCache.set(wakeable, threadIDs);
191+
} else {
192+
threadIDs = pingCache.get(wakeable);
193+
if (threadIDs === undefined) {
193194
threadIDs = new Set();
194195
pingCache.set(wakeable, threadIDs);
195-
} else {
196-
threadIDs = pingCache.get(wakeable);
197-
if (threadIDs === undefined) {
198-
threadIDs = new Set();
199-
pingCache.set(wakeable, threadIDs);
200-
}
201196
}
202-
if (!threadIDs.has(lanes)) {
203-
// Memoize using the thread ID to prevent redundant listeners.
204-
threadIDs.add(lanes);
205-
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
206-
if (enableUpdaterTracking) {
207-
if (isDevToolsPresent) {
208-
// If we have pending work still, restore the original updaters
209-
restorePendingUpdaters(root, lanes);
210-
}
197+
}
198+
if (!threadIDs.has(lanes)) {
199+
// Memoize using the thread ID to prevent redundant listeners.
200+
threadIDs.add(lanes);
201+
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
202+
if (enableUpdaterTracking) {
203+
if (isDevToolsPresent) {
204+
// If we have pending work still, restore the original updaters
205+
restorePendingUpdaters(root, lanes);
211206
}
212-
wakeable.then(ping, ping);
213207
}
208+
wakeable.then(ping, ping);
214209
}
210+
}
215211

212+
function attachRetryListener(
213+
suspenseBoundary: Fiber,
214+
root: FiberRoot,
215+
wakeable: Wakeable,
216+
lanes: Lanes,
217+
) {
216218
// Retry listener
217219
//
218220
// If the fallback does commit, we need to attach a different type of
@@ -472,24 +474,47 @@ function throwException(
472474
root,
473475
rootRenderLanes,
474476
);
475-
attachWakeableListeners(
476-
suspenseBoundary,
477-
root,
478-
wakeable,
479-
rootRenderLanes,
480-
);
477+
// We only attach ping listeners in concurrent mode. Legacy Suspense always
478+
// commits fallbacks synchronously, so there are no pings.
479+
if (suspenseBoundary.mode & ConcurrentMode) {
480+
attachPingListener(root, wakeable, rootRenderLanes);
481+
}
482+
attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
481483
return;
482484
} else {
483-
// No boundary was found. Fallthrough to error mode.
485+
// No boundary was found. If we're inside startTransition, this is OK.
486+
// We can suspend and wait for more data to arrive.
487+
488+
if (includesOnlyTransitions(rootRenderLanes)) {
489+
// This is a transition. Suspend. Since we're not activating a Suspense
490+
// boundary, this will unwind all the way to the root without performing
491+
// a second pass to render a fallback. (This is arguably how refresh
492+
// transitions should work, too, since we're not going to commit the
493+
// fallbacks anyway.)
494+
attachPingListener(root, wakeable, rootRenderLanes);
495+
renderDidSuspendDelayIfPossible();
496+
return;
497+
}
498+
499+
// We're not in a transition. We treat this case like an error because
500+
// discrete renders are expected to finish synchronously to maintain
501+
// consistency with external state.
502+
// TODO: This will error during non-transition concurrent renders, too.
503+
// But maybe it shouldn't?
504+
484505
// TODO: We should never call getComponentNameFromFiber in production.
485506
// Log a warning or something to prevent us from accidentally bundling it.
486-
value = new Error(
507+
const uncaughtSuspenseError = new Error(
487508
(getComponentNameFromFiber(sourceFiber) || 'A React component') +
488509
' suspended while rendering, but no fallback UI was specified.\n' +
489510
'\n' +
490511
'Add a <Suspense fallback=...> component higher in the tree to ' +
491512
'provide a loading indicator or placeholder to display.',
492513
);
514+
515+
// If we're outside a transition, fall through to the regular error path.
516+
// The error will be caught by the nearest suspense boundary.
517+
value = uncaughtSuspenseError;
493518
}
494519
} else {
495520
// This is a regular error, not a Suspense wakeable.

packages/react-reconciler/src/ReactFiberThrow.old.js

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
import {
6464
renderDidError,
6565
renderDidErrorUncaught,
66+
renderDidSuspendDelayIfPossible,
6667
queueConcurrentError,
6768
onUncaughtError,
6869
markLegacyErrorBoundaryAsFailed,
@@ -80,6 +81,7 @@ import {
8081
includesSomeLane,
8182
mergeLanes,
8283
pickArbitraryLane,
84+
includesOnlyTransitions,
8385
} from './ReactFiberLane.old';
8486
import {
8587
getIsHydrating,
@@ -167,12 +169,7 @@ function createClassErrorUpdate(
167169
return update;
168170
}
169171

170-
function attachWakeableListeners(
171-
suspenseBoundary: Fiber,
172-
root: FiberRoot,
173-
wakeable: Wakeable,
174-
lanes: Lanes,
175-
) {
172+
function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
176173
// Attach a ping listener
177174
//
178175
// The data might resolve before we have a chance to commit the fallback. Or,
@@ -185,34 +182,39 @@ function attachWakeableListeners(
185182
//
186183
// We only need to do this in concurrent mode. Legacy Suspense always
187184
// commits fallbacks synchronously, so there are no pings.
188-
if (suspenseBoundary.mode & ConcurrentMode) {
189-
let pingCache = root.pingCache;
190-
let threadIDs;
191-
if (pingCache === null) {
192-
pingCache = root.pingCache = new PossiblyWeakMap();
185+
let pingCache = root.pingCache;
186+
let threadIDs;
187+
if (pingCache === null) {
188+
pingCache = root.pingCache = new PossiblyWeakMap();
189+
threadIDs = new Set();
190+
pingCache.set(wakeable, threadIDs);
191+
} else {
192+
threadIDs = pingCache.get(wakeable);
193+
if (threadIDs === undefined) {
193194
threadIDs = new Set();
194195
pingCache.set(wakeable, threadIDs);
195-
} else {
196-
threadIDs = pingCache.get(wakeable);
197-
if (threadIDs === undefined) {
198-
threadIDs = new Set();
199-
pingCache.set(wakeable, threadIDs);
200-
}
201196
}
202-
if (!threadIDs.has(lanes)) {
203-
// Memoize using the thread ID to prevent redundant listeners.
204-
threadIDs.add(lanes);
205-
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
206-
if (enableUpdaterTracking) {
207-
if (isDevToolsPresent) {
208-
// If we have pending work still, restore the original updaters
209-
restorePendingUpdaters(root, lanes);
210-
}
197+
}
198+
if (!threadIDs.has(lanes)) {
199+
// Memoize using the thread ID to prevent redundant listeners.
200+
threadIDs.add(lanes);
201+
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
202+
if (enableUpdaterTracking) {
203+
if (isDevToolsPresent) {
204+
// If we have pending work still, restore the original updaters
205+
restorePendingUpdaters(root, lanes);
211206
}
212-
wakeable.then(ping, ping);
213207
}
208+
wakeable.then(ping, ping);
214209
}
210+
}
215211

212+
function attachRetryListener(
213+
suspenseBoundary: Fiber,
214+
root: FiberRoot,
215+
wakeable: Wakeable,
216+
lanes: Lanes,
217+
) {
216218
// Retry listener
217219
//
218220
// If the fallback does commit, we need to attach a different type of
@@ -472,24 +474,47 @@ function throwException(
472474
root,
473475
rootRenderLanes,
474476
);
475-
attachWakeableListeners(
476-
suspenseBoundary,
477-
root,
478-
wakeable,
479-
rootRenderLanes,
480-
);
477+
// We only attach ping listeners in concurrent mode. Legacy Suspense always
478+
// commits fallbacks synchronously, so there are no pings.
479+
if (suspenseBoundary.mode & ConcurrentMode) {
480+
attachPingListener(root, wakeable, rootRenderLanes);
481+
}
482+
attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
481483
return;
482484
} else {
483-
// No boundary was found. Fallthrough to error mode.
485+
// No boundary was found. If we're inside startTransition, this is OK.
486+
// We can suspend and wait for more data to arrive.
487+
488+
if (includesOnlyTransitions(rootRenderLanes)) {
489+
// This is a transition. Suspend. Since we're not activating a Suspense
490+
// boundary, this will unwind all the way to the root without performing
491+
// a second pass to render a fallback. (This is arguably how refresh
492+
// transitions should work, too, since we're not going to commit the
493+
// fallbacks anyway.)
494+
attachPingListener(root, wakeable, rootRenderLanes);
495+
renderDidSuspendDelayIfPossible();
496+
return;
497+
}
498+
499+
// We're not in a transition. We treat this case like an error because
500+
// discrete renders are expected to finish synchronously to maintain
501+
// consistency with external state.
502+
// TODO: This will error during non-transition concurrent renders, too.
503+
// But maybe it shouldn't?
504+
484505
// TODO: We should never call getComponentNameFromFiber in production.
485506
// Log a warning or something to prevent us from accidentally bundling it.
486-
value = new Error(
507+
const uncaughtSuspenseError = new Error(
487508
(getComponentNameFromFiber(sourceFiber) || 'A React component') +
488509
' suspended while rendering, but no fallback UI was specified.\n' +
489510
'\n' +
490511
'Add a <Suspense fallback=...> component higher in the tree to ' +
491512
'provide a loading indicator or placeholder to display.',
492513
);
514+
515+
// If we're outside a transition, fall through to the regular error path.
516+
// The error will be caught by the nearest suspense boundary.
517+
value = uncaughtSuspenseError;
493518
}
494519
} else {
495520
// This is a regular error, not a Suspense wakeable.

packages/react-reconciler/src/ReactFiberUnwindWork.new.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,17 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
8989
popTopLevelLegacyContextObject(workInProgress);
9090
resetMutableSourceWorkInProgressVersions();
9191
const flags = workInProgress.flags;
92-
93-
if ((flags & DidCapture) !== NoFlags) {
94-
throw new Error(
95-
'The root failed to unmount after an error. This is likely a bug in ' +
96-
'React. Please file an issue.',
97-
);
92+
if (
93+
(flags & ShouldCapture) !== NoFlags &&
94+
(flags & DidCapture) === NoFlags
95+
) {
96+
// There was an error during render that wasn't captured by a suspense
97+
// boundary. Do a second pass on the root to unmount the children.
98+
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
99+
return workInProgress;
98100
}
99-
100-
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
101-
return workInProgress;
101+
// We unwound to the root without completing it. Exit.
102+
return null;
102103
}
103104
case HostComponent: {
104105
// TODO: popHydrationState

packages/react-reconciler/src/ReactFiberUnwindWork.old.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,17 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
8989
popTopLevelLegacyContextObject(workInProgress);
9090
resetMutableSourceWorkInProgressVersions();
9191
const flags = workInProgress.flags;
92-
93-
if ((flags & DidCapture) !== NoFlags) {
94-
throw new Error(
95-
'The root failed to unmount after an error. This is likely a bug in ' +
96-
'React. Please file an issue.',
97-
);
92+
if (
93+
(flags & ShouldCapture) !== NoFlags &&
94+
(flags & DidCapture) === NoFlags
95+
) {
96+
// There was an error during render that wasn't captured by a suspense
97+
// boundary. Do a second pass on the root to unmount the children.
98+
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
99+
return workInProgress;
98100
}
99-
100-
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
101-
return workInProgress;
101+
// We unwound to the root without completing it. Exit.
102+
return null;
102103
}
103104
case HostComponent: {
104105
// TODO: popHydrationState

0 commit comments

Comments
 (0)