Skip to content

Commit 1d87446

Browse files
committed
Throw opaque object to suspend
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 details — 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 opaque object. In development, we can detect whether the object 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. I did not yet implement the warning in Flight.
1 parent 9e20bfd commit 1d87446

12 files changed

+359
-63
lines changed

packages/react-reconciler/src/ReactFiberHooks.new.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ import {now} from './Scheduler';
137137
import {
138138
prepareThenableState,
139139
trackUsedThenable,
140+
checkIfUseWrappedInTryCatch,
140141
} from './ReactFiberThenable.new';
141142

142143
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
@@ -159,8 +160,10 @@ export type UpdateQueue<S, A> = {
159160

160161
let didWarnAboutMismatchedHooksForComponent;
161162
let didWarnUncachedGetSnapshot;
163+
let didWarnAboutUseWrappedInTryCatch;
162164
if (__DEV__) {
163165
didWarnAboutMismatchedHooksForComponent = new Set();
166+
didWarnAboutUseWrappedInTryCatch = new Set();
164167
}
165168

166169
export type Hook = {
@@ -593,6 +596,22 @@ export function renderWithHooks<Props, SecondArg>(
593596
}
594597
}
595598
}
599+
600+
if (__DEV__) {
601+
if (checkIfUseWrappedInTryCatch()) {
602+
const componentName =
603+
getComponentNameFromFiber(workInProgress) || 'Unknown';
604+
if (!didWarnAboutUseWrappedInTryCatch.has(componentName)) {
605+
didWarnAboutUseWrappedInTryCatch.add(componentName);
606+
console.error(
607+
'`use` was called from inside a try/catch block. This is not allowed ' +
608+
'and can lead to unexpected behavior. To handle errors triggered ' +
609+
'by `use`, wrap your component in a error boundary.',
610+
);
611+
}
612+
}
613+
}
614+
596615
return children;
597616
}
598617

packages/react-reconciler/src/ReactFiberHooks.old.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ import {now} from './Scheduler';
137137
import {
138138
prepareThenableState,
139139
trackUsedThenable,
140+
checkIfUseWrappedInTryCatch,
140141
} from './ReactFiberThenable.old';
141142

142143
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
@@ -159,8 +160,10 @@ export type UpdateQueue<S, A> = {
159160

160161
let didWarnAboutMismatchedHooksForComponent;
161162
let didWarnUncachedGetSnapshot;
163+
let didWarnAboutUseWrappedInTryCatch;
162164
if (__DEV__) {
163165
didWarnAboutMismatchedHooksForComponent = new Set();
166+
didWarnAboutUseWrappedInTryCatch = new Set();
164167
}
165168

166169
export type Hook = {
@@ -593,6 +596,22 @@ export function renderWithHooks<Props, SecondArg>(
593596
}
594597
}
595598
}
599+
600+
if (__DEV__) {
601+
if (checkIfUseWrappedInTryCatch()) {
602+
const componentName =
603+
getComponentNameFromFiber(workInProgress) || 'Unknown';
604+
if (!didWarnAboutUseWrappedInTryCatch.has(componentName)) {
605+
didWarnAboutUseWrappedInTryCatch.add(componentName);
606+
console.error(
607+
'`use` was called from inside a try/catch block. This is not allowed ' +
608+
'and can lead to unexpected behavior. To handle errors triggered ' +
609+
'by `use`, wrap your component in a error boundary.',
610+
);
611+
}
612+
}
613+
}
614+
596615
return children;
597616
}
598617

packages/react-reconciler/src/ReactFiberThenable.new.js

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ const {ReactCurrentActQueue} = ReactSharedInternals;
1919

2020
export opaque type ThenableState = Array<Thenable<any>>;
2121

22+
// An opaque object 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+
// TODO: Alternatively, this could be an actual error with a helpful message.
25+
// The reason not to do that is because someone might be tempted to check the
26+
// message in order to determine if it's caused by Suspense. Maybe it could be
27+
// an actual error object in production, but a plain object in development,
28+
// where it would also be accompanied by a warning.
29+
export const SuspenseException: mixed = {};
30+
2231
let thenableState: ThenableState | null = null;
2332

