Commit 92c0f5f

Browse files
authored
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel (#31554)
This lets us track separately if something was suspended on an Action using useActionState rather than suspended on Data. This approach feels quite bloated and it seems like we'd eventually might want to read more information about the Promise that suspended and the context it suspended in. As a more general reason for suspending. The way useActionState works in combination with the prewarming is quite unfortunate because 1) it renders blocking to update the isPending flag whether you use it or not 2) it prewarms and suspends the useActionState 3) then it does another third render to get back into the useActionState position again.
1 parent 053b3cb commit 92c0f5f

8 files changed

Lines changed: 63 additions & 17 deletions

File tree

‎packages/react-debug-tools/src/ReactDebugHooks.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214214
'`try/catch` block. Capturing without rethrowing will lead to '+
215215
'unexpected behavior.\n\n'+
216216
'To handle async errors, wrap your component in an error boundary, or '+
217-
"call the promise's `.catch` method and pass the result to `use`",
217+
"call the promise's `.catch` method and pass the result to `use`.",
218218
);
219219

220220
functionuse<T>(usable: Usable<T>): T {

‎packages/react-reconciler/src/ReactChildFiber.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
6464
import{pushTreeFork}from'./ReactFiberTreeContext';
6565
import{
6666
SuspenseException,
67+
SuspenseActionException,
6768
createThenableState,
6869
trackUsedThenable,
6970
}from'./ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
19501951
}catch(x){
19511952
if(
19521953
x===SuspenseException||
1954+
x===SuspenseActionException||
19531955
(!disableLegacyMode&&
19541956
(returnFiber.mode&ConcurrentMode)===NoMode&&
19551957
typeofx==='object'&&

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import {
149149
trackUsedThenable,
150150
checkIfUseWrappedInTryCatch,
151151
createThenableState,
152+
SuspenseException,
153+
SuspenseActionException,
152154
}from'./ReactFiberThenable';
153155
importtype{ThenableState}from'./ReactFiberThenable';
154156
importtype{BatchConfigTransition}from'./ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const[isPending]=updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
conststate: Awaited<S>=
2438+
letstate: Awaited<S>;
2439+
if(
24372440
typeofactionResult==='object'&&
24382441
actionResult!==null&&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeofactionResult.then==='function'
2441-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2442-
: (actionResult: any);
2444+
){
2445+
try{
2446+
state =useThenable(((actionResult: any): Thenable<Awaited<S>>));
2447+
}catch(x){
2448+
if(x===SuspenseException){
2449+
// If we Suspend here, mark this separately so that we can track this
2450+
// as an Action in Profiling tools.
2451+
throwSuspenseActionException;
2452+
}else{
2453+
throwx;
2454+
}
2455+
}
2456+
}else{
2457+
state=(actionResult: any);
2458+
}
24432459

24442460
constactionQueueHook=updateWorkInProgressHook();
24452461
constactionQueue=actionQueueHook.queue;

‎packages/react-reconciler/src/ReactFiberThenable.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ export const SuspenseException: mixed = new Error(
4646
'`try/catch` block. Capturing without rethrowing will lead to '+
4747
'unexpected behavior.\n\n'+
4848
'To handle async errors, wrap your component in an error boundary, or '+
49-
"call the promise's `.catch` method and pass the result to `use`",
49+
"call the promise's `.catch` method and pass the result to `use`.",
5050
);
5151

5252
exportconstSuspenseyCommitException: mixed=newError(
5353
'Suspense Exception: This is not a real error, and should not leak into '+
5454
"userspace. If you're seeing this, it's likely a bug in React.",
5555
);
5656

57+
exportconstSuspenseActionException: mixed=newError(
58+
"Suspense Exception: This is not a real error! It's an implementation "+
59+
'detail of `useActionState` to interrupt the current render. You must either '+
60+
'rethrow it immediately, or move the `useActionState` call outside of the '+
61+
'`try/catch` block. Capturing without rethrowing will lead to '+
62+
'unexpected behavior.\n\n'+
63+
'To handle async errors, wrap your component in an error boundary.',
64+
);
5765
// This is a noop thenable that we use to trigger a fallback in throwException.
5866
// TODO: It would be better to refactor throwException into multiple functions
5967
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
296304
// execution context is to check the dispatcher every time `use` is called,
297305
// or some equivalent. That might be preferable for other reasons, too, since
298306
// it matches how we prevent similar mistakes for other hooks.
299-
if(rejectedReason===SuspenseException){
307+
if(
308+
rejectedReason===SuspenseException||
309+
rejectedReason===SuspenseActionException
310+
){
300311
thrownewError(
301312
'Hooks are not supported inside an async component. This '+
302313
"error is often caused by accidentally adding `'use client'` "+

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ import {
298298
import{processTransitionCallbacks}from'./ReactFiberTracingMarkerComponent';
299299
import{
300300
SuspenseException,
301+
SuspenseActionException,
301302
SuspenseyCommitException,
302303
getSuspendedThenable,
303304
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
346347
// The lanes we're rendering
347348
let workInProgressRootRenderLanes: Lanes=NoLanes;
348349

349-
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8;
350+
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8|9;
350351
constNotSuspended: SuspendedReason=0;
351352
constSuspendedOnError: SuspendedReason=1;
352353
constSuspendedOnData: SuspendedReason=2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
constSuspendedOnDeprecatedThrowPromise: SuspendedReason=6;
357358
constSuspendedAndReadyToContinue: SuspendedReason=7;
358359
constSuspendedOnHydration: SuspendedReason=8;
360+
constSuspendedOnAction: SuspendedReason=9;
359361

360362
// When this is true, the work-in-progress fiber just suspended (or errored) and
361363
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -638,7 +640,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes {
638640
}
639641

640642
exportfunctionisWorkLoopSuspendedOnData(): boolean{
641-
returnworkInProgressSuspendedReason===SuspendedOnData;
643+
return(
644+
workInProgressSuspendedReason===SuspendedOnData||
645+
workInProgressSuspendedReason===SuspendedOnAction
646+
);
642647
}
643648

644649
exportfunctiongetCurrentTime(): number{
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
767772
if(
768773
// Suspended render phase
769774
(root===workInProgressRoot&&
770-
workInProgressSuspendedReason===SuspendedOnData)||
775+
(workInProgressSuspendedReason===SuspendedOnData||
776+
workInProgressSuspendedReason===SuspendedOnAction))||
771777
// Suspended commit phase
772778
root.cancelPendingCommit!==null
773779
){
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18151821
resetCurrentFiber();
18161822
}
18171823

1818-
if(thrownValue===SuspenseException){
1824+
if(
1825+
thrownValue===SuspenseException||
1826+
thrownValue===SuspenseActionException
1827+
){
18191828
// This is a special type of exception used for Suspense. For historical
18201829
// reasons, the rest of the Suspense implementation expects the thrown value
18211830
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18361845
!includesNonIdleWork(workInProgressRootSkippedLanes)&&
18371846
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
18381847
? // Suspend work loop until data resolves
1839-
SuspendedOnData
1848+
thrownValue===SuspenseActionException
1849+
? SuspendedOnAction
1850+
: SuspendedOnData
18401851
: // Don't suspend work loop, except to check if the data has
18411852
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
18421853
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
19031914
break;
19041915
}
19051916
caseSuspendedOnData:
1917+
caseSuspendedOnAction:
19061918
caseSuspendedOnImmediate:
19071919
caseSuspendedOnDeprecatedThrowPromise:
19081920
caseSuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
caseSuspendedOnImmediate:
21872199
caseSuspendedOnData:
2200+
caseSuspendedOnAction:
21882201
caseSuspendedOnDeprecatedThrowPromise: {
21892202
if(getSuspenseHandler()===null){
21902203
didSuspendInShell=true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
caseSuspendedOnData: {
2364+
caseSuspendedOnData:
2365+
caseSuspendedOnAction: {
23522366
constthenable: Thenable<mixed>=(thrownValue: any);
23532367
if(isThenableResolved(thenable)){
23542368
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23662380
constonResolution=()=>{
23672381
// Check if the root is still suspended on this promise.
23682382
if(
2369-
workInProgressSuspendedReason===SuspendedOnData&&
2383+
(workInProgressSuspendedReason===SuspendedOnData||
2384+
workInProgressSuspendedReason===SuspendedOnAction)&&
23702385
workInProgressRoot===root
23712386
){
23722387
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
28142829
// can prerender the siblings.
28152830
if(
28162831
suspendedReason===SuspendedOnData||
2832+
suspendedReason===SuspendedOnAction||
28172833
suspendedReason===SuspendedOnImmediate||
28182834
suspendedReason===SuspendedOnDeprecatedThrowPromise
28192835
){

‎packages/react-server/src/ReactFizzThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎packages/react-server/src/ReactFlightThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎scripts/error-codes/codes.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`.",
449449
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450450
"462": "Unexpected SuspendedReason. This is a bug in React.",
451451
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526526
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527527
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528528
"540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529-
"541": "Compared context values must be arrays"
529+
"541": "Compared context values must be arrays",
530+
"542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530531
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 92c0f5f

Browse files
authored
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel (#31554)
This lets us track separately if something was suspended on an Action using useActionState rather than suspended on Data. This approach feels quite bloated and it seems like we'd eventually might want to read more information about the Promise that suspended and the context it suspended in. As a more general reason for suspending. The way useActionState works in combination with the prewarming is quite unfortunate because 1) it renders blocking to update the isPending flag whether you use it or not 2) it prewarms and suspends the useActionState 3) then it does another third render to get back into the useActionState position again.
1 parent 053b3cb commit 92c0f5f

8 files changed

Lines changed: 63 additions & 17 deletions

File tree

‎packages/react-debug-tools/src/ReactDebugHooks.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214214
'`try/catch` block. Capturing without rethrowing will lead to '+
215215
'unexpected behavior.\n\n'+
216216
'To handle async errors, wrap your component in an error boundary, or '+
217-
"call the promise's `.catch` method and pass the result to `use`",
217+
"call the promise's `.catch` method and pass the result to `use`.",
218218
);
219219

220220
functionuse<T>(usable: Usable<T>): T {

‎packages/react-reconciler/src/ReactChildFiber.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
6464
import{pushTreeFork}from'./ReactFiberTreeContext';
6565
import{
6666
SuspenseException,
67+
SuspenseActionException,
6768
createThenableState,
6869
trackUsedThenable,
6970
}from'./ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
19501951
}catch(x){
19511952
if(
19521953
x===SuspenseException||
1954+
x===SuspenseActionException||
19531955
(!disableLegacyMode&&
19541956
(returnFiber.mode&ConcurrentMode)===NoMode&&
19551957
typeofx==='object'&&

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import {
149149
trackUsedThenable,
150150
checkIfUseWrappedInTryCatch,
151151
createThenableState,
152+
SuspenseException,
153+
SuspenseActionException,
152154
}from'./ReactFiberThenable';
153155
importtype{ThenableState}from'./ReactFiberThenable';
154156
importtype{BatchConfigTransition}from'./ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const[isPending]=updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
conststate: Awaited<S>=
2438+
letstate: Awaited<S>;
2439+
if(
24372440
typeofactionResult==='object'&&
24382441
actionResult!==null&&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeofactionResult.then==='function'
2441-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2442-
: (actionResult: any);
2444+
){
2445+
try{
2446+
state =useThenable(((actionResult: any): Thenable<Awaited<S>>));
2447+
}catch(x){
2448+
if(x===SuspenseException){
2449+
// If we Suspend here, mark this separately so that we can track this
2450+
// as an Action in Profiling tools.
2451+
throwSuspenseActionException;
2452+
}else{
2453+
throwx;
2454+
}
2455+
}
2456+
}else{
2457+
state=(actionResult: any);
2458+
}
24432459

24442460
constactionQueueHook=updateWorkInProgressHook();
24452461
constactionQueue=actionQueueHook.queue;

‎packages/react-reconciler/src/ReactFiberThenable.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ export const SuspenseException: mixed = new Error(
4646
'`try/catch` block. Capturing without rethrowing will lead to '+
4747
'unexpected behavior.\n\n'+
4848
'To handle async errors, wrap your component in an error boundary, or '+
49-
"call the promise's `.catch` method and pass the result to `use`",
49+
"call the promise's `.catch` method and pass the result to `use`.",
5050
);
5151

5252
exportconstSuspenseyCommitException: mixed=newError(
5353
'Suspense Exception: This is not a real error, and should not leak into '+
5454
"userspace. If you're seeing this, it's likely a bug in React.",
5555
);
5656

57+
exportconstSuspenseActionException: mixed=newError(
58+
"Suspense Exception: This is not a real error! It's an implementation "+
59+
'detail of `useActionState` to interrupt the current render. You must either '+
60+
'rethrow it immediately, or move the `useActionState` call outside of the '+
61+
'`try/catch` block. Capturing without rethrowing will lead to '+
62+
'unexpected behavior.\n\n'+
63+
'To handle async errors, wrap your component in an error boundary.',
64+
);
5765
// This is a noop thenable that we use to trigger a fallback in throwException.
5866
// TODO: It would be better to refactor throwException into multiple functions
5967
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
296304
// execution context is to check the dispatcher every time `use` is called,
297305
// or some equivalent. That might be preferable for other reasons, too, since
298306
// it matches how we prevent similar mistakes for other hooks.
299-
if(rejectedReason===SuspenseException){
307+
if(
308+
rejectedReason===SuspenseException||
309+
rejectedReason===SuspenseActionException
310+
){
300311
thrownewError(
301312
'Hooks are not supported inside an async component. This '+
302313
"error is often caused by accidentally adding `'use client'` "+

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ import {
298298
import{processTransitionCallbacks}from'./ReactFiberTracingMarkerComponent';
299299
import{
300300
SuspenseException,
301+
SuspenseActionException,
301302
SuspenseyCommitException,
302303
getSuspendedThenable,
303304
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
346347
// The lanes we're rendering
347348
let workInProgressRootRenderLanes: Lanes=NoLanes;
348349

349-
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8;
350+
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8|9;
350351
constNotSuspended: SuspendedReason=0;
351352
constSuspendedOnError: SuspendedReason=1;
352353
constSuspendedOnData: SuspendedReason=2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
constSuspendedOnDeprecatedThrowPromise: SuspendedReason=6;
357358
constSuspendedAndReadyToContinue: SuspendedReason=7;
358359
constSuspendedOnHydration: SuspendedReason=8;
360+
constSuspendedOnAction: SuspendedReason=9;
359361

360362
// When this is true, the work-in-progress fiber just suspended (or errored) and
361363
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -638,7 +640,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes {
638640
}
639641

640642
exportfunctionisWorkLoopSuspendedOnData(): boolean{
641-
returnworkInProgressSuspendedReason===SuspendedOnData;
643+
return(
644+
workInProgressSuspendedReason===SuspendedOnData||
645+
workInProgressSuspendedReason===SuspendedOnAction
646+
);
642647
}
643648

644649
exportfunctiongetCurrentTime(): number{
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
767772
if(
768773
// Suspended render phase
769774
(root===workInProgressRoot&&
770-
workInProgressSuspendedReason===SuspendedOnData)||
775+
(workInProgressSuspendedReason===SuspendedOnData||
776+
workInProgressSuspendedReason===SuspendedOnAction))||
771777
// Suspended commit phase
772778
root.cancelPendingCommit!==null
773779
){
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18151821
resetCurrentFiber();
18161822
}
18171823

1818-
if(thrownValue===SuspenseException){
1824+
if(
1825+
thrownValue===SuspenseException||
1826+
thrownValue===SuspenseActionException
1827+
){
18191828
// This is a special type of exception used for Suspense. For historical
18201829
// reasons, the rest of the Suspense implementation expects the thrown value
18211830
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18361845
!includesNonIdleWork(workInProgressRootSkippedLanes)&&
18371846
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
18381847
? // Suspend work loop until data resolves
1839-
SuspendedOnData
1848+
thrownValue===SuspenseActionException
1849+
? SuspendedOnAction
1850+
: SuspendedOnData
18401851
: // Don't suspend work loop, except to check if the data has
18411852
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
18421853
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
19031914
break;
19041915
}
19051916
caseSuspendedOnData:
1917+
caseSuspendedOnAction:
19061918
caseSuspendedOnImmediate:
19071919
caseSuspendedOnDeprecatedThrowPromise:
19081920
caseSuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
caseSuspendedOnImmediate:
21872199
caseSuspendedOnData:
2200+
caseSuspendedOnAction:
21882201
caseSuspendedOnDeprecatedThrowPromise: {
21892202
if(getSuspenseHandler()===null){
21902203
didSuspendInShell=true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
caseSuspendedOnData: {
2364+
caseSuspendedOnData:
2365+
caseSuspendedOnAction: {
23522366
constthenable: Thenable<mixed>=(thrownValue: any);
23532367
if(isThenableResolved(thenable)){
23542368
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23662380
constonResolution=()=>{
23672381
// Check if the root is still suspended on this promise.
23682382
if(
2369-
workInProgressSuspendedReason===SuspendedOnData&&
2383+
(workInProgressSuspendedReason===SuspendedOnData||
2384+
workInProgressSuspendedReason===SuspendedOnAction)&&
23702385
workInProgressRoot===root
23712386
){
23722387
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
28142829
// can prerender the siblings.
28152830
if(
28162831
suspendedReason===SuspendedOnData||
2832+
suspendedReason===SuspendedOnAction||
28172833
suspendedReason===SuspendedOnImmediate||
28182834
suspendedReason===SuspendedOnDeprecatedThrowPromise
28192835
){

‎packages/react-server/src/ReactFizzThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎packages/react-server/src/ReactFlightThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎scripts/error-codes/codes.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`.",
449449
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450450
"462": "Unexpected SuspendedReason. This is a bug in React.",
451451
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526526
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527527
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528528
"540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529-
"541": "Compared context values must be arrays"
529+
"541": "Compared context values must be arrays",
530+
"542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530531
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 92c0f5f

Browse files
authored
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel (#31554)
This lets us track separately if something was suspended on an Action using useActionState rather than suspended on Data. This approach feels quite bloated and it seems like we'd eventually might want to read more information about the Promise that suspended and the context it suspended in. As a more general reason for suspending. The way useActionState works in combination with the prewarming is quite unfortunate because 1) it renders blocking to update the isPending flag whether you use it or not 2) it prewarms and suspends the useActionState 3) then it does another third render to get back into the useActionState position again.
1 parent 053b3cb commit 92c0f5f

8 files changed

Lines changed: 63 additions & 17 deletions

File tree

‎packages/react-debug-tools/src/ReactDebugHooks.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214214
'`try/catch` block. Capturing without rethrowing will lead to '+
215215
'unexpected behavior.\n\n'+
216216
'To handle async errors, wrap your component in an error boundary, or '+
217-
"call the promise's `.catch` method and pass the result to `use`",
217+
"call the promise's `.catch` method and pass the result to `use`.",
218218
);
219219

220220
functionuse<T>(usable: Usable<T>): T {

‎packages/react-reconciler/src/ReactChildFiber.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
6464
import{pushTreeFork}from'./ReactFiberTreeContext';
6565
import{
6666
SuspenseException,
67+
SuspenseActionException,
6768
createThenableState,
6869
trackUsedThenable,
6970
}from'./ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
19501951
}catch(x){
19511952
if(
19521953
x===SuspenseException||
1954+
x===SuspenseActionException||
19531955
(!disableLegacyMode&&
19541956
(returnFiber.mode&ConcurrentMode)===NoMode&&
19551957
typeofx==='object'&&

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import {
149149
trackUsedThenable,
150150
checkIfUseWrappedInTryCatch,
151151
createThenableState,
152+
SuspenseException,
153+
SuspenseActionException,
152154
}from'./ReactFiberThenable';
153155
importtype{ThenableState}from'./ReactFiberThenable';
154156
importtype{BatchConfigTransition}from'./ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const[isPending]=updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
conststate: Awaited<S>=
2438+
letstate: Awaited<S>;
2439+
if(
24372440
typeofactionResult==='object'&&
24382441
actionResult!==null&&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeofactionResult.then==='function'
2441-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2442-
: (actionResult: any);
2444+
){
2445+
try{
2446+
state =useThenable(((actionResult: any): Thenable<Awaited<S>>));
2447+
}catch(x){
2448+
if(x===SuspenseException){
2449+
// If we Suspend here, mark this separately so that we can track this
2450+
// as an Action in Profiling tools.
2451+
throwSuspenseActionException;
2452+
}else{
2453+
throwx;
2454+
}
2455+
}
2456+
}else{
2457+
state=(actionResult: any);
2458+
}
24432459

24442460
constactionQueueHook=updateWorkInProgressHook();
24452461
constactionQueue=actionQueueHook.queue;

‎packages/react-reconciler/src/ReactFiberThenable.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ export const SuspenseException: mixed = new Error(
4646
'`try/catch` block. Capturing without rethrowing will lead to '+
4747
'unexpected behavior.\n\n'+
4848
'To handle async errors, wrap your component in an error boundary, or '+
49-
"call the promise's `.catch` method and pass the result to `use`",
49+
"call the promise's `.catch` method and pass the result to `use`.",
5050
);
5151

5252
exportconstSuspenseyCommitException: mixed=newError(
5353
'Suspense Exception: This is not a real error, and should not leak into '+
5454
"userspace. If you're seeing this, it's likely a bug in React.",
5555
);
5656

57+
exportconstSuspenseActionException: mixed=newError(
58+
"Suspense Exception: This is not a real error! It's an implementation "+
59+
'detail of `useActionState` to interrupt the current render. You must either '+
60+
'rethrow it immediately, or move the `useActionState` call outside of the '+
61+
'`try/catch` block. Capturing without rethrowing will lead to '+
62+
'unexpected behavior.\n\n'+
63+
'To handle async errors, wrap your component in an error boundary.',
64+
);
5765
// This is a noop thenable that we use to trigger a fallback in throwException.
5866
// TODO: It would be better to refactor throwException into multiple functions
5967
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
296304
// execution context is to check the dispatcher every time `use` is called,
297305
// or some equivalent. That might be preferable for other reasons, too, since
298306
// it matches how we prevent similar mistakes for other hooks.
299-
if(rejectedReason===SuspenseException){
307+
if(
308+
rejectedReason===SuspenseException||
309+
rejectedReason===SuspenseActionException
310+
){
300311
thrownewError(
301312
'Hooks are not supported inside an async component. This '+
302313
"error is often caused by accidentally adding `'use client'` "+

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ import {
298298
import{processTransitionCallbacks}from'./ReactFiberTracingMarkerComponent';
299299
import{
300300
SuspenseException,
301+
SuspenseActionException,
301302
SuspenseyCommitException,
302303
getSuspendedThenable,
303304
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
346347
// The lanes we're rendering
347348
let workInProgressRootRenderLanes: Lanes=NoLanes;
348349

349-
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8;
350+
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8|9;
350351
constNotSuspended: SuspendedReason=0;
351352
constSuspendedOnError: SuspendedReason=1;
352353
constSuspendedOnData: SuspendedReason=2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
constSuspendedOnDeprecatedThrowPromise: SuspendedReason=6;
357358
constSuspendedAndReadyToContinue: SuspendedReason=7;
358359
constSuspendedOnHydration: SuspendedReason=8;
360+
constSuspendedOnAction: SuspendedReason=9;
359361

360362
// When this is true, the work-in-progress fiber just suspended (or errored) and
361363
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -638,7 +640,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes {
638640
}
639641

640642
exportfunctionisWorkLoopSuspendedOnData(): boolean{
641-
returnworkInProgressSuspendedReason===SuspendedOnData;
643+
return(
644+
workInProgressSuspendedReason===SuspendedOnData||
645+
workInProgressSuspendedReason===SuspendedOnAction
646+
);
642647
}
643648

644649
exportfunctiongetCurrentTime(): number{
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
767772
if(
768773
// Suspended render phase
769774
(root===workInProgressRoot&&
770-
workInProgressSuspendedReason===SuspendedOnData)||
775+
(workInProgressSuspendedReason===SuspendedOnData||
776+
workInProgressSuspendedReason===SuspendedOnAction))||
771777
// Suspended commit phase
772778
root.cancelPendingCommit!==null
773779
){
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18151821
resetCurrentFiber();
18161822
}
18171823

1818-
if(thrownValue===SuspenseException){
1824+
if(
1825+
thrownValue===SuspenseException||
1826+
thrownValue===SuspenseActionException
1827+
){
18191828
// This is a special type of exception used for Suspense. For historical
18201829
// reasons, the rest of the Suspense implementation expects the thrown value
18211830
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18361845
!includesNonIdleWork(workInProgressRootSkippedLanes)&&
18371846
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
18381847
? // Suspend work loop until data resolves
1839-
SuspendedOnData
1848+
thrownValue===SuspenseActionException
1849+
? SuspendedOnAction
1850+
: SuspendedOnData
18401851
: // Don't suspend work loop, except to check if the data has
18411852
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
18421853
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
19031914
break;
19041915
}
19051916
caseSuspendedOnData:
1917+
caseSuspendedOnAction:
19061918
caseSuspendedOnImmediate:
19071919
caseSuspendedOnDeprecatedThrowPromise:
19081920
caseSuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
caseSuspendedOnImmediate:
21872199
caseSuspendedOnData:
2200+
caseSuspendedOnAction:
21882201
caseSuspendedOnDeprecatedThrowPromise: {
21892202
if(getSuspenseHandler()===null){
21902203
didSuspendInShell=true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
caseSuspendedOnData: {
2364+
caseSuspendedOnData:
2365+
caseSuspendedOnAction: {
23522366
constthenable: Thenable<mixed>=(thrownValue: any);
23532367
if(isThenableResolved(thenable)){
23542368
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23662380
constonResolution=()=>{
23672381
// Check if the root is still suspended on this promise.
23682382
if(
2369-
workInProgressSuspendedReason===SuspendedOnData&&
2383+
(workInProgressSuspendedReason===SuspendedOnData||
2384+
workInProgressSuspendedReason===SuspendedOnAction)&&
23702385
workInProgressRoot===root
23712386
){
23722387
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
28142829
// can prerender the siblings.
28152830
if(
28162831
suspendedReason===SuspendedOnData||
2832+
suspendedReason===SuspendedOnAction||
28172833
suspendedReason===SuspendedOnImmediate||
28182834
suspendedReason===SuspendedOnDeprecatedThrowPromise
28192835
){

‎packages/react-server/src/ReactFizzThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎packages/react-server/src/ReactFlightThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎scripts/error-codes/codes.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`.",
449449
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450450
"462": "Unexpected SuspendedReason. This is a bug in React.",
451451
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526526
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527527
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528528
"540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529-
"541": "Compared context values must be arrays"
529+
"541": "Compared context values must be arrays",
530+
"542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530531
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 92c0f5f

Browse files
authored
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel (#31554)
This lets us track separately if something was suspended on an Action using useActionState rather than suspended on Data. This approach feels quite bloated and it seems like we'd eventually might want to read more information about the Promise that suspended and the context it suspended in. As a more general reason for suspending. The way useActionState works in combination with the prewarming is quite unfortunate because 1) it renders blocking to update the isPending flag whether you use it or not 2) it prewarms and suspends the useActionState 3) then it does another third render to get back into the useActionState position again.
1 parent 053b3cb commit 92c0f5f

8 files changed

Lines changed: 63 additions & 17 deletions

File tree

‎packages/react-debug-tools/src/ReactDebugHooks.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214214
'`try/catch` block. Capturing without rethrowing will lead to '+
215215
'unexpected behavior.\n\n'+
216216
'To handle async errors, wrap your component in an error boundary, or '+
217-
"call the promise's `.catch` method and pass the result to `use`",
217+
"call the promise's `.catch` method and pass the result to `use`.",
218218
);
219219

220220
functionuse<T>(usable: Usable<T>): T {

‎packages/react-reconciler/src/ReactChildFiber.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
6464
import{pushTreeFork}from'./ReactFiberTreeContext';
6565
import{
6666
SuspenseException,
67+
SuspenseActionException,
6768
createThenableState,
6869
trackUsedThenable,
6970
}from'./ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
19501951
}catch(x){
19511952
if(
19521953
x===SuspenseException||
1954+
x===SuspenseActionException||
19531955
(!disableLegacyMode&&
19541956
(returnFiber.mode&ConcurrentMode)===NoMode&&
19551957
typeofx==='object'&&

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import {
149149
trackUsedThenable,
150150
checkIfUseWrappedInTryCatch,
151151
createThenableState,
152+
SuspenseException,
153+
SuspenseActionException,
152154
}from'./ReactFiberThenable';
153155
importtype{ThenableState}from'./ReactFiberThenable';
154156
importtype{BatchConfigTransition}from'./ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const[isPending]=updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
conststate: Awaited<S>=
2438+
letstate: Awaited<S>;
2439+
if(
24372440
typeofactionResult==='object'&&
24382441
actionResult!==null&&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeofactionResult.then==='function'
2441-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2442-
: (actionResult: any);
2444+
){
2445+
try{
2446+
state =useThenable(((actionResult: any): Thenable<Awaited<S>>));
2447+
}catch(x){
2448+
if(x===SuspenseException){
2449+
// If we Suspend here, mark this separately so that we can track this
2450+
// as an Action in Profiling tools.
2451+
throwSuspenseActionException;
2452+
}else{
2453+
throwx;
2454+
}
2455+
}
2456+
}else{
2457+
state=(actionResult: any);
2458+
}
24432459

24442460
constactionQueueHook=updateWorkInProgressHook();
24452461
constactionQueue=actionQueueHook.queue;

‎packages/react-reconciler/src/ReactFiberThenable.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ export const SuspenseException: mixed = new Error(
4646
'`try/catch` block. Capturing without rethrowing will lead to '+
4747
'unexpected behavior.\n\n'+
4848
'To handle async errors, wrap your component in an error boundary, or '+
49-
"call the promise's `.catch` method and pass the result to `use`",
49+
"call the promise's `.catch` method and pass the result to `use`.",
5050
);
5151

5252
exportconstSuspenseyCommitException: mixed=newError(
5353
'Suspense Exception: This is not a real error, and should not leak into '+
5454
"userspace. If you're seeing this, it's likely a bug in React.",
5555
);
5656

57+
exportconstSuspenseActionException: mixed=newError(
58+
"Suspense Exception: This is not a real error! It's an implementation "+
59+
'detail of `useActionState` to interrupt the current render. You must either '+
60+
'rethrow it immediately, or move the `useActionState` call outside of the '+
61+
'`try/catch` block. Capturing without rethrowing will lead to '+
62+
'unexpected behavior.\n\n'+
63+
'To handle async errors, wrap your component in an error boundary.',
64+
);
5765
// This is a noop thenable that we use to trigger a fallback in throwException.
5866
// TODO: It would be better to refactor throwException into multiple functions
5967
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
296304
// execution context is to check the dispatcher every time `use` is called,
297305
// or some equivalent. That might be preferable for other reasons, too, since
298306
// it matches how we prevent similar mistakes for other hooks.
299-
if(rejectedReason===SuspenseException){
307+
if(
308+
rejectedReason===SuspenseException||
309+
rejectedReason===SuspenseActionException
310+
){
300311
thrownewError(
301312
'Hooks are not supported inside an async component. This '+
302313
"error is often caused by accidentally adding `'use client'` "+

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ import {
298298
import{processTransitionCallbacks}from'./ReactFiberTracingMarkerComponent';
299299
import{
300300
SuspenseException,
301+
SuspenseActionException,
301302
SuspenseyCommitException,
302303
getSuspendedThenable,
303304
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
346347
// The lanes we're rendering
347348
let workInProgressRootRenderLanes: Lanes=NoLanes;
348349

349-
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8;
350+
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8|9;
350351
constNotSuspended: SuspendedReason=0;
351352
constSuspendedOnError: SuspendedReason=1;
352353
constSuspendedOnData: SuspendedReason=2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
constSuspendedOnDeprecatedThrowPromise: SuspendedReason=6;
357358
constSuspendedAndReadyToContinue: SuspendedReason=7;
358359
constSuspendedOnHydration: SuspendedReason=8;
360+
constSuspendedOnAction: SuspendedReason=9;
359361

360362
// When this is true, the work-in-progress fiber just suspended (or errored) and
361363
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -638,7 +640,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes {
638640
}
639641

640642
exportfunctionisWorkLoopSuspendedOnData(): boolean{
641-
returnworkInProgressSuspendedReason===SuspendedOnData;
643+
return(
644+
workInProgressSuspendedReason===SuspendedOnData||
645+
workInProgressSuspendedReason===SuspendedOnAction
646+
);
642647
}
643648

644649
exportfunctiongetCurrentTime(): number{
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
767772
if(
768773
// Suspended render phase
769774
(root===workInProgressRoot&&
770-
workInProgressSuspendedReason===SuspendedOnData)||
775+
(workInProgressSuspendedReason===SuspendedOnData||
776+
workInProgressSuspendedReason===SuspendedOnAction))||
771777
// Suspended commit phase
772778
root.cancelPendingCommit!==null
773779
){
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18151821
resetCurrentFiber();
18161822
}
18171823

1818-
if(thrownValue===SuspenseException){
1824+
if(
1825+
thrownValue===SuspenseException||
1826+
thrownValue===SuspenseActionException
1827+
){
18191828
// This is a special type of exception used for Suspense. For historical
18201829
// reasons, the rest of the Suspense implementation expects the thrown value
18211830
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18361845
!includesNonIdleWork(workInProgressRootSkippedLanes)&&
18371846
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
18381847
? // Suspend work loop until data resolves
1839-
SuspendedOnData
1848+
thrownValue===SuspenseActionException
1849+
? SuspendedOnAction
1850+
: SuspendedOnData
18401851
: // Don't suspend work loop, except to check if the data has
18411852
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
18421853
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
19031914
break;
19041915
}
19051916
caseSuspendedOnData:
1917+
caseSuspendedOnAction:
19061918
caseSuspendedOnImmediate:
19071919
caseSuspendedOnDeprecatedThrowPromise:
19081920
caseSuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
caseSuspendedOnImmediate:
21872199
caseSuspendedOnData:
2200+
caseSuspendedOnAction:
21882201
caseSuspendedOnDeprecatedThrowPromise: {
21892202
if(getSuspenseHandler()===null){
21902203
didSuspendInShell=true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
caseSuspendedOnData: {
2364+
caseSuspendedOnData:
2365+
caseSuspendedOnAction: {
23522366
constthenable: Thenable<mixed>=(thrownValue: any);
23532367
if(isThenableResolved(thenable)){
23542368
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23662380
constonResolution=()=>{
23672381
// Check if the root is still suspended on this promise.
23682382
if(
2369-
workInProgressSuspendedReason===SuspendedOnData&&
2383+
(workInProgressSuspendedReason===SuspendedOnData||
2384+
workInProgressSuspendedReason===SuspendedOnAction)&&
23702385
workInProgressRoot===root
23712386
){
23722387
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
28142829
// can prerender the siblings.
28152830
if(
28162831
suspendedReason===SuspendedOnData||
2832+
suspendedReason===SuspendedOnAction||
28172833
suspendedReason===SuspendedOnImmediate||
28182834
suspendedReason===SuspendedOnDeprecatedThrowPromise
28192835
){

‎packages/react-server/src/ReactFizzThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎packages/react-server/src/ReactFlightThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎scripts/error-codes/codes.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`.",
449449
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450450
"462": "Unexpected SuspendedReason. This is a bug in React.",
451451
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526526
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527527
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528528
"540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529-
"541": "Compared context values must be arrays"
529+
"541": "Compared context values must be arrays",
530+
"542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530531
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 92c0f5f

Browse files
authored
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel (#31554)
This lets us track separately if something was suspended on an Action using useActionState rather than suspended on Data. This approach feels quite bloated and it seems like we'd eventually might want to read more information about the Promise that suspended and the context it suspended in. As a more general reason for suspending. The way useActionState works in combination with the prewarming is quite unfortunate because 1) it renders blocking to update the isPending flag whether you use it or not 2) it prewarms and suspends the useActionState 3) then it does another third render to get back into the useActionState position again.
1 parent 053b3cb commit 92c0f5f

8 files changed

Lines changed: 63 additions & 17 deletions

File tree

‎packages/react-debug-tools/src/ReactDebugHooks.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214214
'`try/catch` block. Capturing without rethrowing will lead to '+
215215
'unexpected behavior.\n\n'+
216216
'To handle async errors, wrap your component in an error boundary, or '+
217-
"call the promise's `.catch` method and pass the result to `use`",
217+
"call the promise's `.catch` method and pass the result to `use`.",
218218
);
219219

220220
functionuse<T>(usable: Usable<T>): T {

‎packages/react-reconciler/src/ReactChildFiber.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
6464
import{pushTreeFork}from'./ReactFiberTreeContext';
6565
import{
6666
SuspenseException,
67+
SuspenseActionException,
6768
createThenableState,
6869
trackUsedThenable,
6970
}from'./ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
19501951
}catch(x){
19511952
if(
19521953
x===SuspenseException||
1954+
x===SuspenseActionException||
19531955
(!disableLegacyMode&&
19541956
(returnFiber.mode&ConcurrentMode)===NoMode&&
19551957
typeofx==='object'&&

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import {
149149
trackUsedThenable,
150150
checkIfUseWrappedInTryCatch,
151151
createThenableState,
152+
SuspenseException,
153+
SuspenseActionException,
152154
}from'./ReactFiberThenable';
153155
importtype{ThenableState}from'./ReactFiberThenable';
154156
importtype{BatchConfigTransition}from'./ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const[isPending]=updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
conststate: Awaited<S>=
2438+
letstate: Awaited<S>;
2439+
if(
24372440
typeofactionResult==='object'&&
24382441
actionResult!==null&&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeofactionResult.then==='function'
2441-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2442-
: (actionResult: any);
2444+
){
2445+
try{
2446+
state =useThenable(((actionResult: any): Thenable<Awaited<S>>));
2447+
}catch(x){
2448+
if(x===SuspenseException){
2449+
// If we Suspend here, mark this separately so that we can track this
2450+
// as an Action in Profiling tools.
2451+
throwSuspenseActionException;
2452+
}else{
2453+
throwx;
2454+
}
2455+
}
2456+
}else{
2457+
state=(actionResult: any);
2458+
}
24432459

24442460
constactionQueueHook=updateWorkInProgressHook();
24452461
constactionQueue=actionQueueHook.queue;

‎packages/react-reconciler/src/ReactFiberThenable.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ export const SuspenseException: mixed = new Error(
4646
'`try/catch` block. Capturing without rethrowing will lead to '+
4747
'unexpected behavior.\n\n'+
4848
'To handle async errors, wrap your component in an error boundary, or '+
49-
"call the promise's `.catch` method and pass the result to `use`",
49+
"call the promise's `.catch` method and pass the result to `use`.",
5050
);
5151

5252
exportconstSuspenseyCommitException: mixed=newError(
5353
'Suspense Exception: This is not a real error, and should not leak into '+
5454
"userspace. If you're seeing this, it's likely a bug in React.",
5555
);
5656

57+
exportconstSuspenseActionException: mixed=newError(
58+
"Suspense Exception: This is not a real error! It's an implementation "+
59+
'detail of `useActionState` to interrupt the current render. You must either '+
60+
'rethrow it immediately, or move the `useActionState` call outside of the '+
61+
'`try/catch` block. Capturing without rethrowing will lead to '+
62+
'unexpected behavior.\n\n'+
63+
'To handle async errors, wrap your component in an error boundary.',
64+
);
5765
// This is a noop thenable that we use to trigger a fallback in throwException.
5866
// TODO: It would be better to refactor throwException into multiple functions
5967
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
296304
// execution context is to check the dispatcher every time `use` is called,
297305
// or some equivalent. That might be preferable for other reasons, too, since
298306
// it matches how we prevent similar mistakes for other hooks.
299-
if(rejectedReason===SuspenseException){
307+
if(
308+
rejectedReason===SuspenseException||
309+
rejectedReason===SuspenseActionException
310+
){
300311
thrownewError(
301312
'Hooks are not supported inside an async component. This '+
302313
"error is often caused by accidentally adding `'use client'` "+

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ import {
298298
import{processTransitionCallbacks}from'./ReactFiberTracingMarkerComponent';
299299
import{
300300
SuspenseException,
301+
SuspenseActionException,
301302
SuspenseyCommitException,
302303
getSuspendedThenable,
303304
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
346347
// The lanes we're rendering
347348
let workInProgressRootRenderLanes: Lanes=NoLanes;
348349

349-
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8;
350+
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8|9;
350351
constNotSuspended: SuspendedReason=0;
351352
constSuspendedOnError: SuspendedReason=1;
352353
constSuspendedOnData: SuspendedReason=2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
constSuspendedOnDeprecatedThrowPromise: SuspendedReason=6;
357358
constSuspendedAndReadyToContinue: SuspendedReason=7;
358359
constSuspendedOnHydration: SuspendedReason=8;
360+
constSuspendedOnAction: SuspendedReason=9;
359361

360362
// When this is true, the work-in-progress fiber just suspended (or errored) and
361363
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -638,7 +640,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes {
638640
}
639641

640642
exportfunctionisWorkLoopSuspendedOnData(): boolean{
641-
returnworkInProgressSuspendedReason===SuspendedOnData;
643+
return(
644+
workInProgressSuspendedReason===SuspendedOnData||
645+
workInProgressSuspendedReason===SuspendedOnAction
646+
);
642647
}
643648

644649
exportfunctiongetCurrentTime(): number{
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
767772
if(
768773
// Suspended render phase
769774
(root===workInProgressRoot&&
770-
workInProgressSuspendedReason===SuspendedOnData)||
775+
(workInProgressSuspendedReason===SuspendedOnData||
776+
workInProgressSuspendedReason===SuspendedOnAction))||
771777
// Suspended commit phase
772778
root.cancelPendingCommit!==null
773779
){
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18151821
resetCurrentFiber();
18161822
}
18171823

1818-
if(thrownValue===SuspenseException){
1824+
if(
1825+
thrownValue===SuspenseException||
1826+
thrownValue===SuspenseActionException
1827+
){
18191828
// This is a special type of exception used for Suspense. For historical
18201829
// reasons, the rest of the Suspense implementation expects the thrown value
18211830
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18361845
!includesNonIdleWork(workInProgressRootSkippedLanes)&&
18371846
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
18381847
? // Suspend work loop until data resolves
1839-
SuspendedOnData
1848+
thrownValue===SuspenseActionException
1849+
? SuspendedOnAction
1850+
: SuspendedOnData
18401851
: // Don't suspend work loop, except to check if the data has
18411852
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
18421853
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
19031914
break;
19041915
}
19051916
caseSuspendedOnData:
1917+
caseSuspendedOnAction:
19061918
caseSuspendedOnImmediate:
19071919
caseSuspendedOnDeprecatedThrowPromise:
19081920
caseSuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
caseSuspendedOnImmediate:
21872199
caseSuspendedOnData:
2200+
caseSuspendedOnAction:
21882201
caseSuspendedOnDeprecatedThrowPromise: {
21892202
if(getSuspenseHandler()===null){
21902203
didSuspendInShell=true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
caseSuspendedOnData: {
2364+
caseSuspendedOnData:
2365+
caseSuspendedOnAction: {
23522366
constthenable: Thenable<mixed>=(thrownValue: any);
23532367
if(isThenableResolved(thenable)){
23542368
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23662380
constonResolution=()=>{
23672381
// Check if the root is still suspended on this promise.
23682382
if(
2369-
workInProgressSuspendedReason===SuspendedOnData&&
2383+
(workInProgressSuspendedReason===SuspendedOnData||
2384+
workInProgressSuspendedReason===SuspendedOnAction)&&
23702385
workInProgressRoot===root
23712386
){
23722387
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
28142829
// can prerender the siblings.
28152830
if(
28162831
suspendedReason===SuspendedOnData||
2832+
suspendedReason===SuspendedOnAction||
28172833
suspendedReason===SuspendedOnImmediate||
28182834
suspendedReason===SuspendedOnDeprecatedThrowPromise
28192835
){

‎packages/react-server/src/ReactFizzThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎packages/react-server/src/ReactFlightThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎scripts/error-codes/codes.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`.",
449449
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450450
"462": "Unexpected SuspendedReason. This is a bug in React.",
451451
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526526
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527527
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528528
"540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529-
"541": "Compared context values must be arrays"
529+
"541": "Compared context values must be arrays",
530+
"542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530531
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 92c0f5f

Browse files
authored
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel (#31554)
This lets us track separately if something was suspended on an Action using useActionState rather than suspended on Data. This approach feels quite bloated and it seems like we'd eventually might want to read more information about the Promise that suspended and the context it suspended in. As a more general reason for suspending. The way useActionState works in combination with the prewarming is quite unfortunate because 1) it renders blocking to update the isPending flag whether you use it or not 2) it prewarms and suspends the useActionState 3) then it does another third render to get back into the useActionState position again.
1 parent 053b3cb commit 92c0f5f

8 files changed

Lines changed: 63 additions & 17 deletions

File tree

‎packages/react-debug-tools/src/ReactDebugHooks.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214214
'`try/catch` block. Capturing without rethrowing will lead to '+
215215
'unexpected behavior.\n\n'+
216216
'To handle async errors, wrap your component in an error boundary, or '+
217-
"call the promise's `.catch` method and pass the result to `use`",
217+
"call the promise's `.catch` method and pass the result to `use`.",
218218
);
219219

220220
functionuse<T>(usable: Usable<T>): T {

‎packages/react-reconciler/src/ReactChildFiber.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
6464
import{pushTreeFork}from'./ReactFiberTreeContext';
6565
import{
6666
SuspenseException,
67+
SuspenseActionException,
6768
createThenableState,
6869
trackUsedThenable,
6970
}from'./ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
19501951
}catch(x){
19511952
if(
19521953
x===SuspenseException||
1954+
x===SuspenseActionException||
19531955
(!disableLegacyMode&&
19541956
(returnFiber.mode&ConcurrentMode)===NoMode&&
19551957
typeofx==='object'&&

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import {
149149
trackUsedThenable,
150150
checkIfUseWrappedInTryCatch,
151151
createThenableState,
152+
SuspenseException,
153+
SuspenseActionException,
152154
}from'./ReactFiberThenable';
153155
importtype{ThenableState}from'./ReactFiberThenable';
154156
importtype{BatchConfigTransition}from'./ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const[isPending]=updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
conststate: Awaited<S>=
2438+
letstate: Awaited<S>;
2439+
if(
24372440
typeofactionResult==='object'&&
24382441
actionResult!==null&&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeofactionResult.then==='function'
2441-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2442-
: (actionResult: any);
2444+
){
2445+
try{
2446+
state =useThenable(((actionResult: any): Thenable<Awaited<S>>));
2447+
}catch(x){
2448+
if(x===SuspenseException){
2449+
// If we Suspend here, mark this separately so that we can track this
2450+
// as an Action in Profiling tools.
2451+
throwSuspenseActionException;
2452+
}else{
2453+
throwx;
2454+
}
2455+
}
2456+
}else{
2457+
state=(actionResult: any);
2458+
}
24432459

24442460
constactionQueueHook=updateWorkInProgressHook();
24452461
constactionQueue=actionQueueHook.queue;

‎packages/react-reconciler/src/ReactFiberThenable.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ export const SuspenseException: mixed = new Error(
4646
'`try/catch` block. Capturing without rethrowing will lead to '+
4747
'unexpected behavior.\n\n'+
4848
'To handle async errors, wrap your component in an error boundary, or '+
49-
"call the promise's `.catch` method and pass the result to `use`",
49+
"call the promise's `.catch` method and pass the result to `use`.",
5050
);
5151

5252
exportconstSuspenseyCommitException: mixed=newError(
5353
'Suspense Exception: This is not a real error, and should not leak into '+
5454
"userspace. If you're seeing this, it's likely a bug in React.",
5555
);
5656

57+
exportconstSuspenseActionException: mixed=newError(
58+
"Suspense Exception: This is not a real error! It's an implementation "+
59+
'detail of `useActionState` to interrupt the current render. You must either '+
60+
'rethrow it immediately, or move the `useActionState` call outside of the '+
61+
'`try/catch` block. Capturing without rethrowing will lead to '+
62+
'unexpected behavior.\n\n'+
63+
'To handle async errors, wrap your component in an error boundary.',
64+
);
5765
// This is a noop thenable that we use to trigger a fallback in throwException.
5866
// TODO: It would be better to refactor throwException into multiple functions
5967
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
296304
// execution context is to check the dispatcher every time `use` is called,
297305
// or some equivalent. That might be preferable for other reasons, too, since
298306
// it matches how we prevent similar mistakes for other hooks.
299-
if(rejectedReason===SuspenseException){
307+
if(
308+
rejectedReason===SuspenseException||
309+
rejectedReason===SuspenseActionException
310+
){
300311
thrownewError(
301312
'Hooks are not supported inside an async component. This '+
302313
"error is often caused by accidentally adding `'use client'` "+

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ import {
298298
import{processTransitionCallbacks}from'./ReactFiberTracingMarkerComponent';
299299
import{
300300
SuspenseException,
301+
SuspenseActionException,
301302
SuspenseyCommitException,
302303
getSuspendedThenable,
303304
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
346347
// The lanes we're rendering
347348
let workInProgressRootRenderLanes: Lanes=NoLanes;
348349

349-
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8;
350+
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8|9;
350351
constNotSuspended: SuspendedReason=0;
351352
constSuspendedOnError: SuspendedReason=1;
352353
constSuspendedOnData: SuspendedReason=2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
constSuspendedOnDeprecatedThrowPromise: SuspendedReason=6;
357358
constSuspendedAndReadyToContinue: SuspendedReason=7;
358359
constSuspendedOnHydration: SuspendedReason=8;
360+
constSuspendedOnAction: SuspendedReason=9;
359361

360362
// When this is true, the work-in-progress fiber just suspended (or errored) and
361363
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -638,7 +640,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes {
638640
}
639641

640642
exportfunctionisWorkLoopSuspendedOnData(): boolean{
641-
returnworkInProgressSuspendedReason===SuspendedOnData;
643+
return(
644+
workInProgressSuspendedReason===SuspendedOnData||
645+
workInProgressSuspendedReason===SuspendedOnAction
646+
);
642647
}
643648

644649
exportfunctiongetCurrentTime(): number{
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
767772
if(
768773
// Suspended render phase
769774
(root===workInProgressRoot&&
770-
workInProgressSuspendedReason===SuspendedOnData)||
775+
(workInProgressSuspendedReason===SuspendedOnData||
776+
workInProgressSuspendedReason===SuspendedOnAction))||
771777
// Suspended commit phase
772778
root.cancelPendingCommit!==null
773779
){
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18151821
resetCurrentFiber();
18161822
}
18171823

1818-
if(thrownValue===SuspenseException){
1824+
if(
1825+
thrownValue===SuspenseException||
1826+
thrownValue===SuspenseActionException
1827+
){
18191828
// This is a special type of exception used for Suspense. For historical
18201829
// reasons, the rest of the Suspense implementation expects the thrown value
18211830
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18361845
!includesNonIdleWork(workInProgressRootSkippedLanes)&&
18371846
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
18381847
? // Suspend work loop until data resolves
1839-
SuspendedOnData
1848+
thrownValue===SuspenseActionException
1849+
? SuspendedOnAction
1850+
: SuspendedOnData
18401851
: // Don't suspend work loop, except to check if the data has
18411852
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
18421853
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
19031914
break;
19041915
}
19051916
caseSuspendedOnData:
1917+
caseSuspendedOnAction:
19061918
caseSuspendedOnImmediate:
19071919
caseSuspendedOnDeprecatedThrowPromise:
19081920
caseSuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
caseSuspendedOnImmediate:
21872199
caseSuspendedOnData:
2200+
caseSuspendedOnAction:
21882201
caseSuspendedOnDeprecatedThrowPromise: {
21892202
if(getSuspenseHandler()===null){
21902203
didSuspendInShell=true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
caseSuspendedOnData: {
2364+
caseSuspendedOnData:
2365+
caseSuspendedOnAction: {
23522366
constthenable: Thenable<mixed>=(thrownValue: any);
23532367
if(isThenableResolved(thenable)){
23542368
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23662380
constonResolution=()=>{
23672381
// Check if the root is still suspended on this promise.
23682382
if(
2369-
workInProgressSuspendedReason===SuspendedOnData&&
2383+
(workInProgressSuspendedReason===SuspendedOnData||
2384+
workInProgressSuspendedReason===SuspendedOnAction)&&
23702385
workInProgressRoot===root
23712386
){
23722387
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
28142829
// can prerender the siblings.
28152830
if(
28162831
suspendedReason===SuspendedOnData||
2832+
suspendedReason===SuspendedOnAction||
28172833
suspendedReason===SuspendedOnImmediate||
28182834
suspendedReason===SuspendedOnDeprecatedThrowPromise
28192835
){

‎packages/react-server/src/ReactFizzThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎packages/react-server/src/ReactFlightThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎scripts/error-codes/codes.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`.",
449449
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450450
"462": "Unexpected SuspendedReason. This is a bug in React.",
451451
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526526
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527527
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528528
"540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529-
"541": "Compared context values must be arrays"
529+
"541": "Compared context values must be arrays",
530+
"542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530531
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 92c0f5f

Browse files
authored
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel (#31554)
This lets us track separately if something was suspended on an Action using useActionState rather than suspended on Data. This approach feels quite bloated and it seems like we'd eventually might want to read more information about the Promise that suspended and the context it suspended in. As a more general reason for suspending. The way useActionState works in combination with the prewarming is quite unfortunate because 1) it renders blocking to update the isPending flag whether you use it or not 2) it prewarms and suspends the useActionState 3) then it does another third render to get back into the useActionState position again.
1 parent 053b3cb commit 92c0f5f

8 files changed

Lines changed: 63 additions & 17 deletions

File tree

‎packages/react-debug-tools/src/ReactDebugHooks.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214214
'`try/catch` block. Capturing without rethrowing will lead to '+
215215
'unexpected behavior.\n\n'+
216216
'To handle async errors, wrap your component in an error boundary, or '+
217-
"call the promise's `.catch` method and pass the result to `use`",
217+
"call the promise's `.catch` method and pass the result to `use`.",
218218
);
219219

220220
functionuse<T>(usable: Usable<T>): T {

‎packages/react-reconciler/src/ReactChildFiber.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
6464
import{pushTreeFork}from'./ReactFiberTreeContext';
6565
import{
6666
SuspenseException,
67+
SuspenseActionException,
6768
createThenableState,
6869
trackUsedThenable,
6970
}from'./ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
19501951
}catch(x){
19511952
if(
19521953
x===SuspenseException||
1954+
x===SuspenseActionException||
19531955
(!disableLegacyMode&&
19541956
(returnFiber.mode&ConcurrentMode)===NoMode&&
19551957
typeofx==='object'&&

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import {
149149
trackUsedThenable,
150150
checkIfUseWrappedInTryCatch,
151151
createThenableState,
152+
SuspenseException,
153+
SuspenseActionException,
152154
}from'./ReactFiberThenable';
153155
importtype{ThenableState}from'./ReactFiberThenable';
154156
importtype{BatchConfigTransition}from'./ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const[isPending]=updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
conststate: Awaited<S>=
2438+
letstate: Awaited<S>;
2439+
if(
24372440
typeofactionResult==='object'&&
24382441
actionResult!==null&&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeofactionResult.then==='function'
2441-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2442-
: (actionResult: any);
2444+
){
2445+
try{
2446+
state =useThenable(((actionResult: any): Thenable<Awaited<S>>));
2447+
}catch(x){
2448+
if(x===SuspenseException){
2449+
// If we Suspend here, mark this separately so that we can track this
2450+
// as an Action in Profiling tools.
2451+
throwSuspenseActionException;
2452+
}else{
2453+
throwx;
2454+
}
2455+
}
2456+
}else{
2457+
state=(actionResult: any);
2458+
}
24432459

24442460
constactionQueueHook=updateWorkInProgressHook();
24452461
constactionQueue=actionQueueHook.queue;

‎packages/react-reconciler/src/ReactFiberThenable.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ export const SuspenseException: mixed = new Error(
4646
'`try/catch` block. Capturing without rethrowing will lead to '+
4747
'unexpected behavior.\n\n'+
4848
'To handle async errors, wrap your component in an error boundary, or '+
49-
"call the promise's `.catch` method and pass the result to `use`",
49+
"call the promise's `.catch` method and pass the result to `use`.",
5050
);
5151

5252
exportconstSuspenseyCommitException: mixed=newError(
5353
'Suspense Exception: This is not a real error, and should not leak into '+
5454
"userspace. If you're seeing this, it's likely a bug in React.",
5555
);
5656

57+
exportconstSuspenseActionException: mixed=newError(
58+
"Suspense Exception: This is not a real error! It's an implementation "+
59+
'detail of `useActionState` to interrupt the current render. You must either '+
60+
'rethrow it immediately, or move the `useActionState` call outside of the '+
61+
'`try/catch` block. Capturing without rethrowing will lead to '+
62+
'unexpected behavior.\n\n'+
63+
'To handle async errors, wrap your component in an error boundary.',
64+
);
5765
// This is a noop thenable that we use to trigger a fallback in throwException.
5866
// TODO: It would be better to refactor throwException into multiple functions
5967
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
296304
// execution context is to check the dispatcher every time `use` is called,
297305
// or some equivalent. That might be preferable for other reasons, too, since
298306
// it matches how we prevent similar mistakes for other hooks.
299-
if(rejectedReason===SuspenseException){
307+
if(
308+
rejectedReason===SuspenseException||
309+
rejectedReason===SuspenseActionException
310+
){
300311
thrownewError(
301312
'Hooks are not supported inside an async component. This '+
302313
"error is often caused by accidentally adding `'use client'` "+

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ import {
298298
import{processTransitionCallbacks}from'./ReactFiberTracingMarkerComponent';
299299
import{
300300
SuspenseException,
301+
SuspenseActionException,
301302
SuspenseyCommitException,
302303
getSuspendedThenable,
303304
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
346347
// The lanes we're rendering
347348
let workInProgressRootRenderLanes: Lanes=NoLanes;
348349

349-
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8;
350+
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8|9;
350351
constNotSuspended: SuspendedReason=0;
351352
constSuspendedOnError: SuspendedReason=1;
352353
constSuspendedOnData: SuspendedReason=2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
constSuspendedOnDeprecatedThrowPromise: SuspendedReason=6;
357358
constSuspendedAndReadyToContinue: SuspendedReason=7;
358359
constSuspendedOnHydration: SuspendedReason=8;
360+
constSuspendedOnAction: SuspendedReason=9;
359361

360362
// When this is true, the work-in-progress fiber just suspended (or errored) and
361363
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -638,7 +640,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes {
638640
}
639641

640642
exportfunctionisWorkLoopSuspendedOnData(): boolean{
641-
returnworkInProgressSuspendedReason===SuspendedOnData;
643+
return(
644+
workInProgressSuspendedReason===SuspendedOnData||
645+
workInProgressSuspendedReason===SuspendedOnAction
646+
);
642647
}
643648

644649
exportfunctiongetCurrentTime(): number{
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
767772
if(
768773
// Suspended render phase
769774
(root===workInProgressRoot&&
770-
workInProgressSuspendedReason===SuspendedOnData)||
775+
(workInProgressSuspendedReason===SuspendedOnData||
776+
workInProgressSuspendedReason===SuspendedOnAction))||
771777
// Suspended commit phase
772778
root.cancelPendingCommit!==null
773779
){
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18151821
resetCurrentFiber();
18161822
}
18171823

1818-
if(thrownValue===SuspenseException){
1824+
if(
1825+
thrownValue===SuspenseException||
1826+
thrownValue===SuspenseActionException
1827+
){
18191828
// This is a special type of exception used for Suspense. For historical
18201829
// reasons, the rest of the Suspense implementation expects the thrown value
18211830
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18361845
!includesNonIdleWork(workInProgressRootSkippedLanes)&&
18371846
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
18381847
? // Suspend work loop until data resolves
1839-
SuspendedOnData
1848+
thrownValue===SuspenseActionException
1849+
? SuspendedOnAction
1850+
: SuspendedOnData
18401851
: // Don't suspend work loop, except to check if the data has
18411852
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
18421853
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
19031914
break;
19041915
}
19051916
caseSuspendedOnData:
1917+
caseSuspendedOnAction:
19061918
caseSuspendedOnImmediate:
19071919
caseSuspendedOnDeprecatedThrowPromise:
19081920
caseSuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
caseSuspendedOnImmediate:
21872199
caseSuspendedOnData:
2200+
caseSuspendedOnAction:
21882201
caseSuspendedOnDeprecatedThrowPromise: {
21892202
if(getSuspenseHandler()===null){
21902203
didSuspendInShell=true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
caseSuspendedOnData: {
2364+
caseSuspendedOnData:
2365+
caseSuspendedOnAction: {
23522366
constthenable: Thenable<mixed>=(thrownValue: any);
23532367
if(isThenableResolved(thenable)){
23542368
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23662380
constonResolution=()=>{
23672381
// Check if the root is still suspended on this promise.
23682382
if(
2369-
workInProgressSuspendedReason===SuspendedOnData&&
2383+
(workInProgressSuspendedReason===SuspendedOnData||
2384+
workInProgressSuspendedReason===SuspendedOnAction)&&
23702385
workInProgressRoot===root
23712386
){
23722387
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
28142829
// can prerender the siblings.
28152830
if(
28162831
suspendedReason===SuspendedOnData||
2832+
suspendedReason===SuspendedOnAction||
28172833
suspendedReason===SuspendedOnImmediate||
28182834
suspendedReason===SuspendedOnDeprecatedThrowPromise
28192835
){

‎packages/react-server/src/ReactFizzThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎packages/react-server/src/ReactFlightThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎scripts/error-codes/codes.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`.",
449449
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450450
"462": "Unexpected SuspendedReason. This is a bug in React.",
451451
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526526
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527527
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528528
"540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529-
"541": "Compared context values must be arrays"
529+
"541": "Compared context values must be arrays",
530+
"542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530531
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 92c0f5f

Browse files
authored
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel (#31554)
This lets us track separately if something was suspended on an Action using useActionState rather than suspended on Data. This approach feels quite bloated and it seems like we'd eventually might want to read more information about the Promise that suspended and the context it suspended in. As a more general reason for suspending. The way useActionState works in combination with the prewarming is quite unfortunate because 1) it renders blocking to update the isPending flag whether you use it or not 2) it prewarms and suspends the useActionState 3) then it does another third render to get back into the useActionState position again.
1 parent 053b3cb commit 92c0f5f

8 files changed

Lines changed: 63 additions & 17 deletions

File tree

‎packages/react-debug-tools/src/ReactDebugHooks.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214214
'`try/catch` block. Capturing without rethrowing will lead to '+
215215
'unexpected behavior.\n\n'+
216216
'To handle async errors, wrap your component in an error boundary, or '+
217-
"call the promise's `.catch` method and pass the result to `use`",
217+
"call the promise's `.catch` method and pass the result to `use`.",
218218
);
219219

220220
functionuse<T>(usable: Usable<T>): T {

‎packages/react-reconciler/src/ReactChildFiber.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
6464
import{pushTreeFork}from'./ReactFiberTreeContext';
6565
import{
6666
SuspenseException,
67+
SuspenseActionException,
6768
createThenableState,
6869
trackUsedThenable,
6970
}from'./ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
19501951
}catch(x){
19511952
if(
19521953
x===SuspenseException||
1954+
x===SuspenseActionException||
19531955
(!disableLegacyMode&&
19541956
(returnFiber.mode&ConcurrentMode)===NoMode&&
19551957
typeofx==='object'&&

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import {
149149
trackUsedThenable,
150150
checkIfUseWrappedInTryCatch,
151151
createThenableState,
152+
SuspenseException,
153+
SuspenseActionException,
152154
}from'./ReactFiberThenable';
153155
importtype{ThenableState}from'./ReactFiberThenable';
154156
importtype{BatchConfigTransition}from'./ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const[isPending]=updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
conststate: Awaited<S>=
2438+
letstate: Awaited<S>;
2439+
if(
24372440
typeofactionResult==='object'&&
24382441
actionResult!==null&&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeofactionResult.then==='function'
2441-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2442-
: (actionResult: any);
2444+
){
2445+
try{
2446+
state =useThenable(((actionResult: any): Thenable<Awaited<S>>));
2447+
}catch(x){
2448+
if(x===SuspenseException){
2449+
// If we Suspend here, mark this separately so that we can track this
2450+
// as an Action in Profiling tools.
2451+
throwSuspenseActionException;
2452+
}else{
2453+
throwx;
2454+
}
2455+
}
2456+
}else{
2457+
state=(actionResult: any);
2458+
}
24432459

24442460
constactionQueueHook=updateWorkInProgressHook();
24452461
constactionQueue=actionQueueHook.queue;

‎packages/react-reconciler/src/ReactFiberThenable.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,22 @@ export const SuspenseException: mixed = new Error(
4646
'`try/catch` block. Capturing without rethrowing will lead to '+
4747
'unexpected behavior.\n\n'+
4848
'To handle async errors, wrap your component in an error boundary, or '+
49-
"call the promise's `.catch` method and pass the result to `use`",
49+
"call the promise's `.catch` method and pass the result to `use`.",
5050
);
5151

5252
exportconstSuspenseyCommitException: mixed=newError(
5353
'Suspense Exception: This is not a real error, and should not leak into '+
5454
"userspace. If you're seeing this, it's likely a bug in React.",
5555
);
5656

57+
exportconstSuspenseActionException: mixed=newError(
58+
"Suspense Exception: This is not a real error! It's an implementation "+
59+
'detail of `useActionState` to interrupt the current render. You must either '+
60+
'rethrow it immediately, or move the `useActionState` call outside of the '+
61+
'`try/catch` block. Capturing without rethrowing will lead to '+
62+
'unexpected behavior.\n\n'+
63+
'To handle async errors, wrap your component in an error boundary.',
64+
);
5765
// This is a noop thenable that we use to trigger a fallback in throwException.
5866
// TODO: It would be better to refactor throwException into multiple functions
5967
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
296304
// execution context is to check the dispatcher every time `use` is called,
297305
// or some equivalent. That might be preferable for other reasons, too, since
298306
// it matches how we prevent similar mistakes for other hooks.
299-
if(rejectedReason===SuspenseException){
307+
if(
308+
rejectedReason===SuspenseException||
309+
rejectedReason===SuspenseActionException
310+
){
300311
thrownewError(
301312
'Hooks are not supported inside an async component. This '+
302313
"error is often caused by accidentally adding `'use client'` "+

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ import {
298298
import{processTransitionCallbacks}from'./ReactFiberTracingMarkerComponent';
299299
import{
300300
SuspenseException,
301+
SuspenseActionException,
301302
SuspenseyCommitException,
302303
getSuspendedThenable,
303304
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
346347
// The lanes we're rendering
347348
let workInProgressRootRenderLanes: Lanes=NoLanes;
348349

349-
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8;
350+
opaquetypeSuspendedReason=0|1|2|3|4|5|6|7|8|9;
350351
constNotSuspended: SuspendedReason=0;
351352
constSuspendedOnError: SuspendedReason=1;
352353
constSuspendedOnData: SuspendedReason=2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
constSuspendedOnDeprecatedThrowPromise: SuspendedReason=6;
357358
constSuspendedAndReadyToContinue: SuspendedReason=7;
358359
constSuspendedOnHydration: SuspendedReason=8;
360+
constSuspendedOnAction: SuspendedReason=9;
359361

360362
// When this is true, the work-in-progress fiber just suspended (or errored) and
361363
// we've yet to unwind the stack. In some cases, we may yield to the main thread
@@ -638,7 +640,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes {
638640
}
639641

640642
exportfunctionisWorkLoopSuspendedOnData(): boolean{
641-
returnworkInProgressSuspendedReason===SuspendedOnData;
643+
return(
644+
workInProgressSuspendedReason===SuspendedOnData||
645+
workInProgressSuspendedReason===SuspendedOnAction
646+
);
642647
}
643648

644649
exportfunctiongetCurrentTime(): number{
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
767772
if(
768773
// Suspended render phase
769774
(root===workInProgressRoot&&
770-
workInProgressSuspendedReason===SuspendedOnData)||
775+
(workInProgressSuspendedReason===SuspendedOnData||
776+
workInProgressSuspendedReason===SuspendedOnAction))||
771777
// Suspended commit phase
772778
root.cancelPendingCommit!==null
773779
){
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18151821
resetCurrentFiber();
18161822
}
18171823

1818-
if(thrownValue===SuspenseException){
1824+
if(
1825+
thrownValue===SuspenseException||
1826+
thrownValue===SuspenseActionException
1827+
){
18191828
// This is a special type of exception used for Suspense. For historical
18201829
// reasons, the rest of the Suspense implementation expects the thrown value
18211830
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
18361845
!includesNonIdleWork(workInProgressRootSkippedLanes)&&
18371846
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
18381847
? // Suspend work loop until data resolves
1839-
SuspendedOnData
1848+
thrownValue===SuspenseActionException
1849+
? SuspendedOnAction
1850+
: SuspendedOnData
18401851
: // Don't suspend work loop, except to check if the data has
18411852
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
18421853
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
19031914
break;
19041915
}
19051916
caseSuspendedOnData:
1917+
caseSuspendedOnAction:
19061918
caseSuspendedOnImmediate:
19071919
caseSuspendedOnDeprecatedThrowPromise:
19081920
caseSuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
caseSuspendedOnImmediate:
21872199
caseSuspendedOnData:
2200+
caseSuspendedOnAction:
21882201
caseSuspendedOnDeprecatedThrowPromise: {
21892202
if(getSuspenseHandler()===null){
21902203
didSuspendInShell=true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
caseSuspendedOnData: {
2364+
caseSuspendedOnData:
2365+
caseSuspendedOnAction: {
23522366
constthenable: Thenable<mixed>=(thrownValue: any);
23532367
if(isThenableResolved(thenable)){
23542368
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23662380
constonResolution=()=>{
23672381
// Check if the root is still suspended on this promise.
23682382
if(
2369-
workInProgressSuspendedReason===SuspendedOnData&&
2383+
(workInProgressSuspendedReason===SuspendedOnData||
2384+
workInProgressSuspendedReason===SuspendedOnAction)&&
23702385
workInProgressRoot===root
23712386
){
23722387
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
28142829
// can prerender the siblings.
28152830
if(
28162831
suspendedReason===SuspendedOnData||
2832+
suspendedReason===SuspendedOnAction||
28172833
suspendedReason===SuspendedOnImmediate||
28182834
suspendedReason===SuspendedOnDeprecatedThrowPromise
28192835
){

‎packages/react-server/src/ReactFizzThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎packages/react-server/src/ReactFlightThenable.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
3131
'`try/catch` block. Capturing without rethrowing will lead to '+
3232
'unexpected behavior.\n\n'+
3333
'To handle async errors, wrap your component in an error boundary, or '+
34-
"call the promise's `.catch` method and pass the result to `use`",
34+
"call the promise's `.catch` method and pass the result to `use`.",
3535
);
3636

3737
exportfunctioncreateThenableState(): ThenableState{

‎scripts/error-codes/codes.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@
445445
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446446
"458": "Currently React only supports one RSC renderer at a time.",
447447
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
448-
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`",
448+
"460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`.",
449449
"461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.",
450450
"462": "Unexpected SuspendedReason. This is a bug in React.",
451451
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526526
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527527
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528528
"540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529-
"541": "Compared context values must be arrays"
529+
"541": "Compared context values must be arrays",
530+
"542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530531
}

0 commit comments

Comments
 (0)