Skip to content

Commit 404fb64

Browse files
committed
Track separate SuspendedOnAction flag by rethrowing a separate SuspenseActionException sentinel
This lets us track separately if something was suspended on an Action using useActionState.
1 parent 3720870 commit 404fb64

File tree

8 files changed

+63
-17
lines changed

8 files changed

+63
-17
lines changed

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
function use<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
typeof x === '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
import type {ThenableState} from './ReactFiberThenable';
154156
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
@@ -2432,13 +2434,27 @@ function updateActionStateImpl<S, P>(
24322434
const [isPending] = updateState(false);
24332435

24342436
// This will suspend until the action finishes.
2435-
const state: Awaited<S> =
2437+
let state: Awaited<S>;
2438+
if (
24362439
typeof actionResult === 'object' &&
24372440
actionResult !== null &&
24382441
// $FlowFixMe[method-unbinding]
24392442
typeof actionResult.then === 'function'
2440-
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
2441-
: (actionResult: any);
2443+
) {
2444+
try {
2445+
state = useThenable(((actionResult: any): Thenable<Awaited<S>>));
2446+
} catch (x) {
2447+
if (x === SuspenseException) {
2448+
// If we Suspend here, mark this separately so that we can track this
2449+
// as an Action in Profiling tools.
2450+
throw SuspenseActionException;
2451+
} else {
2452+
throw x;
2453+
}
2454+
}
2455+
} else {
2456+
state = (actionResult: any);
2457+
}
24422458

24432459
const actionQueueHook = updateWorkInProgressHook();
24442460
const actionQueue = 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
export const SuspenseyCommitException: mixed = new Error(
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+
export const SuspenseActionException: mixed = new Error(
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
throw new Error(
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-
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
350+
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
350351
const NotSuspended: SuspendedReason = 0;
351352
const SuspendedOnError: SuspendedReason = 1;
352353
const SuspendedOnData: SuspendedReason = 2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
356357
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
357358
const SuspendedAndReadyToContinue: SuspendedReason = 7;
358359
const SuspendedOnHydration: SuspendedReason = 8;
360+
const SuspendedOnAction: 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
export function isWorkLoopSuspendedOnData(): boolean {
641-
return workInProgressSuspendedReason === SuspendedOnData;
643+
return (
644+
workInProgressSuspendedReason === SuspendedOnData ||
645+
workInProgressSuspendedReason === SuspendedOnAction
646+
);
642647
}
643648

644649
export function getCurrentTime(): 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
case SuspendedOnData:
1917+
case SuspendedOnAction:
19061918
case SuspendedOnImmediate:
19071919
case SuspendedOnDeprecatedThrowPromise:
19081920
case SuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
21852197
}
21862198
case SuspendedOnImmediate:
21872199
case SuspendedOnData:
2200+
case SuspendedOnAction:
21882201
case SuspendedOnDeprecatedThrowPromise: {
21892202
if (getSuspenseHandler() === null) {
21902203
didSuspendInShell = true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
23482361
);
23492362
break;
23502363
}
2351-
case SuspendedOnData: {
2364+
case SuspendedOnData:
2365+
case SuspendedOnAction: {
23522366
const thenable: 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
const onResolution = () => {
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
export function createThenableState(): 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
export function createThenableState(): 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)