Skip to content

Commit e64ac70

Browse files
committed
Suspend on uncaught error, if inside transition
Usually, if an error isn't caught by an error boundary, we treat it like a panic: the whole app will unmount and we'll throw a top-level error. However, if we're in an async transition, what we can do instead is suspend the transition — i.e. remain on the current screen, like we do during a refresh when we're waiting for new data to load in the background. The reason we only do this for transitions is because synchronous renders are expected to commit synchronously to maintain consistency with external state. (We arguably should suspend-on-uncaught-error for non-sync concurrent renders like continuous inputs, too, but that merits further discussion.) The suspended error is logged with onRecoverableError.
1 parent 9aa10d5 commit e64ac70

File tree

8 files changed

+278
-122
lines changed

8 files changed

+278
-122
lines changed

packages/react-noop-renderer/src/createReactNoop.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
938938
return NoopRenderer.flushSync(fn);
939939
}
940940

941-
function onRecoverableError(error) {
941+
function onRecoverableErrorDefault(error) {
942942
// TODO: Turn this on once tests are fixed
943943
// eslint-disable-next-line react-internal/no-production-logging, react-internal/warning-args
944944
// console.error(error);
@@ -972,15 +972,15 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
972972
null,
973973
false,
974974
'',
975-
onRecoverableError,
975+
onRecoverableErrorDefault,
976976
);
977977
roots.set(rootID, root);
978978
}
979979
return root.current.stateNode.containerInfo;
980980
},
981981

982982
// TODO: Replace ReactNoop.render with createRoot + root.render
983-
createRoot() {
983+
createRoot(options) {
984984
const container = {
985985
rootID: '' + idCounter++,
986986
pendingChildren: [],
@@ -994,8 +994,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
994994
null,
995995
false,
996996
'',
997-
onRecoverableError,
997+
options && options.onRecoverableError
998+
? options.onRecoverableError
999+
: onRecoverableErrorDefault,
9981000
);
1001+
9991002
return {
10001003
_Scheduler: Scheduler,
10011004
render(children: ReactNodeList) {
@@ -1024,7 +1027,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
10241027
null,
10251028
false,
10261029
'',
1027-
onRecoverableError,
1030+
onRecoverableErrorDefault,
10281031
);
10291032
return {
10301033
_Scheduler: Scheduler,

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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import {
6262
} from './ReactFiberSuspenseContext.new';
6363
import {
6464
renderDidError,
65+
renderDidErrorUncaught,
66+
queueConcurrentError,
6567
onUncaughtError,
6668
markLegacyErrorBoundaryAsFailed,
6769
isAlreadyFailedLegacyErrorBoundary,
@@ -516,22 +518,32 @@ function throwException(
516518
queueHydrationError(value);
517519
return;
518520
}
519-
} else {
520-
// Otherwise, fall through to the error path.
521521
}
522+
523+
// Otherwise, fall through to the error path.
524+
525+
// Push the error to a queue. If we end up recovering without surfacing
526+
// the error to the user, we'll updgrade this to a recoverable error and
527+
// log it with onRecoverableError.
528+
//
529+
// This is intentionally a separate call from renderDidError because in
530+
// some cases we use the error handling path as an implementation detail
531+
// to unwind the stack, but we don't want to log it as a real error. An
532+
// example is suspending outside of a Suspense boundary (see previous
533+
// branch above).
534+
queueConcurrentError(value);
522535
}
523536

524537
// We didn't find a boundary that could handle this type of exception. Start
525538
// over and traverse parent path again, this time treating the exception
526539
// as an error.
527-
renderDidError(value);
528-
529-
value = createCapturedValue(value, sourceFiber);
540+
const error = value;
541+
const errorInfo = createCapturedValue(error, sourceFiber);
530542
let workInProgress = returnFiber;
531543
do {
532544
switch (workInProgress.tag) {
533545
case HostRoot: {
534-
const errorInfo = value;
546+
renderDidErrorUncaught();
535547
workInProgress.flags |= ShouldCapture;
536548
const lane = pickArbitraryLane(rootRenderLanes);
537549
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
@@ -541,7 +553,7 @@ function throwException(
541553
}
542554
case ClassComponent:
543555
// Capture and retry
544-
const errorInfo = value;
556+
renderDidError();
545557
const ctor = workInProgress.type;
546558
const instance = workInProgress.stateNode;
547559
if (

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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import {
6262
} from './ReactFiberSuspenseContext.old';
6363
import {
6464
renderDidError,
65+
renderDidErrorUncaught,
66+
queueConcurrentError,
6567
onUncaughtError,
6668
markLegacyErrorBoundaryAsFailed,
6769
isAlreadyFailedLegacyErrorBoundary,
@@ -516,22 +518,32 @@ function throwException(
516518
queueHydrationError(value);
517519
return;
518520
}
519-
} else {
520-
// Otherwise, fall through to the error path.
521521
}
522+
523+
// Otherwise, fall through to the error path.
524+
525+
// Push the error to a queue. If we end up recovering without surfacing
526+
// the error to the user, we'll updgrade this to a recoverable error and
527+
// log it with onRecoverableError.
528+
//
529+
// This is intentionally a separate call from renderDidError because in
530+
// some cases we use the error handling path as an implementation detail
531+
// to unwind the stack, but we don't want to log it as a real error. An
532+
// example is suspending outside of a Suspense boundary (see previous
533+
// branch above).
534+
queueConcurrentError(value);
522535
}
523536

524537
// We didn't find a boundary that could handle this type of exception. Start
525538
// over and traverse parent path again, this time treating the exception
526539
// as an error.
527-
renderDidError(value);
528-
529-
value = createCapturedValue(value, sourceFiber);
540+
const error = value;
541+
const errorInfo = createCapturedValue(error, sourceFiber);
530542
let workInProgress = returnFiber;
531543
do {
532544
switch (workInProgress.tag) {
533545
case HostRoot: {
534-
const errorInfo = value;
546+
renderDidErrorUncaught();
535547
workInProgress.flags |= ShouldCapture;
536548
const lane = pickArbitraryLane(rootRenderLanes);
537549
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
@@ -541,7 +553,7 @@ function throwException(
541553
}
542554
case ClassComponent:
543555
// Capture and retry
544-
const errorInfo = value;
556+
renderDidError();
545557
const ctor = workInProgress.type;
546558
const instance = workInProgress.stateNode;
547559
if (

0 commit comments

Comments
 (0)