From 5c8dabf8864e1d826c831d6096b2dfa66309961a Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 1 Jul 2023 22:44:41 -0400 Subject: [PATCH] Detect and warn about native async function components in development (#27031) Adds a development warning to complement the error introduced by https://github.com/facebook/react/pull/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 https://github.com/facebook/react/pull/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.) --- .../react-reconciler/src/ReactFiberHooks.js | 85 +++++++++++++++++-- .../src/ReactFiberThenable.js | 40 ++++++--- .../src/__tests__/ReactUse-test.js | 84 +++++++++++++++--- packages/shared/ReactComponentStackFrame.js | 10 ++- scripts/error-codes/codes.json | 3 +- 5 files changed, 188 insertions(+), 34 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 1b842a205c49c..77e9edebe7c67 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -163,9 +163,11 @@ export type UpdateQueue = { let didWarnAboutMismatchedHooksForComponent; let didWarnUncachedGetSnapshot: void | true; let didWarnAboutUseWrappedInTryCatch; +let didWarnAboutAsyncClientComponent; if (__DEV__) { didWarnAboutMismatchedHooksForComponent = new Set(); didWarnAboutUseWrappedInTryCatch = new Set(); + didWarnAboutAsyncClientComponent = new Set(); } export type Hook = { @@ -370,6 +372,59 @@ function warnOnHookMismatchInDev(currentHookName: HookType): void { } } +function warnIfAsyncClientComponent( + Component: Function, + componentDoesIncludeHooks: boolean, +) { + if (__DEV__) { + // This dev-only check only works for detecting native async functions, + // not transpiled ones. There's also a prod check that we use to prevent + // async client components from crashing the app; the prod one works even + // for transpiled async functions. Neither mechanism is completely + // bulletproof but together they cover the most common cases. + const isAsyncFunction = + // $FlowIgnore[method-unbinding] + Object.prototype.toString.call(Component) === '[object AsyncFunction]'; + if (isAsyncFunction) { + // Encountered an async Client Component. This is not yet supported, + // except in certain constrained cases, like during a route navigation. + const componentName = getComponentNameFromFiber(currentlyRenderingFiber); + if (!didWarnAboutAsyncClientComponent.has(componentName)) { + didWarnAboutAsyncClientComponent.add(componentName); + + // Check if this is a sync update. We use the "root" render lanes here + // because the "subtree" render lanes may include additional entangled + // lanes related to revealing previously hidden content. + const root = getWorkInProgressRoot(); + const rootRenderLanes = getWorkInProgressRootRenderLanes(); + if (root !== null && includesBlockingLane(root, rootRenderLanes)) { + console.error( + '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.', + ); + } else { + // This is a concurrent (Transition, Retry, etc) render. We don't + // warn in these cases. + // + // However, Async Components are forbidden to include hooks, even + // during a transition, so let's check for that here. + // + // TODO: Add a corresponding warning to Server Components runtime. + if (componentDoesIncludeHooks) { + console.error( + '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.', + ); + } + } + } + } + } +} + function throwInvalidHookError() { throw new Error( 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + @@ -554,20 +609,28 @@ export function renderWithHooks( } } - finishRenderingHooks(current, workInProgress); + finishRenderingHooks(current, workInProgress, Component); return children; } -function finishRenderingHooks(current: Fiber | null, workInProgress: Fiber) { - // We can assume the previous dispatcher is always this one, since we set it - // at the beginning of the render phase and there's no re-entrance. - ReactCurrentDispatcher.current = ContextOnlyDispatcher; - +function finishRenderingHooks( + current: Fiber | null, + workInProgress: Fiber, + Component: (p: Props, arg: SecondArg) => any, +): void { if (__DEV__) { workInProgress._debugHookTypes = hookTypesDev; + + const componentDoesIncludeHooks = + workInProgressHook !== null || thenableIndexCounter !== 0; + warnIfAsyncClientComponent(Component, componentDoesIncludeHooks); } + // We can assume the previous dispatcher is always this one, since we set it + // at the beginning of the render phase and there's no re-entrance. + ReactCurrentDispatcher.current = ContextOnlyDispatcher; + // This check uses currentHook so that it works the same in DEV and prod bundles. // hookTypesDev could catch more cases (e.g. context) but only in DEV bundles. const didRenderTooFewHooks = @@ -645,7 +708,13 @@ function finishRenderingHooks(current: Fiber | null, workInProgress: Fiber) { if (checkIfUseWrappedInTryCatch()) { const componentName = getComponentNameFromFiber(workInProgress) || 'Unknown'; - if (!didWarnAboutUseWrappedInTryCatch.has(componentName)) { + if ( + !didWarnAboutUseWrappedInTryCatch.has(componentName) && + // This warning also fires if you suspend with `use` inside an + // async component. Since we warn for that above, we'll silence this + // second warning by checking here. + !didWarnAboutAsyncClientComponent.has(componentName) + ) { didWarnAboutUseWrappedInTryCatch.add(componentName); console.error( '`use` was called from inside a try/catch block. This is not allowed ' + @@ -683,7 +752,7 @@ export function replaySuspendedComponentWithHooks( props, secondArg, ); - finishRenderingHooks(current, workInProgress); + finishRenderingHooks(current, workInProgress, Component); return children; } diff --git a/packages/react-reconciler/src/ReactFiberThenable.js b/packages/react-reconciler/src/ReactFiberThenable.js index bc2aecf8b3d8a..6d99a07132218 100644 --- a/packages/react-reconciler/src/ReactFiberThenable.js +++ b/packages/react-reconciler/src/ReactFiberThenable.js @@ -94,6 +94,7 @@ export function trackUsedThenable( } case 'rejected': { const rejectedError = thenable.reason; + checkIfUseWrappedInAsyncCatch(rejectedError); throw rejectedError; } default: { @@ -149,17 +150,19 @@ export function trackUsedThenable( } }, ); - } - // Check one more time in case the thenable resolved synchronously. - switch (thenable.status) { - case 'fulfilled': { - const fulfilledThenable: FulfilledThenable = (thenable: any); - return fulfilledThenable.value; - } - case 'rejected': { - const rejectedThenable: RejectedThenable = (thenable: any); - throw rejectedThenable.reason; + // Check one more time in case the thenable resolved synchronously. + switch (thenable.status) { + case 'fulfilled': { + const fulfilledThenable: FulfilledThenable = (thenable: any); + return fulfilledThenable.value; + } + case 'rejected': { + const rejectedThenable: RejectedThenable = (thenable: any); + const rejectedError = rejectedThenable.reason; + checkIfUseWrappedInAsyncCatch(rejectedError); + throw rejectedError; + } } } @@ -223,3 +226,20 @@ export function checkIfUseWrappedInTryCatch(): boolean { } return false; } + +export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) { + // This check runs in prod, too, because it prevents a more confusing + // downstream error, where SuspenseException is caught by a promise and + // thrown asynchronously. + // TODO: Another way to prevent SuspenseException from leaking into an async + // execution context is to check the dispatcher every time `use` is called, + // or some equivalent. That might be preferable for other reasons, too, since + // it matches how we prevent similar mistakes for other hooks. + if (rejectedReason === SuspenseException) { + throw new Error( + '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.', + ); + } +} diff --git a/packages/react-reconciler/src/__tests__/ReactUse-test.js b/packages/react-reconciler/src/__tests__/ReactUse-test.js index 219fc7d0c5521..7f96506260d1b 100644 --- a/packages/react-reconciler/src/__tests__/ReactUse-test.js +++ b/packages/react-reconciler/src/__tests__/ReactUse-test.js @@ -1639,13 +1639,20 @@ describe('ReactUse', () => { } const root = ReactNoop.createRoot(); - await act(() => { - root.render( - - - , - ); - }); + await expect(async () => { + await act(() => { + root.render( + + + , + ); + }); + }).toErrorDev([ + '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.', + ]); assertLog([ 'async/await is not yet supported in Client Components, only Server ' + 'Components. This error is often caused by accidentally adding ' + @@ -1685,13 +1692,20 @@ describe('ReactUse', () => { } const root = ReactNoop.createRoot(); - await act(() => { - root.render( - - - , - ); - }); + await expect(async () => { + await act(() => { + root.render( + + + , + ); + }); + }).toErrorDev([ + '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.', + ]); assertLog([ 'async/await is not yet supported in Client Components, only Server ' + 'Components. This error is often caused by accidentally adding ' + @@ -1709,4 +1723,46 @@ describe('ReactUse', () => { 'the server.', ); }); + + test('warn if async client component calls a hook (e.g. useState)', async () => { + async function AsyncClientComponent() { + useState(); + return ; + } + + const root = ReactNoop.createRoot(); + await expect(async () => { + await act(() => { + startTransition(() => { + root.render(); + }); + }); + }).toErrorDev([ + '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.', + ]); + }); + + test('warn if async client component calls a hook (e.g. use)', async () => { + const promise = Promise.resolve(); + + async function AsyncClientComponent() { + use(promise); + return ; + } + + const root = ReactNoop.createRoot(); + await expect(async () => { + await act(() => { + startTransition(() => { + root.render(); + }); + }); + }).toErrorDev([ + '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.', + ]); + }); }); diff --git a/packages/shared/ReactComponentStackFrame.js b/packages/shared/ReactComponentStackFrame.js index 9f89bad3875e0..9df78e0fae465 100644 --- a/packages/shared/ReactComponentStackFrame.js +++ b/packages/shared/ReactComponentStackFrame.js @@ -132,7 +132,15 @@ export function describeNativeComponentFrame( // TODO(luna): This will currently only throw if the function component // tries to access React/ReactDOM/props. We should probably make this throw // in simple components too - fn(); + const maybePromise = fn(); + + // If the function component returns a promise, it's likely an async + // component, which we don't yet support. Attach a noop catch handler to + // silence the error. + // TODO: Implement component stacks for async client components? + if (maybePromise && typeof maybePromise.catch === 'function') { + maybePromise.catch(() => {}); + } } } catch (sample) { // This is inlined manually because closure doesn't do it for us. diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 3423de2fa99b4..395b61cc70119 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -467,5 +467,6 @@ "479": "Cannot update optimistic state while rendering.", "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.", "481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.", - "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." + "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.", + "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." }