@samitouri / QOS-React-2 / commits / d2a0176a13

Detect and warn if use(promise) is wrapped with try/catch block (#25543)

The old (unstable) mechanism for suspending was to throw a promise. The purpose of throwing is to interrupt the component's execution, and also to signal to React that the interruption was caused by Suspense as opposed to some other error. A flaw is that throwing is meant to be an implementation detail — if code in userspace catches the promise, it can lead to unexpected behavior. With `use`, userspace code does not throw promises directly, but `use` itself still needs to throw something to interrupt the component and unwind the stack. The solution is to throw an internal error. In development, we can detect whether the error was caught by a userspace try/catch block and log a warning — though it's not foolproof, since a clever user could catch the object and rethrow it later. The error message includes advice to move `use` outside of the try/catch block. I did not yet implement the warning in Flight.

Andrew Clark committed Oct 28, 2022 at 14:46 UTC d2a0176a13c95bd4a48cb355592db1b9105bd5d8
12 files changed +373 -63
packages/react-reconciler/src/ReactFiberHooks.new.js
+19
@@ -138,6 +138,7 @@ import {now} from './Scheduler';
138 import {
139 prepareThenableState,
140 trackUsedThenable,
141 + checkIfUseWrappedInTryCatch,
142 } from './ReactFiberThenable.new';
143
144 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
@@ -160,8 +161,10 @@ export type UpdateQueue<S, A> = {
161
162 let didWarnAboutMismatchedHooksForComponent;
163 let didWarnUncachedGetSnapshot;
164 +let didWarnAboutUseWrappedInTryCatch;
165 if (__DEV__) {
166 didWarnAboutMismatchedHooksForComponent = new Set();
167 + didWarnAboutUseWrappedInTryCatch = new Set();
168 }
169
170 export type Hook = {
@@ -594,6 +597,22 @@ export function renderWithHooks<Props, SecondArg>(
597 }
598 }
599 }
600 +
601 + if (__DEV__) {
602 + if (checkIfUseWrappedInTryCatch()) {
603 + const componentName =
604 + getComponentNameFromFiber(workInProgress) || 'Unknown';
605 + if (!didWarnAboutUseWrappedInTryCatch.has(componentName)) {
606 + didWarnAboutUseWrappedInTryCatch.add(componentName);
607 + console.error(
608 + '`use` was called from inside a try/catch block. This is not allowed ' +
609 + 'and can lead to unexpected behavior. To handle errors triggered ' +
610 + 'by `use`, wrap your component in a error boundary.',
611 + );
612 + }
613 + }
614 + }
615 +
616 return children;
617 }
618
packages/react-reconciler/src/ReactFiberHooks.old.js
+19
@@ -138,6 +138,7 @@ import {now} from './Scheduler';
138 import {
139 prepareThenableState,
140 trackUsedThenable,
141 + checkIfUseWrappedInTryCatch,
142 } from './ReactFiberThenable.old';
143
144 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
@@ -160,8 +161,10 @@ export type UpdateQueue<S, A> = {
161
162 let didWarnAboutMismatchedHooksForComponent;
163 let didWarnUncachedGetSnapshot;
164 +let didWarnAboutUseWrappedInTryCatch;
165 if (__DEV__) {
166 didWarnAboutMismatchedHooksForComponent = new Set();
167 + didWarnAboutUseWrappedInTryCatch = new Set();
168 }
169
170 export type Hook = {
@@ -594,6 +597,22 @@ export function renderWithHooks<Props, SecondArg>(
597 }
598 }
599 }
600 +
601 + if (__DEV__) {
602 + if (checkIfUseWrappedInTryCatch()) {
603 + const componentName =
604 + getComponentNameFromFiber(workInProgress) || 'Unknown';
605 + if (!didWarnAboutUseWrappedInTryCatch.has(componentName)) {
606 + didWarnAboutUseWrappedInTryCatch.add(componentName);
607 + console.error(
608 + '`use` was called from inside a try/catch block. This is not allowed ' +
609 + 'and can lead to unexpected behavior. To handle errors triggered ' +
610 + 'by `use`, wrap your component in a error boundary.',
611 + );
612 + }
613 + }
614 + }
615 +
616 return children;
617 }
618
packages/react-reconciler/src/ReactFiberThenable.new.js
+63 -20
@@ -19,6 +19,18 @@ const {ReactCurrentActQueue} = ReactSharedInternals;
19
20 export opaque type ThenableState = Array<Thenable<any>>;
21
22 +// An error that is thrown (e.g. by `use`) to trigger Suspense. If we
23 +// detect this is caught by userspace, we'll log a warning in development.
24 +export const SuspenseException: mixed = new Error(
25 + "Suspense Exception: This is not a real error! It's an implementation " +
26 + 'detail of `use` to interrupt the current render. You must either ' +
27 + 'rethrow it immediately, or move the `use` call outside of the ' +
28 + '`try/catch` block. Capturing without rethrowing will lead to ' +
29 + 'unexpected behavior.\n\n' +
30 + 'To handle async errors, wrap your component in an error boundary, or ' +
31 + "call the promise's `.catch` method and pass the result to `use`",
32 +);
33 +
34 let thenableState: ThenableState | null = null;
35
36 export function createThenableState(): ThenableState {
@@ -37,19 +49,9 @@ export function prepareThenableState(prevThenableState: ThenableState | null) {
49 export function getThenableStateAfterSuspending(): ThenableState | null {
50 // Called by the work loop so it can stash the thenable state. It will use
51 // the state to replay the component when the promise resolves.
40 - if (
41 - thenableState !== null &&
42 - // If we only `use`-ed resolved promises, then there is no suspended state
43 - // TODO: The only reason we do this is to distinguish between throwing a
44 - // promise (old Suspense pattern) versus `use`-ing one. A better solution is
45 - // for `use` to throw a special, opaque value instead of a promise.
46 - !isThenableStateResolved(thenableState)
47 - ) {
48 - const state = thenableState;
49 - thenableState = null;
50 - return state;
51 - }
52 - return null;
52 + const state = thenableState;
53 + thenableState = null;
54 + return state;
55 }
56
57 export function isThenableStateResolved(thenables: ThenableState): boolean {
@@ -129,13 +131,54 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number): T {
131 }
132
133 // Suspend.
132 - // TODO: Throwing here is an implementation detail that allows us to
133 - // unwind the call stack. But we shouldn't allow it to leak into
134 - // userspace. Throw an opaque placeholder value instead of the
135 - // actual thenable. If it doesn't get captured by the work loop, log
136 - // a warning, because that means something in userspace must have
137 - // caught it.
138 - throw thenable;
134 + //
135 + // Throwing here is an implementation detail that allows us to unwind the
136 + // call stack. But we shouldn't allow it to leak into userspace. Throw an
137 + // opaque placeholder value instead of the actual thenable. If it doesn't
138 + // get captured by the work loop, log a warning, because that means
139 + // something in userspace must have caught it.
140 + suspendedThenable = thenable;
141 + if (__DEV__) {
142 + needsToResetSuspendedThenableDEV = true;
143 + }
144 + throw SuspenseException;
145 + }
146 + }
147 +}
148 +
149 +// This is used to track the actual thenable that suspended so it can be
150 +// passed to the rest of the Suspense implementation — which, for historical
151 +// reasons, expects to receive a thenable.
152 +let suspendedThenable: Thenable<any> | null = null;
153 +let needsToResetSuspendedThenableDEV = false;
154 +export function getSuspendedThenable(): Thenable<mixed> {
155 + // This is called right after `use` suspends by throwing an exception. `use`
156 + // throws an opaque value instead of the thenable itself so that it can't be
157 + // caught in userspace. Then the work loop accesses the actual thenable using
158 + // this function.
159 + if (suspendedThenable === null) {
160 + throw new Error(
161 + 'Expected a suspended thenable. This is a bug in React. Please file ' +
162 + 'an issue.',
163 + );
164 + }
165 + const thenable = suspendedThenable;
166 + suspendedThenable = null;
167 + if (__DEV__) {
168 + needsToResetSuspendedThenableDEV = false;
169 + }
170 + return thenable;
171 +}
172 +
173 +export function checkIfUseWrappedInTryCatch(): boolean {
174 + if (__DEV__) {
175 + // This was set right before SuspenseException was thrown, and it should
176 + // have been cleared when the exception was handled. If it wasn't,
177 + // it must have been caught by userspace.
178 + if (needsToResetSuspendedThenableDEV) {
179 + needsToResetSuspendedThenableDEV = false;
180 + return true;
181 }
182 }
183 + return false;
184 }
packages/react-reconciler/src/ReactFiberThenable.old.js
+63 -20
@@ -19,6 +19,18 @@ const {ReactCurrentActQueue} = ReactSharedInternals;
19
20 export opaque type ThenableState = Array<Thenable<any>>;
21
22 +// An error that is thrown (e.g. by `use`) to trigger Suspense. If we
23 +// detect this is caught by userspace, we'll log a warning in development.
24 +export const SuspenseException: mixed = new Error(
25 + "Suspense Exception: This is not a real error! It's an implementation " +
26 + 'detail of `use` to interrupt the current render. You must either ' +
27 + 'rethrow it immediately, or move the `use` call outside of the ' +
28 + '`try/catch` block. Capturing without rethrowing will lead to ' +
29 + 'unexpected behavior.\n\n' +
30 + 'To handle async errors, wrap your component in an error boundary, or ' +
31 + "call the promise's `.catch` method and pass the result to `use`",
32 +);
33 +
34 let thenableState: ThenableState | null = null;
35
36 export function createThenableState(): ThenableState {
@@ -37,19 +49,9 @@ export function prepareThenableState(prevThenableState: ThenableState | null) {
49 export function getThenableStateAfterSuspending(): ThenableState | null {
50 // Called by the work loop so it can stash the thenable state. It will use
51 // the state to replay the component when the promise resolves.
40 - if (
41 - thenableState !== null &&
42 - // If we only `use`-ed resolved promises, then there is no suspended state
43 - // TODO: The only reason we do this is to distinguish between throwing a
44 - // promise (old Suspense pattern) versus `use`-ing one. A better solution is
45 - // for `use` to throw a special, opaque value instead of a promise.
46 - !isThenableStateResolved(thenableState)
47 - ) {
48 - const state = thenableState;
49 - thenableState = null;
50 - return state;
51 - }
52 - return null;
52 + const state = thenableState;
53 + thenableState = null;
54 + return state;
55 }
56
57 export function isThenableStateResolved(thenables: ThenableState): boolean {
@@ -129,13 +131,54 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number): T {
131 }
132
133 // Suspend.
132 - // TODO: Throwing here is an implementation detail that allows us to
133 - // unwind the call stack. But we shouldn't allow it to leak into
134 - // userspace. Throw an opaque placeholder value instead of the
135 - // actual thenable. If it doesn't get captured by the work loop, log
136 - // a warning, because that means something in userspace must have
137 - // caught it.
138 - throw thenable;
134 + //
135 + // Throwing here is an implementation detail that allows us to unwind the
136 + // call stack. But we shouldn't allow it to leak into userspace. Throw an
137 + // opaque placeholder value instead of the actual thenable. If it doesn't
138 + // get captured by the work loop, log a warning, because that means
139 + // something in userspace must have caught it.
140 + suspendedThenable = thenable;
141 + if (__DEV__) {
142 + needsToResetSuspendedThenableDEV = true;
143 + }
144 + throw SuspenseException;
145 + }
146 + }
147 +}
148 +
149 +// This is used to track the actual thenable that suspended so it can be
150 +// passed to the rest of the Suspense implementation — which, for historical
151 +// reasons, expects to receive a thenable.
152 +let suspendedThenable: Thenable<any> | null = null;
153 +let needsToResetSuspendedThenableDEV = false;
154 +export function getSuspendedThenable(): Thenable<mixed> {
155 + // This is called right after `use` suspends by throwing an exception. `use`
156 + // throws an opaque value instead of the thenable itself so that it can't be
157 + // caught in userspace. Then the work loop accesses the actual thenable using
158 + // this function.
159 + if (suspendedThenable === null) {
160 + throw new Error(
161 + 'Expected a suspended thenable. This is a bug in React. Please file ' +
162 + 'an issue.',
163 + );
164 + }
165 + const thenable = suspendedThenable;
166 + suspendedThenable = null;
167 + if (__DEV__) {
168 + needsToResetSuspendedThenableDEV = false;
169 + }
170 + return thenable;
171 +}
172 +
173 +export function checkIfUseWrappedInTryCatch(): boolean {
174 + if (__DEV__) {
175 + // This was set right before SuspenseException was thrown, and it should
176 + // have been cleared when the exception was handled. If it wasn't,
177 + // it must have been caught by userspace.
178 + if (needsToResetSuspendedThenableDEV) {
179 + needsToResetSuspendedThenableDEV = false;
180 + return true;
181 }
182 }
183 + return false;
184 }
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+18 -1
@@ -266,6 +266,8 @@ import {
266 } from './ReactFiberAct.new';
267 import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new';
268 import {
269 + SuspenseException,
270 + getSuspendedThenable,
271 getThenableStateAfterSuspending,
272 isThenableStateResolved,
273 } from './ReactFiberThenable.new';
@@ -1722,13 +1724,26 @@ function handleThrow(root, thrownValue): void {
1724 // separate issue. Write a regression test using string refs.
1725 ReactCurrentOwner.current = null;
1726
1727 + if (thrownValue === SuspenseException) {
1728 + // This is a special type of exception used for Suspense. For historical
1729 + // reasons, the rest of the Suspense implementation expects the thrown value
1730 + // to be a thenable, because before `use` existed that was the (unstable)
1731 + // API for suspending. This implementation detail can change later, once we
1732 + // deprecate the old API in favor of `use`.
1733 + thrownValue = getSuspendedThenable();
1734 + workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1735 + } else {
1736 + // This is a regular error. If something earlier in the component already
1737 + // suspended, we must clear the thenable state to unblock the work loop.
1738 + workInProgressSuspendedThenableState = null;
1739 + }
1740 +
1741 // Setting this to `true` tells the work loop to unwind the stack instead
1742 // of entering the begin phase. It's called "suspended" because it usually
1743 // happens because of Suspense, but it also applies to errors. Think of it
1744 // as suspending the execution of the work loop.
1745 workInProgressIsSuspended = true;
1746 workInProgressThrownValue = thrownValue;
1731 - workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1747
1748 const erroredWork = workInProgress;
1749 if (erroredWork === null) {
@@ -1750,6 +1765,7 @@ function handleThrow(root, thrownValue): void {
1765 if (
1766 thrownValue !== null &&
1767 typeof thrownValue === 'object' &&
1768 + // $FlowFixMe[method-unbinding]
1769 typeof thrownValue.then === 'function'
1770 ) {
1771 const wakeable: Wakeable = (thrownValue: any);
@@ -3503,6 +3519,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
3519 } catch (originalError) {
3520 if (
3521 didSuspendOrErrorWhileHydratingDEV() ||
3522 + originalError === SuspenseException ||
3523 (originalError !== null &&
3524 typeof originalError === 'object' &&
3525 typeof originalError.then === 'function')
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+18 -1
@@ -266,6 +266,8 @@ import {
266 } from './ReactFiberAct.old';
267 import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old';
268 import {
269 + SuspenseException,
270 + getSuspendedThenable,
271 getThenableStateAfterSuspending,
272 isThenableStateResolved,
273 } from './ReactFiberThenable.old';
@@ -1722,13 +1724,26 @@ function handleThrow(root, thrownValue): void {
1724 // separate issue. Write a regression test using string refs.
1725 ReactCurrentOwner.current = null;
1726
1727 + if (thrownValue === SuspenseException) {
1728 + // This is a special type of exception used for Suspense. For historical
1729 + // reasons, the rest of the Suspense implementation expects the thrown value
1730 + // to be a thenable, because before `use` existed that was the (unstable)
1731 + // API for suspending. This implementation detail can change later, once we
1732 + // deprecate the old API in favor of `use`.
1733 + thrownValue = getSuspendedThenable();
1734 + workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1735 + } else {
1736 + // This is a regular error. If something earlier in the component already
1737 + // suspended, we must clear the thenable state to unblock the work loop.
1738 + workInProgressSuspendedThenableState = null;
1739 + }
1740 +
1741 // Setting this to `true` tells the work loop to unwind the stack instead
1742 // of entering the begin phase. It's called "suspended" because it usually
1743 // happens because of Suspense, but it also applies to errors. Think of it
1744 // as suspending the execution of the work loop.
1745 workInProgressIsSuspended = true;
1746 workInProgressThrownValue = thrownValue;
1731 - workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
1747
1748 const erroredWork = workInProgress;
1749 if (erroredWork === null) {
@@ -1750,6 +1765,7 @@ function handleThrow(root, thrownValue): void {
1765 if (
1766 thrownValue !== null &&
1767 typeof thrownValue === 'object' &&
1768 + // $FlowFixMe[method-unbinding]
1769 typeof thrownValue.then === 'function'
1770 ) {
1771 const wakeable: Wakeable = (thrownValue: any);
@@ -3503,6 +3519,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
3519 } catch (originalError) {
3520 if (
3521 didSuspendOrErrorWhileHydratingDEV() ||
3522 + originalError === SuspenseException ||
3523 (originalError !== null &&
3524 typeof originalError === 'object' &&
3525 typeof originalError.then === 'function')
packages/react-reconciler/src/__tests__/ReactThenable-test.js
+36
@@ -461,4 +461,40 @@ describe('ReactThenable', () => {
461
462 expect(root).toMatchRenderedOutput(<div>Hello world!</div>);
463 });
464 +
465 + // @gate enableUseHook || !__DEV__
466 + test('warns if use(promise) is wrapped with try/catch block', async () => {
467 + function Async() {
468 + try {
469 + return <Text text={use(Promise.resolve('Async'))} />;
470 + } catch (e) {
471 + return <Text text="Fallback" />;
472 + }
473 + }
474 +
475 + spyOnDev(console, 'error');
476 + function App() {
477 + return (
478 + <Suspense fallback={<Text text="Loading..." />}>
479 + <Async />
480 + </Suspense>
481 + );
482 + }
483 +
484 + const root = ReactNoop.createRoot();
485 + await act(async () => {
486 + startTransition(() => {
487 + root.render(<App />);
488 + });
489 + });
490 +
491 + if (__DEV__) {
492 + expect(console.error.calls.count()).toBe(1);
493 + expect(console.error.calls.argsFor(0)[0]).toContain(
494 + 'Warning: `use` was called from inside a try/catch block. This is not ' +
495 + 'allowed and can lead to unexpected behavior. To handle errors ' +
496 + 'triggered by `use`, wrap your component in a error boundary.',
497 + );
498 + }
499 + });
500 });
packages/react-server/src/ReactFizzServer.js
+31 -4
@@ -17,6 +17,7 @@ import type {
17 ReactContext,
18 ReactProviderType,
19 OffscreenMode,
20 + Wakeable,
21 } from 'shared/ReactTypes';
22 import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
23 import type {
@@ -138,6 +139,7 @@ import {
139 import assign from 'shared/assign';
140 import getComponentNameFromType from 'shared/getComponentNameFromType';
141 import isArray from 'shared/isArray';
142 +import {SuspenseException, getSuspendedThenable} from './ReactFizzThenable';
143
144 const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
145 const ReactCurrentCache = ReactSharedInternals.ReactCurrentCache;
@@ -1522,7 +1524,7 @@ function spawnNewSuspendedTask(
1524 request: Request,
1525 task: Task,
1526 thenableState: ThenableState | null,
1525 - x: Promise<any>,
1527 + x: Wakeable,
1528 ): void {
1529 // Something suspended, we'll need to create a new segment and resolve it later.
1530 const segment = task.blockedSegment;
@@ -1580,11 +1582,24 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void {
1582 }
1583 try {
1584 return renderNodeDestructive(request, task, null, node);
1583 - } catch (x) {
1585 + } catch (thrownValue) {
1586 resetHooksState();
1587 +
1588 + const x =
1589 + thrownValue === SuspenseException
1590 + ? // This is a special type of exception used for Suspense. For historical
1591 + // reasons, the rest of the Suspense implementation expects the thrown
1592 + // value to be a thenable, because before `use` existed that was the
1593 + // (unstable) API for suspending. This implementation detail can change
1594 + // later, once we deprecate the old API in favor of `use`.
1595 + getSuspendedThenable()
1596 + : thrownValue;
1597 +
1598 + // $FlowFixMe[method-unbinding]
1599 if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1600 + const wakeable: Wakeable = (x: any);
1601 const thenableState = getThenableStateAfterSuspending();
1587 - spawnNewSuspendedTask(request, task, thenableState, x);
1602 + spawnNewSuspendedTask(request, task, thenableState, wakeable);
1603
1604 // Restore the context. We assume that this will be restored by the inner
1605 // functions in case nothing throws so we don't use "finally" here.
@@ -1869,8 +1884,20 @@ function retryTask(request: Request, task: Task): void {
1884 task.abortSet.delete(task);
1885 segment.status = COMPLETED;
1886 finishedTask(request, task.blockedBoundary, segment);
1872 - } catch (x) {
1887 + } catch (thrownValue) {
1888 resetHooksState();
1889 +
1890 + const x =
1891 + thrownValue === SuspenseException
1892 + ? // This is a special type of exception used for Suspense. For historical
1893 + // reasons, the rest of the Suspense implementation expects the thrown
1894 + // value to be a thenable, because before `use` existed that was the
1895 + // (unstable) API for suspending. This implementation detail can change
1896 + // later, once we deprecate the old API in favor of `use`.
1897 + getSuspendedThenable()
1898 + : thrownValue;
1899 +
1900 + // $FlowFixMe[method-unbinding]
1901 if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1902 // Something suspended again, let's pick it back up later.
1903 const ping = task.ping;
packages/react-server/src/ReactFizzThenable.js
+40 -7
@@ -22,6 +22,18 @@ import type {
22
23 export opaque type ThenableState = Array<Thenable<any>>;
24
25 +// An error that is thrown (e.g. by `use`) to trigger Suspense. If we
26 +// detect this is caught by userspace, we'll log a warning in development.
27 +export const SuspenseException: mixed = new Error(
28 + "Suspense Exception: This is not a real error! It's an implementation " +
29 + 'detail of `use` to interrupt the current render. You must either ' +
30 + 'rethrow it immediately, or move the `use` call outside of the ' +
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`",
35 +);
36 +
37 export function createThenableState(): ThenableState {
38 // The ThenableState is created the first time a component suspends. If it
39 // suspends again, we'll reuse the same state.
@@ -92,13 +104,34 @@ export function trackUsedThenable<T>(
104 }
105
106 // Suspend.
95 - // TODO: Throwing here is an implementation detail that allows us to
96 - // unwind the call stack. But we shouldn't allow it to leak into
97 - // userspace. Throw an opaque placeholder value instead of the
98 - // actual thenable. If it doesn't get captured by the work loop, log
99 - // a warning, because that means something in userspace must have
100 - // caught it.
101 - throw thenable;
107 + //
108 + // Throwing here is an implementation detail that allows us to unwind the
109 + // call stack. But we shouldn't allow it to leak into userspace. Throw an
110 + // opaque placeholder value instead of the actual thenable. If it doesn't
111 + // get captured by the work loop, log a warning, because that means
112 + // something in userspace must have caught it.
113 + suspendedThenable = thenable;
114 + throw SuspenseException;
115 }
116 }
117 }
118 +
119 +// This is used to track the actual thenable that suspended so it can be
120 +// passed to the rest of the Suspense implementation — which, for historical
121 +// reasons, expects to receive a thenable.
122 +let suspendedThenable: Thenable<any> | null = null;
123 +export function getSuspendedThenable(): Thenable<mixed> {
124 + // This is called right after `use` suspends by throwing an exception. `use`
125 + // throws an opaque value instead of the thenable itself so that it can't be
126 + // caught in userspace. Then the work loop accesses the actual thenable using
127 + // this function.
128 + if (suspendedThenable === null) {
129 + throw new Error(
130 + 'Expected a suspended thenable. This is a bug in React. Please file ' +
131 + 'an issue.',
132 + );
133 + }
134 + const thenable = suspendedThenable;
135 + suspendedThenable = null;
136 + return thenable;
137 +}
packages/react-server/src/ReactFlightServer.js
+23 -2
@@ -84,6 +84,7 @@ import {
84 import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
85 import ReactSharedInternals from 'shared/ReactSharedInternals';
86 import isArray from 'shared/isArray';
87 +import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
88
89 type ReactJSONValue =
90 | string
@@ -842,7 +843,17 @@ export function resolveModelToJSON(
843 break;
844 }
845 }
845 - } catch (x) {
846 + } catch (thrownValue) {
847 + const x =
848 + thrownValue === SuspenseException
849 + ? // This is a special type of exception used for Suspense. For historical
850 + // reasons, the rest of the Suspense implementation expects the thrown
851 + // value to be a thenable, because before `use` existed that was the
852 + // (unstable) API for suspending. This implementation detail can change
853 + // later, once we deprecate the old API in favor of `use`.
854 + getSuspendedThenable()
855 + : thrownValue;
856 + // $FlowFixMe[method-unbinding]
857 if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
858 // Something suspended, we'll need to create a new task and resolve it later.
859 request.pendingChunks++;
@@ -1173,7 +1184,17 @@ function retryTask(request: Request, task: Task): void {
1184 request.completedJSONChunks.push(processedChunk);
1185 request.abortableTasks.delete(task);
1186 task.status = COMPLETED;
1176 - } catch (x) {
1187 + } catch (thrownValue) {
1188 + const x =
1189 + thrownValue === SuspenseException
1190 + ? // This is a special type of exception used for Suspense. For historical
1191 + // reasons, the rest of the Suspense implementation expects the thrown
1192 + // value to be a thenable, because before `use` existed that was the
1193 + // (unstable) API for suspending. This implementation detail can change
1194 + // later, once we deprecate the old API in favor of `use`.
1195 + getSuspendedThenable()
1196 + : thrownValue;
1197 + // $FlowFixMe[method-unbinding]
1198 if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1199 // Something suspended again, let's pick it back up later.
1200 const ping = task.ping;
packages/react-server/src/ReactFlightThenable.js
+40 -7
@@ -22,6 +22,18 @@ import type {
22
23 export opaque type ThenableState = Array<Thenable<any>>;
24
25 +// An error that is thrown (e.g. by `use`) to trigger Suspense. If we
26 +// detect this is caught by userspace, we'll log a warning in development.
27 +export const SuspenseException: mixed = new Error(
28 + "Suspense Exception: This is not a real error! It's an implementation " +
29 + 'detail of `use` to interrupt the current render. You must either ' +
30 + 'rethrow it immediately, or move the `use` call outside of the ' +
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`",
35 +);
36 +
37 export function createThenableState(): ThenableState {
38 // The ThenableState is created the first time a component suspends. If it
39 // suspends again, we'll reuse the same state.
@@ -92,13 +104,34 @@ export function trackUsedThenable<T>(
104 }
105
106 // Suspend.
95 - // TODO: Throwing here is an implementation detail that allows us to
96 - // unwind the call stack. But we shouldn't allow it to leak into
97 - // userspace. Throw an opaque placeholder value instead of the
98 - // actual thenable. If it doesn't get captured by the work loop, log
99 - // a warning, because that means something in userspace must have
100 - // caught it.
101 - throw thenable;
107 + //
108 + // Throwing here is an implementation detail that allows us to unwind the
109 + // call stack. But we shouldn't allow it to leak into userspace. Throw an
110 + // opaque placeholder value instead of the actual thenable. If it doesn't
111 + // get captured by the work loop, log a warning, because that means
112 + // something in userspace must have caught it.
113 + suspendedThenable = thenable;
114 + throw SuspenseException;
115 }
116 }
117 }
118 +
119 +// This is used to track the actual thenable that suspended so it can be
120 +// passed to the rest of the Suspense implementation — which, for historical
121 +// reasons, expects to receive a thenable.
122 +let suspendedThenable: Thenable<any> | null = null;
123 +export function getSuspendedThenable(): Thenable<mixed> {
124 + // This is called right after `use` suspends by throwing an exception. `use`
125 + // throws an opaque value instead of the thenable itself so that it can't be
126 + // caught in userspace. Then the work loop accesses the actual thenable using
127 + // this function.
128 + if (suspendedThenable === null) {
129 + throw new Error(
130 + 'Expected a suspended thenable. This is a bug in React. Please file ' +
131 + 'an issue.',
132 + );
133 + }
134 + const thenable = suspendedThenable;
135 + suspendedThenable = null;
136 + return thenable;
137 +}
scripts/error-codes/codes.json
+3 -1
@@ -443,5 +443,7 @@
443 "455": "This CacheSignal was requested outside React which means that it is immediately aborted.",
444 "456": "Calling Offscreen.detach before instance handle has been set.",
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."
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`"
449 }