Skip to content

Commit f5e2cc3

Browse files
committedMay 4, 2025
feat: add 'jsx-key-before-spread'
2 parents b27f313 + 58c6f28 commit f5e2cc3

File tree

10 files changed

+184
-12
lines changed

10 files changed

+184
-12
lines changed
 

‎apps/website/content/docs/rules/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"pages": [
33
"overview",
44
"---X Rules---",
5+
"jsx-key-before-spread",
56
"jsx-no-comment-textnodes",
67
"jsx-no-duplicate-props",
78
"jsx-no-undef",

‎apps/website/content/docs/rules/overview.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The `jsx-*` rules check for issues exclusive to JSX syntax, which are absent fro
3131

3232
| Rule || 🌟 | Description | `react` |
3333
| :----------------------------------------------------------------------------------- | :-- | :-------: | :-------------------------------------------------------------------------------------------------- | :------: |
34+
| [`jsx-key-before-spread`](./jsx-key-before-spread) | 1️⃣ | | Enforces that the `key` attribute is placed before the spread attribute in JSX elements | |
3435
| [`jsx-no-comment-textnodes`](./jsx-no-comment-textnodes) | 1️⃣ | | Prevents comments from being inserted as text nodes | |
3536
| [`jsx-no-duplicate-props`](./jsx-no-duplicate-props) | 1️⃣ | | Disallow duplicate props in JSX elements | |
3637
| [`jsx-no-undef`](./jsx-no-undef) | 0️⃣ | | Disallow undefined variables in JSX elements | |

‎package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
"tsup": "^8.4.0",
8888
"tsx": "^4.19.4",
8989
"type-fest": "^4.40.1",
90-
"typedoc": "^0.28.3",
90+
"typedoc": "^0.28.4",
9191
"typedoc-plugin-markdown": "^4.6.3",
9292
"typedoc-plugin-mdn-links": "^5.0.1",
9393
"typescript": "^5.8.3",

‎packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const name = "react-x/recommended";
55

66
export const rules = {
77
"react-x/jsx-no-comment-textnodes": "warn",
8+
"react-x/jsx-key-before-spread": "warn",
89
"react-x/jsx-no-duplicate-props": "warn",
910
// "react-x/jsx-no-undef": "error",
1011
"react-x/jsx-uses-react": "warn",

‎packages/plugins/eslint-plugin-react-x/src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { name, version } from "../package.json";
2+
import jsxKeyBeforeSpread from "./rules/jsx-key-before-spread";
23
import jsxNoCommentTextnodes from "./rules/jsx-no-comment-textnodes";
34
import jsxNoDuplicateProps from "./rules/jsx-no-duplicate-props";
45
import jsxNoUndef from "./rules/jsx-no-undef";
@@ -64,6 +65,7 @@ export const plugin = {
6465
version,
6566
},
6667
rules: {
68+
"jsx-key-before-spread": jsxKeyBeforeSpread,
6769
"jsx-no-comment-textnodes": jsxNoCommentTextnodes,
6870
"jsx-no-duplicate-props": jsxNoDuplicateProps,
6971
"jsx-no-undef": jsxNoUndef,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
title: jsx-key-before-spread
3+
---
4+
5+
**Full Name in `eslint-plugin-react-x`**
6+
7+
```sh copy
8+
react-x/jsx-key-before-spread
9+
```
10+
11+
**Full Name in `@eslint-react/eslint-plugin`**
12+
13+
```sh copy
14+
@eslint-react/jsx-key-before-spread
15+
```
16+
17+
**Presets**
18+
19+
- `x`
20+
- `recommended`
21+
- `recommended-typescript`
22+
- `recommended-type-checked`
23+
24+
## Description
25+
26+
Enforces that the `key` attribute is placed before the spread attribute in JSX elements.
27+
28+
When using the JSX automatic runtime, `key` is a special attribute in the JSX transform. See the [Babel repl](https://babeljs.io/repl#?browsers=last%202%20chrome%20versions&build=&builtIns=false&corejs=3.21&spec=false&loose=false&code_lz=DwEwlgbgBA1gpgTwLwCICMKoG8B0eAOATgPb4DOAvlAPQB8A3AFCiTZ45GmWyKoBMmOkxbR4yFAGZMAYwA2AQzJkAcvIC2cVIIbNw0OYpXrNKTGNRSaDIA&forceAllTransforms=false&modules=false&shippedProposals=false&evaluate=true&fileSize=false&timeTravel=false&sourceType=module&lineWrap=true&presets=react&prettier=false&targets=&version=7.27.0&externalPlugins=&assumptions=%7B%7D) and [TypeScript playground](https://www.typescriptlang.org/play/?target=99&jsx=4#code/DwEwlgbgBA1gpgTwLwCICMKoG8B0eAOATgPb4DOAvlAPQB8A3ALABQok2eORplsiqAJkx0mrcNHjIUAZkwBjADYBDMmQBySgLZxUwhizbRFK9Vp0pMk1ABY99IA)
29+
30+
If the `key` prop is _before_ any spread props, it is passed as the `key` argument of the `_jsx` / `_jsxs` / `_jsxDev` function. But if the `key` prop is _after_ spread props, The compiler uses `createElement` instead and passes `key` as a regular prop.
31+
32+
## Examples
33+
34+
### Failing
35+
36+
```tsx
37+
<div {...props} key="2" />;
38+
```
39+
40+
### Passing
41+
42+
```tsx
43+
<div key="1" {...props} />;
44+
<div key="3" className="" />;
45+
<div className="" key="3" />;
46+
```
47+
48+
## Implementation
49+
50+
- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.ts)
51+
- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.spec.ts)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import tsx from "dedent";
2+
3+
import { allValid, ruleTester } from "../../../../../test";
4+
import rule, { RULE_NAME } from "./jsx-key-before-spread";
5+
6+
ruleTester.run(RULE_NAME, rule, {
7+
invalid: [
8+
{
9+
code: tsx`
10+
const App = (props) => {
11+
return [
12+
<div {...props} key="1">1</div>,
13+
<div {...props} key="1">2</div>,
14+
<div {...props} key="1">3</div>,
15+
]
16+
};
17+
`,
18+
errors: [
19+
{ messageId: "jsxKeyBeforeSpread" },
20+
{ messageId: "jsxKeyBeforeSpread" },
21+
{ messageId: "jsxKeyBeforeSpread" },
22+
],
23+
},
24+
{
25+
code: tsx`
26+
27+
const App = (props) => {
28+
return [
29+
<div {...props} key="1">1</div>,
30+
<div {...props} key="1">2</div>,
31+
<div {...props} key="1">3</div>,
32+
]
33+
};
34+
`,
35+
errors: [
36+
{ messageId: "jsxKeyBeforeSpread" },
37+
{ messageId: "jsxKeyBeforeSpread" },
38+
{ messageId: "jsxKeyBeforeSpread" },
39+
],
40+
},
41+
],
42+
valid: [
43+
...allValid,
44+
tsx`
45+
const App = (props) => {
46+
return [<div key="1">1</div>]
47+
};
48+
`,
49+
tsx`
50+
const App = (props) => {
51+
return [
52+
<div key="1" {...props}>1</div>,
53+
<div key="2" {...props}>2</div>,
54+
<div key="3" {...props}>3</div>,
55+
]
56+
};
57+
`,
58+
tsx`
59+
const App = (props) => {
60+
return [1, 2, 3].map((item) => <div key={Math.random()}>{item}</div>)
61+
};
62+
`,
63+
],
64+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { RuleContext, RuleFeature } from "@eslint-react/kit";
2+
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
3+
import type { CamelCase } from "string-ts";
4+
5+
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
6+
import { createRule } from "../utils";
7+
8+
export const RULE_NAME = "jsx-key-before-spread";
9+
10+
export const RULE_FEATURES = [
11+
"EXP",
12+
] as const satisfies RuleFeature[];
13+
14+
export type MessageID = CamelCase<typeof RULE_NAME>;
15+
16+
export default createRule<[], MessageID>({
17+
meta: {
18+
type: "problem",
19+
docs: {
20+
description: "Enforces that the 'key' attribute is placed before the spread attribute in JSX elements.",
21+
[Symbol.for("rule_features")]: RULE_FEATURES,
22+
},
23+
messages: {
24+
jsxKeyBeforeSpread: "The 'key' attribute must be placed before the spread attribute.",
25+
},
26+
schema: [],
27+
},
28+
name: RULE_NAME,
29+
create,
30+
defaultOptions: [],
31+
});
32+
33+
export function create(context: RuleContext<MessageID, []>): RuleListener {
34+
return {
35+
JSXOpeningElement(node) {
36+
let firstSpreadAttributeIndex: null | number = null;
37+
for (const [index, attr] of node.attributes.entries()) {
38+
if (attr.type === T.JSXSpreadAttribute) {
39+
firstSpreadAttributeIndex ??= index;
40+
continue;
41+
}
42+
if (attr.name.name === "key" && firstSpreadAttributeIndex != null && index > firstSpreadAttributeIndex) {
43+
context.report({
44+
messageId: "jsxKeyBeforeSpread",
45+
node: attr,
46+
});
47+
}
48+
}
49+
},
50+
};
51+
}

‎packages/plugins/eslint-plugin/src/configs/x.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const name = "@eslint-react/x";
66

77
export const rules = {
88
"@eslint-react/jsx-no-comment-textnodes": "warn",
9+
"@eslint-react/jsx-key-before-spread": "warn",
910
"@eslint-react/jsx-no-duplicate-props": "warn",
1011
// "@eslint-react/jsx-no-undef": "error",
1112
"@eslint-react/jsx-uses-react": "warn",

‎pnpm-lock.yaml

Lines changed: 11 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)