Repository: jsx-eslint/eslint-plugin-react
Pull request #3724 (2024-04-03 04:00:53)

[New] add `jsx-props-no-spread-multi`
Please see notes in code.

I think this rule has value in catching some niche bugs, I personally only saw this ~3-4 times throughout my career.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c62eed2..b7ca4f67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,9 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
 
 ### Added
 * export flat configs from plugin root and fix flat config crash ([#3694][] @bradzacher @mdjermanovic)
+* add [`jsx-props-no-spread-multi`] ([#3724][] @SimonSchick)
 
+[#3724]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3724
 [#3694]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3694
 
 ## [7.34.4] - 2024.07.13
@@ -4267,6 +4269,7 @@ If you're still not using React 15 you can keep the old behavior by setting the
 [`jsx-one-expression-per-line`]: docs/rules/jsx-one-expression-per-line.md
 [`jsx-pascal-case`]: docs/rules/jsx-pascal-case.md
 [`jsx-props-no-multi-spaces`]: docs/rules/jsx-props-no-multi-spaces.md
+[`jsx-props-no-spread-multi`]: docs/rules/jsx-props-no-spread-multi.md
 [`jsx-props-no-spreading`]: docs/rules/jsx-props-no-spreading.md
 [`jsx-props-no-spreading`]: docs/rules/jsx-props-no-spreading.md
 [`jsx-sort-default-props`]: docs/rules/jsx-sort-default-props.md
diff --git a/README.md b/README.md
index 55281520..693ad667 100644
--- a/README.md
+++ b/README.md
@@ -333,6 +333,7 @@ module.exports = [
 | [jsx-one-expression-per-line](docs/rules/jsx-one-expression-per-line.md)                     | Require one JSX element per line                                                                                                             |    |    | 🔧 |    |    |
 | [jsx-pascal-case](docs/rules/jsx-pascal-case.md)                                             | Enforce PascalCase for user-defined JSX components                                                                                           |    |    |    |    |    |
 | [jsx-props-no-multi-spaces](docs/rules/jsx-props-no-multi-spaces.md)                         | Disallow multiple spaces between inline JSX props                                                                                            |    |    | 🔧 |    |    |
+| [jsx-props-no-spread-multi](docs/rules/jsx-props-no-spread-multi.md)                         | Disallow JSX prop spreading the same identifier multiple times                                                                               |    |    |    |    |    |
 | [jsx-props-no-spreading](docs/rules/jsx-props-no-spreading.md)                               | Disallow JSX prop spreading                                                                                                                  |    |    |    |    |    |
 | [jsx-sort-default-props](docs/rules/jsx-sort-default-props.md)                               | Enforce defaultProps declarations alphabetical sorting                                                                                       |    |    |    |    | ❌  |
 | [jsx-sort-props](docs/rules/jsx-sort-props.md)                                               | Enforce props alphabetical sorting                                                                                                           |    |    | 🔧 |    |    |
diff --git a/docs/rules/jsx-props-no-spread-multi.md b/docs/rules/jsx-props-no-spread-multi.md
new file mode 100644
index 00000000..bc3a8bc1
--- /dev/null
+++ b/docs/rules/jsx-props-no-spread-multi.md
@@ -0,0 +1,26 @@
+# Disallow JSX prop spreading the same identifier multiple times (`react/jsx-props-no-spread-multi`)
+
+<!-- end auto-generated rule header -->
+
+Enforces that any unique expression is only spread once.
+Generally spreading the same expression twice is an indicator of a mistake since any attribute between the spreads may be overridden when the intent was not to.
+Even when that is not the case this will lead to unnecessary computations being performed.
+
+## Rule Details
+
+Examples of **incorrect** code for this rule:
+
+```jsx
+<App {...props} myAttr="1" {...props} />
+```
+
+Examples of **correct** code for this rule:
+
+```jsx
+<App myAttr="1" {...props} />
+<App {...props} myAttr="1" />
+```
+
+## When Not To Use It
+
+When spreading the same expression multiple times yields different results.
diff --git a/lib/rules/index.js b/lib/rules/index.js
index c30dc6e6..11a4475b 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -50,6 +50,7 @@ module.exports = {
   'jsx-fragments': require('./jsx-fragments'),
   'jsx-props-no-multi-spaces': require('./jsx-props-no-multi-spaces'),
   'jsx-props-no-spreading': require('./jsx-props-no-spreading'),
+  'jsx-props-no-spread-multi': require('./jsx-props-no-spread-multi'),
   'jsx-sort-default-props': require('./jsx-sort-default-props'),
   'jsx-sort-props': require('./jsx-sort-props'),
   'jsx-space-before-closing': require('./jsx-space-before-closing'),
diff --git a/lib/rules/jsx-props-no-spread-multi.js b/lib/rules/jsx-props-no-spread-multi.js
new file mode 100644
index 00000000..2eeed0be
--- /dev/null
+++ b/lib/rules/jsx-props-no-spread-multi.js
@@ -0,0 +1,53 @@
+/**
+ * @fileoverview Prevent JSX prop spreading the same expression multiple times
+ * @author Simon Schick
+ */
+
+'use strict';
+
+const docsUrl = require('../util/docsUrl');
+const report = require('../util/report');
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+const messages = {
+  noMultiSpreading: 'Spreading the same expression multiple times is forbidden',
+};
+
+module.exports = {
+  meta: {
+    docs: {
+      description: 'Disallow JSX prop spreading the same identifier multiple times',
+      category: 'Best Practices',
+      recommended: false,
+      url: docsUrl('jsx-props-no-spread-multi'),
+    },
+    messages,
+  },
+
+  create(context) {
+    return {
+      JSXOpeningElement(node) {
+        const spreads = node.attributes.filter(
+          (attr) => attr.type === 'JSXSpreadAttribute'
+          && attr.argument.type === 'Identifier'
+        );
+        if (spreads.length < 2) {
+          return;
+        }
+        // We detect duplicate expressions by their identifier
+        const identifierNames = new Set();
+        spreads.forEach((spread) => {
+          if (identifierNames.has(spread.argument.name)) {
+            report(context, messages.noMultiSpreading, 'noMultiSpreading', {
+              node: spread,
+            });
+          }
+          identifierNames.add(spread.argument.name);
+        });
+      },
+    };
+  },
+};
diff --git a/lib/types.d.ts b/lib/types.d.ts
index e13e2045..30df9c02 100644
--- a/lib/types.d.ts
+++ b/lib/types.d.ts
@@ -11,9 +11,10 @@ declare global {
   type JSXAttribute = ASTNode;
   type JSXElement = ASTNode;
   type JSXFragment = ASTNode;
+  type JSXOpeningElement = ASTNode;
   type JSXSpreadAttribute = ASTNode;
 
-  type Context = eslint.Rule.RuleContext
+  type Context = eslint.Rule.RuleContext;
 
   type TypeDeclarationBuilder = (annotation: ASTNode, parentName: string, seen: Set<typeof annotation>) => object;
 


Test patch:
diff --git a/tests/lib/rules/jsx-props-no-spread-multi.js b/tests/lib/rules/jsx-props-no-spread-multi.js
new file mode 100644
index 00000000..4566ef90
--- /dev/null
+++ b/tests/lib/rules/jsx-props-no-spread-multi.js
@@ -0,0 +1,71 @@
+/**
+ * @fileoverview Tests for jsx-props-no-spread-multi
+ */
+
+'use strict';
+
+// -----------------------------------------------------------------------------
+// Requirements
+// -----------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester;
+const rule = require('../../../lib/rules/jsx-props-no-spread-multi');
+
+const parsers = require('../../helpers/parsers');
+
+const parserOptions = {
+  ecmaVersion: 2018,
+  sourceType: 'module',
+  ecmaFeatures: {
+    jsx: true,
+  },
+};
+
+// -----------------------------------------------------------------------------
+// Tests
+// -----------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({ parserOptions });
+const expectedError = { messageId: 'noMultiSpreading' };
+
+ruleTester.run('jsx-props-no-spread-multi', rule, {
+  valid: parsers.all([
+    {
+      code: `
+        const a = {};
+        <App {...a} />
+      `,
+    },
+    {
+      code: `
+        const a = {};
+        const b = {};
+        <App {...a} {...b} />
+      `,
+    },
+  ]),
+
+  invalid: parsers.all([
+    {
+      code: `
+        const props = {};
+        <App {...props} {...props} />
+      `,
+      errors: [expectedError],
+    },
+    {
+      code: `
+        const props = {};
+        <div {...props} a="a" {...props} />
+      `,
+      errors: [expectedError],
+    },
+    {
+      code: `
+        const props = {};
+        <div {...props} {...props} {...props} />
+      `,
+      errors: [expectedError, expectedError],
+    },
+  ]),
+});