diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c62eed275..b7ca4f6713 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 5528152083..693ad667b0 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 0000000000..bc3a8bc148
--- /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`)
+
+
+
+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
+
+```
+
+Examples of **correct** code for this rule:
+
+```jsx
+
+
+```
+
+## 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 c30dc6e609..11a4475ba2 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 0000000000..2eeed0be49
--- /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 e13e204524..30df9c02c1 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) => object;
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 0000000000..4566ef9066
--- /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 = {};
+
+ `,
+ },
+ {
+ code: `
+ const a = {};
+ const b = {};
+
+ `,
+ },
+ ]),
+
+ invalid: parsers.all([
+ {
+ code: `
+ const props = {};
+
+ `,
+ errors: [expectedError],
+ },
+ {
+ code: `
+ const props = {};
+
+ `,
+ errors: [expectedError],
+ },
+ {
+ code: `
+ const props = {};
+
+ `,
+ errors: [expectedError, expectedError],
+ },
+ ]),
+});