Skip to content

Commit f8f734e

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 #26801 for more details. DiffTrain build for commit fc80111.
1 parent 61a5c5b commit f8f734e

File tree

13 files changed

+397
-171
lines changed

13 files changed

+397
-171
lines changed

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react-test-renderer/cjs/ReactTestRenderer-dev.js

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @noflow
88
* @nolint
99
* @preventMunge
10-
* @generated SignedSource<<658af937953acc877e6c9c23bdafbf60>>
10+
* @generated SignedSource<<b3edba4ee6aa87997395f161d846a4b0>>
1111
*/
1212

1313
'use strict';
@@ -1629,6 +1629,7 @@ function markRootFinished(root, remainingLanes) {
16291629
root.expiredLanes &= remainingLanes;
16301630
root.entangledLanes &= remainingLanes;
16311631
root.errorRecoveryDisabledLanes &= remainingLanes;
1632+
root.shellSuspendCounter = 0;
16321633
var entanglements = root.entanglements;
16331634
var expirationTimes = root.expirationTimes;
16341635
var hiddenUpdates = root.hiddenUpdates; // Clear the lanes that no longer have pending work
@@ -4129,6 +4130,32 @@ function trackUsedThenable(thenableState, thenable, index) {
41294130
// happen. Flight lazily parses JSON when the value is actually awaited.
41304131
thenable.then(noop, noop);
41314132
} else {
4133+
// This is an uncached thenable that we haven't seen before.
4134+
// Detect infinite ping loops caused by uncached promises.
4135+
var root = getWorkInProgressRoot();
4136+
4137+
if (root !== null && root.shellSuspendCounter > 100) {
4138+
// This root has suspended repeatedly in the shell without making any
4139+
// progress (i.e. committing something). This is highly suggestive of
4140+
// an infinite ping loop, often caused by an accidental Async Client
4141+
// Component.
4142+
//
4143+
// During a transition, we can suspend the work loop until the promise
4144+
// to resolve, but this is a sync render, so that's not an option. We
4145+
// also can't show a fallback, because none was provided. So our last
4146+
// resort is to throw an error.
4147+
//
4148+
// TODO: Remove this error in a future release. Other ways of handling
4149+
// this case include forcing a concurrent render, or putting the whole
4150+
// root into offscreen mode.
4151+
throw new Error(
4152+
"async/await is not yet supported in Client Components, only " +
4153+
"Server Components. This error is often caused by accidentally " +
4154+
"adding `'use client'` to a module that was originally written " +
4155+
"for the server."
4156+
);
4157+
}
4158+
41324159
var pendingThenable = thenable;
41334160
pendingThenable.status = "pending";
41344161
pendingThenable.then(
@@ -21140,6 +21167,8 @@ function renderRootSync(root, lanes) {
2114021167
prepareFreshStack(root, lanes);
2114121168
}
2114221169

21170+
var didSuspendInShell = false;
21171+
2114321172
outer: do {
2114421173
try {
2114521174
if (
@@ -21167,6 +21196,13 @@ function renderRootSync(root, lanes) {
2116721196
break outer;
2116821197
}
2116921198

21199+
case SuspendedOnImmediate:
21200+
case SuspendedOnData: {
21201+
if (!didSuspendInShell && getSuspenseHandler() === null) {
21202+
didSuspendInShell = true;
21203+
} // Intentional fallthrough
21204+
}
21205+
2117021206
default: {
2117121207
// Unwind then continue with the normal work loop.
2117221208
workInProgressSuspendedReason = NotSuspended;
@@ -21182,7 +21218,16 @@ function renderRootSync(root, lanes) {
2118221218
} catch (thrownValue) {
2118321219
handleThrow(root, thrownValue);
2118421220
}
21185-
} while (true);
21221+
} while (true); // Check if something suspended in the shell. We use this to detect an
21222+
// infinite ping loop caused by an uncached promise.
21223+
//
21224+
// Only increment this counter once per synchronous render attempt across the
21225+
// whole tree. Even if there are many sibling components that suspend, this
21226+
// counter only gets incremented once.
21227+
21228+
if (didSuspendInShell) {
21229+
root.shellSuspendCounter++;
21230+
}
2118621231

2118721232
resetContextDependencies();
2118821233
executionContext = prevExecutionContext;
@@ -23903,6 +23948,7 @@ function FiberRootNode(
2390323948
this.expiredLanes = NoLanes;
2390423949
this.finishedLanes = NoLanes;
2390523950
this.errorRecoveryDisabledLanes = NoLanes;
23951+
this.shellSuspendCounter = 0;
2390623952
this.entangledLanes = NoLanes;
2390723953
this.entanglements = createLaneMap(NoLanes);
2390823954
this.hiddenUpdates = createLaneMap(null);
@@ -23991,7 +24037,7 @@ function createFiberRoot(
2399124037
return root;
2399224038
}
2399324039

23994-
var ReactVersion = "18.3.0-canary-d9c333199-20230629";
24040+
var ReactVersion = "18.3.0-canary-fc801116c-20230629";
2399524041

2399624042
// Might add PROFILE later.
2399724043

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react-test-renderer/cjs/ReactTestRenderer-prod.js

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @noflow
88
* @nolint
99
* @preventMunge
10-
* @generated SignedSource<<cd96739734a9eb22ce0310e98eaa9c31>>
10+
* @generated SignedSource<<6619e9e4c84d9d98c2974ba039dd316b>>
1111
*/
1212

1313
"use strict";
@@ -474,6 +474,7 @@ function markRootFinished(root, remainingLanes) {
474474
root.expiredLanes &= remainingLanes;
475475
root.entangledLanes &= remainingLanes;
476476
root.errorRecoveryDisabledLanes &= remainingLanes;
477+
root.shellSuspendCounter = 0;
477478
remainingLanes = root.entanglements;
478479
var expirationTimes = root.expirationTimes;
479480
for (root = root.hiddenUpdates; 0 < noLongerPendingLanes; ) {
@@ -1063,26 +1064,32 @@ function trackUsedThenable(thenableState, thenable, index) {
10631064
case "rejected":
10641065
throw thenable.reason;
10651066
default:
1066-
"string" === typeof thenable.status
1067-
? thenable.then(noop, noop)
1068-
: ((thenableState = thenable),
1069-
(thenableState.status = "pending"),
1070-
thenableState.then(
1071-
function (fulfilledValue) {
1072-
if ("pending" === thenable.status) {
1073-
var fulfilledThenable = thenable;
1074-
fulfilledThenable.status = "fulfilled";
1075-
fulfilledThenable.value = fulfilledValue;
1076-
}
1077-
},
1078-
function (error) {
1079-
if ("pending" === thenable.status) {
1080-
var rejectedThenable = thenable;
1081-
rejectedThenable.status = "rejected";
1082-
rejectedThenable.reason = error;
1083-
}
1067+
if ("string" === typeof thenable.status) thenable.then(noop, noop);
1068+
else {
1069+
thenableState = workInProgressRoot;
1070+
if (null !== thenableState && 100 < thenableState.shellSuspendCounter)
1071+
throw Error(
1072+
"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."
1073+
);
1074+
thenableState = thenable;
1075+
thenableState.status = "pending";
1076+
thenableState.then(
1077+
function (fulfilledValue) {
1078+
if ("pending" === thenable.status) {
1079+
var fulfilledThenable = thenable;
1080+
fulfilledThenable.status = "fulfilled";
1081+
fulfilledThenable.value = fulfilledValue;
10841082
}
1085-
));
1083+
},
1084+
function (error) {
1085+
if ("pending" === thenable.status) {
1086+
var rejectedThenable = thenable;
1087+
rejectedThenable.status = "rejected";
1088+
rejectedThenable.reason = error;
1089+
}
1090+
}
1091+
);
1092+
}
10861093
switch (thenable.status) {
10871094
case "fulfilled":
10881095
return thenable.value;
@@ -6799,20 +6806,26 @@ function renderRootSync(root, lanes) {
67996806
prevCacheDispatcher = pushCacheDispatcher();
68006807
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes)
68016808
(workInProgressTransitions = null), prepareFreshStack(root, lanes);
6809+
lanes = !1;
68026810
a: do
68036811
try {
68046812
if (0 !== workInProgressSuspendedReason && null !== workInProgress) {
6805-
lanes = workInProgress;
6806-
var thrownValue = workInProgressThrownValue;
6813+
var unitOfWork = workInProgress,
6814+
thrownValue = workInProgressThrownValue;
68076815
switch (workInProgressSuspendedReason) {
68086816
case 8:
68096817
resetWorkInProgressStack();
68106818
workInProgressRootExitStatus = 6;
68116819
break a;
6820+
case 3:
6821+
case 2:
6822+
lanes ||
6823+
null !== suspenseHandlerStackCursor.current ||
6824+
(lanes = !0);
68126825
default:
68136826
(workInProgressSuspendedReason = 0),
68146827
(workInProgressThrownValue = null),
6815-
throwAndUnwindWorkLoop(lanes, thrownValue);
6828+
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
68166829
}
68176830
}
68186831
workLoopSync();
@@ -6821,6 +6834,7 @@ function renderRootSync(root, lanes) {
68216834
handleThrow(root, thrownValue$104);
68226835
}
68236836
while (1);
6837+
lanes && root.shellSuspendCounter++;
68246838
resetContextDependencies();
68256839
executionContext = prevExecutionContext;
68266840
ReactCurrentDispatcher.current = prevDispatcher;
@@ -8228,6 +8242,7 @@ function FiberRootNode(
82288242
this.callbackPriority = 0;
82298243
this.expirationTimes = createLaneMap(-1);
82308244
this.entangledLanes =
8245+
this.shellSuspendCounter =
82318246
this.errorRecoveryDisabledLanes =
82328247
this.finishedLanes =
82338248
this.expiredLanes =
@@ -8646,7 +8661,7 @@ var devToolsConfig$jscomp$inline_1036 = {
86468661
throw Error("TestRenderer does not support findFiberByHostInstance()");
86478662
},
86488663
bundleType: 0,
8649-
version: "18.3.0-canary-d9c333199-20230629",
8664+
version: "18.3.0-canary-fc801116c-20230629",
86508665
rendererPackageName: "react-test-renderer"
86518666
};
86528667
var internals$jscomp$inline_1238 = {
@@ -8677,7 +8692,7 @@ var internals$jscomp$inline_1238 = {
86778692
scheduleRoot: null,
86788693
setRefreshHandler: null,
86798694
getCurrentFiber: null,
8680-
reconcilerVersion: "18.3.0-canary-d9c333199-20230629"
8695+
reconcilerVersion: "18.3.0-canary-fc801116c-20230629"
86818696
};
86828697
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
86838698
var hook$jscomp$inline_1239 = __REACT_DEVTOOLS_GLOBAL_HOOK__;

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react-test-renderer/cjs/ReactTestRenderer-profiling.js

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @noflow
88
* @nolint
99
* @preventMunge
10-
* @generated SignedSource<<ffbe954552317f00dee72cc8607e7ec6>>
10+
* @generated SignedSource<<7df15d761b4fe2b8499cc4f6c9df49c8>>
1111
*/
1212

1313
"use strict";
@@ -492,6 +492,7 @@ function markRootFinished(root, remainingLanes) {
492492
root.expiredLanes &= remainingLanes;
493493
root.entangledLanes &= remainingLanes;
494494
root.errorRecoveryDisabledLanes &= remainingLanes;
495+
root.shellSuspendCounter = 0;
495496
remainingLanes = root.entanglements;
496497
var expirationTimes = root.expirationTimes;
497498
for (root = root.hiddenUpdates; 0 < noLongerPendingLanes; ) {
@@ -1081,26 +1082,32 @@ function trackUsedThenable(thenableState, thenable, index) {
10811082
case "rejected":
10821083
throw thenable.reason;
10831084
default:
1084-
"string" === typeof thenable.status
1085-
? thenable.then(noop, noop)
1086-
: ((thenableState = thenable),
1087-
(thenableState.status = "pending"),
1088-
thenableState.then(
1089-
function (fulfilledValue) {
1090-
if ("pending" === thenable.status) {
1091-
var fulfilledThenable = thenable;
1092-
fulfilledThenable.status = "fulfilled";
1093-
fulfilledThenable.value = fulfilledValue;
1094-
}
1095-
},
1096-
function (error) {
1097-
if ("pending" === thenable.status) {
1098-
var rejectedThenable = thenable;
1099-
rejectedThenable.status = "rejected";
1100-
rejectedThenable.reason = error;
1101-
}
1085+
if ("string" === typeof thenable.status) thenable.then(noop, noop);
1086+
else {
1087+
thenableState = workInProgressRoot;
1088+
if (null !== thenableState && 100 < thenableState.shellSuspendCounter)
1089+
throw Error(
1090+
"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."
1091+
);
1092+
thenableState = thenable;
1093+
thenableState.status = "pending";
1094+
thenableState.then(
1095+
function (fulfilledValue) {
1096+
if ("pending" === thenable.status) {
1097+
var fulfilledThenable = thenable;
1098+
fulfilledThenable.status = "fulfilled";
1099+
fulfilledThenable.value = fulfilledValue;
11021100
}
1103-
));
1101+
},
1102+
function (error) {
1103+
if ("pending" === thenable.status) {
1104+
var rejectedThenable = thenable;
1105+
rejectedThenable.status = "rejected";
1106+
rejectedThenable.reason = error;
1107+
}
1108+
}
1109+
);
1110+
}
11041111
switch (thenable.status) {
11051112
case "fulfilled":
11061113
return thenable.value;
@@ -7141,20 +7148,26 @@ function renderRootSync(root, lanes) {
71417148
prevCacheDispatcher = pushCacheDispatcher();
71427149
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes)
71437150
(workInProgressTransitions = null), prepareFreshStack(root, lanes);
7151+
lanes = !1;
71447152
a: do
71457153
try {
71467154
if (0 !== workInProgressSuspendedReason && null !== workInProgress) {
7147-
lanes = workInProgress;
7148-
var thrownValue = workInProgressThrownValue;
7155+
var unitOfWork = workInProgress,
7156+
thrownValue = workInProgressThrownValue;
71497157
switch (workInProgressSuspendedReason) {
71507158
case 8:
71517159
resetWorkInProgressStack();
71527160
workInProgressRootExitStatus = 6;
71537161
break a;
7162+
case 3:
7163+
case 2:
7164+
lanes ||
7165+
null !== suspenseHandlerStackCursor.current ||
7166+
(lanes = !0);
71547167
default:
71557168
(workInProgressSuspendedReason = 0),
71567169
(workInProgressThrownValue = null),
7157-
throwAndUnwindWorkLoop(lanes, thrownValue);
7170+
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
71587171
}
71597172
}
71607173
workLoopSync();
@@ -7163,6 +7176,7 @@ function renderRootSync(root, lanes) {
71637176
handleThrow(root, thrownValue$117);
71647177
}
71657178
while (1);
7179+
lanes && root.shellSuspendCounter++;
71667180
resetContextDependencies();
71677181
executionContext = prevExecutionContext;
71687182
ReactCurrentDispatcher.current = prevDispatcher;
@@ -8652,6 +8666,7 @@ function FiberRootNode(
86528666
this.callbackPriority = 0;
86538667
this.expirationTimes = createLaneMap(-1);
86548668
this.entangledLanes =
8669+
this.shellSuspendCounter =
86558670
this.errorRecoveryDisabledLanes =
86568671
this.finishedLanes =
86578672
this.expiredLanes =
@@ -9072,7 +9087,7 @@ var devToolsConfig$jscomp$inline_1078 = {
90729087
throw Error("TestRenderer does not support findFiberByHostInstance()");
90739088
},
90749089
bundleType: 0,
9075-
version: "18.3.0-canary-d9c333199-20230629",
9090+
version: "18.3.0-canary-fc801116c-20230629",
90769091
rendererPackageName: "react-test-renderer"
90779092
};
90789093
var internals$jscomp$inline_1279 = {
@@ -9103,7 +9118,7 @@ var internals$jscomp$inline_1279 = {
91039118
scheduleRoot: null,
91049119
setRefreshHandler: null,
91059120
getCurrentFiber: null,
9106-
reconcilerVersion: "18.3.0-canary-d9c333199-20230629"
9121+
reconcilerVersion: "18.3.0-canary-fc801116c-20230629"
91079122
};
91089123
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
91099124
var hook$jscomp$inline_1280 = __REACT_DEVTOOLS_GLOBAL_HOOK__;

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/cjs/React-dev.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-canary-d9c333199-20230629";
30+
var ReactVersion = "18.3.0-canary-fc801116c-20230629";
3131

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

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/cjs/React-prod.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,4 +623,4 @@ exports.useSyncExternalStore = function (
623623
);
624624
};
625625
exports.useTransition = useTransition;
626-
exports.version = "18.3.0-canary-d9c333199-20230629";
626+
exports.version = "18.3.0-canary-fc801116c-20230629";

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/cjs/React-profiling.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ exports.useSyncExternalStore = function (
626626
);
627627
};
628628
exports.useTransition = useTransition;
629-
exports.version = "18.3.0-canary-d9c333199-20230629";
629+
exports.version = "18.3.0-canary-fc801116c-20230629";
630630

631631
/* global __REACT_DEVTOOLS_GLOBAL_HOOK__ */
632632
if (

0 commit comments

Comments
 (0)