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

File tree

8 files changed

+63
-17
lines changed

8 files changed

+63
-17
lines changed

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

+1-1
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

+2
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

+19-3
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';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
24332435
const [isPending] = updateState(false);
24342436

24352437
// This will suspend until the action finishes.
2436-
const state: Awaited<S> =
2438+
let state: Awaited<S>;
2439+
if (
24372440
typeof actionResult === 'object' &&
24382441
actionResult !== null &&
24392442
// $FlowFixMe[method-unbinding]
24402443
typeof actionResult.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+
throw SuspenseActionException;
2452+
} else {
2453+
throw x;
2454+
}
2455+
}
2456+
} else {
2457+
state = (actionResult: any);
2458+
}
24432459

24442460
const actionQueueHook = updateWorkInProgressHook();
24452461
const actionQueue = actionQueueHook.queue;

packages/react-reconciler/src/ReactFiberThenable.js

+13-2
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

+23-7
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

+1-1
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

+1-1
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

+3-2
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)