Skip to content

Commit

Permalink
Detect crashes caused by Async Client Components (#27019)
Browse files Browse the repository at this point in the history
Suspending with an uncached promise is not yet supported. We only
support suspending on promises that are cached between render attempts.
(We do plan to partially support this in the future, at least in certain
constrained cases, like during a route transition.)

This includes the case where a component returns an uncached promise,
which is effectively what happens if a Client Component is authored
using async/await syntax.

This is an easy mistake to make in a Server Components app, because
async/await _is_ available in Server Components.

In the current behavior, this can sometimes cause the app to crash with
an infinite loop, because React will repeatedly keep trying to render
the component, which will result in a fresh promise, which will result
in a new render attempt, and so on. We have some strategies we can use
to prevent this — during a concurrent render, we can suspend the work
loop until the promise resolves. If it's not a concurrent render, we can
show a Suspense fallback and try again at concurrent priority.

There's one case where neither of these strategies work, though: during
a sync render when there's no parent Suspense boundary. (We refer to
this as the "shell" of the app because it exists outside of any loading
UI.)

Since we don't have any great options for this scenario, we should at
least error gracefully instead of crashing the app.

So this commit adds a detection mechanism for render loops caused by
async client components. The way it works is, if an app suspends
repeatedly in the shell during a synchronous render, without committing
anything in between, we will count the number of attempts and eventually
trigger an error once the count exceeds a threshold.

In the future, we will consider ways to make this case a warning instead
of a hard error.

See #26801 for more details.

DiffTrain build for [fc80111](fc80111)
  • Loading branch information
acdlite committed Jun 29, 2023
1 parent f2e8115 commit 569cd7f
Show file tree
Hide file tree
Showing 20 changed files with 2,165 additions and 1,695 deletions.
2 changes: 1 addition & 1 deletion compiled/facebook-www/REVISION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
822386f252fd1f0e949efa904a1ed790133329f7
fc801116c80b68f7ebdaf66ac77d5f2dcd9e50eb
2 changes: 1 addition & 1 deletion compiled/facebook-www/React-dev.classic.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ if (
}
"use strict";

var ReactVersion = "18.3.0-www-classic-7bcc71be";
var ReactVersion = "18.3.0-www-classic-a3146e4f";

// ATTENTION
// When adding new symbols to this file,
Expand Down
2 changes: 1 addition & 1 deletion compiled/facebook-www/React-dev.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ if (
}
"use strict";

var ReactVersion = "18.3.0-www-modern-fbda1d19";
var ReactVersion = "18.3.0-www-modern-f95d3ddd";

// ATTENTION
// When adding new symbols to this file,
Expand Down
2 changes: 1 addition & 1 deletion compiled/facebook-www/React-prod.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -622,4 +622,4 @@ exports.useSyncExternalStore = function (
);
};
exports.useTransition = useTransition;
exports.version = "18.3.0-www-modern-ead87ff5";
exports.version = "18.3.0-www-modern-0b35a427";
50 changes: 48 additions & 2 deletions compiled/facebook-www/ReactART-dev.classic.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function _assertThisInitialized(self) {
return self;
}

var ReactVersion = "18.3.0-www-classic-4705a186";
var ReactVersion = "18.3.0-www-classic-a18a6ba5";

