Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8d26b9e

Browse files
committed
Detect native async function components in dev
Adds a development warning to complement the error introduced by facebook#27019. We can detect and warn about async client components by checking the prototype of the function. This won't work for environments where async functions are transpiled, but for native async functions, it allows us to log an earlier warning during development, including in cases that don't trigger the infinite loop guard added in facebook#27019. It does not supersede the infinite loop guard, though, because that mechanism also prevents the app from crashing. I also added a warning for calling a hook inside an async function. This one fires even during a transition. We could add a corresponding warning to Flight, since hooks are not allowed in async Server Components, either. (Though in both environments, this is better handled by a lint rule.)
1 parent 5e7136d commit 8d26b9e

File tree

5 files changed

+186
-34
lines changed

5 files changed

+186
-34
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,11 @@ export type UpdateQueue<S, A> = {
163163
let didWarnAboutMismatchedHooksForComponent;
164164
let didWarnUncachedGetSnapshot: void | true;
165165
let didWarnAboutUseWrappedInTryCatch;
166+
let didWarnAboutAsyncClientComponent;
166167
if (__DEV__) {
167168
didWarnAboutMismatchedHooksForComponent = new Set<string | null>();
168169
didWarnAboutUseWrappedInTryCatch = new Set<string | null>();
170+
didWarnAboutAsyncClientComponent = new Set<string | null>();
169171
}
170172

171173
export type Hook = {
@@ -370,6 +372,57 @@ function warnOnHookMismatchInDev(currentHookName: HookType): void {
370372
}
371373
}
372374

375+
function warnIfAsyncClientComponent(
376+
Component: Function,
377+
componentDoesIncludeHooks: boolean,
378+
) {
379+
if (__DEV__) {
380+
// This dev-only check only works for detecting native async functions,
381+
// not transpiled ones. There's also a prod check that we use to prevent
382+
// async client components from crashing the app; the prod one works even
383+
// for transpiled async functions. Neither mechanism is completely
384+
// bulletproof but together they cover the most common cases.
385+
const prototype: Object | null = Object.getPrototypeOf(Component);
386+
if (prototype && prototype[Symbol.toStringTag] === 'AsyncFunction') {
387+
// Encountered an async Client Component. This is not yet supported,
388+
// except in certain constrained cases, like during a route navigation.
389+
const componentName = getComponentNameFromFiber(currentlyRenderingFiber);
390+
if (!didWarnAboutAsyncClientComponent.has(componentName)) {
391+
didWarnAboutAsyncClientComponent.add(componentName);
392+
393+
// Check if this is a sync update. We use the "root" render lanes here
394+
// because the "subtree" render lanes may include additional entangled
395+
// lanes related to revealing previously hidden content.
396+
const root = getWorkInProgressRoot();
397+
const rootRenderLanes = getWorkInProgressRootRenderLanes();
398+
if (root !== null && includesBlockingLane(root, rootRenderLanes)) {
399+
console.error(
400+
'async/await is not yet supported in Client Components, only ' +
401+
'Server Components. This error is often caused by accidentally ' +
402+
"adding `'use client'` to a module that was originally written " +
403+
'for the server.',
404+
);
405+
} else {
406+
// This is a concurrent (Transition, Retry, etc) render. We don't
407+
// warn in these cases.
408+
//
409+
// However, Async Components are forbidden to include hooks, even
410+
// during a transition, so let's check for that here.
411+
//
412+
// TODO: Add a corresponding warning to Server Components runtime.
413+
if (componentDoesIncludeHooks) {
414+
console.error(
415+
'Hooks are not supported inside an async component. This ' +
416+
"error is often caused by accidentally adding `'use client'` " +
417+
'to a module that was originally written for the server.',
418+
);
419+
}
420+
}
421+
}
422+
}
423+
}
424+
}
425+
373426
function throwInvalidHookError() {
374427
throw new Error(
375428
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
@@ -554,20 +607,28 @@ export function renderWithHooks<Props, SecondArg>(
554607
}
555608
}
556609

557-
finishRenderingHooks(current, workInProgress);
610+
finishRenderingHooks(current, workInProgress, Component);
558611

559612
return children;
560613
}
561614

562-
function finishRenderingHooks(current: Fiber | null, workInProgress: Fiber) {
563-
// We can assume the previous dispatcher is always this one, since we set it
564-
// at the beginning of the render phase and there's no re-entrance.
565-
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
566-
615+
function finishRenderingHooks<Props, SecondArg>(
616+
current: Fiber | null,
617+
workInProgress: Fiber,
618+
Component: (p: Props, arg: SecondArg) => any,
619+
): void {
567620
if (__DEV__) {
568621
workInProgress._debugHookTypes = hookTypesDev;
622+
623+
const componentDoesIncludeHooks =
624+
workInProgressHook !== null || thenableIndexCounter !== 0;
625+
warnIfAsyncClientComponent(Component, componentDoesIncludeHooks);
569626
}
570627

628+
// We can assume the previous dispatcher is always this one, since we set it
629+
// at the beginning of the render phase and there's no re-entrance.
630+
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
631+
571632
// This check uses currentHook so that it works the same in DEV and prod bundles.
572633
// hookTypesDev could catch more cases (e.g. context) but only in DEV bundles.
573634
const didRenderTooFewHooks =
@@ -645,7 +706,13 @@ function finishRenderingHooks(current: Fiber | null, workInProgress: Fiber) {
645706
if (checkIfUseWrappedInTryCatch()) {
646707
const componentName =
647708
getComponentNameFromFiber(workInProgress) || 'Unknown';
648-
if (!didWarnAboutUseWrappedInTryCatch.has(componentName)) {
709+
if (
710+
!didWarnAboutUseWrappedInTryCatch.has(componentName) &&
711+
// This warning also fires if you suspend with `use` inside an
712+
// async component. Since we warn for that above, we'll silence this
713+
// second warning by checking here.
714+
!didWarnAboutAsyncClientComponent.has(componentName)
715+
) {
649716
didWarnAboutUseWrappedInTryCatch.add(componentName);
650717
console.error(
651718
'`use` was called from inside a try/catch block. This is not allowed ' +
@@ -683,7 +750,7 @@ export function replaySuspendedComponentWithHooks<Props, SecondArg>(
683750
props,
684751
secondArg,
685752
);
686-
finishRenderingHooks(current, workInProgress);
753+
finishRenderingHooks(current, workInProgress, Component);
687754
return children;
688755
}
689756

packages/react-reconciler/src/ReactFiberThenable.js

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export function trackUsedThenable<T>(
9494
}
9595
case 'rejected': {
9696
const rejectedError = thenable.reason;
97+
checkIfUseWrappedInAsyncCatch(rejectedError);
9798
throw rejectedError;
9899
}
99100
default: {
@@ -149,17 +150,19 @@ export function trackUsedThenable<T>(
149150
}
150151
},
151152
);
152-
}
153153

154-
// Check one more time in case the thenable resolved synchronously.
155-
switch (thenable.status) {
156-
case 'fulfilled': {
157-
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
158-
return fulfilledThenable.value;
159-
}
160-
case 'rejected': {
161-
const rejectedThenable: RejectedThenable<T> = (thenable: any);
162-
throw rejectedThenable.reason;
154+
// Check one more time in case the thenable resolved synchronously.
155+
switch (thenable.status) {
156+
case 'fulfilled': {
157+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
158+
return fulfilledThenable.value;
159+
}
160+
case 'rejected': {
161+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
162+
const rejectedError = rejectedThenable.reason;
163+
checkIfUseWrappedInAsyncCatch(rejectedError);
164+
throw rejectedError;
165+
}
163166
}
164167
}
165168

@@ -223,3 +226,20 @@ export function checkIfUseWrappedInTryCatch(): boolean {
223226
}
224227
return false;
225228
}
229+
230+
export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
231+
// This check runs in prod, too, because it prevents a more confusing
232+
// downstream error, where SuspenseException is caught by a promise and
233+
// thrown asynchronously.
234+
// TODO: Another way to prevent SuspenseException from leaking into an async
235+
// execution context is to check the dispatcher every time `use` is called,
236+
// or some equivalent. That might be preferable for other reasons, too, since
237+
// it matches how we prevent similar mistakes for other hooks.
238+
if (rejectedReason === SuspenseException) {
239+
throw new Error(
240+
'Hooks are not supported inside an async component. This ' +
241+
"error is often caused by accidentally adding `'use client'` " +
242+
'to a module that was originally written for the server.',
243+
);
244+
}
245+
}

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

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,13 +1639,20 @@ describe('ReactUse', () => {
16391639
}
16401640

16411641
const root = ReactNoop.createRoot();
1642-
await act(() => {
1643-
root.render(
1644-
<ErrorBoundary>
1645-
<AsyncClientComponent />
1646-
</ErrorBoundary>,
1647-
);
1648-
});
1642+
await expect(async () => {
1643+
await act(() => {
1644+
root.render(
1645+
<ErrorBoundary>
1646+
<AsyncClientComponent />
1647+
</ErrorBoundary>,
1648+
);
1649+
});
1650+
}).toErrorDev([
1651+
'async/await is not yet supported in Client Components, only ' +
1652+
'Server Components. This error is often caused by accidentally ' +
1653+
"adding `'use client'` to a module that was originally written " +
1654+
'for the server.',
1655+
]);
16491656
assertLog([
16501657
'async/await is not yet supported in Client Components, only Server ' +
16511658
'Components. This error is often caused by accidentally adding ' +
@@ -1685,13 +1692,20 @@ describe('ReactUse', () => {
16851692
}
16861693

16871694
const root = ReactNoop.createRoot();
1688-
await act(() => {
1689-
root.render(
1690-
<ErrorBoundary>
1691-
<AsyncClientComponent />
1692-
</ErrorBoundary>,
1693-
);
1694-
});
1695+
await expect(async () => {
1696+
await act(() => {
1697+
root.render(
1698+
<ErrorBoundary>
1699+
<AsyncClientComponent />
1700+
</ErrorBoundary>,
1701+
);
1702+
});
1703+
}).toErrorDev([
1704+
'async/await is not yet supported in Client Components, only ' +
1705+
'Server Components. This error is often caused by accidentally ' +
1706+
"adding `'use client'` to a module that was originally written " +
1707+
'for the server.',
1708+
]);
16951709
assertLog([
16961710
'async/await is not yet supported in Client Components, only Server ' +
16971711
'Components. This error is often caused by accidentally adding ' +
@@ -1709,4 +1723,46 @@ describe('ReactUse', () => {
17091723
'the server.',
17101724
);
17111725
});
1726+
1727+
test('warn if async client component calls a hook (e.g. useState)', async () => {
1728+
async function AsyncClientComponent() {
1729+
useState();
1730+
return <Text text="Hi" />;
1731+
}
1732+
1733+
const root = ReactNoop.createRoot();
1734+
await expect(async () => {
1735+
await act(() => {
1736+
startTransition(() => {
1737+
root.render(<AsyncClientComponent />);
1738+
});
1739+
});
1740+
}).toErrorDev([
1741+
'Hooks are not supported inside an async component. This ' +
1742+
"error is often caused by accidentally adding `'use client'` " +
1743+
'to a module that was originally written for the server.',
1744+
]);
1745+
});
1746+
1747+
test('warn if async client component calls a hook (e.g. use)', async () => {
1748+
const promise = Promise.resolve();
1749+
1750+
async function AsyncClientComponent() {
1751+
use(promise);
1752+
return <Text text="Hi" />;
1753+
}
1754+
1755+
const root = ReactNoop.createRoot();
1756+
await expect(async () => {
1757+
await act(() => {
1758+
startTransition(() => {
1759+
root.render(<AsyncClientComponent />);
1760+
});
1761+
});
1762+
}).toErrorDev([
1763+
'Hooks are not supported inside an async component. This ' +
1764+
"error is often caused by accidentally adding `'use client'` " +
1765+
'to a module that was originally written for the server.',
1766+
]);
1767+
});
17121768
});

packages/shared/ReactComponentStackFrame.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,15 @@ export function describeNativeComponentFrame(
132132
// TODO(luna): This will currently only throw if the function component
133133
// tries to access React/ReactDOM/props. We should probably make this throw
134134
// in simple components too
135-
fn();
135+
const maybePromise = fn();
136+
137+
// If the function component returns a promise, it's likely an async
138+
// component, which we don't yet support. Attach a noop catch handler to
139+
// silence the error.
140+
// TODO: Implement component stacks for async client components?
141+
if (maybePromise && typeof maybePromise.catch === 'function') {
142+
maybePromise.catch(() => {});
143+
}
136144
}
137145
} catch (sample) {
138146
// This is inlined manually because closure doesn't do it for us.

scripts/error-codes/codes.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,5 +467,6 @@
467467
"479": "Cannot update optimistic state while rendering.",
468468
"480": "File/Blob fields are not yet supported in progressive forms. It probably means you are closing over binary data or FormData in a Server Action.",
469469
"481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.",
470-
"482": "async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server."
470+
"482": "async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",
471+
"483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server."
471472
}

0 commit comments

Comments
 (0)