diff --git a/apps/website/content/docs/rules/meta.json b/apps/website/content/docs/rules/meta.json index 3fe23e91c..950857376 100644 --- a/apps/website/content/docs/rules/meta.json +++ b/apps/website/content/docs/rules/meta.json @@ -2,6 +2,7 @@ "pages": [ "overview", "---X Rules---", + "jsx-key-before-spread", "jsx-no-duplicate-props", "jsx-no-undef", "jsx-uses-react", diff --git a/apps/website/content/docs/rules/overview.mdx b/apps/website/content/docs/rules/overview.mdx index cffdc7f32..1e180a92c 100644 --- a/apps/website/content/docs/rules/overview.mdx +++ b/apps/website/content/docs/rules/overview.mdx @@ -31,6 +31,7 @@ The `jsx-*` rules check for issues exclusive to JSX syntax, which are absent fro | Rule | ✅ | 🌟 | Description | `react` | | :----------------------------------------------------------------------------------- | :-- | :-------: | :-------------------------------------------------------------------------------------------------- | :------: | +| [`jsx-key-before-spread`](./jsx-key-before-spread) | 1️⃣ | | Enforces that the `key` attribute is placed before the spread attribute in JSX elements | | | [`jsx-no-duplicate-props`](./jsx-no-duplicate-props) | 1️⃣ | | Disallow duplicate props in JSX elements | | | [`jsx-no-undef`](./jsx-no-undef) | 0️⃣ | | Disallow undefined variables in JSX elements | | | [`jsx-uses-react`](./jsx-uses-react) | 1️⃣ | | Marks React variables as used when JSX is used | | diff --git a/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts b/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts index 33f916537..7e1deae04 100644 --- a/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts +++ b/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts @@ -4,6 +4,7 @@ import { DEFAULT_ESLINT_REACT_SETTINGS } from "@eslint-react/shared"; export const name = "react-x/recommended"; export const rules = { + "react-x/jsx-key-before-spread": "warn", "react-x/jsx-no-duplicate-props": "warn", "react-x/jsx-uses-react": "warn", "react-x/jsx-uses-vars": "warn", diff --git a/packages/plugins/eslint-plugin-react-x/src/plugin.ts b/packages/plugins/eslint-plugin-react-x/src/plugin.ts index 5d7bf3f09..d07b2bef1 100644 --- a/packages/plugins/eslint-plugin-react-x/src/plugin.ts +++ b/packages/plugins/eslint-plugin-react-x/src/plugin.ts @@ -1,6 +1,7 @@ import { name, version } from "../package.json"; import avoidShorthandBoolean from "./rules/avoid-shorthand-boolean"; import avoidShorthandFragment from "./rules/avoid-shorthand-fragment"; +import jsxKeyBeforeSpread from "./rules/jsx-key-before-spread"; import jsxNoDuplicateProps from "./rules/jsx-no-duplicate-props"; import jsxNoUndef from "./rules/jsx-no-undef"; import jsxUsesReact from "./rules/jsx-uses-react"; @@ -116,6 +117,7 @@ export const plugin = { "prefer-shorthand-fragment": preferShorthandFragment, // Part: JSX only rules + "jsx-key-before-spread": jsxKeyBeforeSpread, "jsx-no-duplicate-props": jsxNoDuplicateProps, "jsx-no-undef": jsxNoUndef, "jsx-uses-react": jsxUsesReact, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.md b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.md new file mode 100644 index 000000000..2a7107097 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.md @@ -0,0 +1,51 @@ +--- +title: jsx-key-before-spread +--- + +**Full Name in `eslint-plugin-react-x`** + +```sh copy +react-x/jsx-key-before-spread +``` + +**Full Name in `@eslint-react/eslint-plugin`** + +```sh copy +@eslint-react/jsx-key-before-spread +``` + +**Presets** + +- `x` +- `recommended` +- `recommended-typescript` +- `recommended-type-checked` + +## Description + +Enforces that the `key` attribute is placed before the spread attribute in JSX elements. + +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) + +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. + +## Examples + +### Failing + +```tsx +
; +``` + +### Passing + +```tsx +
; +
; +
; +``` + +## Implementation + +- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.ts) +- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.spec.ts) diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.spec.ts new file mode 100644 index 000000000..843075404 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.spec.ts @@ -0,0 +1,64 @@ +import tsx from "dedent"; + +import { allValid, ruleTester } from "../../../../../test"; +import rule, { RULE_NAME } from "./jsx-key-before-spread"; + +ruleTester.run(RULE_NAME, rule, { + invalid: [ + { + code: tsx` + const App = (props) => { + return [ +
1
, +
2
, +
3
, + ] + }; + `, + errors: [ + { messageId: "jsxKeyBeforeSpread" }, + { messageId: "jsxKeyBeforeSpread" }, + { messageId: "jsxKeyBeforeSpread" }, + ], + }, + { + code: tsx` + + const App = (props) => { + return [ +
1
, +
2
, +
3
, + ] + }; + `, + errors: [ + { messageId: "jsxKeyBeforeSpread" }, + { messageId: "jsxKeyBeforeSpread" }, + { messageId: "jsxKeyBeforeSpread" }, + ], + }, + ], + valid: [ + ...allValid, + tsx` + const App = (props) => { + return [
1
] + }; + `, + tsx` + const App = (props) => { + return [ +
1
, +
2
, +
3
, + ] + }; + `, + tsx` + const App = (props) => { + return [1, 2, 3].map((item) =>
{item}
) + }; + `, + ], +}); diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.ts new file mode 100644 index 000000000..3776542fc --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-key-before-spread.ts @@ -0,0 +1,51 @@ +import type { RuleContext, RuleFeature } from "@eslint-react/kit"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import type { CamelCase } from "string-ts"; + +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { createRule } from "../utils"; + +export const RULE_NAME = "jsx-key-before-spread"; + +export const RULE_FEATURES = [ + "EXP", +] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + docs: { + description: "Enforces that the 'key' attribute is placed before the spread attribute in JSX elements.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + messages: { + jsxKeyBeforeSpread: "The 'key' attribute must be placed before the spread attribute.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +export function create(context: RuleContext): RuleListener { + return { + JSXOpeningElement(node) { + let firstSpreadAttributeIndex: null | number = null; + for (const [index, attr] of node.attributes.entries()) { + if (attr.type === T.JSXSpreadAttribute) { + firstSpreadAttributeIndex ??= index; + continue; + } + if (attr.name.name === "key" && firstSpreadAttributeIndex != null && index > firstSpreadAttributeIndex) { + context.report({ + messageId: "jsxKeyBeforeSpread", + node: attr, + }); + } + } + }, + }; +} diff --git a/packages/plugins/eslint-plugin/src/configs/all.ts b/packages/plugins/eslint-plugin/src/configs/all.ts index 972e5c2eb..b0e33ef6e 100644 --- a/packages/plugins/eslint-plugin/src/configs/all.ts +++ b/packages/plugins/eslint-plugin/src/configs/all.ts @@ -12,6 +12,7 @@ export const name = "@eslint-react/all"; export const rules = { "@eslint-react/avoid-shorthand-boolean": "warn", "@eslint-react/avoid-shorthand-fragment": "warn", + "@eslint-react/jsx-key-before-spread": "warn", "@eslint-react/jsx-no-duplicate-props": "warn", "@eslint-react/jsx-no-undef": "error", "@eslint-react/jsx-uses-react": "warn", diff --git a/packages/plugins/eslint-plugin/src/configs/x.ts b/packages/plugins/eslint-plugin/src/configs/x.ts index 3b258967e..f57bcca8c 100644 --- a/packages/plugins/eslint-plugin/src/configs/x.ts +++ b/packages/plugins/eslint-plugin/src/configs/x.ts @@ -5,6 +5,7 @@ import react from "eslint-plugin-react-x"; export const name = "@eslint-react/x"; export const rules = { + "@eslint-react/jsx-key-before-spread": "warn", "@eslint-react/jsx-no-duplicate-props": "warn", "@eslint-react/jsx-uses-react": "warn", "@eslint-react/jsx-uses-vars": "warn",