From 4d1e087c4a7cc98df04c926f0c196a6e067cb6a9 Mon Sep 17 00:00:00 2001 From: Jorge Zreik Date: Mon, 8 Apr 2024 13:22:48 -0400 Subject: [PATCH] [New] `async-server-action`: Add rule to require that server actions be async --- CHANGELOG.md | 7 + README.md | 1 + docs/rules/async-server-action.md | 55 +++ lib/rules/async-server-action.js | 59 +++ lib/rules/index.js | 1 + tests/lib/rules/async-server-action.js | 554 +++++++++++++++++++++++++ 6 files changed, 677 insertions(+) create mode 100644 docs/rules/async-server-action.md create mode 100644 lib/rules/async-server-action.js create mode 100644 tests/lib/rules/async-server-action.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b9d9ed001..b9aac06e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +### Added + +- [`async-server-action`]: add rule ([#3729][] @jorgezreik) + +[#3729]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3729 + ### Fixed * [`prop-types`]: null-check rootNode before calling getScope ([#3762][] @crnhrv) * [`boolean-prop-naming`]: avoid a crash with a spread prop ([#3733][] @ljharb) @@ -40,6 +46,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange * [`no-unknown-property`]: support `popover`, `popovertarget`, `popovertargetaction` attributes ([#3707][] @ljharb) * [`no-unknown-property`]: only match `data-*` attributes containing `-` ([#3713][] @silverwind) * [`checked-requires-onchange-or-readonly`]: correct options that were behaving opposite ([#3715][] @jaesoekjjang) +* [`boolean-prop-naming`]: avoid a crash with a non-TSTypeReference type ([#3718][] @developer-bandi) ### Changed * [`boolean-prop-naming`]: improve error message (@ljharb) diff --git a/README.md b/README.md index 64e57912a1..383a4ca384 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,7 @@ module.exports = [ | Name                                  | Description | 💼 | 🚫 | 🔧 | 💡 | ❌ | | :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------- | :- | :- | :- | :- | :- | +| [async-server-action](docs/rules/async-server-action.md) | Require functions with the `use server` directive to be async | | | | 💡 | | | [boolean-prop-naming](docs/rules/boolean-prop-naming.md) | Enforces consistent naming for boolean props | | | | | | | [button-has-type](docs/rules/button-has-type.md) | Disallow usage of `button` elements without an explicit `type` attribute | | | | | | | [checked-requires-onchange-or-readonly](docs/rules/checked-requires-onchange-or-readonly.md) | Enforce using `onChange` or `readonly` attribute when `checked` is used | | | | | | diff --git a/docs/rules/async-server-action.md b/docs/rules/async-server-action.md new file mode 100644 index 0000000000..7814336694 --- /dev/null +++ b/docs/rules/async-server-action.md @@ -0,0 +1,55 @@ +# Require functions with the `use server` directive to be async (`react/async-server-action`) + +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + + + +Require Server Actions (functions with the `use server` directive) to be async, as mandated by the `use server` [spec](https://react.dev/reference/react/use-server). + +This must be the case even if the function does not use `await` or `return` a promise. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```jsx +
{ + 'use server'; + ... + }} +> + ... +
+``` + +```jsx +function action() { + 'use server'; + ... +} +``` + +Examples of **correct** code for this rule: + +```jsx +
{ + 'use server'; + ... + }} +> + ... +
+``` + +```jsx +async function action() { + 'use server'; + ... +} +``` + +## When Not To Use It + +If you are not using React Server Components. diff --git a/lib/rules/async-server-action.js b/lib/rules/async-server-action.js new file mode 100644 index 0000000000..f21f141e5a --- /dev/null +++ b/lib/rules/async-server-action.js @@ -0,0 +1,59 @@ +/** + * @fileoverview Require functions with the `use server` directive to be async + * @author Jorge Zreik + */ + +'use strict'; + +const docsUrl = require('../util/docsUrl'); +const report = require('../util/report'); + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +const messages = { + asyncServerAction: 'Server Actions must be async', + suggestAsync: 'Make {{functionName}} async', +}; + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + docs: { + description: 'Require functions with the `use server` directive to be async', + category: 'Possible Errors', + recommended: false, + url: docsUrl('async-server-action'), + }, + + messages, + + type: 'suggestion', + hasSuggestions: true, + + schema: [], + }, + + create(context) { + return { + ':function[async=false][generator=false]>BlockStatement>:first-child[expression.value="use server"]'(node) { + const currentFunction = node.parent.parent; + const functionName = currentFunction.id ? `\`${currentFunction.id.name}\`` : 'this function'; + + const data = { functionName }; + report(context, messages.asyncServerAction, 'asyncServerAction', { + node: currentFunction, + data, + suggest: [{ + desc: messages.suggestAsync, + data, + fix(fixer) { + return fixer.insertTextBefore(currentFunction, 'async '); + }, + }], + }); + }, + }; + }, +}; diff --git a/lib/rules/index.js b/lib/rules/index.js index 784831bba7..f46a5bd921 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -4,6 +4,7 @@ /** @type {Record} */ module.exports = { + 'async-server-action': require('./async-server-action'), 'boolean-prop-naming': require('./boolean-prop-naming'), 'button-has-type': require('./button-has-type'), 'checked-requires-onchange-or-readonly': require('./checked-requires-onchange-or-readonly'), diff --git a/tests/lib/rules/async-server-action.js b/tests/lib/rules/async-server-action.js new file mode 100644 index 0000000000..381defbc1e --- /dev/null +++ b/tests/lib/rules/async-server-action.js @@ -0,0 +1,554 @@ +/** + * @fileoverview Require functions with the `use server` directive to be async + * @author Jorge Zreik + */ + +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester; +const rule = require('../../../lib/rules/async-server-action'); + +const parsers = require('../../helpers/parsers'); + +const parserOptions = { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, +}; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ parserOptions }); + +ruleTester.run('async-server-action', rule, { + valid: parsers.all([ + { + code: ` + async function addToCart(data) { + 'use server'; + } + `, + }, + { + code: ` + async function requestUsername(formData) { + 'use server'; + const username = formData.get('username'); + } + `, + }, + { + code: ` + async function addToCart(data) { + "use server"; + } + `, + }, + { + code: ` + async function requestUsername(formData) { + "use server"; + const username = formData.get('username'); + } + `, + }, + { + code: ` + function addToCart(data) { + console.log("test"); + 'use server'; + } + `, + }, + { + code: ` + function requestUsername(formData) { + const username = formData.get('username'); + 'use server'; + } + `, + }, + { + code: ` + function addToCart(data) { + console.log("use server"); + } + `, + }, + { + code: ` + function requestUsername(formData) { + console.log("use server"); + const username = formData.get('username'); + } + `, + }, + { + code: ` + const addToCart = async (data) => { + 'use server'; + } + `, + }, + { + code: ` + const requestUsername = async (formData) => { + 'use server'; + const username = formData.get('username'); + } + `, + }, + { + code: ` + const addToCart = async (data) => { + "use server"; + } + `, + }, + { + code: ` + const requestUsername = async (formData) => { + "use server"; + const username = formData.get('username'); + } + `, + }, + { + code: ` + const addToCart = (data) => { + console.log("test"); + 'use server'; + } + `, + }, + { + code: ` + const requestUsername = (formData) => { + const username = formData.get('username'); + 'use server'; + } + `, + }, + { + code: ` + const addToCart = (data) => { + console.log("use server"); + } + `, + }, + { + code: ` + const requestUsername = (formData) => { + console.log("use server"); + const username = formData.get('username'); + } + `, + }, + { + code: ` + const addToCart = async function (data) { + 'use server'; + } + `, + }, + { + code: ` + const requestUsername = async function (formData) { + 'use server'; + const username = formData.get('username'); + } + `, + }, + { + code: ` + const addToCart = async function (data) { + "use server"; + } + `, + }, + { + code: ` + const requestUsername = async function (formData) { + "use server"; + const username = formData.get('username'); + } + `, + }, + { + code: ` + const addToCart = function (data) { + console.log("test"); + 'use server'; + } + `, + }, + { + code: ` + const requestUsername = function (formData) { + const username = formData.get('username'); + 'use server'; + } + `, + }, + { + code: ` + const addToCart = function (data) { + console.log("use server"); + } + `, + }, + { + code: ` + const requestUsername = function (formData) { + console.log("use server"); + const username = formData.get('username'); + } + `, + }, + { + code: ` + async function addToCart(data) { + \`use server\`; + } + `, + }, + { + code: ` + function addToCart(data) { + \`use server\`; + } + `, + }, + { + code: ` + const addToCart = async (data) => { + \`use server\`; + } + `, + }, + { + code: ` + const addToCart = (data) => { + \`use server\`; + } + `, + }, + { + code: ` + const addToCart = async function (data) { + \`use server\`; + } + `, + }, + { + code: ` + const addToCart = function (data) { + \`use server\`; + } + `, + }, + { + code: ` + const addToCart = async function* (data) { + 'use server'; + } + `, + }, + { + code: ` + const addToCart = async function* (data) { + "use server"; + } + `, + }, + { + code: ` + const addToCart = function* (data) { + 'use server'; + } + `, + }, + { + code: ` + const addToCart = function* (data) { + "use server"; + } + `, + }, + ]), + + invalid: parsers.all([ + { + code: ` + function addToCart(data) { + 'use server'; + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + async function addToCart(data) { + 'use server'; + } + `, + }, + ], + }, + ], + }, + { + code: ` + function requestUsername(formData) { + 'use server'; + const username = formData.get('username'); + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + async function requestUsername(formData) { + 'use server'; + const username = formData.get('username'); + } + `, + }, + ], + }, + ], + }, + { + code: ` + function addToCart(data) { + "use server"; + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + async function addToCart(data) { + "use server"; + } + `, + }, + ], + }, + ], + }, + { + code: ` + function requestUsername(formData) { + "use server"; + const username = formData.get('username'); + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + async function requestUsername(formData) { + "use server"; + const username = formData.get('username'); + } + `, + }, + ], + }, + ], + }, + { + code: ` + const addToCart = (data) => { + 'use server'; + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + const addToCart = async (data) => { + 'use server'; + } + `, + }, + ], + }, + ], + }, + { + code: ` + const requestUsername = (formData) => { + 'use server'; + const username = formData.get('username'); + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + const requestUsername = async (formData) => { + 'use server'; + const username = formData.get('username'); + } + `, + }, + ], + }, + ], + }, + { + code: ` + const addToCart = (data) => { + "use server"; + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + const addToCart = async (data) => { + "use server"; + } + `, + }, + ], + }, + ], + }, + { + code: ` + const requestUsername = (formData) => { + "use server"; + const username = formData.get('username'); + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + const requestUsername = async (formData) => { + "use server"; + const username = formData.get('username'); + } + `, + }, + ], + }, + ], + }, + { + code: ` + const addToCart = function (data) { + 'use server'; + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + const addToCart = async function (data) { + 'use server'; + } + `, + }, + ], + }, + ], + }, + { + code: ` + const requestUsername = function (formData) { + 'use server'; + const username = formData.get('username'); + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + const requestUsername = async function (formData) { + 'use server'; + const username = formData.get('username'); + } + `, + }, + ], + }, + ], + }, + { + code: ` + const addToCart = function (data) { + "use server"; + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + const addToCart = async function (data) { + "use server"; + } + `, + }, + ], + }, + ], + }, + { + code: ` + const requestUsername = function (formData) { + "use server"; + const username = formData.get('username'); + } + `, + errors: [ + { + message: 'Server Actions must be async', + suggestions: [ + { + output: ` + const requestUsername = async function (formData) { + "use server"; + const username = formData.get('username'); + } + `, + }, + ], + }, + ], + }, + ]), +});