Skip to content

Commit af184d1

Browse files
committed
Detect crashes caused by Async Client Components (#27019)
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 facebook/react#26801 for more details. DiffTrain build for [fc801116c80b68f7ebdaf66ac77d5f2dcd9e50eb](facebook/react@fc80111)
1 parent 0c1dfd7 commit af184d1

20 files changed

+2165
-1695
lines changed

compiled/facebook-www/REVISION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
822386f252fd1f0e949efa904a1ed790133329f7
1+
fc801116c80b68f7ebdaf66ac77d5f2dcd9e50eb

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-7bcc71be";
30+
var ReactVersion = "18.3.0-www-classic-a3146e4f";
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-fbda1d19";
30+
var ReactVersion = "18.3.0-www-modern-f95d3ddd";
3131

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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,4 +622,4 @@ exports.useSyncExternalStore = function (
622622
);
623623
};
624624
exports.useTransition = useTransition;
625-
exports.version = "18.3.0-www-modern-ead87ff5";
625+
exports.version = "18.3.0-www-modern-0b35a427";

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

Lines changed: 48 additions & 2 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-4705a186";
72+
var ReactVersion = "18.3.0-www-classic-a18a6ba5";
7373

7474
var LegacyRoot = 0;
7575
var ConcurrentRoot = 1;
@@ -2210,6 +2210,7 @@ function markRootFinished(root, remainingLanes) {
22102210
root.expiredLanes &= remainingLanes;
22112211
root.entangledLanes &= remainingLanes;
22122212
root.errorRecoveryDisabledLanes &= remainingLanes;
2213+
root.shellSuspendCounter = 0;
22132214
var entanglements = root.entanglements;
22142215
var expirationTimes = root.expirationTimes;
22152216
var hiddenUpdates = root.hiddenUpdates; // Clear the lanes that no longer have pending work
@@ -5412,6 +5413,32 @@ function trackUsedThenable(thenableState, thenable, index) {
54125413
// happen. Flight lazily parses JSON when the value is actually awaited.
54135414
thenable.then(noop, noop);
54145415
} else {
5416+
// This is an uncached thenable that we haven't seen before.
5417+
// Detect infinite ping loops caused by uncached promises.
5418+
var root = getWorkInProgressRoot();
5419+
5420+
if (root !== null && root.shellSuspendCounter > 100) {
5421+
// This root has suspended repeatedly in the shell without making any
5422+
// progress (i.e. committing something). This is highly suggestive of
5423+
// an infinite ping loop, often caused by an accidental Async Client
5424+
// Component.
5425+
//
5426+
// During a transition, we can suspend the work loop until the promise
5427+
// to resolve, but this is a sync render, so that's not an option. We
5428+
// also can't show a fallback, because none was provided. So our last
5429+
// resort is to throw an error.
5430+
//
5431+
// TODO: Remove this error in a future release. Other ways of handling
5432+
// this case include forcing a concurrent render, or putting the whole
5433+
// root into offscreen mode.
5434+
throw new Error(
5435+
"async/await is not yet supported in Client Components, only " +
5436+
"Server Components. This error is often caused by accidentally " +
5437+
"adding `'use client'` to a module that was originally written " +
5438+
"for the server."
5439+
);
5440+
}
5441+
54155442
var pendingThenable = thenable;
54165443
pendingThenable.status = "pending";
54175444
pendingThenable.then(
@@ -25109,6 +25136,8 @@ function renderRootSync(root, lanes) {
2510925136
markRenderStarted(lanes);
2511025137
}
2511125138

25139+
var didSuspendInShell = false;
25140+
2511225141
outer: do {
2511325142
try {
2511425143
if (
@@ -25136,6 +25165,13 @@ function renderRootSync(root, lanes) {
2513625165
break outer;
2513725166
}
2513825167

25168+
case SuspendedOnImmediate:
25169+
case SuspendedOnData: {
25170+
if (!didSuspendInShell && getSuspenseHandler() === null) {
25171+
didSuspendInShell = true;
25172+
} // Intentional fallthrough
25173+
}
25174+
2513925175
default: {
2514025176
// Unwind then continue with the normal work loop.
2514125177
workInProgressSuspendedReason = NotSuspended;
@@ -25151,7 +25187,16 @@ function renderRootSync(root, lanes) {
2515125187
} catch (thrownValue) {
2515225188
handleThrow(root, thrownValue);
2515325189
}
25154-
} while (true);
25190+
} while (true); // Check if something suspended in the shell. We use this to detect an
25191+
// infinite ping loop caused by an uncached promise.
25192+
//
25193+
// Only increment this counter once per synchronous render attempt across the
25194+
// whole tree. Even if there are many sibling components that suspend, this
25195+
// counter only gets incremented once.
25196+
25197+
if (didSuspendInShell) {
25198+
root.shellSuspendCounter++;
25199+
}
2515525200

2515625201
resetContextDependencies();
2515725202
executionContext = prevExecutionContext;
@@ -28236,6 +28281,7 @@ function FiberRootNode(
2823628281
this.expiredLanes = NoLanes;
2823728282
this.finishedLanes = NoLanes;
2823828283
this.errorRecoveryDisabledLanes = NoLanes;
28284+
this.shellSuspendCounter = 0;
2823928285
this.entangledLanes = NoLanes;
2824028286
this.entanglements = createLaneMap(NoLanes);
2824128287
this.hiddenUpdates = createLaneMap(null);

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

Lines changed: 48 additions & 2 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-modern-5550294b";
72+
var ReactVersion = "18.3.0-www-modern-7d12a802";
7373

7474
var LegacyRoot = 0;
7575
var ConcurrentRoot = 1;
@@ -2207,6 +2207,7 @@ function markRootFinished(root, remainingLanes) {
22072207
root.expiredLanes &= remainingLanes;
22082208
root.entangledLanes &= remainingLanes;
22092209
root.errorRecoveryDisabledLanes &= remainingLanes;
2210+
root.shellSuspendCounter = 0;
22102211
var entanglements = root.entanglements;
22112212
var expirationTimes = root.expirationTimes;
22122213
var hiddenUpdates = root.hiddenUpdates; // Clear the lanes that no longer have pending work
@@ -5168,6 +5169,32 @@ function trackUsedThenable(thenableState, thenable, index) {
51685169
// happen. Flight lazily parses JSON when the value is actually awaited.
51695170
thenable.then(noop, noop);
51705171
} else {
5172+
// This is an uncached thenable that we haven't seen before.
5173+
// Detect infinite ping loops caused by uncached promises.
5174+
var root = getWorkInProgressRoot();
5175+
5176+
if (root !== null && root.shellSuspendCounter > 100) {
5177+
// This root has suspended repeatedly in the shell without making any
5178+
// progress (i.e. committing something). This is highly suggestive of
5179+
// an infinite ping loop, often caused by an accidental Async Client
5180+
// Component.
5181+
//
5182+
// During a transition, we can suspend the work loop until the promise
5183+
// to resolve, but this is a sync render, so that's not an option. We
5184+
// also can't show a fallback, because none was provided. So our last
5185+
// resort is to throw an error.
5186+
//
5187+
// TODO: Remove this error in a future release. Other ways of handling
5188+
// this case include forcing a concurrent render, or putting the whole
5189+
// root into offscreen mode.
5190+
throw new Error(
5191+
"async/await is not yet supported in Client Components, only " +
5192+
"Server Components. This error is often caused by accidentally " +
5193+
"adding `'use client'` to a module that was originally written " +
5194+
"for the server."
5195+
);
5196+
}
5197+
51715198
var pendingThenable = thenable;
51725199
pendingThenable.status = "pending";
51735200
pendingThenable.then(
@@ -24774,6 +24801,8 @@ function renderRootSync(root, lanes) {
2477424801
markRenderStarted(lanes);
2477524802
}
2477624803

24804+
var didSuspendInShell = false;
24805+
2477724806
outer: do {
2477824807
try {
2477924808
if (
@@ -24801,6 +24830,13 @@ function renderRootSync(root, lanes) {
2480124830
break outer;
2480224831
}
2480324832

24833+
case SuspendedOnImmediate:
24834+
case SuspendedOnData: {
24835+
if (!didSuspendInShell && getSuspenseHandler() === null) {
24836+
didSuspendInShell = true;
24837+
} // Intentional fallthrough
24838+
}
24839+
2480424840
default: {
2480524841
// Unwind then continue with the normal work loop.
2480624842
workInProgressSuspendedReason = NotSuspended;
@@ -24816,7 +24852,16 @@ function renderRootSync(root, lanes) {
2481624852
} catch (thrownValue) {
2481724853
handleThrow(root, thrownValue);
2481824854
}
24819-
} while (true);
24855+
} while (true); // Check if something suspended in the shell. We use this to detect an
24856+
// infinite ping loop caused by an uncached promise.
24857+
//
24858+
// Only increment this counter once per synchronous render attempt across the
24859+
// whole tree. Even if there are many sibling components that suspend, this
24860+
// counter only gets incremented once.
24861+
24862+
if (didSuspendInShell) {
24863+
root.shellSuspendCounter++;
24864+
}
2482024865

2482124866
resetContextDependencies();
2482224867
executionContext = prevExecutionContext;
@@ -27896,6 +27941,7 @@ function FiberRootNode(
2789627941
this.expiredLanes = NoLanes;
2789727942
this.finishedLanes = NoLanes;
2789827943
this.errorRecoveryDisabledLanes = NoLanes;
27944+
this.shellSuspendCounter = 0;
2789927945
this.entangledLanes = NoLanes;
2790027946
this.entanglements = createLaneMap(NoLanes);
2790127947
this.hiddenUpdates = createLaneMap(null);

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

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,7 @@ function markRootFinished(root, remainingLanes) {
564564
root.expiredLanes &= remainingLanes;
565565
root.entangledLanes &= remainingLanes;
566566
root.errorRecoveryDisabledLanes &= remainingLanes;
567+
root.shellSuspendCounter = 0;
567568
remainingLanes = root.entanglements;
568569
var expirationTimes = root.expirationTimes;
569570
for (root = root.hiddenUpdates; 0 < noLongerPendingLanes; ) {
@@ -1383,26 +1384,30 @@ function trackUsedThenable(thenableState, thenable, index) {
13831384
case "rejected":
13841385
throw thenable.reason;
13851386
default:
1386-
"string" === typeof thenable.status
1387-
? thenable.then(noop, noop)
1388-
: ((thenableState = thenable),
1389-
(thenableState.status = "pending"),
1390-
thenableState.then(
1391-
function (fulfilledValue) {
1392-
if ("pending" === thenable.status) {
1393-
var fulfilledThenable = thenable;
1394-
fulfilledThenable.status = "fulfilled";
1395-
fulfilledThenable.value = fulfilledValue;
1396-
}
1397-
},
1398-
function (error) {
1399-
if ("pending" === thenable.status) {
1400-
var rejectedThenable = thenable;
1401-
rejectedThenable.status = "rejected";
1402-
rejectedThenable.reason = error;
1403-
}
1387+
if ("string" === typeof thenable.status) thenable.then(noop, noop);
1388+
else {
1389+
thenableState = workInProgressRoot;
1390+
if (null !== thenableState && 100 < thenableState.shellSuspendCounter)
1391+
throw Error(formatProdErrorMessage(482));
1392+
thenableState = thenable;
1393+
thenableState.status = "pending";
1394+
thenableState.then(
1395+
function (fulfilledValue) {
1396+
if ("pending" === thenable.status) {
1397+
var fulfilledThenable = thenable;
1398+
fulfilledThenable.status = "fulfilled";
1399+
fulfilledThenable.value = fulfilledValue;
14041400
}
1405-
));
1401+
},
1402+
function (error) {
1403+
if ("pending" === thenable.status) {
1404+
var rejectedThenable = thenable;
1405+
rejectedThenable.status = "rejected";
1406+
rejectedThenable.reason = error;
1407+
}
1408+
}
1409+
);
1410+
}
14061411
switch (thenable.status) {
14071412
case "fulfilled":
14081413
return thenable.value;
@@ -8444,20 +8449,26 @@ function renderRootSync(root, lanes) {
84448449
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes)
84458450
(workInProgressTransitions = getTransitionsForLanes(root, lanes)),
84468451
prepareFreshStack(root, lanes);
8452+
lanes = !1;
84478453
a: do
84488454
try {
84498455
if (0 !== workInProgressSuspendedReason && null !== workInProgress) {
8450-
lanes = workInProgress;
8451-
var thrownValue = workInProgressThrownValue;
8456+
var unitOfWork = workInProgress,
8457+
thrownValue = workInProgressThrownValue;
84528458
switch (workInProgressSuspendedReason) {
84538459
case 8:
84548460
resetWorkInProgressStack();
84558461
workInProgressRootExitStatus = 6;
84568462
break a;
8463+
case 3:
8464+
case 2:
8465+
lanes ||
8466+
null !== suspenseHandlerStackCursor.current ||
8467+
(lanes = !0);
84578468
default:
84588469
(workInProgressSuspendedReason = 0),
84598470
(workInProgressThrownValue = null),
8460-
throwAndUnwindWorkLoop(lanes, thrownValue);
8471+
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
84618472
}
84628473
}
84638474
workLoopSync();
@@ -8466,6 +8477,7 @@ function renderRootSync(root, lanes) {
84668477
handleThrow(root, thrownValue$127);
84678478
}
84688479
while (1);
8480+
lanes && root.shellSuspendCounter++;
84698481
resetContextDependencies();
84708482
executionContext = prevExecutionContext;
84718483
ReactCurrentDispatcher.current = prevDispatcher;
@@ -9924,6 +9936,7 @@ function FiberRootNode(
99249936
this.callbackPriority = 0;
99259937
this.expirationTimes = createLaneMap(-1);
99269938
this.entangledLanes =
9939+
this.shellSuspendCounter =
99279940
this.errorRecoveryDisabledLanes =
99289941
this.finishedLanes =
99299942
this.expiredLanes =
@@ -10129,7 +10142,7 @@ var slice = Array.prototype.slice,
1012910142
return null;
1013010143
},
1013110144
bundleType: 0,
10132-
version: "18.3.0-www-classic-7bcc71be",
10145+
version: "18.3.0-www-classic-a3146e4f",
1013310146
rendererPackageName: "react-art"
1013410147
};
1013510148
var internals$jscomp$inline_1309 = {
@@ -10160,7 +10173,7 @@ var internals$jscomp$inline_1309 = {
1016010173
scheduleRoot: null,
1016110174
setRefreshHandler: null,
1016210175
getCurrentFiber: null,
10163-
reconcilerVersion: "18.3.0-www-classic-7bcc71be"
10176+
reconcilerVersion: "18.3.0-www-classic-a3146e4f"
1016410177
};
1016510178
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
1016610179
var hook$jscomp$inline_1310 = __REACT_DEVTOOLS_GLOBAL_HOOK__;

0 commit comments

Comments
 (0)