From 3cd646525adf47140e2abfb01c74fdbf1a406250 Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Tue, 28 Jan 2025 05:56:07 +0800 Subject: [PATCH 1/7] feat(plugins/x): add 'no-use-context', closes #930 --- .../plugins/eslint-plugin-react-x/README.md | 1 + .../eslint-plugin-react-x/src/index.ts | 2 + .../src/rules/no-use-context.spec.ts | 161 ++++++++++++++++++ .../src/rules/no-use-context.ts | 102 +++++++++++ .../plugins/eslint-plugin/src/configs/all.ts | 1 + .../plugins/eslint-plugin/src/configs/core.ts | 1 + website/content/docs/rules/meta.json | 1 + website/content/docs/rules/no-use-context.md | 85 +++++++++ website/content/docs/rules/overview.md | 1 + 9 files changed, 355 insertions(+) create mode 100644 packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.spec.ts create mode 100644 packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.ts create mode 100644 website/content/docs/rules/no-use-context.md 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..9639502e4 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.spec.ts @@ -0,0 +1,161 @@ +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 { 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..ff49e8634 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-use-context.ts @@ -0,0 +1,102 @@ +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 { 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 {}; + } + const { version } = getSettingsFromContext(context); + if (compare(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; + } + let isUseImported = false; + for (const specifier of node.specifiers) { + if (specifier.type !== T.ImportSpecifier) continue; + if (specifier.imported.type !== T.Identifier) continue; + if (specifier.imported.name === "use") { + isUseImported = true; + } + 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..3ff36ce89 --- /dev/null +++ b/website/content/docs/rules/no-use-context.md @@ -0,0 +1,85 @@ +--- +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"; + +const MyComponent = () => { + const value = useContext(MyContext); + return
{value}
; +}; +``` + +```tsx +import React from "react"; + +const MyComponent = () => { + const value = React.useContext(MyContext); + return
{value}
; +}; +``` + +### Passing + +```tsx +import { use } from "react"; + +const MyComponent = () => { + const value = use(MyContext); + return
{value}
; +}; +``` + +```tsx +import React from "react"; + +const MyComponent = () => { + const value = React.use(MyContext); + return
{value}
; +}; +``` + +## 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: 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..80f178f7a 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`. | | | [`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 | | From f842c7e9018af2e9df309738f0ef51e5eb224453 Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Tue, 28 Jan 2025 06:01:20 +0800 Subject: [PATCH 2/7] test(no-use-context): add test case for 'useContext' with generic type parameters --- .../src/rules/no-use-context.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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 index 9639502e4..ae36f51ff 100644 --- 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 @@ -30,6 +30,33 @@ ruleTester.run(RULE_NAME, rule, { }, }, }, + { + 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' From cc0aeaf56f0e142ba38adde6738aa7ec48e06ede Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Tue, 28 Jan 2025 06:05:16 +0800 Subject: [PATCH 3/7] docs(no-use-context): update examples --- website/content/docs/rules/no-use-context.md | 34 +++++--------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/website/content/docs/rules/no-use-context.md b/website/content/docs/rules/no-use-context.md index 3ff36ce89..06c41a5cf 100644 --- a/website/content/docs/rules/no-use-context.md +++ b/website/content/docs/rules/no-use-context.md @@ -40,19 +40,10 @@ An **unsafe** codemod is available for this rule. ```tsx import { useContext } from "react"; -const MyComponent = () => { - const value = useContext(MyContext); - return
{value}
; -}; -``` - -```tsx -import React from "react"; - -const MyComponent = () => { - const value = React.useContext(MyContext); - return
{value}
; -}; +function Button() { + const theme = useContext(ThemeContext); + // ... +} ``` ### Passing @@ -60,19 +51,10 @@ const MyComponent = () => { ```tsx import { use } from "react"; -const MyComponent = () => { - const value = use(MyContext); - return
{value}
; -}; -``` - -```tsx -import React from "react"; - -const MyComponent = () => { - const value = React.use(MyContext); - return
{value}
; -}; +function Button() { + const theme = use(ThemeContext); + // ... +} ``` ## Implementation From b008553fdcd1d6a58b728fbc7dcda6c1b959a6da Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Tue, 28 Jan 2025 06:19:19 +0800 Subject: [PATCH 4/7] fix: 'isUseImported' checks all imports --- .../eslint-plugin-react-x/src/rules/no-use-context.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 index ff49e8634..d9510d9e5 100644 --- 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 @@ -4,6 +4,7 @@ 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"; @@ -67,13 +68,11 @@ export default createRule<[], MessageID>({ if (node.source.value !== settings.importSource) { return; } - let isUseImported = false; + 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 === "use") { - isUseImported = true; - } if (specifier.imported.name === "useContext") { if (specifier.local.name !== "useContext") { useContextAlias.add(specifier.local.name); From bb9836b74e2477c64ac0f3727499a6d8546b49b2 Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Tue, 28 Jan 2025 06:24:00 +0800 Subject: [PATCH 5/7] docs: update 'no-use-context' features and version requirement --- website/content/docs/rules/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/content/docs/rules/overview.md b/website/content/docs/rules/overview.md index 80f178f7a..c0e028051 100644 --- a/website/content/docs/rules/overview.md +++ b/website/content/docs/rules/overview.md @@ -62,7 +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`. | | +| [`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 | | From 709b4b7ca790f785674ccae77c159a79e921d34f Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Tue, 28 Jan 2025 06:30:38 +0800 Subject: [PATCH 6/7] docs(no-use-context): add link to React blog for new feature use --- website/content/docs/rules/no-use-context.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/content/docs/rules/no-use-context.md b/website/content/docs/rules/no-use-context.md index 06c41a5cf..7bffdacb3 100644 --- a/website/content/docs/rules/no-use-context.md +++ b/website/content/docs/rules/no-use-context.md @@ -64,4 +64,5 @@ function Button() { ## 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) From f47de61bc763d75a3d42f1e3d4b44d9bf0169daf Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Wed, 29 Jan 2025 05:06:53 +0800 Subject: [PATCH 7/7] fix(no-use-context): use 'settings.version' instead of 'getSettingsFromContext' for version check --- .../plugins/eslint-plugin-react-x/src/rules/no-use-context.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index d9510d9e5..2094c4716 100644 --- 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 @@ -38,8 +38,7 @@ export default createRule<[], MessageID>({ if (!context.sourceCode.text.includes("useContext")) { return {}; } - const { version } = getSettingsFromContext(context); - if (compare(version, "19.0.0", "<")) { + if (compare(settings.version, "19.0.0", "<")) { return {}; } return {