2433
export function createThenableState(): ThenableState {
@@ -37,19 +46,9 @@ export function prepareThenableState(prevThenableState: ThenableState | null) {
3746
export function getThenableStateAfterSuspending(): ThenableState | null {
3847
// Called by the work loop so it can stash the thenable state. It will use
3948
// 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;
49+
const state = thenableState;
50+
thenableState = null;
51+
return state;
5352
}
5453

5554
export function isThenableStateResolved(thenables: ThenableState): boolean {
@@ -129,13 +128,54 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number): T {
129128
}
130129

131130
// 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;
131+
//
132+
// Throwing here is an implementation detail that allows us to unwind the
133+
// call stack. But we shouldn't allow it to leak into userspace. Throw an
134+
// opaque placeholder value instead of the actual thenable. If it doesn't
135+
// get captured by the work loop, log a warning, because that means
136+
// something in userspace must have caught it.
137+
suspendedThenable = thenable;
138+
if (__DEV__) {
139+
needsToResetSuspendedThenableDEV = true;
140+
}
141+
throw SuspenseException;
142+
}
143+
}
144+
}
145+
146+
// This is used to track the actual thenable that suspended so it can be
147+
// passed to the rest of the Suspense implementation — which, for historical
148+
// reasons, expects to receive a thenable.
149+
let suspendedThenable: Thenable<any> | null = null;
150+
let needsToResetSuspendedThenableDEV = false;
151+
export function getSuspendedThenable(): Thenable<mixed> {
152+
// This is called right after `use` suspends by throwing an exception. `use`
153+
// throws an opaque value instead of the thenable itself so that it can't be
154+
// caught in userspace. Then the work loop accesses the actual thenable using
155+
// this function.
156+
if (suspendedThenable === null) {
157+
throw new Error(
158+
'Expected a suspended thenable. This is a bug in React. Please file ' +
159+
'an issue.',
160+
);
161+
}
162+
const thenable = suspendedThenable;
163+
suspendedThenable = null;
164+
if (__DEV__) {
165+
needsToResetSuspendedThenableDEV = false;
166+
}
167+
return thenable;
168+
}
169+
170+
export function checkIfUseWrappedInTryCatch(): boolean {
171+
if (__DEV__) {
172+
// This was set right before SuspenseException was thrown, and it should
173+
// have been cleared when the exception was handled. If it wasn't,
174+
// it must have been caught by userspace.
175+
if (needsToResetSuspendedThenableDEV) {
176+
needsToResetSuspendedThenableDEV = false;
177+
return true;
139178
}
140179
}
180+
return false;
141181
}

packages/react-reconciler/src/ReactFiberThenable.old.js

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ const {ReactCurrentActQueue} = ReactSharedInternals;
1919

2020
export opaque type ThenableState = Array<Thenable<any>>;
2121

22+
// An opaque object 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+
// TODO: Alternatively, this could be an actual error with a helpful message.
25+
// The reason not to do that is because someone might be tempted to check the
26+
// message in order to determine if it's caused by Suspense. Maybe it could be
27+
// an actual error object in production, but a plain object in development,
28+
// where it would also be accompanied by a warning.
29+
export const SuspenseException: mixed = {};
30+
2231
let thenableState: ThenableState | null = null;
2332

