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.
This commit is contained in:
Sebastian Markbåge
2024-11-15 17:52:24 -05:00
committed by GitHub
parent 392edc4865
commit b649f95b99
8 changed files with 63 additions and 17 deletions
+1 -1
View File
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary, or ' +
"call the promise's `.catch` method and pass the result to `use`",
"call the promise's `.catch` method and pass the result to `use`.",
);
function use<T>(usable: Usable<T>): T {
+2
View File
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
import {pushTreeFork} from './ReactFiberTreeContext';
import {
SuspenseException,
SuspenseActionException,
createThenableState,
trackUsedThenable,
} from './ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
} catch (x) {
if (
x === SuspenseException ||
x === SuspenseActionException ||
(!disableLegacyMode &&
(returnFiber.mode & ConcurrentMode) === NoMode &&
typeof x === 'object' &&
+19 -3
View File
@@ -149,6 +149,8 @@ import {
trackUsedThenable,
checkIfUseWrappedInTryCatch,
createThenableState,
SuspenseException,
SuspenseActionException,
} from './ReactFiberThenable';
import type {ThenableState} from './ReactFiberThenable';
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
const [isPending] = updateState(false);
// This will suspend until the action finishes.
const state: Awaited<S> =
let state: Awaited<S>;
if (
typeof actionResult === 'object' &&
actionResult !== null &&
// $FlowFixMe[method-unbinding]
typeof actionResult.then === 'function'
? useThenable(((actionResult: any): Thenable<Awaited<S>>))
: (actionResult: any);
) {
try {
state = useThenable(((actionResult: any): Thenable<Awaited<S>>));
} catch (x) {
if (x === SuspenseException) {
// If we Suspend here, mark this separately so that we can track this
// as an Action in Profiling tools.
throw SuspenseActionException;
} else {
throw x;
}
}
} else {
state = (actionResult: any);
}
const actionQueueHook = updateWorkInProgressHook();
const actionQueue = actionQueueHook.queue;
+13 -2
View File
@@ -46,7 +46,7 @@ export const SuspenseException: mixed = new Error(
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary, or ' +
"call the promise's `.catch` method and pass the result to `use`",
"call the promise's `.catch` method and pass the result to `use`.",
);
export const SuspenseyCommitException: mixed = new Error(
@@ -54,6 +54,14 @@ export const SuspenseyCommitException: mixed = new Error(
"userspace. If you're seeing this, it's likely a bug in React.",
);
export const SuspenseActionException: mixed = new Error(
"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\n' +
'To handle async errors, wrap your component in an error boundary.',
);
// This is a noop thenable that we use to trigger a fallback in throwException.
// TODO: It would be better to refactor throwException into multiple functions
// so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
// execution context is to check the dispatcher every time `use` is called,
// or some equivalent. That might be preferable for other reasons, too, since
// it matches how we prevent similar mistakes for other hooks.
if (rejectedReason === SuspenseException) {
if (
rejectedReason === SuspenseException ||
rejectedReason === SuspenseActionException
) {
throw new Error(
'Hooks are not supported inside an async component. This ' +
"error is often caused by accidentally adding `'use client'` " +
+23 -7
View File
@@ -298,6 +298,7 @@ import {
import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent';
import {
SuspenseException,
SuspenseActionException,
SuspenseyCommitException,
getSuspendedThenable,
isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
// The lanes we're rendering
let workInProgressRootRenderLanes: Lanes = NoLanes;
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
const NotSuspended: SuspendedReason = 0;
const SuspendedOnError: SuspendedReason = 1;
const SuspendedOnData: SuspendedReason = 2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
const SuspendedAndReadyToContinue: SuspendedReason = 7;
const SuspendedOnHydration: SuspendedReason = 8;
const SuspendedOnAction: SuspendedReason = 9;
// When this is true, the work-in-progress fiber just suspended (or errored) and
// 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 {
}
export function isWorkLoopSuspendedOnData(): boolean {
return workInProgressSuspendedReason === SuspendedOnData;
return (
workInProgressSuspendedReason === SuspendedOnData ||
workInProgressSuspendedReason === SuspendedOnAction
);
}
export function getCurrentTime(): number {
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
if (
// Suspended render phase
(root === workInProgressRoot &&
workInProgressSuspendedReason === SuspendedOnData) ||
(workInProgressSuspendedReason === SuspendedOnData ||
workInProgressSuspendedReason === SuspendedOnAction)) ||
// Suspended commit phase
root.cancelPendingCommit !== null
) {
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
resetCurrentFiber();
}
if (thrownValue === SuspenseException) {
if (
thrownValue === SuspenseException ||
thrownValue === SuspenseActionException
) {
// This is a special type of exception used for Suspense. For historical
// reasons, the rest of the Suspense implementation expects the thrown value
// to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
!includesNonIdleWork(workInProgressRootSkippedLanes) &&
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
? // Suspend work loop until data resolves
SuspendedOnData
thrownValue === SuspenseActionException
? SuspendedOnAction
: SuspendedOnData
: // Don't suspend work loop, except to check if the data has
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
// nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
break;
}
case SuspendedOnData:
case SuspendedOnAction:
case SuspendedOnImmediate:
case SuspendedOnDeprecatedThrowPromise:
case SuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
}
case SuspendedOnImmediate:
case SuspendedOnData:
case SuspendedOnAction:
case SuspendedOnDeprecatedThrowPromise: {
if (getSuspenseHandler() === null) {
didSuspendInShell = true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
);
break;
}
case SuspendedOnData: {
case SuspendedOnData:
case SuspendedOnAction: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
// The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
const onResolution = () => {
// Check if the root is still suspended on this promise.
if (
workInProgressSuspendedReason === SuspendedOnData &&
(workInProgressSuspendedReason === SuspendedOnData ||
workInProgressSuspendedReason === SuspendedOnAction) &&
workInProgressRoot === root
) {
// Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
// can prerender the siblings.
if (
suspendedReason === SuspendedOnData ||
suspendedReason === SuspendedOnAction ||
suspendedReason === SuspendedOnImmediate ||
suspendedReason === SuspendedOnDeprecatedThrowPromise
) {
+1 -1
View File
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary, or ' +
"call the promise's `.catch` method and pass the result to `use`",
"call the promise's `.catch` method and pass the result to `use`.",
);
export function createThenableState(): ThenableState {
+1 -1
View File
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary, or ' +
"call the promise's `.catch` method and pass the result to `use`",
"call the promise's `.catch` method and pass the result to `use`.",
);
export function createThenableState(): ThenableState {
+3 -2
View File
@@ -445,7 +445,7 @@
"457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
"458": "Currently React only supports one RSC renderer at a time.",
"459": "Expected a suspended thenable. This is a bug in React. Please file an issue.",
"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`",
"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`.",
"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.",
"462": "Unexpected SuspendedReason. This is a bug in React.",
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
"538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
"539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
"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.",
"541": "Compared context values must be arrays"
"541": "Compared context values must be arrays",
"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."
}