@samitouri / QOS-React-1 / commits / 92c0f5f85f

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.

Sebastian Markbåge committed Nov 15, 2024 at 17:52 UTC 92c0f5f85fed42024b17bf6595291f9f5d6e8734
8 files changed +63 -17
packages/react-debug-tools/src/ReactDebugHooks.js
+1 -1
@@ -214,7 +214,7 @@ const SuspenseException: mixed = new Error(
214 '`try/catch` block. Capturing without rethrowing will lead to ' +
215 'unexpected behavior.\n\n' +
216 '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`.",
218 );
219
220 function use<T>(usable: Usable<T>): T {
packages/react-reconciler/src/ReactChildFiber.js
+2
@@ -64,6 +64,7 @@ import {getIsHydrating} from './ReactFiberHydrationContext';
64 import {pushTreeFork} from './ReactFiberTreeContext';
65 import {
66 SuspenseException,
67 + SuspenseActionException,
68 createThenableState,
69 trackUsedThenable,
70 } from './ReactFiberThenable';
@@ -1950,6 +1951,7 @@ function createChildReconciler(
1951 } catch (x) {
1952 if (
1953 x === SuspenseException ||
1954 + x === SuspenseActionException ||
1955 (!disableLegacyMode &&
1956 (returnFiber.mode & ConcurrentMode) === NoMode &&
1957 typeof x === 'object' &&
packages/react-reconciler/src/ReactFiberHooks.js
+19 -3
@@ -149,6 +149,8 @@ import {
149 trackUsedThenable,
150 checkIfUseWrappedInTryCatch,
151 createThenableState,
152 + SuspenseException,
153 + SuspenseActionException,
154 } from './ReactFiberThenable';
155 import type {ThenableState} from './ReactFiberThenable';
156 import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
@@ -2433,13 +2435,27 @@ function updateActionStateImpl<S, P>(
2435 const [isPending] = updateState(false);
2436
2437 // This will suspend until the action finishes.
2436 - const state: Awaited<S> =
2438 + let state: Awaited<S>;
2439 + if (
2440 typeof actionResult === 'object' &&
2441 actionResult !== null &&
2442 // $FlowFixMe[method-unbinding]
2443 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 + }
2459
2460 const actionQueueHook = updateWorkInProgressHook();
2461 const actionQueue = actionQueueHook.queue;
packages/react-reconciler/src/ReactFiberThenable.js
+13 -2
@@ -46,7 +46,7 @@ export const SuspenseException: mixed = new Error(
46 '`try/catch` block. Capturing without rethrowing will lead to ' +
47 'unexpected behavior.\n\n' +
48 '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`.",
50 );
51
52 export const SuspenseyCommitException: mixed = new Error(
@@ -54,6 +54,14 @@ export const SuspenseyCommitException: mixed = new Error(
54 "userspace. If you're seeing this, it's likely a bug in React.",
55 );
56
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 +);
65 // This is a noop thenable that we use to trigger a fallback in throwException.
66 // TODO: It would be better to refactor throwException into multiple functions
67 // so we can trigger a fallback directly without having to check the type. But
@@ -296,7 +304,10 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
304 // execution context is to check the dispatcher every time `use` is called,
305 // or some equivalent. That might be preferable for other reasons, too, since
306 // it matches how we prevent similar mistakes for other hooks.
299 - if (rejectedReason === SuspenseException) {
307 + if (
308 + rejectedReason === SuspenseException ||
309 + rejectedReason === SuspenseActionException
310 + ) {
311 throw new Error(
312 'Hooks are not supported inside an async component. This ' +
313 "error is often caused by accidentally adding `'use client'` " +
packages/react-reconciler/src/ReactFiberWorkLoop.js
+23 -7
@@ -298,6 +298,7 @@ import {
298 import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent';
299 import {
300 SuspenseException,
301 + SuspenseActionException,
302 SuspenseyCommitException,
303 getSuspendedThenable,
304 isThenableResolved,
@@ -346,7 +347,7 @@ let workInProgress: Fiber | null = null;
347 // The lanes we're rendering
348 let workInProgressRootRenderLanes: Lanes = NoLanes;
349
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;
351 const NotSuspended: SuspendedReason = 0;
352 const SuspendedOnError: SuspendedReason = 1;
353 const SuspendedOnData: SuspendedReason = 2;
@@ -356,6 +357,7 @@ const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
357 const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
358 const SuspendedAndReadyToContinue: SuspendedReason = 7;
359 const SuspendedOnHydration: SuspendedReason = 8;
360 +const SuspendedOnAction: SuspendedReason = 9;
361
362 // When this is true, the work-in-progress fiber just suspended (or errored) and
363 // 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 {
640 }
641
642 export function isWorkLoopSuspendedOnData(): boolean {
641 - return workInProgressSuspendedReason === SuspendedOnData;
643 + return (
644 + workInProgressSuspendedReason === SuspendedOnData ||
645 + workInProgressSuspendedReason === SuspendedOnAction
646 + );
647 }
648
649 export function getCurrentTime(): number {
@@ -767,7 +772,8 @@ export function scheduleUpdateOnFiber(
772 if (
773 // Suspended render phase
774 (root === workInProgressRoot &&
770 - workInProgressSuspendedReason === SuspendedOnData) ||
775 + (workInProgressSuspendedReason === SuspendedOnData ||
776 + workInProgressSuspendedReason === SuspendedOnAction)) ||
777 // Suspended commit phase
778 root.cancelPendingCommit !== null
779 ) {
@@ -1815,7 +1821,10 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
1821 resetCurrentFiber();
1822 }
1823
1818 - if (thrownValue === SuspenseException) {
1824 + if (
1825 + thrownValue === SuspenseException ||
1826 + thrownValue === SuspenseActionException
1827 + ) {
1828 // This is a special type of exception used for Suspense. For historical
1829 // reasons, the rest of the Suspense implementation expects the thrown value
1830 // to be a thenable, because before `use` existed that was the (unstable)
@@ -1836,7 +1845,9 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
1845 !includesNonIdleWork(workInProgressRootSkippedLanes) &&
1846 !includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
1847 ? // Suspend work loop until data resolves
1839 - SuspendedOnData
1848 + thrownValue === SuspenseActionException
1849 + ? SuspendedOnAction
1850 + : SuspendedOnData
1851 : // Don't suspend work loop, except to check if the data has
1852 // immediately resolved (i.e. in a microtask). Otherwise, trigger the
1853 // nearest Suspense fallback.
@@ -1903,6 +1914,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void {
1914 break;
1915 }
1916 case SuspendedOnData:
1917 + case SuspendedOnAction:
1918 case SuspendedOnImmediate:
1919 case SuspendedOnDeprecatedThrowPromise:
1920 case SuspendedAndReadyToContinue: {
@@ -2185,6 +2197,7 @@ function renderRootSync(
2197 }
2198 case SuspendedOnImmediate:
2199 case SuspendedOnData:
2200 + case SuspendedOnAction:
2201 case SuspendedOnDeprecatedThrowPromise: {
2202 if (getSuspenseHandler() === null) {
2203 didSuspendInShell = true;
@@ -2348,7 +2361,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2361 );
2362 break;
2363 }
2351 - case SuspendedOnData: {
2364 + case SuspendedOnData:
2365 + case SuspendedOnAction: {
2366 const thenable: Thenable<mixed> = (thrownValue: any);
2367 if (isThenableResolved(thenable)) {
2368 // The data resolved. Try rendering the component again.
@@ -2366,7 +2380,8 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2380 const onResolution = () => {
2381 // Check if the root is still suspended on this promise.
2382 if (
2369 - workInProgressSuspendedReason === SuspendedOnData &&
2383 + (workInProgressSuspendedReason === SuspendedOnData ||
2384 + workInProgressSuspendedReason === SuspendedOnAction) &&
2385 workInProgressRoot === root
2386 ) {
2387 // Mark the root as ready to continue rendering.
@@ -2814,6 +2829,7 @@ function throwAndUnwindWorkLoop(
2829 // can prerender the siblings.
2830 if (
2831 suspendedReason === SuspendedOnData ||
2832 + suspendedReason === SuspendedOnAction ||
2833 suspendedReason === SuspendedOnImmediate ||
2834 suspendedReason === SuspendedOnDeprecatedThrowPromise
2835 ) {
packages/react-server/src/ReactFizzThenable.js
+1 -1
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
31 '`try/catch` block. Capturing without rethrowing will lead to ' +
32 'unexpected behavior.\n\n' +
33 '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`.",
35 );
36
37 export function createThenableState(): ThenableState {
packages/react-server/src/ReactFlightThenable.js
+1 -1
@@ -31,7 +31,7 @@ export const SuspenseException: mixed = new Error(
31 '`try/catch` block. Capturing without rethrowing will lead to ' +
32 'unexpected behavior.\n\n' +
33 '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`.",
35 );
36
37 export function createThenableState(): ThenableState {
scripts/error-codes/codes.json
+3 -2
@@ -445,7 +445,7 @@
445 "457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React.",
446 "458": "Currently React only supports one RSC renderer at a time.",
447 "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`.",
449 "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.",
450 "462": "Unexpected SuspendedReason. This is a bug in React.",
451 "463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
@@ -526,5 +526,6 @@
526 "538": "Cannot use state or effect Hooks in renderToHTML because this component will never be hydrated.",
527 "539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528 "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."
531 }