var LegacyRoot = 0;
var ConcurrentRoot = 1;
Expand Down Expand Up @@ -2210,6 +2210,7 @@ function markRootFinished(root, remainingLanes) {
root.expiredLanes &= remainingLanes;
root.entangledLanes &= remainingLanes;
root.errorRecoveryDisabledLanes &= remainingLanes;
root.shellSuspendCounter = 0;
var entanglements = root.entanglements;
var expirationTimes = root.expirationTimes;
var hiddenUpdates = root.hiddenUpdates; // Clear the lanes that no longer have pending work
Expand Down Expand Up @@ -5412,6 +5413,32 @@ function trackUsedThenable(thenableState, thenable, index) {
// happen. Flight lazily parses JSON when the value is actually awaited.
thenable.then(noop, noop);
} else {
// This is an uncached thenable that we haven't seen before.
// Detect infinite ping loops caused by uncached promises.
var root = getWorkInProgressRoot();

if (root !== null && root.shellSuspendCounter > 100) {
// This root has suspended repeatedly in the shell without making any
// progress (i.e. committing something). This is highly suggestive of
// an infinite ping loop, often caused by an accidental Async Client
// Component.
//
// During a transition, we can suspend the work loop until the promise
// to resolve, but this is a sync render, so that's not an option. We
// also can't show a fallback, because none was provided. So our last
// resort is to throw an error.
//
// TODO: Remove this error in a future release. Other ways of handling
// this case include forcing a concurrent render, or putting the whole
// root into offscreen mode.
throw new Error(
"async/await is not yet supported in Client Components, only " +
"Server Components. This error is often caused by accidentally " +
"adding `'use client'` to a module that was originally written " +
"for the server."
);
}

var pendingThenable = thenable;
pendingThenable.status = "pending";
pendingThenable.then(
Expand Down Expand Up @@ -25109,6 +25136,8 @@ function renderRootSync(root, lanes) {
markRenderStarted(lanes);
}

var didSuspendInShell = false;

outer: do {
try {
if (
Expand Down Expand Up @@ -25136,6 +25165,13 @@ function renderRootSync(root, lanes) {
break outer;
}

case SuspendedOnImmediate:
case SuspendedOnData: {
if (!didSuspendInShell && getSuspenseHandler() === null) {
didSuspendInShell = true;
} // Intentional fallthrough
}

default: {
// Unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
Expand All @@ -25151,7 +25187,16 @@ function renderRootSync(root, lanes) {
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
} while (true);
} while (true); // Check if something suspended in the shell. We use this to detect an
// infinite ping loop caused by an uncached promise.
//
// Only increment this counter once per synchronous render attempt across the
// whole tree. Even if there are many sibling components that suspend, this
// counter only gets incremented once.

if (didSuspendInShell) {
root.shellSuspendCounter++;
}

resetContextDependencies();
executionContext = prevExecutionContext;
Expand Down Expand Up @@ -28236,6 +28281,7 @@ function FiberRootNode(
this.expiredLanes = NoLanes;
this.finishedLanes = NoLanes;
this.errorRecoveryDisabledLanes = NoLanes;
this.shellSuspendCounter = 0;
this.entangledLanes = NoLanes;
this.entanglements = createLaneMap(NoLanes);
this.hiddenUpdates = createLaneMap(null);
Expand Down
50 changes: 48 additions & 2 deletions compiled/facebook-www/ReactART-dev.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function _assertThisInitialized(self) {
return self;
}

var ReactVersion = "18.3.0-www-modern-5550294b";
var ReactVersion = "18.3.0-www-modern-7d12a802";

var LegacyRoot = 0;
var ConcurrentRoot = 1;
Expand Down Expand Up @@ -2207,6 +2207,7 @@ function markRootFinished(root, remainingLanes) {
root.expiredLanes &= remainingLanes;
root.entangledLanes &= remainingLanes;
root.errorRecoveryDisabledLanes &= remainingLanes;
root.shellSuspendCounter = 0;
var entanglements = root.entanglements;
var expirationTimes = root.expirationTimes;
var hiddenUpdates = root.hiddenUpdates; // Clear the lanes that no longer have pending work
Expand Down Expand Up @@ -5168,6 +5169,32 @@ function trackUsedThenable(thenableState, thenable, index) {
// happen. Flight lazily parses JSON when the value is actually awaited.
thenable.then(noop, noop);
} else {
// This is an uncached thenable that we haven't seen before.
// Detect infinite ping loops caused by uncached promises.
var root = getWorkInProgressRoot();

if (root !== null && root.shellSuspendCounter > 100) {
// This root has suspended repeatedly in the shell without making any
// progress (i.e. committing something). This is highly suggestive of
// an infinite ping loop, often caused by an accidental Async Client
// Component.
//
// During a transition, we can suspend the work loop until the promise
// to resolve, but this is a sync render, so that's not an option. We
// also can't show a fallback, because none was provided. So our last
// resort is to throw an error.
//
// TODO: Remove this error in a future release. Other ways of handling
// this case include forcing a concurrent render, or putting the whole
// root into offscreen mode.
throw new Error(
"async/await is not yet supported in Client Components, only " +
"Server Components. This error is often caused by accidentally " +
"adding `'use client'` to a module that was originally written " +
"for the server."
);
}

var pendingThenable = thenable;
pendingThenable.status = "pending";
pendingThenable.then(
Expand Down Expand Up @@ -24774,6 +24801,8 @@ function renderRootSync(root, lanes) {
markRenderStarted(lanes);
}

var didSuspendInShell = false;

outer: do {
try {
if (
Expand Down Expand Up @@ -24801,6 +24830,13 @@ function renderRootSync(root, lanes) {
break outer;
}

case SuspendedOnImmediate:
case SuspendedOnData: {
if (!didSuspendInShell && getSuspenseHandler() === null) {
didSuspendInShell = true;
} // Intentional fallthrough
}

default: {
// Unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
Expand All @@ -24816,7 +24852,16 @@ function renderRootSync(root, lanes) {
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
} while (true);
} while (true); // Check if something suspended in the shell. We use this to detect an
// infinite ping loop caused by an uncached promise.
//
// Only increment this counter once per synchronous render attempt across the
// whole tree. Even if there are many sibling components that suspend, this
// counter only gets incremented once.

if (didSuspendInShell) {
root.shellSuspendCounter++;
}

resetContextDependencies();
executionContext = prevExecutionContext;
Expand Down Expand Up @@ -27896,6 +27941,7 @@ function FiberRootNode(
this.expiredLanes = NoLanes;
this.finishedLanes = NoLanes;
this.errorRecoveryDisabledLanes = NoLanes;
this.shellSuspendCounter = 0;
this.entangledLanes = NoLanes;
this.entanglements = createLaneMap(NoLanes);
this.hiddenUpdates = createLaneMap(null);
Expand Down
61 changes: 37 additions & 24 deletions compiled/facebook-www/ReactART-prod.classic.js
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,7 @@ function markRootFinished(root, remainingLanes) {
root.expiredLanes &= remainingLanes;
root.entangledLanes &= remainingLanes;
root.errorRecoveryDisabledLanes &= remainingLanes;
root.shellSuspendCounter = 0;
remainingLanes = root.entanglements;
var expirationTimes = root.expirationTimes;
for (root = root.hiddenUpdates; 0 < noLongerPendingLanes; ) {
Expand Down Expand Up @@ -1383,26 +1384,30 @@ function trackUsedThenable(thenableState, thenable, index) {
case "rejected":
throw thenable.reason;
default:
"string" === typeof thenable.status
? thenable.then(noop, noop)
: ((thenableState = thenable),
(thenableState.status = "pending"),
thenableState.then(
function (fulfilledValue) {
if ("pending" === thenable.status) {
var fulfilledThenable = thenable;
fulfilledThenable.status = "fulfilled";
fulfilledThenable.value = fulfilledValue;
}
},
function (error) {
if ("pending" === thenable.status) {
var rejectedThenable = thenable;
rejectedThenable.status = "rejected";
rejectedThenable.reason = error;
}
if ("string" === typeof thenable.status) thenable.then(noop, noop);
else {
thenableState = workInProgressRoot;
if (null !== thenableState && 100 < thenableState.shellSuspendCounter)
throw Error(formatProdErrorMessage(482));
thenableState = thenable;
thenableState.status = "pending";
thenableState.then(
function (fulfilledValue) {
if ("pending" === thenable.status) {
var fulfilledThenable = thenable;
fulfilledThenable.status = "fulfilled";
fulfilledThenable.value = fulfilledValue;
}
));
},
function (error) {
if ("pending" === thenable.status) {
var rejectedThenable = thenable;
rejectedThenable.status = "rejected";
rejectedThenable.reason = error;
}
}
);
}
switch (thenable.status) {
case "fulfilled":
return thenable.value;
Expand Down Expand Up @@ -8444,20 +8449,26 @@ function renderRootSync(root, lanes) {
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes)
(workInProgressTransitions = getTransitionsForLanes(root, lanes)),
prepareFreshStack(root, lanes);
lanes = !1;
a: do
try {
if (0 !== workInProgressSuspendedReason && null !== workInProgress) {
lanes = workInProgress;
var thrownValue = workInProgressThrownValue;
var unitOfWork = workInProgress,
thrownValue = workInProgressThrownValue;
switch (workInProgressSuspendedReason) {
case 8:
resetWorkInProgressStack();
workInProgressRootExitStatus = 6;
break a;
case 3:
case 2:
lanes ||
null !== suspenseHandlerStackCursor.current ||
(lanes = !0);
default:
(workInProgressSuspendedReason = 0),
(workInProgressThrownValue = null),
throwAndUnwindWorkLoop(lanes, thrownValue);
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
}
}
workLoopSync();
Expand All @@ -8466,6 +8477,7 @@ function renderRootSync(root, lanes) {
handleThrow(root, thrownValue$127);
}
while (1);
lanes && root.shellSuspendCounter++;
resetContextDependencies();
executionContext = prevExecutionContext;
ReactCurrentDispatcher.current = prevDispatcher;
Expand Down Expand Up @@ -9924,6 +9936,7 @@ function FiberRootNode(
this.callbackPriority = 0;
this.expirationTimes = createLaneMap(-1);
this.entangledLanes =
this.shellSuspendCounter =
this.errorRecoveryDisabledLanes =
this.finishedLanes =
this.expiredLanes =
Expand Down Expand Up @@ -10129,7 +10142,7 @@ var slice = Array.prototype.slice,
return null;
},
bundleType: 0,
version: "18.3.0-www-classic-7bcc71be",
version: "18.3.0-www-classic-a3146e4f",
rendererPackageName: "react-art"
};
var internals$jscomp$inline_1309 = {
Expand Down Expand Up @@ -10160,7 +10173,7 @@ var internals$jscomp$inline_1309 = {
scheduleRoot: null,
setRefreshHandler: null,
getCurrentFiber: null,
reconcilerVersion: "18.3.0-www-classic-7bcc71be"
reconcilerVersion: "18.3.0-www-classic-a3146e4f"
};
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
var hook$jscomp$inline_1310 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
Expand Down
Loading

0 comments on commit 569cd7f

Please sign in to comment.