diff --git a/docs/02-app/01-building-your-application/06-configuring/02-eslint.mdx b/docs/02-app/01-building-your-application/06-configuring/02-eslint.mdx index 55e3f9655ab0..f32817faf8bb 100644 --- a/docs/02-app/01-building-your-application/06-configuring/02-eslint.mdx +++ b/docs/02-app/01-building-your-application/06-configuring/02-eslint.mdx @@ -88,6 +88,7 @@ Next.js provides an ESLint plugin, [`eslint-plugin-next`](https://www.npmjs.com/ | | [@next/next/inline-script-id](/docs/messages/inline-script-id) | Enforce `id` attribute on `next/script` components with inline content. | | | [@next/next/next-script-for-ga](/docs/messages/next-script-for-ga) | Prefer `next/script` component when using the inline script for Google Analytics. | | | [@next/next/no-assign-module-variable](/docs/messages/no-assign-module-variable) | Prevent assignment to the `module` variable. | +| | [@next/next/no-async-client-component](/docs/messages/no-async-client-component) | Prevent client components from being async functions. | | | [@next/next/no-before-interactive-script-outside-document](/docs/messages/no-before-interactive-script-outside-document) | Prevent usage of `next/script`'s `beforeInteractive` strategy outside of `pages/_document.js`. | | | [@next/next/no-css-tags](/docs/messages/no-css-tags) | Prevent manual stylesheet tags. | | | [@next/next/no-document-import-in-page](/docs/messages/no-document-import-in-page) | Prevent importing `next/document` outside of `pages/_document.js`. | diff --git a/errors/no-async-client-component.md b/errors/no-async-client-component.md new file mode 100644 index 000000000000..c2fb5e712c97 --- /dev/null +++ b/errors/no-async-client-component.md @@ -0,0 +1,12 @@ +# No async client component + +> Client components cannot be async functions. + +#### Why This Error Occurred + +As per the [React Server Component RFC on promise support](https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md), [client components cannot be async functions](https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md#why-cant-client-components-be-async-functions). + +#### Possible Ways to Fix It + +1. Remove the `async` keyword from the client component function declaration, or +2. Convert the client component to a server component diff --git a/packages/eslint-plugin-next/src/index.ts b/packages/eslint-plugin-next/src/index.ts index c0d365b0cd9f..78b8553f8f43 100644 --- a/packages/eslint-plugin-next/src/index.ts +++ b/packages/eslint-plugin-next/src/index.ts @@ -5,6 +5,7 @@ module.exports = { 'inline-script-id': require('./rules/inline-script-id'), 'next-script-for-ga': require('./rules/next-script-for-ga'), 'no-assign-module-variable': require('./rules/no-assign-module-variable'), + 'no-async-client-component': require('./rules/no-async-client-component'), 'no-before-interactive-script-outside-document': require('./rules/no-before-interactive-script-outside-document'), 'no-css-tags': require('./rules/no-css-tags'), 'no-document-import-in-page': require('./rules/no-document-import-in-page'), @@ -29,6 +30,7 @@ module.exports = { '@next/next/google-font-display': 'warn', '@next/next/google-font-preconnect': 'warn', '@next/next/next-script-for-ga': 'warn', + '@next/next/no-async-client-component': 'warn', '@next/next/no-before-interactive-script-outside-document': 'warn', '@next/next/no-css-tags': 'warn', '@next/next/no-head-element': 'warn', diff --git a/packages/eslint-plugin-next/src/rules/no-async-client-component.ts b/packages/eslint-plugin-next/src/rules/no-async-client-component.ts new file mode 100644 index 000000000000..387b7973b3d8 --- /dev/null +++ b/packages/eslint-plugin-next/src/rules/no-async-client-component.ts @@ -0,0 +1,76 @@ +import { defineRule } from '../utils/define-rule' + +const url = 'https://nextjs.org/docs/messages/no-async-client-component' +const description = 'Prevent client components from being async functions.' +const message = `${description} See: ${url}` + +function isCapitalized(str: string): boolean { + return /[A-Z]/.test(str?.[0]) +} + +export = defineRule({ + meta: { + docs: { + description, + recommended: true, + url, + }, + type: 'problem', + schema: [], + }, + + create(context) { + return { + Program(node) { + let isClientComponent: boolean = false + + for (const block of node.body) { + if ( + block.type === 'ExpressionStatement' && + block.expression.type === 'Literal' && + block.expression.value === 'use client' + ) { + isClientComponent = true + } + + if (block.type === 'ExportDefaultDeclaration' && isClientComponent) { + // export default async function MyComponent() {...} + if ( + block.declaration.type === 'FunctionDeclaration' && + block.declaration.async && + isCapitalized(block.declaration.id.name) + ) { + context.report({ + node: block, + message, + }) + } + + // async function MyComponent() {...}; export default MyComponent; + if ( + block.declaration.type === 'Identifier' && + isCapitalized(block.declaration.name) + ) { + const functionName = block.declaration.name + const functionDeclaration = node.body.find( + (localBlock) => + localBlock.type === 'FunctionDeclaration' && + localBlock.id.name === functionName + ) + + if ( + functionDeclaration.type === 'FunctionDeclaration' && + functionDeclaration.async + ) { + context.report({ + node: functionDeclaration, + message, + }) + } + } + } + } + }, + } + }, +}) diff --git a/test/unit/eslint-plugin-next/no-async-client-component.test.ts b/test/unit/eslint-plugin-next/no-async-client-component.test.ts new file mode 100644 index 000000000000..f4d43cd2077e --- /dev/null +++ b/test/unit/eslint-plugin-next/no-async-client-component.test.ts @@ -0,0 +1,132 @@ +import rule from '@next/eslint-plugin-next/dist/rules/no-async-client-component' +import { RuleTester } from 'eslint' +;(RuleTester as any).setDefaultConfig({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + modules: true, + jsx: true, + }, + }, +}) +const ruleTester = new RuleTester() + +const message = + 'Prevent client components from being async functions. See: https://nextjs.org/docs/messages/no-async-client-component' + +ruleTester.run('no-async-client-component single line', rule, { + valid: [ + ` + export default async function MyComponent() { + return <> + } + `, + ], + invalid: [ + { + code: ` + "use client" + + export default async function MyComponent() { + return <> + } + `, + errors: [ + { + message, + }, + ], + }, + ], +}) + +ruleTester.run('no-async-client-component single line capitalization', rule, { + valid: [ + ` + "use client" + + export default async function myFunction() { + return '' + } + `, + ], + invalid: [ + { + code: ` + "use client" + + export default async function MyFunction() { + return '' + } + `, + errors: [ + { + message, + }, + ], + }, + ], +}) + +ruleTester.run('no-async-client-component multiple line', rule, { + valid: [ + ` + async function MyComponent() { + return <> + } + + export default MyComponent + `, + ], + invalid: [ + { + code: ` + "use client" + + async function MyComponent() { + return <> + } + + export default MyComponent + `, + errors: [ + { + message, + }, + ], + }, + ], +}) + +ruleTester.run('no-async-client-component multiple line capitalization', rule, { + valid: [ + ` + "use client" + + async function myFunction() { + return '' + } + + export default myFunction + `, + ], + invalid: [ + { + code: ` + "use client" + + async function MyFunction() { + return '' + } + + export default MyFunction + `, + errors: [ + { + message, + }, + ], + }, + ], +})