diff --git a/apps/website/content/docs/rules/meta.json b/apps/website/content/docs/rules/meta.json index 85c5003f06..db32b8301b 100644 --- a/apps/website/content/docs/rules/meta.json +++ b/apps/website/content/docs/rules/meta.json @@ -82,6 +82,7 @@ "hooks-extra-prefer-use-state-lazy-initialization", "---Naming Convention Rules---", "naming-convention-component-name", + "naming-convention-context-name", "naming-convention-filename", "naming-convention-filename-extension", "naming-convention-use-state", diff --git a/apps/website/content/docs/rules/overview.md b/apps/website/content/docs/rules/overview.md index 0ec685aed4..ac3a11aca4 100644 --- a/apps/website/content/docs/rules/overview.md +++ b/apps/website/content/docs/rules/overview.md @@ -114,6 +114,7 @@ full: true | Rule | ✅ | Features | Description | | :------------------------------------------------------------- | :- | :------- | :------------------------------------------------------------------------------- | | [`component-name`](./naming-convention-component-name) | 0️⃣ | `🔍` `⚙️` | Enforces naming conventions for components. | +| [`context-name`](./naming-convention-context-name) | 0️⃣ | `🔍` | Enforces naming conventions for context providers. | | [`filename`](./naming-convention-filename) | 0️⃣ | `🔍` `⚙️` | Enforces naming convention for JSX files. | | [`filename-extension`](./naming-convention-filename-extension) | 0️⃣ | `🔍` `⚙️` | Enforces consistent use of the JSX file extension. | | [`use-state`](./naming-convention-use-state) | 0️⃣ | `🔍` | Enforces destructuring and symmetric naming of `useState` hook value and setter. | diff --git a/packages/core/src/utils/is-instance-id-equal.ts b/packages/core/src/utils/is-instance-id-equal.ts index 40b8a8c4be..e7b918493e 100644 --- a/packages/core/src/utils/is-instance-id-equal.ts +++ b/packages/core/src/utils/is-instance-id-equal.ts @@ -1,3 +1,4 @@ +/* eslint-disable jsdoc/require-param */ import * as AST from "@eslint-react/ast"; import type { RuleContext } from "@eslint-react/shared"; import * as VAR from "@eslint-react/var"; diff --git a/packages/plugins/eslint-plugin-react-naming-convention/package.json b/packages/plugins/eslint-plugin-react-naming-convention/package.json index 8ebd8be438..5b093bc1ab 100644 --- a/packages/plugins/eslint-plugin-react-naming-convention/package.json +++ b/packages/plugins/eslint-plugin-react-naming-convention/package.json @@ -54,6 +54,7 @@ "@eslint-react/eff": "workspace:*", "@eslint-react/jsx": "workspace:*", "@eslint-react/shared": "workspace:*", + "@eslint-react/var": "workspace:*", "@typescript-eslint/scope-manager": "^8.25.0", "@typescript-eslint/type-utils": "^8.25.0", "@typescript-eslint/types": "^8.25.0", diff --git a/packages/plugins/eslint-plugin-react-naming-convention/src/plugin.ts b/packages/plugins/eslint-plugin-react-naming-convention/src/plugin.ts index e25bd14b07..6a1f31b532 100644 --- a/packages/plugins/eslint-plugin-react-naming-convention/src/plugin.ts +++ b/packages/plugins/eslint-plugin-react-naming-convention/src/plugin.ts @@ -1,5 +1,6 @@ import { name, version } from "../package.json"; import componentName from "./rules/component-name"; +import contextName from "./rules/context-name"; import filename from "./rules/filename"; import filenameExtension from "./rules/filename-extension"; import useState from "./rules/use-state"; @@ -11,6 +12,7 @@ export const plugin = { }, rules: { "component-name": componentName, + "context-name": contextName, filename, "filename-extension": filenameExtension, "use-state": useState, diff --git a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.md b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.md new file mode 100644 index 0000000000..7d37a01a4b --- /dev/null +++ b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.md @@ -0,0 +1,42 @@ +--- +title: context-name +--- + +**Full Name in `eslint-plugin-react-naming-convention`** + +```plain copy +react-naming-convention/context-name +``` + +**Full Name in `@eslint-react/eslint-plugin`** + +```plain copy +@eslint-react/naming-convention/context-name +``` + +**Features** + +`🔍` + +## What it does + +Enforces naming conventions for context providers. + +## Examples + +### Failing + +```tsx +const Theme = createContext({}); +``` + +### Passing + +```tsx +const ThemeContext = createContext({}); +``` + +## Implementation + +- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.ts) +- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.spec.ts) diff --git a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.spec.ts b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.spec.ts new file mode 100644 index 0000000000..1cc7df7545 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.spec.ts @@ -0,0 +1,28 @@ +import { allFunctions, ruleTester } from "../../../../../test"; +import rule, { RULE_NAME } from "./context-name"; + +ruleTester.run(RULE_NAME, rule, { + invalid: [ + { + code: ` + import { createContext } from "react"; + const Foo = createContext({}); + `, + errors: [{ messageId: "contextName" }], + }, + { + code: ` + import { createContext } from "react"; + const Ctx = createContext({}); + `, + errors: [{ messageId: "contextName" }], + }, + ], + valid: [ + ...allFunctions, + /* tsx */ ` + import { createContext } from "react"; + const MyContext = createContext({}); + `, + ], +}); diff --git a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.ts b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.ts new file mode 100644 index 0000000000..94656a19e1 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/context-name.ts @@ -0,0 +1,51 @@ +import { getInstanceId, isCreateContextCall } from "@eslint-react/core"; +import { _, identity } from "@eslint-react/eff"; +import type { RuleFeature } from "@eslint-react/shared"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import type { CamelCase } from "string-ts"; +import { match, P } from "ts-pattern"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "context-name"; + +export const RULE_FEATURES = [ + "CHK", +] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + docs: { + description: "enforce context name to end with `Context`.", + }, + messages: { + contextName: "Context name must end with `Context`.", + }, + schema: [], + }, + name: RULE_NAME, + create(context) { + if (!context.sourceCode.text.includes("createContext")) return {}; + return { + CallExpression(node) { + if (!isCreateContextCall(context, node)) return; + const id = getInstanceId(node); + if (id == null) return; + const name = match(id) + .with({ type: T.Identifier, name: P.select() }, identity) + .with({ type: T.MemberExpression, property: { name: P.select(P.string) } }, identity) + .otherwise(() => _); + if (name == null) return; + if (name.endsWith("Context")) return; + context.report({ + messageId: "contextName", + node: id, + }); + }, + }; + }, + defaultOptions: [], +}); diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.md b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.md index 0e0949b365..ad15af4f75 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.md +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.md @@ -31,6 +31,8 @@ Prevents non-stable values (i.e. object literals) from being used as a value for React will re-render all consumers of a context whenever the context value changes, and if the value is not stable, this can lead to unnecessary re-renders. +In React 19 and later, the [`Context` component can be used via `` instead of ``](https://react.dev/blog/2024/12/05/react-19#context-as-a-provider), so it is recommended to use the [`context-name`](./naming-convention-context-name) rule to avoid false negatives. + ## Examples ### Failing diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.spec.ts index cad22c5390..d5ae5b2d43 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.spec.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.spec.ts @@ -88,6 +88,92 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + code: /* tsx */ ` + function App() { + const foo = {} + return ; + } + `, + errors: [{ + messageId: "unstableContextValue", + data: { + type: "object expression", + suggestion: "Consider wrapping it in a useMemo hook.", + }, + }], + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + { + code: /* tsx */ ` + function App() { + const foo = [] + return + } + `, + errors: [ + { + messageId: "unstableContextValue", + data: { + type: "array expression", + suggestion: "Consider wrapping it in a useMemo hook.", + }, + }, + ], + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + { + code: /* tsx */ ` + function App() { + const foo = [] + return + } + `, + errors: [ + { + messageId: "unstableContextValue", + data: { + type: "array expression", + suggestion: "Consider wrapping it in a useMemo hook.", + }, + }, + ], + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + { + code: /* tsx */ ` + function App() { + const foo = [] + return + } + `, + errors: [ + { + messageId: "unstableContextValue", + data: { + type: "array expression", + suggestion: "Consider wrapping it in a useMemo hook.", + }, + }, + ], + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, ], valid: [ ...allValid, @@ -127,5 +213,57 @@ ruleTester.run(RULE_NAME, rule, { return ; } `, + { + code: /* tsx */ ` + function App() { + const foo = {} + return ; + } + `, + settings: { + "react-x": { + version: "18.0.0", + }, + }, + }, + { + code: /* tsx */ ` + function App() { + const foo = [] + return + } + `, + settings: { + "react-x": { + version: "18.0.0", + }, + }, + }, + { + code: /* tsx */ ` + function App() { + const foo = [] + return + } + `, + settings: { + "react-x": { + version: "18.0.0", + }, + }, + }, + { + code: /* tsx */ ` + function App() { + const foo = [] + return + } + `, + settings: { + "react-x": { + version: "18.0.0", + }, + }, + }, ], }); diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts index 2732b169b0..5c3a38d58a 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts @@ -1,9 +1,11 @@ import * as AST from "@eslint-react/ast"; import { isReactHookCall, useComponentCollector } from "@eslint-react/core"; import { getOrUpdate } from "@eslint-react/eff"; -import type { RuleFeature } from "@eslint-react/shared"; +import * as JSX from "@eslint-react/jsx"; +import { getSettingsFromContext, type RuleFeature } from "@eslint-react/shared"; import * as VAR from "@eslint-react/var"; import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { compare } from "compare-versions"; import { createRule } from "../utils"; @@ -30,19 +32,18 @@ export default createRule<[], MessageID>({ }, name: RULE_NAME, create(context) { + const { version } = getSettingsFromContext(context); + const isReact18OrBelow = compare(version, "19.0.0", "<"); const { ctx, listeners } = useComponentCollector(context); const constructions = new Map(); return { ...listeners, JSXOpeningElement(node) { - const openingElementName = node.name; - if (openingElementName.type !== T.JSXMemberExpression) { - return; - } - if (openingElementName.property.name !== "Provider") { - return; - } + const fullName = JSX.getElementName(node.parent); + const selfName = fullName.split(".").at(-1); + if (selfName == null) return; + if (!isContextName(selfName, isReact18OrBelow)) return; const functionEntry = ctx.getCurrentEntry(); if (functionEntry == null) return; const attribute = node @@ -86,3 +87,11 @@ export default createRule<[], MessageID>({ }, defaultOptions: [], }); + +function isContextName(name: string, isReact18OrBelow: boolean): boolean { + if (name === "Provider") return true; + if (!isReact18OrBelow) { + return name.endsWith("Context") || name.endsWith("CONTEXT"); + } + return false; +} diff --git a/packages/plugins/eslint-plugin/src/configs/all.ts b/packages/plugins/eslint-plugin/src/configs/all.ts index 40c83997f1..fa7155620c 100644 --- a/packages/plugins/eslint-plugin/src/configs/all.ts +++ b/packages/plugins/eslint-plugin/src/configs/all.ts @@ -88,6 +88,7 @@ export const rules = { // Part: Naming Convention "@eslint-react/naming-convention/component-name": "warn", + "@eslint-react/naming-convention/context-name": "warn", "@eslint-react/naming-convention/filename": "warn", "@eslint-react/naming-convention/filename-extension": "warn", "@eslint-react/naming-convention/use-state": "warn", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb11641120..8f7c35ba00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -998,6 +998,9 @@ importers: '@eslint-react/shared': specifier: workspace:* version: link:../../shared + '@eslint-react/var': + specifier: workspace:* + version: link:../../utilities/var '@typescript-eslint/scope-manager': specifier: ^8.25.0 version: 8.25.0