diff --git a/packages/plugins/eslint-plugin-react-x/README.md b/packages/plugins/eslint-plugin-react-x/README.md index ac4f11316..ecb80ca8a 100644 --- a/packages/plugins/eslint-plugin-react-x/README.md +++ b/packages/plugins/eslint-plugin-react-x/README.md @@ -65,6 +65,7 @@ export default [ "react-x/no-unstable-default-props": "warn", "react-x/no-unused-class-component-members": "warn", "react-x/no-unused-state": "warn", + "react-x/no-use-context": "warn", "react-x/use-jsx-vars": "warn", }, }, diff --git a/packages/plugins/eslint-plugin-react-x/src/index.ts b/packages/plugins/eslint-plugin-react-x/src/index.ts index 57d74ad15..f62dcff06 100644 --- a/packages/plugins/eslint-plugin-react-x/src/index.ts +++ b/packages/plugins/eslint-plugin-react-x/src/index.ts @@ -42,6 +42,7 @@ import noUnstableContextValue from "./rules/no-unstable-context-value"; import noUnstableDefaultProps from "./rules/no-unstable-default-props"; import noUnusedClassComponentMembers from "./rules/no-unused-class-component-members"; import noUnusedState from "./rules/no-unused-state"; +import noUseContext from "./rules/no-use-context"; import noUselessFragment from "./rules/no-useless-fragment"; import preferDestructuringAssignment from "./rules/prefer-destructuring-assignment"; import preferReactNamespaceImport from "./rules/prefer-react-namespace-import"; @@ -99,6 +100,7 @@ export default { "no-unstable-default-props": noUnstableDefaultProps, "no-unused-class-component-members": noUnusedClassComponentMembers, "no-unused-state": noUnusedState, + "no-use-context": noUseContext, "no-useless-fragment": noUselessFragment, "prefer-destructuring-assignment": preferDestructuringAssignment, "prefer-react-namespace-import": preferReactNamespaceImport, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.spec.ts new file mode 100644 index 000000000..ae36f51ff --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.spec.ts @@ -0,0 +1,188 @@ +import { ruleTester } from "../../../../../test"; +import rule, { RULE_NAME } from "./no-use-context"; + +ruleTester.run(RULE_NAME, rule, { + invalid: [ + { + code: /* tsx */ ` + import { useContext } from 'react' + + export const Component = () => { + const value = useContext(MyContext) + return
{value}
+ } + `, + errors: [ + { messageId: "noUseContext" }, + { messageId: "noUseContext" }, + ], + output: /* tsx */ ` + import { use } from 'react' + + export const Component = () => { + const value = use(MyContext) + return
{value}
+ } + `, + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + { + code: /* tsx */ ` + import { useContext } from 'react' + + export const Component = () => { + const value = useContext(MyContext) + return
{value}
+ } + `, + errors: [ + { messageId: "noUseContext" }, + { messageId: "noUseContext" }, + ], + output: /* tsx */ ` + import { use } from 'react' + + export const Component = () => { + const value = use(MyContext) + return
{value}
+ } + `, + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + { + code: /* tsx */ ` + import { use, useContext } from 'react' + + export const Component = () => { + const value = useContext(MyContext) + return
{value}
+ } + `, + errors: [ + { messageId: "noUseContext" }, + { messageId: "noUseContext" }, + ], + output: /* tsx */ ` + import { use, } from 'react' + + export const Component = () => { + const value = use(MyContext) + return
{value}
+ } + `, + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + { + code: /* tsx */ ` + import React from 'react' + + export const Component = () => { + const value = React.useContext(MyContext) + return
{value}
+ } + `, + errors: [ + { messageId: "noUseContext" }, + ], + output: /* tsx */ ` + import React from 'react' + + export const Component = () => { + const value = React.use(MyContext) + return
{value}
+ } + `, + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + { + code: /* tsx */ ` + import { use, useContext as useCtx } from 'react' + + export const Component = () => { + const value = useCtx(MyContext) + return
{value}
+ } + `, + errors: [ + { messageId: "noUseContext" }, + { messageId: "noUseContext" }, + ], + output: /* tsx */ ` + import { use, useContext as useCtx } from 'react' + + export const Component = () => { + const value = use(MyContext) + return
{value}
+ } + `, + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + ], + valid: [ + { + code: /* tsx */ ` + import { useContext } from 'react' + + export const Component = () => { + const value = useContext(MyContext) + return
{value}
+ } + `, + settings: { + "react-x": { + version: "18.3.1", + }, + }, + }, + { + code: /* tsx */ ` + import { use } from 'react' + + export const Component = () => { + const value = use(MyContext) + return
{value}
+ } + `, + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + { + code: /* tsx */ ` + import React from 'react' + + export const Component = () => { + const value = React.use(MyContext) + return
{value}
+ } + `, + settings: { + "react-x": { + version: "19.0.0", + }, + }, + }, + ], +}); diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.ts new file mode 100644 index 000000000..2094c4716 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.ts @@ -0,0 +1,100 @@ +import { isReactHookCall, isReactHookCallWithNameAlias } from "@eslint-react/core"; +import type { RuleFeature } from "@eslint-react/shared"; +import { getSettingsFromContext } from "@eslint-react/shared"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { compare } from "compare-versions"; +import type { CamelCase } from "string-ts"; +import { isMatching } from "ts-pattern"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "no-use-context"; + +export const RULE_FEATURES = [ + "CHK", + "MOD", +] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + docs: { + description: "disallow the use of 'useContext'", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + fixable: "code", + messages: { + noUseContext: "In React 19, 'use' is preferred over 'useContext' because it is more flexible.", + }, + schema: [], + }, + name: RULE_NAME, + create(context) { + const settings = getSettingsFromContext(context); + const useContextAlias = new Set(); + + if (!context.sourceCode.text.includes("useContext")) { + return {}; + } + if (compare(settings.version, "19.0.0", "<")) { + return {}; + } + return { + CallExpression(node) { + if (!isReactHookCall(node)) { + return; + } + if (!isReactHookCallWithNameAlias("useContext", context, [...useContextAlias])(node)) { + return; + } + context.report({ + messageId: "noUseContext", + node, + fix(fixer) { + switch (node.callee.type) { + case T.Identifier: + return fixer.replaceText(node.callee, "use"); + case T.MemberExpression: + return fixer.replaceText(node.callee.property, "use"); + } + return null; + }, + }); + }, + ImportDeclaration(node) { + if (node.source.value !== settings.importSource) { + return; + } + const isUseImported = node.specifiers + .some(isMatching({ local: { type: T.Identifier, name: "use" } })); + for (const specifier of node.specifiers) { + if (specifier.type !== T.ImportSpecifier) continue; + if (specifier.imported.type !== T.Identifier) continue; + if (specifier.imported.name === "useContext") { + if (specifier.local.name !== "useContext") { + useContextAlias.add(specifier.local.name); + context.report({ + messageId: "noUseContext", + node: specifier, + }); + return; + } + context.report({ + messageId: "noUseContext", + node: specifier, + fix(fixer) { + if (isUseImported) { + return fixer.replaceText(specifier, " ".repeat(specifier.range[1] - specifier.range[0])); + } + return fixer.replaceText(specifier.imported, "use"); + }, + }); + } + } + }, + }; + }, + defaultOptions: [], +}); diff --git a/packages/plugins/eslint-plugin/src/configs/all.ts b/packages/plugins/eslint-plugin/src/configs/all.ts index 250115a05..2d7ccc549 100644 --- a/packages/plugins/eslint-plugin/src/configs/all.ts +++ b/packages/plugins/eslint-plugin/src/configs/all.ts @@ -50,6 +50,7 @@ export const rules = { "@eslint-react/no-unstable-default-props": "warn", "@eslint-react/no-unused-class-component-members": "warn", "@eslint-react/no-unused-state": "warn", + "@eslint-react/no-use-context": "warn", "@eslint-react/no-useless-fragment": "warn", "@eslint-react/prefer-destructuring-assignment": "warn", "@eslint-react/prefer-shorthand-boolean": "warn", diff --git a/packages/plugins/eslint-plugin/src/configs/core.ts b/packages/plugins/eslint-plugin/src/configs/core.ts index 9f910e69c..ebc9c1b27 100644 --- a/packages/plugins/eslint-plugin/src/configs/core.ts +++ b/packages/plugins/eslint-plugin/src/configs/core.ts @@ -41,6 +41,7 @@ export const rules = { "@eslint-react/no-unstable-default-props": "warn", "@eslint-react/no-unused-class-component-members": "warn", "@eslint-react/no-unused-state": "warn", + "@eslint-react/no-use-context": "warn", "@eslint-react/use-jsx-vars": "warn", } as const satisfies RulePreset; diff --git a/website/content/docs/rules/meta.json b/website/content/docs/rules/meta.json index 16698ca27..626379f36 100644 --- a/website/content/docs/rules/meta.json +++ b/website/content/docs/rules/meta.json @@ -43,6 +43,7 @@ "no-unstable-default-props", "no-unused-class-component-members", "no-unused-state", + "no-use-context", "no-useless-fragment", "prefer-destructuring-assignment", "prefer-react-namespace-import", diff --git a/website/content/docs/rules/no-use-context.md b/website/content/docs/rules/no-use-context.md new file mode 100644 index 000000000..7bffdacb3 --- /dev/null +++ b/website/content/docs/rules/no-use-context.md @@ -0,0 +1,68 @@ +--- +title: no-use-context +--- + +**Full Name in `eslint-plugin-react-x`** + +```plain copy +react-x/no-use-context +``` + +**Full Name in `@eslint-react/eslint-plugin`** + +```plain copy +@eslint-react/no-use-context +``` + +**Features** + +`🔍` `🔄` + +**Presets** + +- `core` +- `recommended` +- `recommended-typescript` +- `recommended-type-checked` + +## What it does + +Disallows using `React.useContext`. + +In React 19, `use` is preferred over `useContext` because it is more flexible. + +An **unsafe** codemod is available for this rule. + +## Examples + +### Failing + +```tsx +import { useContext } from "react"; + +function Button() { + const theme = useContext(ThemeContext); + // ... +} +``` + +### Passing + +```tsx +import { use } from "react"; + +function Button() { + const theme = use(ThemeContext); + // ... +} +``` + +## Implementation + +- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.ts) +- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.spec.ts) + +## Further Reading + +- [React Blog: New feature use](https://react.dev/blog/2024/12/05/react-19#new-feature-use) +- [React: Reading context with use](https://react.dev/reference/react/use#reading-context-with-use) diff --git a/website/content/docs/rules/overview.md b/website/content/docs/rules/overview.md index 79315276d..c0e028051 100644 --- a/website/content/docs/rules/overview.md +++ b/website/content/docs/rules/overview.md @@ -62,6 +62,7 @@ full: true | [`no-unstable-default-props`](no-unstable-default-props) | 1️⃣ | `🔍` | Prevents using referential-type values as default props in object destructuring. | | | [`no-unused-class-component-members`](no-unused-class-component-members) | 0️⃣ | `🔍` | Warns unused class component methods and properties. | | | [`no-unused-state`](no-unused-state) | 1️⃣ | `🔍` | Warns unused class component state. | | +| [`no-use-context`](no-use-context) | 1️⃣ | `🔍` `🔄` | Prevents using `useContext` in favor of `use`. | >=19.0.0 | | [`no-useless-fragment`](no-useless-fragment) | 1️⃣ | `🔍` `🔧` `⚙️` | Prevents using useless `fragment` components or `<>` syntax. | | | [`prefer-destructuring-assignment`](prefer-destructuring-assignment) | 0️⃣ | `🔍` | Enforces using destructuring assignment over property assignment. | | | [`prefer-react-namespace-import`](prefer-react-namespace-import) | 0️⃣ | `🔍` `🔧` | Enforces React is imported via a namespace import | |