Skip to content

Commit 54b1617

Browse files
committed
Warn if uncached promise suspends during render
We do not yet support async/await on the client. You can only unwrap a promise that was passed from a Server Component, or a Suspense-compatible framework like Relay.
1 parent fd66aa6 commit 54b1617

File tree

2 files changed

+90
-13
lines changed

2 files changed

+90
-13
lines changed

packages/react-reconciler/src/ReactFiberThenable.js

+58-5
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,24 @@ import {getWorkInProgressRoot} from './ReactFiberWorkLoop';
1919
import ReactSharedInternals from 'shared/ReactSharedInternals';
2020
const {ReactCurrentActQueue} = ReactSharedInternals;
2121

22-
export opaque type ThenableState = Array<Thenable<any>>;
22+
opaque type ThenableStateDev = {
23+
didWarnAboutUncachedPromise: boolean,
24+
thenables: Array<Thenable<any>>,
25+
};
26+
27+
opaque type ThenableStateProd = Array<Thenable<any>>;
28+
29+
export opaque type ThenableState = ThenableStateDev | ThenableStateProd;
30+
31+
function getThenablesFromState(state: ThenableState): Array<Thenable<any>> {
32+
if (__DEV__) {
33+
const devState: ThenableStateDev = (state: any);
34+
return devState.thenables;
35+
} else {
36+
const prodState = (state: any);
37+
return prodState;
38+
}
39+
}
2340

2441
// An error that is thrown (e.g. by `use`) to trigger Suspense. If we
2542
// detect this is caught by userspace, we'll log a warning in development.
@@ -56,7 +73,14 @@ export const noopSuspenseyCommitThenable = {
5673
export function createThenableState(): ThenableState {
5774
// The ThenableState is created the first time a component suspends. If it
5875
// suspends again, we'll reuse the same state.
59-
return [];
76+
if (__DEV__) {
77+
return {
78+
didWarnAboutUncachedPromise: false,
79+
thenables: [],
80+
};
81+
} else {
82+
return [];
83+
}
6084
}
6185

6286
export function isThenableResolved(thenable: Thenable<mixed>): boolean {
@@ -74,15 +98,44 @@ export function trackUsedThenable<T>(
7498
if (__DEV__ && ReactCurrentActQueue.current !== null) {
7599
ReactCurrentActQueue.didUsePromise = true;
76100
}
77-
78-
const previous = thenableState[index];
101+
const trackedThenables = getThenablesFromState(thenableState);
102+
const previous = trackedThenables[index];
79103
if (previous === undefined) {
80-
thenableState.push(thenable);
104+
trackedThenables.push(thenable);
81105
} else {
82106
if (previous !== thenable) {
83107
// Reuse the previous thenable, and drop the new one. We can assume
84108
// they represent the same value, because components are idempotent.
85109

110+
if (__DEV__) {
111+
const thenableStateDev: ThenableStateDev = (thenableState: any);
112+
if (!thenableStateDev.didWarnAboutUncachedPromise) {
113+
// We should only warn the first time an uncached thenable is
114+
// discovered per component, because if there are multiple, the
115+
// subsequent ones are likely derived from the first.
116+
//
117+
// We track this on the thenableState instead of deduping using the
118+
// component name like we usually do, because in the case of a
119+
// promise-as-React-node, the owner component is likely different from
120+
// the parent that's currently being reconciled. We'd have to track
121+
// the owner using state, which we're trying to move away from. Though
122+
// since this is dev-only, maybe that'd be OK.
123+
//
124+
// However, another benefit of doing it this way is we might
125+
// eventually have a thenableState per memo/Forget boundary instead
126+
// of per component, so this would allow us to have more
127+
// granular warnings.
128+
thenableStateDev.didWarnAboutUncachedPromise = true;
129+
130+
// TODO: This warning should link to a corresponding docs page.
131+
console.error(
132+
'A component was suspended by an uncached promise. Creating ' +
133+
'promises inside a Client Component or hook is not yet ' +
134+
'supported, except via a Suspense-compatible library or framework.',
135+
);
136+
}
137+
}
138+
86139
// Avoid an unhandled rejection errors for the Promises that we'll
87140
// intentionally ignore.
88141
thenable.then(noop, noop);

packages/react-reconciler/src/__tests__/ReactUse-test.js

+32-8
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,17 @@ describe('ReactUse', () => {
230230
}
231231

232232
const root = ReactNoop.createRoot();
233-
await act(() => {
234-
startTransition(() => {
235-
root.render(<App />);
233+
await expect(async () => {
234+
await act(() => {
235+
startTransition(() => {
236+
root.render(<App />);
237+
});
236238
});
237-
});
239+
}).toErrorDev([
240+
'A component was suspended by an uncached promise. Creating ' +
241+
'promises inside a Client Component or hook is not yet ' +
242+
'supported, except via a Suspense-compatible library or framework.',
243+
]);
238244
assertLog(['ABC']);
239245
expect(root).toMatchRenderedOutput('ABC');
240246
});
@@ -394,11 +400,20 @@ describe('ReactUse', () => {
394400
}
395401

396402
const root = ReactNoop.createRoot();
397-
await act(() => {
398-
startTransition(() => {
399-
root.render(<App />);
403+
await expect(async () => {
404+
await act(() => {
405+
startTransition(() => {
406+
root.render(<App />);
407+
});
400408
});
401-
});
409+
}).toErrorDev([
410+
'A component was suspended by an uncached promise. Creating ' +
411+
'promises inside a Client Component or hook is not yet ' +
412+
'supported, except via a Suspense-compatible library or framework.',
413+
'A component was suspended by an uncached promise. Creating ' +
414+
'promises inside a Client Component or hook is not yet ' +
415+
'supported, except via a Suspense-compatible library or framework.',
416+
]);
402417
assertLog([
403418
// First attempt. The uncached promise suspends.
404419
'Suspend! [Async]',
@@ -1759,6 +1774,9 @@ describe('ReactUse', () => {
17591774
'Components. This error is often caused by accidentally adding ' +
17601775
"`'use client'` to a module that was originally written for " +
17611776
'the server.',
1777+
'A component was suspended by an uncached promise. Creating ' +
1778+
'promises inside a Client Component or hook is not yet ' +
1779+
'supported, except via a Suspense-compatible library or framework.',
17621780
]);
17631781
},
17641782
);
@@ -1788,6 +1806,12 @@ describe('ReactUse', () => {
17881806
'Components. This error is often caused by accidentally adding ' +
17891807
"`'use client'` to a module that was originally written for " +
17901808
'the server.',
1809+
'A component was suspended by an uncached promise. Creating ' +
1810+
'promises inside a Client Component or hook is not yet ' +
1811+
'supported, except via a Suspense-compatible library or framework.',
1812+
'A component was suspended by an uncached promise. Creating ' +
1813+
'promises inside a Client Component or hook is not yet ' +
1814+
'supported, except via a Suspense-compatible library or framework.',
17911815
]);
17921816
});
17931817
});

0 commit comments

Comments
 (0)