2433
export function createThenableState(): ThenableState {
@@ -37,19 +46,9 @@ export function prepareThenableState(prevThenableState: ThenableState | null) {
3746
export function getThenableStateAfterSuspending(): ThenableState | null {
3847
// Called by the work loop so it can stash the thenable state. It will use
3948
// 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;
49+
const state = thenableState;
50+
thenableState = null;
51+
return state;
5352
}
5453

5554
export function isThenableStateResolved(thenables: ThenableState): boolean {
@@ -129,13 +128,54 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number): T {
129128
}
130129

131130
// 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;
131+
//
132+
// Throwing here is an implementation detail that allows us to unwind the
133+
// call stack. But we shouldn't allow it to leak into userspace. Throw an
134+
// opaque placeholder value instead of the actual thenable. If it doesn't
135+
// get captured by the work loop, log a warning, because that means
136+
// something in userspace must have caught it.
137+
suspendedThenable = thenable;
138+
if (__DEV__) {
139+
needsToResetSuspendedThenableDEV = true;
140+
}
141+
throw SuspenseException;
142+
}
143+
}
144+
}
145+
146+
// This is used to track the actual thenable that suspended so it can be
147+
// passed to the rest of the Suspense implementation — which, for historical
148+
// reasons, expects to receive a thenable.
149+
let suspendedThenable: Thenable<any> | null = null;
150+
let needsToResetSuspendedThenableDEV = false;
151+
export function getSuspendedThenable(): Thenable<mixed> {
152+
// This is called right after `use` suspends by throwing an exception. `use`
153+
// throws an opaque value instead of the thenable itself so that it can't be
154+
// caught in userspace. Then the work loop accesses the actual thenable using
155+
// this function.
156+
if (suspendedThenable === null) {
157+
throw new Error(
158+
'Expected a suspended thenable. This is a bug in React. Please file ' +
159+
'an issue.',
160+
);
161+
}
162+
const thenable = suspendedThenable;
163+
suspendedThenable = null;
164+
if (__DEV__) {
165+
needsToResetSuspendedThenableDEV = false;
166+
}
167+
return thenable;
168+
}
169+
170+
export function checkIfUseWrappedInTryCatch(): boolean {
171+
if (__DEV__) {
172+
// This was set right before SuspenseException was thrown, and it should
173+
// have been cleared when the exception was handled. If it wasn't,
174+
// it must have been caught by userspace.
175+
if (needsToResetSuspendedThenableDEV) {
176+
needsToResetSuspendedThenableDEV = false;
177+
return true;
139178
}
140179
}
180+
return false;
141181
}

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ import {
266266
} from './ReactFiberAct.new';
267267
import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new';
268268
import {
269+
SuspenseException,
270+
getSuspendedThenable,
269271
getThenableStateAfterSuspending,
270272
isThenableStateResolved,
271273
} from './ReactFiberThenable.new';
@@ -1722,13 +1724,26 @@ function handleThrow(root, thrownValue): void {
17221724
// separate issue. Write a regression test using string refs.
17231725
ReactCurrentOwner.current = null;
17241726

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+
17251741
// Setting this to `true` tells the work loop to unwind the stack instead
17261742
// of entering the begin phase. It's called "suspended" because it usually
17271743
// happens because of Suspense, but it also applies to errors. Think of it
17281744
// as suspending the execution of the work loop.
17291745
workInProgressIsSuspended = true;
17301746
workInProgressThrownValue = thrownValue;
1731-
workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
17321747

17331748
const erroredWork = workInProgress;
17341749
if (erroredWork === null) {
@@ -1750,6 +1765,7 @@ function handleThrow(root, thrownValue): void {
17501765
if (
17511766
thrownValue !== null &&
17521767
typeof thrownValue === 'object' &&
1768+
// $FlowFixMe[method-unbinding]
17531769
typeof thrownValue.then === 'function'
17541770
) {
17551771
const wakeable: Wakeable = (thrownValue: any);
@@ -3503,6 +3519,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
35033519
} catch (originalError) {
35043520
if (
35053521
didSuspendOrErrorWhileHydratingDEV() ||
3522+
originalError === SuspenseException ||
35063523
(originalError !== null &&
35073524
typeof originalError === 'object' &&
35083525
typeof originalError.then === 'function')

packages/react-reconciler/src/ReactFiberWorkLoop.old.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ import {
266266
} from './ReactFiberAct.old';
267267
import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old';
268268
import {
269+
SuspenseException,
270+
getSuspendedThenable,
269271
getThenableStateAfterSuspending,
270272
isThenableStateResolved,
271273
} from './ReactFiberThenable.old';
@@ -1722,13 +1724,26 @@ function handleThrow(root, thrownValue): void {
17221724
// separate issue. Write a regression test using string refs.
17231725
ReactCurrentOwner.current = null;
17241726

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+
17251741
// Setting this to `true` tells the work loop to unwind the stack instead
17261742
// of entering the begin phase. It's called "suspended" because it usually
17271743
// happens because of Suspense, but it also applies to errors. Think of it
17281744
// as suspending the execution of the work loop.
17291745
workInProgressIsSuspended = true;
17301746
workInProgressThrownValue = thrownValue;
1731-
workInProgressSuspendedThenableState = getThenableStateAfterSuspending();
17321747

17331748
const erroredWork = workInProgress;
17341749
if (erroredWork === null) {
@@ -1750,6 +1765,7 @@ function handleThrow(root, thrownValue): void {
17501765
if (
17511766
thrownValue !== null &&
17521767
typeof thrownValue === 'object' &&
1768+
// $FlowFixMe[method-unbinding]
17531769
typeof thrownValue.then === 'function'
17541770
) {
17551771
const wakeable: Wakeable = (thrownValue: any);
@@ -3503,6 +3519,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
35033519
} catch (originalError) {
35043520
if (
35053521
didSuspendOrErrorWhileHydratingDEV() ||
3522+
originalError === SuspenseException ||
35063523
(originalError !== null &&
35073524
typeof originalError === 'object' &&
35083525
typeof originalError.then === 'function')

0 commit comments

Comments
 (0)