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
+
+```
+
+```jsx
+function action() {
+ 'use server';
+ ...
+}
+```
+
+Examples of **correct** code for this rule:
+
+```jsx
+
+```
+
+```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');
+ }
+ `,
+ },
+ ],
+ },
+ ],
+ },
+ ]),
+});