diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c2c272d1aa..9579669fb0 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - - name: Setup node@23 + - name: Setup node@24 uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 - name: Enable Corepack run: corepack enable - name: Install front-end dependencies diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f377c7ca07..7793cff6e8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,7 +27,7 @@ jobs: # - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/using-larger-runners (GitHub.com only) # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + runs-on: ${{ (matrix.language == 'swift' && 'ubuntu-24.04-arm') || 'ubuntu-latest' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: # required for all workflows diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9443724cbf..48532eb59d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,10 +22,10 @@ jobs: id-token: write steps: - uses: actions/checkout@v4 - - name: Setup node@23 + - name: Setup node@24 uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 registry-url: "https://registry.npmjs.org" - name: Enable Corepack run: corepack enable diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d3fb4ebf62..9fb5bafbbe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,23 +15,23 @@ jobs: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - - name: Setup node@23 + - name: Setup node@24 uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 - name: Enable Corepack run: corepack enable - name: Install front-end dependencies run: pnpm install - name: Build front-end assets run: pnpm run build - - name: Test on node@23 + - name: Test on node@24 run: pnpm run test - - name: Setup node@18 + - name: Setup node@20 uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - name: Enable Corepack run: corepack enable - - name: Test on node@18 + - name: Test on node@20 run: pnpm run test diff --git a/.pkgs/configs/eslint.js b/.pkgs/configs/eslint.js index 344af0eea0..ff2879ca5b 100644 --- a/.pkgs/configs/eslint.js +++ b/.pkgs/configs/eslint.js @@ -13,6 +13,7 @@ const templateIndentTags = [ "ts", "tsx", "html", + "glsl", "dedent", "outdent", ]; diff --git a/.pkgs/configs/eslint.ts b/.pkgs/configs/eslint.ts index 6f138a50b1..58d4238f1f 100644 --- a/.pkgs/configs/eslint.ts +++ b/.pkgs/configs/eslint.ts @@ -17,6 +17,7 @@ const templateIndentTags = [ "ts", "tsx", "html", + "glsl", "dedent", "outdent", ]; diff --git a/.pkgs/eslint-plugin-local/package.json b/.pkgs/eslint-plugin-local/package.json index cf113f59eb..b8000008b3 100644 --- a/.pkgs/eslint-plugin-local/package.json +++ b/.pkgs/eslint-plugin-local/package.json @@ -47,11 +47,11 @@ "tsup": "^8.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "^4.9.5 || ^5.3.3" + "eslint": "^9.29.0", + "typescript": "^4.9.5 || ^5.4.5" }, "engines": { "bun": ">=1.0.15", - "node": ">=18.18.0" + "node": ">=20.19.0" } } diff --git a/.pkgs/eslint-plugin-local/tsup.config.ts b/.pkgs/eslint-plugin-local/tsup.config.ts index 8cfefa2faa..e037e267d9 100644 --- a/.pkgs/eslint-plugin-local/tsup.config.ts +++ b/.pkgs/eslint-plugin-local/tsup.config.ts @@ -11,6 +11,6 @@ export default { platform: "node", sourcemap: false, splitting: false, - target: "node18", + target: "node20", treeshake: true, } satisfies Options; diff --git a/README.md b/README.md index be5734d7c3..1eb2162b0f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ - [`eslint-plugin-react-x`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) - X Rules (renderer-agnostic, compatible with x-platform). - [`eslint-plugin-react-dom`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) - DOM specific rules for React DOM. - [`eslint-plugin-react-web-api`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-web-api) - Rules for interacting with Web APIs. -- [`eslint-plugin-react-hooks-extra`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-hooks-extra) - Extra React Hooks rules. - [`eslint-plugin-react-naming-convention`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-naming-convention) - Naming convention rules. ### Unified @@ -53,8 +52,8 @@ > [!NOTE]\ > ESLint React requires the following minimum versions: > -> - Node.js: 18.18.0 -> - ESLint: 8.57.0 +> - Node.js: 20.19.0 +> - ESLint: 9.24.0 > - TypeScript: 4.9.5 ### Install diff --git a/VERSION b/VERSION index 4ee8389621..5d5c27a28c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.52.3-next.6 \ No newline at end of file +2.0.0-next.47 diff --git a/apps/website/content/docs/contributing.mdx b/apps/website/content/docs/contributing.mdx index 5601e70779..436b2c748f 100644 --- a/apps/website/content/docs/contributing.mdx +++ b/apps/website/content/docs/contributing.mdx @@ -38,7 +38,6 @@ flowchart TB ReactX["eslint-plugin-react-x"]:::plugins ReactDOM["eslint-plugin-react-dom"]:::plugins WebAPI["eslint-plugin-react-web-api"]:::plugins - HooksExtra["eslint-plugin-react-hooks-extra"]:::plugins NamingConvention["eslint-plugin-react-naming-convention"]:::plugins Aggregated["Aggregated Plugin (@eslint-react/eslint-plugin)"]:::plugins end @@ -56,13 +55,11 @@ flowchart TB Core -->|"dependency"| ReactX Core -->|"dependency"| ReactDOM Core -->|"dependency"| WebAPI - Core -->|"dependency"| HooksExtra Core -->|"dependency"| NamingConvention Shared -->|"dependency"| ReactX Shared -->|"dependency"| ReactDOM Shared -->|"dependency"| WebAPI - Shared -->|"dependency"| HooksExtra Shared -->|"dependency"| NamingConvention %% Utilities used by Core and Plugins @@ -80,7 +77,6 @@ flowchart TB ReactX -->|"aggregated"| Aggregated ReactDOM -->|"aggregated"| Aggregated WebAPI -->|"aggregated"| Aggregated - HooksExtra -->|"aggregated"| Aggregated NamingConvention -->|"aggregated"| Aggregated %% Website Documentation uses Core and Shared docs @@ -97,7 +93,6 @@ flowchart TB Scripts -->|"CI/CD"| ReactX Scripts -->|"CI/CD"| ReactDOM Scripts -->|"CI/CD"| WebAPI - Scripts -->|"CI/CD"| HooksExtra Scripts -->|"CI/CD"| NamingConvention Scripts -->|"CI/CD"| Aggregated Scripts -->|"CI/CD"| Tests @@ -114,7 +109,6 @@ flowchart TB click ReactX "https://github.com/rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x" click ReactDOM "https://github.com/rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom" click WebAPI "https://github.com/rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-web-api" - click HooksExtra "https://github.com/rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-hooks-extra" click NamingConvention "https://github.com/rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-naming-convention" click Aggregated "https://github.com/rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin" click Scripts "https://github.com/rel1cx/eslint-react/tree/main/scripts" @@ -154,7 +148,6 @@ This section provides a summary of the packages in the monorepo. - `packages/plugins/eslint-plugin-react-x`: Core React rules - `packages/plugins/eslint-plugin-react-dom`: React DOM rules - `packages/plugins/eslint-plugin-react-web-api`: Web API interaction rules - - `packages/plugins/eslint-plugin-react-hooks-extra`: Extra React Hooks rules - `packages/plugins/eslint-plugin-react-naming-convention`: Naming convention rules - `packages/plugins/eslint-plugin-react-debug`: Debugging rules for inspecting React patterns in code - `packages/plugins/eslint-plugin`: A unified plugin that combines all individual plugins into one diff --git a/apps/website/content/docs/faq.mdx b/apps/website/content/docs/faq.mdx index 00d0ddafa4..fca9e24e0b 100644 --- a/apps/website/content/docs/faq.mdx +++ b/apps/website/content/docs/faq.mdx @@ -39,7 +39,6 @@ Currently, it includes the following: - [`eslint-plugin-react-x`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) - Core React rules. - [`eslint-plugin-react-dom`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) - React DOM rules. - [`eslint-plugin-react-web-api`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-web-api) - Web API interaction rules. - - [`eslint-plugin-react-hooks-extra`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-hooks-extra) - Extra React Hooks rules. - [`eslint-plugin-react-naming-convention`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-naming-convention) - Naming convention rules. - [`eslint-plugin-react-debug`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-debug) - Debugging rules for inspecting React patterns in code. - [`@eslint-react/eslint-plugin`](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin) - A unified plugin that combines all individual plugins into one. diff --git a/apps/website/content/docs/getting-started/javascript.mdx b/apps/website/content/docs/getting-started/javascript.mdx index 64a313bcf9..dcabf17c37 100644 --- a/apps/website/content/docs/getting-started/javascript.mdx +++ b/apps/website/content/docs/getting-started/javascript.mdx @@ -7,8 +7,8 @@ import { Step, Steps } from "fumadocs-ui/components/steps"; -- Node.js: 18.18.0 -- ESLint: 8.57.0 +- Node.js: 20.19.0 +- ESLint: 9.24.0 - TypeScript: 4.9.5 diff --git a/apps/website/content/docs/getting-started/typescript.mdx b/apps/website/content/docs/getting-started/typescript.mdx index 92f8717de5..7a5f64e3f8 100644 --- a/apps/website/content/docs/getting-started/typescript.mdx +++ b/apps/website/content/docs/getting-started/typescript.mdx @@ -7,8 +7,8 @@ import { Step, Steps } from "fumadocs-ui/components/steps"; -- Node.js: 18.18.0 -- ESLint: 8.57.0 +- Node.js: 20.19.0 +- ESLint: 9.24.0 - TypeScript: 4.9.5 diff --git a/apps/website/content/docs/rules/meta.json b/apps/website/content/docs/rules/meta.json index e731b47acd..4c284c2852 100644 --- a/apps/website/content/docs/rules/meta.json +++ b/apps/website/content/docs/rules/meta.json @@ -3,9 +3,12 @@ "overview", "---X Rules---", "jsx-key-before-spread", + "jsx-no-comment-textnodes", "jsx-no-duplicate-props", "jsx-no-iife", "jsx-no-undef", + "jsx-shorthand-boolean", + "jsx-shorthand-fragment", "jsx-uses-react", "jsx-uses-vars", "no-access-state-in-setstate", @@ -18,8 +21,6 @@ "no-children-to-array", "no-class-component", "no-clone-element", - "no-comment-textnodes", - "no-complex-conditional-rendering", "no-component-will-mount", "no-component-will-receive-props", "no-component-will-update", @@ -27,6 +28,8 @@ "no-create-ref", "no-default-props", "no-direct-mutation-state", + "no-direct-set-state-in-use-effect", + "no-direct-set-state-in-use-layout-effect", "no-duplicate-key", "no-forward-ref", "no-implicit-key", @@ -43,6 +46,9 @@ "no-set-state-in-component-did-update", "no-set-state-in-component-will-update", "no-string-refs", + "no-unnecessary-use-callback", + "no-unnecessary-use-memo", + "no-unnecessary-use-prefix", "no-unsafe-component-will-mount", "no-unsafe-component-will-receive-props", "no-unsafe-component-will-update", @@ -52,14 +58,10 @@ "no-unused-state", "no-use-context", "no-useless-forward-ref", - "no-useless-fragment", "prefer-destructuring-assignment", - "prefer-react-namespace-import", + "prefer-namespace-import", "prefer-read-only-props", - "prefer-shorthand-boolean", - "prefer-shorthand-fragment", - "avoid-shorthand-boolean", - "avoid-shorthand-fragment", + "prefer-use-state-lazy-initialization", "---DOM Rules---", "dom-no-dangerously-set-innerhtml", "dom-no-dangerously-set-innerhtml-with-children", @@ -82,13 +84,6 @@ "web-api-no-leaked-interval", "web-api-no-leaked-resize-observer", "web-api-no-leaked-timeout", - "---Hooks Extra Rules---", - "hooks-extra-no-direct-set-state-in-use-effect", - "hooks-extra-no-direct-set-state-in-use-layout-effect", - "hooks-extra-no-unnecessary-use-callback", - "hooks-extra-no-unnecessary-use-memo", - "hooks-extra-no-unnecessary-use-prefix", - "hooks-extra-prefer-use-state-lazy-initialization", "---Naming Convention Rules---", "naming-convention-component-name", "naming-convention-context-name", diff --git a/apps/website/content/docs/rules/overview.mdx b/apps/website/content/docs/rules/overview.mdx index a2eba45cf3..79aee2ec1d 100644 --- a/apps/website/content/docs/rules/overview.mdx +++ b/apps/website/content/docs/rules/overview.mdx @@ -35,6 +35,8 @@ The `jsx-*` rules check for issues exclusive to JSX syntax, which are absent fro | [`jsx-no-duplicate-props`](./jsx-no-duplicate-props) | 1️⃣ | | Disallow duplicate props in JSX elements | | | [`jsx-no-iife`](./jsx-no-iife) | 0️⃣ | `🧪` | Disallows `IIFE` in JSX elements | | | [`jsx-no-undef`](./jsx-no-undef) | 0️⃣ | | Disallow undefined variables in JSX elements | | +| [`jsx-shorthand-boolean`](./jsx-shorthand-boolean) | 0️⃣ | `🔧` `⚙️` | Enforces the use of shorthand syntax for boolean attributes | | +| [`jsx-shorthand-fragment`](./jsx-shorthand-fragment) | 0️⃣ | `🔧` `⚙️` | Enforces the use of shorthand syntax for fragments | | | [`jsx-uses-react`](./jsx-uses-react) | 1️⃣ | | Marks React variables as used when JSX is used | | | [`jsx-uses-vars`](./jsx-uses-vars) | 1️⃣ | | Marks variables used in JSX elements as used | | | [`no-access-state-in-setstate`](./no-access-state-in-setstate) | 2️⃣ | | Disallow accessing `this.state` inside `setState` calls | | @@ -72,6 +74,9 @@ The `jsx-*` rules check for issues exclusive to JSX syntax, which are absent fro | [`no-set-state-in-component-did-update`](./no-set-state-in-component-did-update) | 1️⃣ | | Disallow calling `this.setState` in `componentDidUpdate` outside of functions, such as callbacks | | | [`no-set-state-in-component-will-update`](./no-set-state-in-component-will-update) | 1️⃣ | | Disallow calling `this.setState` in `componentWillUpdate` outside of functions, such as callbacks | | | [`no-string-refs`](./no-string-refs) | 2️⃣ | `🔄` | Replaces string refs with callback refs | >=16.3.0 | +| [`no-unnecessary-use-callback`](./no-unnecessary-use-callback) | 0️⃣ | `🧪` | Disallow unnecessary usage of `useCallback` | | +| [`no-unnecessary-use-memo`](./no-unnecessary-use-memo) | 0️⃣ | `🧪` | Disallow unnecessary usage of `useMemo` | | +| [`no-unnecessary-use-prefix`](./no-unnecessary-use-prefix) | 0️⃣ | | Enforces that a function with the `use` prefix should use at least one Hook inside of it | | | [`no-unsafe-component-will-mount`](./no-unsafe-component-will-mount) | 1️⃣ | | Warns the usage of `UNSAFE_componentWillMount` in class components | | | [`no-unsafe-component-will-receive-props`](./no-unsafe-component-will-receive-props) | 1️⃣ | | Warns the usage of `UNSAFE_componentWillReceiveProps` in class components | | | [`no-unsafe-component-will-update`](./no-unsafe-component-will-update) | 1️⃣ | | Warns the usage of `UNSAFE_componentWillUpdate` in class components | | @@ -85,10 +90,7 @@ The `jsx-*` rules check for issues exclusive to JSX syntax, which are absent fro | [`prefer-destructuring-assignment`](./prefer-destructuring-assignment) | 0️⃣ | | Enforces destructuring assignment for component props and context | | | [`prefer-react-namespace-import`](./prefer-react-namespace-import) | 0️⃣ | `🔧` | Enforces React is imported via a namespace import | | | [`prefer-read-only-props`](./prefer-read-only-props) | 0️⃣ | `💭` | Enforces read-only props in components | | -| [`prefer-shorthand-boolean`](./prefer-shorthand-boolean) | 0️⃣ | `🔧` | Enforces shorthand syntax for boolean attributes | | -| [`prefer-shorthand-fragment`](./prefer-shorthand-fragment) | 0️⃣ | `🔧` | Enforces shorthand syntax for fragments | | -| [`avoid-shorthand-boolean`](./avoid-shorthand-boolean) | 0️⃣ | `🔧` | Enforces explicit boolean values for boolean attributes | | -| [`avoid-shorthand-fragment`](./avoid-shorthand-fragment) | 0️⃣ | | Enforces explicit `` components instead of the shorthand `<>` or `` syntax | | +| [`prefer-use-state-lazy-initialization`](./prefer-use-state-lazy-initialization) | 1️⃣ | | Enforces function calls made inside `useState` to be wrapped in an `initializer function` | | ## DOM Rules @@ -132,10 +134,6 @@ This section contains rules that are not part of the official `eslint-plugin-rea | :--------------------------------------------------------------------------------------------------- | :-- | :--: | :---------------------------------------------------------------------------------------- | | [`no-direct-set-state-in-use-effect`](./hooks-extra-no-direct-set-state-in-use-effect) | 1️⃣ | | Disallow direct calls to the `set` function of `useState` in `useEffect` | | [`no-direct-set-state-in-use-layout-effect`](./hooks-extra-no-direct-set-state-in-use-layout-effect) | 0️⃣ | `🧪` | Disallow direct calls to the `set` function of `useState` in `useLayoutEffect` | -| [`no-unnecessary-use-callback`](./hooks-extra-no-unnecessary-use-callback) | 0️⃣ | `🧪` | Disallow unnecessary usage of `useCallback` | -| [`no-unnecessary-use-memo`](./hooks-extra-no-unnecessary-use-memo) | 0️⃣ | `🧪` | Disallow unnecessary usage of `useMemo` | -| [`no-unnecessary-use-prefix`](./hooks-extra-no-unnecessary-use-prefix) | 1️⃣ | | Enforces that a function with the `use` prefix should use at least one hook inside of it | -| [`prefer-use-state-lazy-initialization`](./hooks-extra-prefer-use-state-lazy-initialization) | 1️⃣ | | Enforces function calls made inside `useState` to be wrapped in an `initializer function` | ## Naming Convention Rules diff --git a/apps/website/migration/index.ts b/apps/website/migration/index.ts index 826e944533..bb36af0c29 100644 --- a/apps/website/migration/index.ts +++ b/apps/website/migration/index.ts @@ -1,16 +1,5 @@ /* eslint-disable perfectionist/sort-objects */ export const redirects = [ - // Redirects for old documentation links - { - source: "/docs/getting-started/javascript-with-alternative-parser", - destination: "/docs/using-an-alternative-parser/babel-eslint-parser", - permanent: true, - }, - { - source: "/docs/getting-started/typescript-with-alternative-parser", - destination: "/docs/using-an-alternative-parser/ts-blank-eslint-parser", - permanent: true, - }, // Redirects for old preset links { source: "/docs/rules/overview#core-rules", @@ -22,60 +11,4 @@ export const redirects = [ destination: "/docs/rules/overview#x-rules", permanent: true, }, - // Redirects for old rule names - { - source: "/docs/rules/use-jsx-vars", - destination: "/docs/rules/jsx-uses-vars", - permanent: true, - }, - { - source: "/docs/rules/no-duplicate-jsx-props", - destination: "/docs/rules/jsx-no-duplicate-props", - permanent: true, - }, - { - source: "/docs/rules/no-complicated-conditional-rendering", - destination: "/docs/rules/no-complex-conditional-rendering", - permanent: true, - }, - { - source: "/docs/rules/ensure-forward-ref-using-ref", - destination: "/docs/rules/no-useless-forward-ref", - permanent: true, - }, - { - source: "/docs/rules/no-nested-components", - destination: "/docs/rules/no-nested-component-definitions", - permanent: true, - }, - { - source: "/docs/rules/dom-no-children-in-void-dom-elements", - destination: "/docs/rules/dom-no-void-elements-with-children", - permanent: true, - }, - { - source: "/docs/rules/hooks-extra-ensure-use-memo-has-non-empty-deps", - destination: "/docs/rules/hooks-extra-no-unnecessary-use-memo", - permanent: true, - }, - { - source: "/docs/rules/hooks-extra-ensure-use-callback-has-non-empty-deps", - destination: "/docs/rules/hooks-extra-no-unnecessary-use-callback", - permanent: true, - }, - { - source: "/docs/rules/hooks-extra-ensure-custom-hooks-using-other-hooks", - destination: "/docs/rules/hooks-extra-no-unnecessary-use-prefix", - permanent: true, - }, - { - source: "/docs/rules/hooks-extra-no-redundant-custom-hook", - destination: "/docs/rules/hooks-extra-no-unnecessary-use-prefix", - permanent: true, - }, - { - source: "/docs/rules/hooks-extra-no-useless-custom-hooks", - destination: "/docs/rules/hooks-extra-no-unnecessary-use-prefix", - permanent: true, - }, ] as const; diff --git a/examples/next-app/eslint.config.mjs b/examples/next-app/eslint.config.js similarity index 100% rename from examples/next-app/eslint.config.mjs rename to examples/next-app/eslint.config.js diff --git a/examples/next-app/package.json b/examples/next-app/package.json index 9232e683d1..b0f7028722 100644 --- a/examples/next-app/package.json +++ b/examples/next-app/package.json @@ -2,6 +2,7 @@ "name": "@examples/next-app", "version": "0.0.0", "private": true, + "type": "module", "scripts": { "build": "next build", "dev": "next dev", @@ -32,6 +33,6 @@ "typescript-eslint": "^8.34.1" }, "engines": { - "node": ">=18.18.0" + "node": ">=20.19.0" } } diff --git a/examples/rspeedy-react-lynx-app/eslint.config.js b/examples/rspeedy-react-lynx-app/eslint.config.js new file mode 100644 index 0000000000..668832a1b9 --- /dev/null +++ b/examples/rspeedy-react-lynx-app/eslint.config.js @@ -0,0 +1,55 @@ +import eslintJs from "@eslint/js"; +import eslintPluginReactX from "eslint-plugin-react-x"; +import eslintPluginReactHooks from "eslint-plugin-react-hooks"; +import tseslint from "typescript-eslint"; +import gitignore from "eslint-config-flat-gitignore"; + +import TSCONFIG from "./tsconfig.json" with { type: "json" }; + +const GLOB_TS = ["**/*.ts", "**/*.tsx"]; +const GLOB_JS = ["**/*.js", "**/*.jsx"]; +const GLOB_CONFIG = ["**/*.config.{js,mjs,ts,tsx}"]; + +export default tseslint.config( + gitignore(), + { + files: GLOB_TS, + extends: [ + eslintJs.configs.recommended, + tseslint.configs.recommended, + ], + }, + { + files: TSCONFIG.include, + extends: [ + tseslint.configs.recommendedTypeChecked, + ], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: TSCONFIG.include, + extends: [ + eslintPluginReactX.configs["recommended-type-checked"], + ], + plugins: { + "react-hooks": eslintPluginReactHooks, + }, + rules: { + ...eslintPluginReactHooks.configs.recommended.rules, + }, + }, + { + files: [...GLOB_JS, ...GLOB_CONFIG], + extends: [tseslint.configs.disableTypeChecked], + rules: { + "no-undef": "off", + "no-console": "off", + }, + }, +); diff --git a/examples/rspeedy-react-lynx-app/lynx.config.ts b/examples/rspeedy-react-lynx-app/lynx.config.ts new file mode 100644 index 0000000000..922d82558a --- /dev/null +++ b/examples/rspeedy-react-lynx-app/lynx.config.ts @@ -0,0 +1,18 @@ +import { pluginQRCode } from "@lynx-js/qrcode-rsbuild-plugin"; +import { pluginReactLynx } from "@lynx-js/react-rsbuild-plugin"; +import { defineConfig } from "@lynx-js/rspeedy"; +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { pluginTailwindCSS } from "rsbuild-plugin-tailwindcss"; + +export default defineConfig({ + plugins: [ + pluginReactLynx(), + pluginQRCode(), + pluginTailwindCSS(), + pluginTypeCheck(), + ], + environments: { + web: {}, + lynx: {}, + }, +}); diff --git a/examples/rspeedy-react-lynx-app/package copy.json b/examples/rspeedy-react-lynx-app/package copy.json new file mode 100644 index 0000000000..651fa7777d --- /dev/null +++ b/examples/rspeedy-react-lynx-app/package copy.json @@ -0,0 +1,31 @@ +{ + "name": "@lynx-example/tailwindcss", + "version": "0.3.0", + "type": "module", + "scripts": { + "build": "rspeedy build", + "dev": "rspeedy dev" + }, + "dependencies": { + "@lynx-js/react": "catalog:" + }, + "devDependencies": { + "@lynx-js/qrcode-rsbuild-plugin": "catalog:", + "@lynx-js/react-rsbuild-plugin": "catalog:", + "@lynx-js/rspeedy": "catalog:", + "@lynx-js/types": "^3.2.0", + "@types/react": "^18.3.18", + "typescript": "~5.7.3", + "@lynx-contrib/tailwind-preset": "0.0.2", + "rsbuild-plugin-tailwindcss": "^0.2.0", + "tailwindcss": "^3.4.17" + }, + "engines": { + "node": ">=18" + }, + "repository": { + "directory": "examples/tailwindcss", + "url": "git+https://github.com/lynx-family/lynx-examples.git", + "type": "git" + } +} diff --git a/examples/rspeedy-react-lynx-app/package.json b/examples/rspeedy-react-lynx-app/package.json new file mode 100644 index 0000000000..bb06e48fab --- /dev/null +++ b/examples/rspeedy-react-lynx-app/package.json @@ -0,0 +1,35 @@ +{ + "name": "@examples/rspeedy-react-lynx-app", + "version": "0.6.0", + "description": "An example shows how to use list in Lynx", + "license": "Apache-2.0", + "type": "module", + "files": [ + "dist/", + "src/", + "lynx.config.mjs" + ], + "scripts": { + "build": "rspeedy build", + "dev": "rspeedy dev", + "preview": "rspeedy preview" + }, + "dependencies": { + "@lynx-js/react": "^0.109.2" + }, + "devDependencies": { + "@eslint/config-inspector": "^1.1.0", + "@eslint/js": "^9.29.0", + "@lynx-contrib/tailwind-preset": "^0.0.2", + "@lynx-js/qrcode-rsbuild-plugin": "^0.3.6", + "@lynx-js/react-rsbuild-plugin": "^0.10.3", + "@lynx-js/rspeedy": "^0.9.9", + "@lynx-js/types": "^3.3.0", + "@rsbuild/plugin-sass": "^1.3.2", + "@rsbuild/plugin-type-check": "^1.2.3", + "@types/react": "^19.1.8", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-x": "workspace:*", + "rsbuild-plugin-tailwindcss": "^0.2.2" + } +} diff --git a/examples/rspeedy-react-lynx-app/rspeedy-env.d.ts b/examples/rspeedy-react-lynx-app/rspeedy-env.d.ts new file mode 100644 index 0000000000..1c813a68b0 --- /dev/null +++ b/examples/rspeedy-react-lynx-app/rspeedy-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/rspeedy-react-lynx-app/src/App.css b/examples/rspeedy-react-lynx-app/src/App.css new file mode 100644 index 0000000000..b5c61c9567 --- /dev/null +++ b/examples/rspeedy-react-lynx-app/src/App.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/rspeedy-react-lynx-app/src/App.tsx b/examples/rspeedy-react-lynx-app/src/App.tsx new file mode 100644 index 0000000000..c5e8d4210e --- /dev/null +++ b/examples/rspeedy-react-lynx-app/src/App.tsx @@ -0,0 +1,40 @@ +import { useState } from "@lynx-js/react"; + +import "./App.css"; + +export function App() { + const [count] = useState(0); + + return ( + + + + ReactLynx + TailwindCSS + + + Start building amazing things with ReactLynx. + + + + Count: {count} + + + + 01 + 02 + 03 + 04 + 05 + 06 + + + + ); +} diff --git a/examples/rspeedy-react-lynx-app/src/index.tsx b/examples/rspeedy-react-lynx-app/src/index.tsx new file mode 100644 index 0000000000..35a1c07711 --- /dev/null +++ b/examples/rspeedy-react-lynx-app/src/index.tsx @@ -0,0 +1,11 @@ +import { root } from "@lynx-js/react"; + +import { App } from "./App.jsx"; + +root.render( + , +); + +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); +} diff --git a/examples/rspeedy-react-lynx-app/src/rspeedy-env.d.ts b/examples/rspeedy-react-lynx-app/src/rspeedy-env.d.ts new file mode 100644 index 0000000000..1c813a68b0 --- /dev/null +++ b/examples/rspeedy-react-lynx-app/src/rspeedy-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/rspeedy-react-lynx-app/tailwind.config.js b/examples/rspeedy-react-lynx-app/tailwind.config.js new file mode 100644 index 0000000000..b69032abfe --- /dev/null +++ b/examples/rspeedy-react-lynx-app/tailwind.config.js @@ -0,0 +1,7 @@ +import preset from "@lynx-contrib/tailwind-preset"; + +export default { + presets: [ + preset, + ], +}; diff --git a/examples/rspeedy-react-lynx-app/tsconfig.json b/examples/rspeedy-react-lynx-app/tsconfig.json new file mode 100644 index 0000000000..bc5b91001c --- /dev/null +++ b/examples/rspeedy-react-lynx-app/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@lynx-js/react", + "module": "node16", + "moduleResolution": "node16", + "strict": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "paths": { + "@assets/*": [ + "./assets/*" + ] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ], + "exclude": [ + "dist" + ] +} diff --git a/examples/vite-react-dom-app-v1/.gitignore b/examples/vite-react-dom-app-v1/.gitignore new file mode 100644 index 0000000000..8b7e50214d --- /dev/null +++ b/examples/vite-react-dom-app-v1/.gitignore @@ -0,0 +1,22 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/vite-react-dom-app-v1/.vscode/extensions.json b/examples/vite-react-dom-app-v1/.vscode/extensions.json new file mode 100644 index 0000000000..b308e58914 --- /dev/null +++ b/examples/vite-react-dom-app-v1/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint" + ] +} diff --git a/examples/vite-react-dom-app-v1/.vscode/settings.json b/examples/vite-react-dom-app-v1/.vscode/settings.json new file mode 100644 index 0000000000..802c5083af --- /dev/null +++ b/examples/vite-react-dom-app-v1/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "eslint.rules.customizations": [ + { "rule": "@eslint-react/debug/*", "severity": "info" } + ] +} diff --git a/examples/vite-react-dom-app-v1/eslint.config.d.ts b/examples/vite-react-dom-app-v1/eslint.config.d.ts new file mode 100644 index 0000000000..d05344c1ac --- /dev/null +++ b/examples/vite-react-dom-app-v1/eslint.config.d.ts @@ -0,0 +1 @@ +declare module "@eslint/js"; diff --git a/examples/vite-react-dom-app-v1/eslint.config.js b/examples/vite-react-dom-app-v1/eslint.config.js new file mode 100644 index 0000000000..212f3e4e27 --- /dev/null +++ b/examples/vite-react-dom-app-v1/eslint.config.js @@ -0,0 +1,158 @@ +import eslintJs from "@eslint/js"; +import eslintReact from "@eslint-react/eslint-plugin"; +import eslintPluginReactHooks from "eslint-plugin-react-hooks"; +import eslintPluginReactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +import TSCONFIG_APP from "./tsconfig.app.json" with { type: "json" }; +import TSCONFIG_NODE from "./tsconfig.node.json" with { type: "json" }; + +const GLOB_TS = ["**/*.ts", "**/*.tsx"]; + +export default tseslint.config( + { + files: GLOB_TS, + extends: [ + eslintJs.configs.recommended, + tseslint.configs.recommended, + ], + }, + // base configuration for browser environment source files + { + files: TSCONFIG_APP.include, + extends: [ + tseslint.configs.recommendedTypeChecked, + ], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: "./tsconfig.app.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + // base configuration for node environment source files (*.config.ts, etc.) + { + files: TSCONFIG_NODE.include, + ignores: TSCONFIG_NODE.exclude, + extends: [tseslint.configs.disableTypeChecked], + languageOptions: { + parserOptions: { + project: "./tsconfig.node.json", + projectService: false, + }, + }, + rules: { + "no-console": "off", + }, + }, + // react specific configurations + { + files: TSCONFIG_APP.include, + extends: [ + eslintReact.configs["recommended-type-checked"], + eslintPluginReactRefresh.configs.recommended, + ], + plugins: { + "react-hooks": eslintPluginReactHooks, + }, + rules: { + ...eslintPluginReactHooks.configs.recommended.rules, + + // Place the v1 ruleset here to test the compatibility in the v2 branch + "@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-iife": "warn", + "@eslint-react/jsx-no-undef": "error", + "@eslint-react/jsx-uses-react": "warn", + "@eslint-react/jsx-uses-vars": "warn", + "@eslint-react/no-access-state-in-setstate": "error", + "@eslint-react/no-array-index-key": "warn", + "@eslint-react/no-children-count": "warn", + "@eslint-react/no-children-for-each": "warn", + "@eslint-react/no-children-map": "warn", + "@eslint-react/no-children-only": "warn", + "@eslint-react/no-children-prop": "warn", + "@eslint-react/no-children-to-array": "warn", + "@eslint-react/no-class-component": "warn", + "@eslint-react/no-clone-element": "warn", + "@eslint-react/no-comment-textnodes": "warn", + "@eslint-react/no-complex-conditional-rendering": "warn", + "@eslint-react/no-component-will-mount": "error", + "@eslint-react/no-component-will-receive-props": "error", + "@eslint-react/no-component-will-update": "error", + "@eslint-react/no-context-provider": "warn", + "@eslint-react/no-create-ref": "error", + "@eslint-react/no-default-props": "error", + "@eslint-react/no-direct-mutation-state": "error", + "@eslint-react/no-duplicate-key": "warn", + "@eslint-react/no-forward-ref": "warn", + "@eslint-react/no-implicit-key": "warn", + // "@eslint-react/no-leaked-conditional-rendering": "warn", + "@eslint-react/no-missing-component-display-name": "warn", + "@eslint-react/no-missing-context-display-name": "warn", + "@eslint-react/no-missing-key": "error", + "@eslint-react/no-misused-capture-owner-stack": "error", + "@eslint-react/no-nested-component-definitions": "error", + "@eslint-react/no-nested-lazy-component-declarations": "warn", + "@eslint-react/no-prop-types": "error", + "@eslint-react/no-redundant-should-component-update": "error", + "@eslint-react/no-set-state-in-component-did-mount": "warn", + "@eslint-react/no-set-state-in-component-did-update": "warn", + "@eslint-react/no-set-state-in-component-will-update": "warn", + "@eslint-react/no-string-refs": "error", + "@eslint-react/no-unsafe-component-will-mount": "warn", + "@eslint-react/no-unsafe-component-will-receive-props": "warn", + "@eslint-react/no-unsafe-component-will-update": "warn", + "@eslint-react/no-unstable-context-value": "warn", + "@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-forward-ref": "warn", + "@eslint-react/no-useless-fragment": "warn", + "@eslint-react/prefer-destructuring-assignment": "warn", + "@eslint-react/prefer-react-namespace-import": "warn", + // "@eslint-react/prefer-read-only-props": "warn", + "@eslint-react/prefer-shorthand-boolean": "off", + "@eslint-react/prefer-shorthand-fragment": "off", + + "@eslint-react/dom/no-dangerously-set-innerhtml": "warn", + "@eslint-react/dom/no-dangerously-set-innerhtml-with-children": "error", + "@eslint-react/dom/no-find-dom-node": "error", + "@eslint-react/dom/no-flush-sync": "error", + "@eslint-react/dom/no-hydrate": "error", + "@eslint-react/dom/no-missing-button-type": "warn", + "@eslint-react/dom/no-missing-iframe-sandbox": "warn", + "@eslint-react/dom/no-namespace": "error", + "@eslint-react/dom/no-render": "error", + "@eslint-react/dom/no-render-return-value": "error", + "@eslint-react/dom/no-script-url": "warn", + "@eslint-react/dom/no-unknown-property": "warn", + "@eslint-react/dom/no-unsafe-iframe-sandbox": "warn", + "@eslint-react/dom/no-unsafe-target-blank": "warn", + "@eslint-react/dom/no-use-form-state": "error", + "@eslint-react/dom/no-void-elements-with-children": "error", + + "@eslint-react/web-api/no-leaked-event-listener": "warn", + "@eslint-react/web-api/no-leaked-interval": "warn", + "@eslint-react/web-api/no-leaked-resize-observer": "warn", + "@eslint-react/web-api/no-leaked-timeout": "warn", + + "@eslint-react/hooks-extra/no-direct-set-state-in-use-effect": "warn", + "@eslint-react/hooks-extra/no-direct-set-state-in-use-layout-effect": "warn", + "@eslint-react/hooks-extra/no-unnecessary-use-callback": "warn", + "@eslint-react/hooks-extra/no-unnecessary-use-memo": "warn", + "@eslint-react/hooks-extra/no-unnecessary-use-prefix": "warn", + "@eslint-react/hooks-extra/prefer-use-state-lazy-initialization": "warn", + + "@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/examples/vite-react-dom-app-v1/index.html b/examples/vite-react-dom-app-v1/index.html new file mode 100644 index 0000000000..c4a704fdbf --- /dev/null +++ b/examples/vite-react-dom-app-v1/index.html @@ -0,0 +1,13 @@ + + + + + + eslint-react-example + + + +
+ + + diff --git a/examples/vite-react-dom-app-v1/package.json b/examples/vite-react-dom-app-v1/package.json new file mode 100644 index 0000000000..42d4c31f12 --- /dev/null +++ b/examples/vite-react-dom-app-v1/package.json @@ -0,0 +1,37 @@ +{ + "name": "@examples/vite-react-dom-ts-v1", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite", + "inspect:eslint-config": "eslint-config-inspector", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@eslint-react/eslint-plugin": "workspace:*", + "@eslint/config-inspector": "^1.1.0", + "@eslint/js": "^9.29.0", + "@tsconfig/node22": "^22.0.2", + "@tsconfig/strictest": "^2.0.5", + "@tsconfig/vite-react": "^6.3.6", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "typescript": "^5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^6.3.5" + }, + "engines": { + "node": ">=20.19.0" + } +} diff --git a/examples/vite-react-dom-app-v1/src/App.css b/examples/vite-react-dom-app-v1/src/App.css new file mode 100644 index 0000000000..655c06f48a --- /dev/null +++ b/examples/vite-react-dom-app-v1/src/App.css @@ -0,0 +1,41 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 8em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} + +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} + +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a>.logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} diff --git a/examples/vite-react-dom-app-v1/src/App.tsx b/examples/vite-react-dom-app-v1/src/App.tsx new file mode 100644 index 0000000000..3af45273b2 --- /dev/null +++ b/examples/vite-react-dom-app-v1/src/App.tsx @@ -0,0 +1,26 @@ +import "./App.css"; + +import { useState } from "react"; + +import logo from "./assets/eslint-react.svg"; + +function App() { + const [count, setCount] = useState(0n); + + return ( +
+
+ + logo + +
+
+ +
+
+ ); +} + +export default App; diff --git a/examples/vite-react-dom-app-v1/src/assets/eslint-react.svg b/examples/vite-react-dom-app-v1/src/assets/eslint-react.svg new file mode 100644 index 0000000000..5573445ab8 --- /dev/null +++ b/examples/vite-react-dom-app-v1/src/assets/eslint-react.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/examples/vite-react-dom-app-v1/src/assets/react.svg b/examples/vite-react-dom-app-v1/src/assets/react.svg new file mode 100644 index 0000000000..bbcc554ee6 --- /dev/null +++ b/examples/vite-react-dom-app-v1/src/assets/react.svg @@ -0,0 +1,6 @@ + diff --git a/examples/vite-react-dom-app-v1/src/index.css b/examples/vite-react-dom-app-v1/src/index.css new file mode 100644 index 0000000000..f5ac7a3860 --- /dev/null +++ b/examples/vite-react-dom-app-v1/src/index.css @@ -0,0 +1,88 @@ +:root { + font-family: ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + 'Noto Sans', + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji'; + + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } +} diff --git a/examples/vite-react-dom-app-v1/src/main.ts b/examples/vite-react-dom-app-v1/src/main.ts new file mode 100644 index 0000000000..18c08d5d89 --- /dev/null +++ b/examples/vite-react-dom-app-v1/src/main.ts @@ -0,0 +1,7 @@ +import "./index.css"; + +import ReactDOM from "react-dom/client"; + +import { root } from "./root"; + +ReactDOM.createRoot(document.querySelector("#root")!).render(root); diff --git a/examples/vite-react-dom-app-v1/src/root.tsx b/examples/vite-react-dom-app-v1/src/root.tsx new file mode 100644 index 0000000000..df78caa030 --- /dev/null +++ b/examples/vite-react-dom-app-v1/src/root.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +import App from "./App"; + +export const root = ( + + + +); diff --git a/examples/vite-react-dom-app-v1/tsconfig.app.json b/examples/vite-react-dom-app-v1/tsconfig.app.json new file mode 100644 index 0000000000..57ac7498dc --- /dev/null +++ b/examples/vite-react-dom-app-v1/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@tsconfig/strictest/tsconfig.json", + "@tsconfig/vite-react/tsconfig.json" + ], + "compilerOptions": { + "target": "ES2021", + "lib": [ + "ES2021", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vite/client" + ], + "erasableSyntaxOnly": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/examples/vite-react-dom-app-v1/tsconfig.json b/examples/vite-react-dom-app-v1/tsconfig.json new file mode 100644 index 0000000000..1ffef600d9 --- /dev/null +++ b/examples/vite-react-dom-app-v1/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/examples/vite-react-dom-app-v1/tsconfig.node.json b/examples/vite-react-dom-app-v1/tsconfig.node.json new file mode 100644 index 0000000000..9614e693fc --- /dev/null +++ b/examples/vite-react-dom-app-v1/tsconfig.node.json @@ -0,0 +1,29 @@ +{ + "extends": [ + "@tsconfig/strictest/tsconfig.json", + "@tsconfig/node22/tsconfig.json" + ], + "compilerOptions": { + "incremental": false, + "skipLibCheck": true, + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "allowJs": true, + "noEmit": true + }, + "include": [ + "*.ts", + "*.cts", + "*.mts", + "*.d.ts" + ], + "exclude": [ + "node_modules", + "dist", + "src", + "benchmark" + ] +} diff --git a/examples/vite-react-dom-app-v1/vite.config.ts b/examples/vite-react-dom-app-v1/vite.config.ts new file mode 100644 index 0000000000..a88da26ab2 --- /dev/null +++ b/examples/vite-react-dom-app-v1/vite.config.ts @@ -0,0 +1,9 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + ], +}); diff --git a/examples/vite-react-dom-app/package.json b/examples/vite-react-dom-app/package.json index 30582d8a8c..048e18544a 100644 --- a/examples/vite-react-dom-app/package.json +++ b/examples/vite-react-dom-app/package.json @@ -32,6 +32,6 @@ "vite": "^6.3.5" }, "engines": { - "node": ">=18.18.0" + "node": ">=20.19.0" } } diff --git a/examples/vite-react-dom-js-app/package.json b/examples/vite-react-dom-js-app/package.json index adb996fd93..47b18b1e7e 100644 --- a/examples/vite-react-dom-js-app/package.json +++ b/examples/vite-react-dom-js-app/package.json @@ -28,6 +28,6 @@ "vite": "^6.3.5" }, "engines": { - "node": ">=18.18.0" + "node": ">=20.19.0" } } diff --git a/examples/vite-react-dom-js-with-babel-eslint-parser-app/eslint.config.js b/examples/vite-react-dom-js-with-babel-eslint-parser-app/eslint.config.js index 7361c29de3..85f1683561 100644 --- a/examples/vite-react-dom-js-with-babel-eslint-parser-app/eslint.config.js +++ b/examples/vite-react-dom-js-with-babel-eslint-parser-app/eslint.config.js @@ -3,7 +3,6 @@ import eslintPluginReactX from "eslint-plugin-react-x"; import eslintPluginReactDom from "eslint-plugin-react-dom"; import eslintPluginReactWebApi from "eslint-plugin-react-web-api"; import eslintPluginReactHooks from "eslint-plugin-react-hooks"; -import eslintPluginReactHooksExtra from "eslint-plugin-react-hooks-extra"; import eslintPluginReactNamingConvention from "eslint-plugin-react-naming-convention"; import eslintPluginReactRefresh from "eslint-plugin-react-refresh"; import eslintPluginReactDebug from "eslint-plugin-react-debug"; @@ -66,7 +65,6 @@ export default defineConfig([ eslintPluginReactX.configs.recommended, eslintPluginReactDom.configs.recommended, eslintPluginReactWebApi.configs.recommended, - eslintPluginReactHooksExtra.configs.recommended, eslintPluginReactNamingConvention.configs.recommended, eslintPluginReactRefresh.configs.recommended, eslintPluginReactDebug.configs.all, diff --git a/examples/vite-react-dom-js-with-babel-eslint-parser-app/package.json b/examples/vite-react-dom-js-with-babel-eslint-parser-app/package.json index baf3485d3f..fb8353864c 100644 --- a/examples/vite-react-dom-js-with-babel-eslint-parser-app/package.json +++ b/examples/vite-react-dom-js-with-babel-eslint-parser-app/package.json @@ -30,7 +30,6 @@ "eslint-plugin-react-debug": "workspace:*", "eslint-plugin-react-dom": "workspace:*", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-hooks-extra": "workspace:*", "eslint-plugin-react-naming-convention": "workspace:*", "eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-web-api": "workspace:*", @@ -39,6 +38,6 @@ "vite": "^6.3.5" }, "engines": { - "node": ">=18.18.0" + "node": ">=20.19.0" } } diff --git a/examples/vite-react-dom-with-ts-blank-eslint-parser-app/package.json b/examples/vite-react-dom-with-ts-blank-eslint-parser-app/package.json index af55059120..58db39f7fa 100644 --- a/examples/vite-react-dom-with-ts-blank-eslint-parser-app/package.json +++ b/examples/vite-react-dom-with-ts-blank-eslint-parser-app/package.json @@ -36,6 +36,6 @@ "vite": "^6.3.5" }, "engines": { - "node": ">=18.18.0" + "node": ">=20.19.0" } } diff --git a/package.json b/package.json index becf1515bb..1dcc9a3542 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@eslint-react/monorepo", - "version": "1.52.3-next.6", + "version": "2.0.0-next.47", "private": true, - "description": "Monorepo for eslint-plugin-react-[x, dom, web-api, hooks-extra, naming-convention].", + "description": "Monorepo for eslint-plugin-react-[x, dom, web-api, naming-convention].", "keywords": [ "react", "eslint", @@ -11,7 +11,6 @@ "eslint-plugin-react-x", "eslint-plugin-react-dom", "eslint-plugin-react-web-api", - "eslint-plugin-react-hooks-extra", "eslint-plugin-react-naming-convention" ], "homepage": "https://github.com/Rel1cx/eslint-react", @@ -37,7 +36,7 @@ "inspect:eslint-config": "eslint-config-inspector", "lint": "pnpm run lint:deps && pnpm run lint:publish && pnpm run lint:ts && pnpm run lint:es && pnpm run lint:examples", "lint:deps": "skott -m file-tree -e .ts -s", - "lint:es": "eslint . --max-warnings 10", + "lint:es": "eslint . --max-warnings 20", "lint:examples": "pnpm m -F \"./examples/*\" run lint", "lint:publish": "pnpm m run lint:publish", "lint:spell": "cspell lint --relative --no-progress \"**\"", @@ -98,7 +97,7 @@ }, "packageManager": "pnpm@10.12.1", "engines": { - "node": ">=18.18.0" + "node": ">=20.19.0" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/packages/core/docs/README.md b/packages/core/docs/README.md index 5be37efebd..86296191c0 100644 --- a/packages/core/docs/README.md +++ b/packages/core/docs/README.md @@ -31,7 +31,6 @@ - [ComponentPhaseKind](type-aliases/ComponentPhaseKind.md) - [ComponentStateKind](type-aliases/ComponentStateKind.md) - [EffectKind](type-aliases/EffectKind.md) -- [HookKind](type-aliases/HookKind.md) - [JSXDetectionHint](type-aliases/JSXDetectionHint.md) ## Variables @@ -88,6 +87,7 @@ - [isUseSyncExternalStoreCall](variables/isUseSyncExternalStoreCall.md) - [isUseTransitionCall](variables/isUseTransitionCall.md) - [JSXDetectionHint](variables/JSXDetectionHint.md) +- [REACT\_BUILTIN\_HOOK\_NAMES](variables/REACT_BUILTIN_HOOK_NAMES.md) ## Functions @@ -119,12 +119,8 @@ - [isComponentWrapperCallLoose](functions/isComponentWrapperCallLoose.md) - [isDeclaredInRenderPropLoose](functions/isDeclaredInRenderPropLoose.md) - [isFragmentElement](functions/isFragmentElement.md) -- [isFunctionOfComponentDidMount](functions/isFunctionOfComponentDidMount.md) -- [isFunctionOfComponentWillUnmount](functions/isFunctionOfComponentWillUnmount.md) - [isFunctionOfRender](functions/isFunctionOfRender.md) - [isFunctionOfRenderMethod](functions/isFunctionOfRenderMethod.md) -- [isFunctionOfUseEffectCleanup](functions/isFunctionOfUseEffectCleanup.md) -- [isFunctionOfUseEffectSetup](functions/isFunctionOfUseEffectSetup.md) - [isGetChildContext](functions/isGetChildContext.md) - [isGetDefaultProps](functions/isGetDefaultProps.md) - [isGetDerivedStateFromError](functions/isGetDerivedStateFromError.md) @@ -133,7 +129,6 @@ - [isGetSnapshotBeforeUpdate](functions/isGetSnapshotBeforeUpdate.md) - [isHostElement](functions/isHostElement.md) - [isInitializedFromReact](functions/isInitializedFromReact.md) -- [isInsideRenderMethod](functions/isInsideRenderMethod.md) - [isJsxLike](functions/isJsxLike.md) - [isJsxText](functions/isJsxText.md) - [isKeyedElement](functions/isKeyedElement.md) @@ -145,6 +140,7 @@ - [isReactHookCallWithName](functions/isReactHookCallWithName.md) - [isReactHookCallWithNameAlias](functions/isReactHookCallWithNameAlias.md) - [isReactHookCallWithNameLoose](functions/isReactHookCallWithNameLoose.md) +- [isReactHookId](functions/isReactHookId.md) - [isReactHookName](functions/isReactHookName.md) - [isRenderFunctionLoose](functions/isRenderFunctionLoose.md) - [isRenderLike](functions/isRenderLike.md) diff --git a/packages/core/docs/functions/isFunctionOfComponentDidMount.md b/packages/core/docs/functions/isFunctionOfComponentDidMount.md deleted file mode 100644 index 28dac0a08a..0000000000 --- a/packages/core/docs/functions/isFunctionOfComponentDidMount.md +++ /dev/null @@ -1,19 +0,0 @@ -[**@eslint-react/core**](../README.md) - -*** - -[@eslint-react/core](../README.md) / isFunctionOfComponentDidMount - -# Function: isFunctionOfComponentDidMount() - -> **isFunctionOfComponentDidMount**(`node`): `boolean` - -## Parameters - -### node - -`Node` - -## Returns - -`boolean` diff --git a/packages/core/docs/functions/isFunctionOfComponentWillUnmount.md b/packages/core/docs/functions/isFunctionOfComponentWillUnmount.md deleted file mode 100644 index 03d881a84e..0000000000 --- a/packages/core/docs/functions/isFunctionOfComponentWillUnmount.md +++ /dev/null @@ -1,19 +0,0 @@ -[**@eslint-react/core**](../README.md) - -*** - -[@eslint-react/core](../README.md) / isFunctionOfComponentWillUnmount - -# Function: isFunctionOfComponentWillUnmount() - -> **isFunctionOfComponentWillUnmount**(`node`): `boolean` - -## Parameters - -### node - -`Node` - -## Returns - -`boolean` diff --git a/packages/core/docs/functions/isFunctionOfUseEffectCleanup.md b/packages/core/docs/functions/isFunctionOfUseEffectCleanup.md deleted file mode 100644 index 6be5b3f60f..0000000000 --- a/packages/core/docs/functions/isFunctionOfUseEffectCleanup.md +++ /dev/null @@ -1,19 +0,0 @@ -[**@eslint-react/core**](../README.md) - -*** - -[@eslint-react/core](../README.md) / isFunctionOfUseEffectCleanup - -# Function: isFunctionOfUseEffectCleanup() - -> **isFunctionOfUseEffectCleanup**(`node`): `boolean` - -## Parameters - -### node - -`undefined` | `Node` - -## Returns - -`boolean` diff --git a/packages/core/docs/functions/isFunctionOfUseEffectSetup.md b/packages/core/docs/functions/isFunctionOfUseEffectSetup.md deleted file mode 100644 index 1a20571039..0000000000 --- a/packages/core/docs/functions/isFunctionOfUseEffectSetup.md +++ /dev/null @@ -1,19 +0,0 @@ -[**@eslint-react/core**](../README.md) - -*** - -[@eslint-react/core](../README.md) / isFunctionOfUseEffectSetup - -# Function: isFunctionOfUseEffectSetup() - -> **isFunctionOfUseEffectSetup**(`node`): `boolean` - -## Parameters - -### node - -`undefined` | `Node` - -## Returns - -`boolean` diff --git a/packages/core/docs/functions/isInsideRenderMethod.md b/packages/core/docs/functions/isInsideRenderMethod.md deleted file mode 100644 index 2307fb745e..0000000000 --- a/packages/core/docs/functions/isInsideRenderMethod.md +++ /dev/null @@ -1,35 +0,0 @@ -[**@eslint-react/core**](../README.md) - -*** - -[@eslint-react/core](../README.md) / isInsideRenderMethod - -# Function: isInsideRenderMethod() - -> **isInsideRenderMethod**(`node`): `boolean` - -Check whether given node is declared inside class component's render block -```tsx -class Component extends React.Component { - render() { - class NestedClassComponent extends React.Component { - render() { return
; } - } - const nestedFunctionComponent = () =>
; - } -} -``` - -## Parameters - -### node - -`Node` - -The AST node being checked - -## Returns - -`boolean` - -`true` if node is inside class component's render block, `false` if not diff --git a/packages/core/docs/functions/isReactHookId.md b/packages/core/docs/functions/isReactHookId.md new file mode 100644 index 0000000000..be8aebee6b --- /dev/null +++ b/packages/core/docs/functions/isReactHookId.md @@ -0,0 +1,19 @@ +[**@eslint-react/core**](../README.md) + +*** + +[@eslint-react/core](../README.md) / isReactHookId + +# Function: isReactHookId() + +> **isReactHookId**(`id`): `boolean` + +## Parameters + +### id + +`Identifier` | `MemberExpression` + +## Returns + +`boolean` diff --git a/packages/core/docs/functions/stringifyJsx.md b/packages/core/docs/functions/stringifyJsx.md index 664b8a8f67..6c88de9e50 100644 --- a/packages/core/docs/functions/stringifyJsx.md +++ b/packages/core/docs/functions/stringifyJsx.md @@ -16,7 +16,7 @@ Get the stringified representation of a JSX node The JSX node -`JSXIdentifier` | `JSXMemberExpression` | `JSXNamespacedName` | `JSXOpeningElement` | `JSXClosingElement` | `JSXOpeningFragment` | `JSXClosingFragment` | `JSXText` +`JSXClosingElement` | `JSXClosingFragment` | `JSXIdentifier` | `JSXMemberExpression` | `JSXNamespacedName` | `JSXOpeningElement` | `JSXOpeningFragment` | `JSXText` ## Returns diff --git a/packages/core/docs/type-aliases/HookKind.md b/packages/core/docs/type-aliases/HookKind.md deleted file mode 100644 index 132c47ac57..0000000000 --- a/packages/core/docs/type-aliases/HookKind.md +++ /dev/null @@ -1,9 +0,0 @@ -[**@eslint-react/core**](../README.md) - -*** - -[@eslint-react/core](../README.md) / HookKind - -# Type Alias: HookKind - -> **HookKind** = `"custom"` \| `"useActionState"` \| `"useCallback"` \| `"useContext"` \| `"useDebugValue"` \| `"useDeferredValue"` \| `"useEffect"` \| `"useId"` \| `"useImperativeHandle"` \| `"useInsertionEffect"` \| `"useLayoutEffect"` \| `"useMemo"` \| `"useOptimistic"` \| `"useReducer"` \| `"useRef"` \| `"useState"` \| `"useSyncExternalStore"` \| `"useTransition"` diff --git a/packages/core/docs/variables/REACT_BUILTIN_HOOK_NAMES.md b/packages/core/docs/variables/REACT_BUILTIN_HOOK_NAMES.md new file mode 100644 index 0000000000..e057c6ff49 --- /dev/null +++ b/packages/core/docs/variables/REACT_BUILTIN_HOOK_NAMES.md @@ -0,0 +1,9 @@ +[**@eslint-react/core**](../README.md) + +*** + +[@eslint-react/core](../README.md) / REACT\_BUILTIN\_HOOK\_NAMES + +# Variable: REACT\_BUILTIN\_HOOK\_NAMES + +> `const` **REACT\_BUILTIN\_HOOK\_NAMES**: readonly \[`"use"`, `"useActionState"`, `"useCallback"`, `"useContext"`, `"useDebugValue"`, `"useDeferredValue"`, `"useEffect"`, `"useFormStatus"`, `"useId"`, `"useImperativeHandle"`, `"useInsertionEffect"`, `"useLayoutEffect"`, `"useMemo"`, `"useOptimistic"`, `"useReducer"`, `"useRef"`, `"useState"`, `"useSyncExternalStore"`, `"useTransition"`\] diff --git a/packages/core/package.json b/packages/core/package.json index 456ee95054..f1d10d31bf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@eslint-react/core", - "version": "1.52.3-next.6", + "version": "2.0.0-next.47", "description": "ESLint React's ESLint utility module for static analysis of React core APIs and patterns.", "homepage": "https://github.com/Rel1cx/eslint-react", "bugs": { @@ -14,22 +14,14 @@ "license": "MIT", "author": "Rel1cx", "sideEffects": false, + "type": "module", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./package.json": "./package.json" }, - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", "files": [ "dist", "./package.json" @@ -60,6 +52,6 @@ }, "engines": { "bun": ">=1.0.15", - "node": ">=18.18.0" + "node": ">=20.19.0" } } diff --git a/packages/core/src/component/component-children.ts b/packages/core/src/component/component-children.ts new file mode 100644 index 0000000000..95f0d12d3d --- /dev/null +++ b/packages/core/src/component/component-children.ts @@ -0,0 +1,19 @@ +import type { RuleContext } from "@eslint-react/kit"; +import type { TSESTree } from "@typescript-eslint/types"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { isCreateElementCall } from "../utils/is-react-api"; + +/** + * Determines whether inside `createElement`'s children. + * @param context The rule context + * @param node The AST node to check + * @returns `true` if the node is inside createElement's children + */ +export function isChildrenOfCreateElement(context: RuleContext, node: TSESTree.Node) { + const parent = node.parent; + if (parent == null || parent.type !== T.CallExpression) return false; + if (!isCreateElementCall(context, parent)) return false; + return parent.arguments + .slice(2) + .some((arg) => arg === node); +} diff --git a/packages/core/src/component/component-definition.ts b/packages/core/src/component/component-definition.ts index 3c65298420..7b52551ca1 100644 --- a/packages/core/src/component/component-definition.ts +++ b/packages/core/src/component/component-definition.ts @@ -3,8 +3,10 @@ import * as AST from "@eslint-react/ast"; import { type RuleContext } from "@eslint-react/kit"; import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; import { isMatching, P } from "ts-pattern"; +import { isChildrenOfCreateElement } from "./component-children"; import { ComponentDetectionHint } from "./component-detection-hint"; -import { isChildrenOfCreateElement, isFunctionOfRenderMethod } from "./component-hierarchy"; +import { isClassComponent } from "./component-is"; +import { isRenderMethodLike } from "./component-render-method"; const isFunctionOfClassMethod = isMatching({ type: P.union(T.ArrowFunctionExpression, T.FunctionExpression), @@ -26,6 +28,26 @@ const isFunctionOfObjectMethod = isMatching({ }, }); +/** + * Check whether given node is a function of a render method of a class component + * @example + * ```tsx + * class Component extends React.Component { + * renderHeader = () =>
; + * renderFooter = () =>
; + * } + * ``` + * @param node The AST node to check + * @returns `true` if node is a render function, `false` if not + */ +export function isFunctionOfRenderMethod(node: AST.TSESTreeFunction) { + if (!isRenderMethodLike(node.parent)) { + return false; + } + + return isClassComponent(node.parent.parent.parent); +} + export function isValidComponentDefinition(context: RuleContext, node: AST.TSESTreeFunction, hint: bigint) { if (isChildrenOfCreateElement(context, node) || isFunctionOfRenderMethod(node)) { return false; diff --git a/packages/core/src/component/component-hierarchy.ts b/packages/core/src/component/component-hierarchy.ts deleted file mode 100644 index f0f5778e20..0000000000 --- a/packages/core/src/component/component-hierarchy.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { TSESTree } from "@typescript-eslint/types"; -import * as AST from "@eslint-react/ast"; -import { type RuleContext } from "@eslint-react/kit"; - -import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; -import { isCreateElementCall } from "../utils"; -import { isClassComponent } from "./component-is"; -import { isComponentDidMount, isComponentWillUnmount } from "./component-lifecycle"; -import { isRenderLike } from "./component-render"; -import { isRenderMethodLike } from "./component-render-method"; - -/** - * Determines whether inside `createElement`'s children. - * @param context The rule context - * @param node The AST node to check - * @returns `true` if the node is inside createElement's children - */ -export function isChildrenOfCreateElement(context: RuleContext, node: TSESTree.Node) { - const parent = node.parent; - if (parent == null || parent.type !== T.CallExpression) return false; - if (!isCreateElementCall(context, parent)) return false; - return parent.arguments - .slice(2) - .some((arg) => arg === node); -} - -/** - * Check whether given node is declared inside class component's render block - * ```tsx - * class Component extends React.Component { - * render() { - * class NestedClassComponent extends React.Component { - * render() { return
; } - * } - * const nestedFunctionComponent = () =>
; - * } - * } - * ``` - * @param node The AST node being checked - * @returns `true` if node is inside class component's render block, `false` if not - */ -export function isInsideRenderMethod(node: TSESTree.Node) { - return AST.findParentNode(node, (n) => isRenderLike(n) && isClassComponent(n.parent.parent)) != null; -} - -export function isFunctionOfComponentDidMount(node: TSESTree.Node) { - return AST.isFunction(node) - && isComponentDidMount(node.parent) - && node.parent.value === node; -} - -export function isFunctionOfComponentWillUnmount(node: TSESTree.Node) { - return AST.isFunction(node) - && isComponentWillUnmount(node.parent) - && node.parent.value === node; -} - -/** - * Check whether given node is a function of a render method of a class component - * @example - * ```tsx - * class Component extends React.Component { - * renderHeader = () =>
; - * renderFooter = () =>
; - * } - * ``` - * @param node The AST node to check - * @returns `true` if node is a render function, `false` if not - */ -export function isFunctionOfRenderMethod(node: AST.TSESTreeFunction) { - if (!isRenderMethodLike(node.parent)) { - return false; - } - - return isClassComponent(node.parent.parent.parent); -} diff --git a/packages/core/src/component/component-lifecycle.ts b/packages/core/src/component/component-method.ts similarity index 100% rename from packages/core/src/component/component-lifecycle.ts rename to packages/core/src/component/component-method.ts diff --git a/packages/core/src/component/index.ts b/packages/core/src/component/index.ts index 1e709b9888..096e2a4b88 100644 --- a/packages/core/src/component/index.ts +++ b/packages/core/src/component/index.ts @@ -1,14 +1,14 @@ +export * from "./component-children"; export * from "./component-collector"; export * from "./component-collector-legacy"; export * from "./component-definition"; export * from "./component-detection-hint"; export * from "./component-flag"; -export * from "./component-hierarchy"; export * from "./component-id"; export * from "./component-init-path"; export * from "./component-is"; export type * from "./component-kind"; -export * from "./component-lifecycle"; +export * from "./component-method"; export * from "./component-name"; export * from "./component-phase"; export * from "./component-render"; diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-react-hook-identifier.ts b/packages/core/src/hook/hook-id.ts similarity index 55% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-react-hook-identifier.ts rename to packages/core/src/hook/hook-id.ts index 63ed7fff1a..d48d0ff95b 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-react-hook-identifier.ts +++ b/packages/core/src/hook/hook-id.ts @@ -1,13 +1,14 @@ import type { TSESTree } from "@typescript-eslint/types"; import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { isReactHookName } from "./hook-name"; -export function isReactHookIdentifier(id: TSESTree.Identifier | TSESTree.MemberExpression) { +export function isReactHookId(id: TSESTree.Identifier | TSESTree.MemberExpression) { switch (id.type) { case T.Identifier: - return id.name.startsWith("use"); + return isReactHookName(id.name); case T.MemberExpression: return "name" in id.property - && id.property.name.startsWith("use"); + && isReactHookName(id.property.name); default: return false; } diff --git a/packages/core/src/hook/hook-kind.ts b/packages/core/src/hook/hook-kind.ts deleted file mode 100644 index 31fd2bd037..0000000000 --- a/packages/core/src/hook/hook-kind.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type HookKind = - | "custom" - | "useActionState" - | "useCallback" - | "useContext" - | "useDebugValue" - | "useDeferredValue" - | "useEffect" - | "useId" - | "useImperativeHandle" - | "useInsertionEffect" - | "useLayoutEffect" - | "useMemo" - | "useOptimistic" - | "useReducer" - | "useRef" - | "useState" - | "useSyncExternalStore" - | "useTransition"; diff --git a/packages/core/src/hook/hook-name.ts b/packages/core/src/hook/hook-name.ts index a876f0aba5..f66e48d85f 100644 --- a/packages/core/src/hook/hook-name.ts +++ b/packages/core/src/hook/hook-name.ts @@ -1,3 +1,25 @@ +export const REACT_BUILTIN_HOOK_NAMES = [ + "use", + "useActionState", + "useCallback", + "useContext", + "useDebugValue", + "useDeferredValue", + "useEffect", + "useFormStatus", + "useId", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useMemo", + "useOptimistic", + "useReducer", + "useRef", + "useState", + "useSyncExternalStore", + "useTransition", +] as const; + /** * Catch all identifiers that begin with "use" followed by an uppercase Latin * character to exclude identifiers like "user". diff --git a/packages/core/src/hook/index.ts b/packages/core/src/hook/index.ts index 0ac0de64e5..f878fca179 100644 --- a/packages/core/src/hook/index.ts +++ b/packages/core/src/hook/index.ts @@ -1,6 +1,5 @@ export * from "./hook-collector"; -export * from "./hook-hierarchy"; +export * from "./hook-id"; export * from "./hook-is"; -export type * from "./hook-kind"; export * from "./hook-name"; export type * from "./hook-semantic-node"; diff --git a/packages/core/src/utils/is-react-api.ts b/packages/core/src/utils/is-react-api.ts index fa1637f800..5ad85dae1d 100644 --- a/packages/core/src/utils/is-react-api.ts +++ b/packages/core/src/utils/is-react-api.ts @@ -20,7 +20,7 @@ export function isReactAPI(api: string): isReactAPI.ReturnType { { if (node == null) return false; const getText = (n: TSESTree.Node) => context.sourceCode.getText(n); - const name = AST.toString(node, getText); + const name = AST.toStringFormat(node, getText); if (name === api) return true; if (name.substring(name.indexOf(".") + 1) === api) return true; return false; diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index a84cd44465..e037e267d9 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -5,12 +5,12 @@ export default { dts: true, entry: ["src/index.ts"], external: ["eslint", "typescript"], - format: ["cjs", "esm"], + format: ["esm"], minify: false, outDir: "dist", platform: "node", sourcemap: false, splitting: false, - target: "node18", + target: "node20", treeshake: true, } satisfies Options; diff --git a/packages/plugins/eslint-plugin-react-debug/package.json b/packages/plugins/eslint-plugin-react-debug/package.json index 2da16442b6..0837ec7dc1 100644 --- a/packages/plugins/eslint-plugin-react-debug/package.json +++ b/packages/plugins/eslint-plugin-react-debug/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-debug", - "version": "1.52.3-next.6", + "version": "2.0.0-next.47", "description": "ESLint React's ESLint plugin for debugging related rules.", "keywords": [ "react", @@ -22,22 +22,14 @@ "license": "MIT", "author": "Rel1cx", "sideEffects": false, + "type": "module", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./package.json": "./package.json" }, - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "./dist/index.d.ts", "files": [ "dist", "./package.json" @@ -69,8 +61,8 @@ "tsup": "^8.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "^4.9.5 || ^5.3.3" + "eslint": "^9.29.0", + "typescript": "^4.9.5 || ^5.4.5" }, "peerDependenciesMeta": { "eslint": { @@ -82,7 +74,7 @@ }, "engines": { "bun": ">=1.0.15", - "node": ">=18.18.0" + "node": ">=20.19.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/eslint-plugin-react-debug/src/plugin.ts b/packages/plugins/eslint-plugin-react-debug/src/plugin.ts index e0710a251e..bf345d6e8c 100644 --- a/packages/plugins/eslint-plugin-react-debug/src/plugin.ts +++ b/packages/plugins/eslint-plugin-react-debug/src/plugin.ts @@ -16,9 +16,5 @@ export const plugin = { ["hook"]: hook, ["is-from-react"]: isFromReact, ["jsx"]: jsx, - - // Part: deprecated rules - /** @deprecated Use `hook` instead */ - "react-hooks": hook, }, } as const; diff --git a/packages/plugins/eslint-plugin-react-debug/tsup.config.ts b/packages/plugins/eslint-plugin-react-debug/tsup.config.ts index a84cd44465..e037e267d9 100644 --- a/packages/plugins/eslint-plugin-react-debug/tsup.config.ts +++ b/packages/plugins/eslint-plugin-react-debug/tsup.config.ts @@ -5,12 +5,12 @@ export default { dts: true, entry: ["src/index.ts"], external: ["eslint", "typescript"], - format: ["cjs", "esm"], + format: ["esm"], minify: false, outDir: "dist", platform: "node", sourcemap: false, splitting: false, - target: "node18", + target: "node20", treeshake: true, } satisfies Options; diff --git a/packages/plugins/eslint-plugin-react-dom/package.json b/packages/plugins/eslint-plugin-react-dom/package.json index 011d89f317..1d7dc6196e 100644 --- a/packages/plugins/eslint-plugin-react-dom/package.json +++ b/packages/plugins/eslint-plugin-react-dom/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-dom", - "version": "1.52.3-next.6", + "version": "2.0.0-next.47", "description": "ESLint React's ESLint plugin for React DOM related rules.", "keywords": [ "react", @@ -22,22 +22,14 @@ "license": "MIT", "author": "Rel1cx", "sideEffects": false, + "type": "module", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./package.json": "./package.json" }, - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", "files": [ "dist", "./package.json" @@ -69,8 +61,8 @@ "tsup": "^8.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "^4.9.5 || ^5.3.3" + "eslint": "^9.29.0", + "typescript": "^4.9.5 || ^5.4.5" }, "peerDependenciesMeta": { "eslint": { @@ -82,7 +74,7 @@ }, "engines": { "bun": ">=1.0.15", - "node": ">=18.18.0" + "node": ">=20.19.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/eslint-plugin-react-dom/src/plugin.ts b/packages/plugins/eslint-plugin-react-dom/src/plugin.ts index bb552c45f2..f0c0d232e1 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/plugin.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/plugin.ts @@ -38,9 +38,5 @@ export const plugin = { "no-unsafe-target-blank": noUnsafeTargetBlank, "no-use-form-state": noUseFormState, "no-void-elements-with-children": noVoidElementsWithChildren, - - // Part: deprecated rules - /** @deprecated Use `no-void-elements-with-children` instead */ - "no-children-in-void-dom-elements": noVoidElementsWithChildren, }, } as const; diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.md b/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.md new file mode 100644 index 0000000000..2f96e2193d --- /dev/null +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.md @@ -0,0 +1,45 @@ +--- +title: prefer-namespace-import +--- + +**Full Name in `eslint-plugin-react-dom`** + +```sh copy +react-dom/prefer-namespace-import +``` + +**Full Name in `@eslint-react/eslint-plugin`** + +```sh copy +@eslint-react/dom/prefer-namespace-import +``` + +**Features** + +`🔧` + +## Description + +Enforces React DOM is imported via a namespace import. + +## Examples + +### Failing + +```tsx +import ReactDOM from "react-dom/client"; + +import type ReactDOM from "react-dom/client"; +``` + +### Passing + +```tsx +import * as ReactDOM from "react-dom/client"; +import type * as ReactDOM from "react-dom/client"; +``` + +## Implementation + +- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.ts) +- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.spec.ts) diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.spec.ts new file mode 100644 index 0000000000..4091cf8e80 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.spec.ts @@ -0,0 +1,77 @@ +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; + +import { ruleTester } from "../../../../../test"; +import rule, { RULE_NAME } from "./prefer-namespace-import"; + +ruleTester.run(RULE_NAME, rule, { + invalid: [ + { + code: `import ReactDOM from 'react-dom';`, + errors: [{ type: T.ImportDeclaration, messageId: "preferNamespaceImport" }], + output: `import * as ReactDOM from 'react-dom';`, + }, + { + code: `import ReactDom from 'react-dom';`, + errors: [{ type: T.ImportDeclaration, messageId: "preferNamespaceImport" }], + output: `import * as ReactDom from 'react-dom';`, + }, + { + code: `import REACTDOM from 'react-dom';`, + errors: [{ type: T.ImportDeclaration, messageId: "preferNamespaceImport" }], + output: `import * as REACTDOM from 'react-dom';`, + }, + { + code: `import ReactDOM from 'react-dom/client';`, + errors: [{ type: T.ImportDeclaration, messageId: "preferNamespaceImport" }], + output: `import * as ReactDOM from 'react-dom/client';`, + }, + { + code: `import ReactDom from 'react-dom/client';`, + errors: [{ type: T.ImportDeclaration, messageId: "preferNamespaceImport" }], + output: `import * as ReactDom from 'react-dom/client';`, + }, + { + code: `import REACTDOM from 'react-dom/client';`, + errors: [{ type: T.ImportDeclaration, messageId: "preferNamespaceImport" }], + output: `import * as REACTDOM from 'react-dom/client';`, + }, + ], + valid: [ + { + code: `import React from 'react';`, + }, + { + code: `import * as React from 'react';`, + }, + { + code: `import { createRoot } from 'react-dom/client';`, + }, + { + code: `import * as ReactDOM from 'react-dom';`, + }, + { + code: `import * as ReactDOM from 'react-dom/client';`, + }, + { + code: `import * as ReactDOM from 'react-dom/server';`, + }, + { + code: `import * as ReactDom from 'react-dom';`, + }, + { + code: `import * as ReactDom from 'react-dom/client';`, + }, + { + code: `import * as ReactDom from 'react-dom/server';`, + }, + { + code: `import * as REACTDOM from 'react-dom';`, + }, + { + code: `import * as REACTDOM from 'react-dom/client';`, + }, + { + code: `import * as REACTDOM from 'react-dom/server';`, + }, + ], +}); diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.ts new file mode 100644 index 0000000000..af832d54ab --- /dev/null +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/prefer-namespace-import.ts @@ -0,0 +1,77 @@ +import type { TSESTree } from "@typescript-eslint/types"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import type { CamelCase } from "string-ts"; +import { type RuleContext, type RuleFeature } from "@eslint-react/kit"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "prefer-namespace-import"; + +export const RULE_FEATURES = [ + "FIX", +] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + docs: { + description: "Enforces React Dom is imported via a namespace import.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + fixable: "code", + messages: { + preferNamespaceImport: "Prefer importing React DOM via a namespace import.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +const importSources = [ + "react-dom", + "react-dom/client", + "react-dom/server", +]; + +export function create(context: RuleContext): RuleListener { + return { + [`ImportDeclaration ImportDefaultSpecifier`](node: TSESTree.ImportDefaultSpecifier) { + const importSource = node.parent.source.value; + if (!importSources.includes(importSource)) return; + const hasOtherSpecifiers = node.parent.specifiers.length > 1; + context.report({ + messageId: "preferNamespaceImport", + node: hasOtherSpecifiers ? node : node.parent, + data: { importSource }, + fix(fixer) { + const importDeclarationText = context.sourceCode.getText(node.parent); + const semi = importDeclarationText.endsWith(";") ? ";" : ""; + const quote = node.parent.source.raw.at(0) ?? "'"; + const isTypeImport = node.parent.importKind === "type"; + const importStringPrefix = `import${isTypeImport ? " type" : ""}`; + const importSourceQuoted = `${quote}${importSource}${quote}`; + if (!hasOtherSpecifiers) { + return fixer.replaceText( + node.parent, + `${importStringPrefix} * as ${node.local.name} from ${importSourceQuoted}${semi}`, + ); + } + // dprint-ignore + // remove the default specifier and prepend the namespace import specifier + const specifiers = importDeclarationText.slice(importDeclarationText.indexOf("{"), importDeclarationText.indexOf("}") + 1); + return fixer.replaceText( + node.parent, + [ + `${importStringPrefix} * as ${node.local.name} from ${importSourceQuoted}${semi}`, + `${importStringPrefix} ${specifiers} from ${importSourceQuoted}${semi}`, + ].join("\n"), + ); + }, + }); + }, + }; +} diff --git a/packages/plugins/eslint-plugin-react-dom/tsup.config.ts b/packages/plugins/eslint-plugin-react-dom/tsup.config.ts index a84cd44465..e037e267d9 100644 --- a/packages/plugins/eslint-plugin-react-dom/tsup.config.ts +++ b/packages/plugins/eslint-plugin-react-dom/tsup.config.ts @@ -5,12 +5,12 @@ export default { dts: true, entry: ["src/index.ts"], external: ["eslint", "typescript"], - format: ["cjs", "esm"], + format: ["esm"], minify: false, outDir: "dist", platform: "node", sourcemap: false, splitting: false, - target: "node18", + target: "node20", treeshake: true, } satisfies Options; diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/package.json b/packages/plugins/eslint-plugin-react-hooks-extra/package.json index 9129e17e7d..57e0feb552 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/package.json +++ b/packages/plugins/eslint-plugin-react-hooks-extra/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-hooks-extra", - "version": "1.52.3-next.6", + "version": "2.0.0-next.47", "description": "ESLint React's ESLint plugin for React Hooks related rules.", "keywords": [ "react", @@ -23,22 +23,14 @@ "license": "MIT", "author": "Rel1cx", "sideEffects": false, + "type": "module", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./package.json": "./package.json" }, - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", "files": [ "dist", "./package.json" diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/configs/recommended.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/configs/recommended.ts index f1038949f2..c17a87a8de 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/configs/recommended.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/configs/recommended.ts @@ -5,5 +5,4 @@ export const name = "react-hooks-extra/recommended"; export const rules = { "react-hooks-extra/no-direct-set-state-in-use-effect": "warn", "react-hooks-extra/no-unnecessary-use-prefix": "warn", - "react-hooks-extra/prefer-use-state-lazy-initialization": "warn", } as const satisfies RulePreset; diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/plugin.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/plugin.ts index 5442fbbd13..7a70f42274 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/plugin.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/plugin.ts @@ -1,10 +1,14 @@ import { name, version } from "../package.json"; + import noDirectSetStateInUseEffect from "./rules/no-direct-set-state-in-use-effect"; import noDirectSetStateInUseLayoutEffect from "./rules/no-direct-set-state-in-use-layout-effect"; -import noUnnecessaryUseCallback from "./rules/no-unnecessary-use-callback"; -import noUnnecessaryUseMemo from "./rules/no-unnecessary-use-memo"; -import noUnnecessaryUsePrefix from "./rules/no-unnecessary-use-prefix"; -import preferUseStateLazyInitialization from "./rules/prefer-use-state-lazy-initialization"; + +/* eslint-disable perfectionist/sort-imports */ +import noUnnecessaryUseCallback from "./rules-removed/no-unnecessary-use-callback"; +import noUnnecessaryUseMemo from "./rules-removed/no-unnecessary-use-memo"; +import noUnnecessaryUsePrefix from "./rules-removed/no-unnecessary-use-prefix"; +import preferUseStateLazyInitialization from "./rules-removed/prefer-use-state-lazy-initialization"; +/* eslint-enable perfectionist/sort-imports */ export const plugin = { meta: { @@ -14,21 +18,26 @@ export const plugin = { rules: { "no-direct-set-state-in-use-effect": noDirectSetStateInUseEffect, "no-direct-set-state-in-use-layout-effect": noDirectSetStateInUseLayoutEffect, + + /** + * @deprecated Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead. + * @see https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-callback + */ "no-unnecessary-use-callback": noUnnecessaryUseCallback, + /** + * @deprecated Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead. + * @see https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-memo + */ "no-unnecessary-use-memo": noUnnecessaryUseMemo, + /** + * @deprecated Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead. + * @see https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-prefix + */ "no-unnecessary-use-prefix": noUnnecessaryUsePrefix, + /** + * @deprecated Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead. + * @see https://next.eslint-react.xyz/docs/rules/prefer-use-state-lazy-initialization + */ "prefer-use-state-lazy-initialization": preferUseStateLazyInitialization, - - // Part: deprecated rules - /** @deprecated Use `no-unnecessary-use-prefix` instead */ - "ensure-custom-hooks-using-other-hooks": noUnnecessaryUsePrefix, - /** @deprecated Use `no-unnecessary-use-callback` instead */ - "ensure-use-callback-has-non-empty-deps": noUnnecessaryUseCallback, - /** @deprecated Use `no-unnecessary-use-memo` instead */ - "ensure-use-memo-has-non-empty-deps": noUnnecessaryUseMemo, - /** @deprecated Use `no-unnecessary-use-prefix` instead */ - "no-redundant-custom-hook": noUnnecessaryUsePrefix, - /** @deprecated Use `no-unnecessary-use-prefix` instead */ - "no-useless-custom-hooks": noUnnecessaryUsePrefix, }, } as const; diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/hooks/use-no-direct-set-state-in-use-effect.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-hooks/use-no-direct-set-state-in-use-effect.ts similarity index 72% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/hooks/use-no-direct-set-state-in-use-effect.ts rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-hooks/use-no-direct-set-state-in-use-effect.ts index 84d2fafb7a..b0d811719e 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/hooks/use-no-direct-set-state-in-use-effect.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-hooks/use-no-direct-set-state-in-use-effect.ts @@ -7,9 +7,8 @@ import { constVoid, getOrElseUpdate, not } from "@eslint-react/eff"; import { getSettingsFromContext } from "@eslint-react/shared"; import * as VAR from "@eslint-react/var"; import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; -import { match } from "ts-pattern"; -import { isFromUseStateCall, isSetFunctionCall, isThenCall, isVariableDeclaratorFromHookCall } from "../utils"; +import { match } from "ts-pattern"; type CallKind = | "useEffect" @@ -46,8 +45,6 @@ export function useNoDirectSetStateInUseEffect( const isUseStateCall = ER.isReactHookCallWithNameAlias(context, "useState", hooks.useState); const isUseMemoCall = ER.isReactHookCallWithNameAlias(context, "useMemo", hooks.useMemo); const isUseCallbackCall = ER.isReactHookCallWithNameAlias(context, "useCallback", hooks.useCallback); - const isSetStateCall = isSetFunctionCall(context, settings); - const isIdFromUseStateCall = isFromUseStateCall(context, settings); const functionEntries: { kind: FunctionKind; node: AST.TSESTreeFunction }[] = []; const setupFunctionRef: { current: AST.TSESTreeFunction | null } = { current: null }; @@ -77,9 +74,9 @@ export function useNoDirectSetStateInUseEffect( function getCallName(node: TSESTree.Node) { if (node.type === T.CallExpression) { - return AST.toString(node.callee, getText); + return AST.toStringFormat(node.callee, getText); } - return AST.toString(node, getText); + return AST.toStringFormat(node, getText); } function getCallKind(node: TSESTree.CallExpression) { @@ -87,7 +84,7 @@ export function useNoDirectSetStateInUseEffect( .when(isUseStateCall, () => "useState") .when(isUseEffectLikeCall, () => useEffectKind) .when(isSetStateCall, () => "setState") - .when(isThenCall, () => "then") + .when(AST.isThenCall, () => "then") .otherwise(() => "other"); } @@ -96,7 +93,7 @@ export function useNoDirectSetStateInUseEffect( switch (true) { case node.async: case parent.type === T.CallExpression - && isThenCall(parent): + && AST.isThenCall(parent): return "deferred"; case node.type !== T.FunctionDeclaration && parent.type === T.CallExpression @@ -109,6 +106,73 @@ export function useNoDirectSetStateInUseEffect( } } + function isIdFromUseStateCall(topLevelId: TSESTree.Identifier, at?: number) { + const variable = VAR.findVariable(topLevelId, context.sourceCode.getScope(topLevelId)); + const variableNode = VAR.getVariableInitNode(variable, 0); + if (variableNode == null) return false; + if (variableNode.type !== T.CallExpression) return false; + if (!ER.isReactHookCallWithNameAlias(context, "useState", hooks.useState)(variableNode)) return false; + const variableNodeParent = variableNode.parent; + if (!("id" in variableNodeParent) || variableNodeParent.id?.type !== T.ArrayPattern) { + return true; + } + return variableNodeParent + .id + .elements + .findIndex((e) => e?.type === T.Identifier && e.name === topLevelId.name) === at; + } + + function isSetStateCall(node: TSESTree.CallExpression) { + switch (node.callee.type) { + // const data = useState(); + // data.at(1)(); + case T.CallExpression: { + const { callee } = node.callee; + if (callee.type !== T.MemberExpression) { + return false; + } + if (!("name" in callee.object)) { + return false; + } + const isAt = callee.property.type === T.Identifier && callee.property.name === "at"; + const [index] = node.callee.arguments; + if (!isAt || index == null) { + return false; + } + const indexScope = context.sourceCode.getScope(node); + const indexValue = VAR.toStaticValue({ + kind: "lazy", + node: index, + initialScope: indexScope, + }).value; + return indexValue === 1 && isIdFromUseStateCall(callee.object); + } + // const [data, setData] = useState(); + // setData(); + case T.Identifier: { + return isIdFromUseStateCall(node.callee, 1); + } + // const data = useState(); + // data[1](); + case T.MemberExpression: { + if (!("name" in node.callee.object)) { + return false; + } + const property = node.callee.property; + const propertyScope = context.sourceCode.getScope(node); + const propertyValue = VAR.toStaticValue({ + kind: "lazy", + node: property, + initialScope: propertyScope, + }).value; + return propertyValue === 1 && isIdFromUseStateCall(node.callee.object, 1); + } + default: { + return false; + } + } + } + return { ":function"(node: AST.TSESTreeFunction) { const kind = getFunctionKind(node); @@ -134,10 +198,9 @@ export function useNoDirectSetStateInUseEffect( .with("setState", () => { switch (true) { case pEntry.kind === "deferred": - case pEntry.node.async: { + case pEntry.node.async: // do nothing, this is a deferred setState call break; - } case pEntry.node === setupFunction: case pEntry.kind === "immediate" && AST.findParentNode(pEntry.node, AST.isFunction) === setupFunction: { @@ -167,7 +230,7 @@ export function useNoDirectSetStateInUseEffect( if (node.parent.type === T.CallExpression && node.parent.callee === node) { return; } - if (!isIdFromUseStateCall(node)) { + if (!isIdFromUseStateCall(node, 1)) { return; } switch (node.parent.type) { @@ -254,3 +317,25 @@ export function useNoDirectSetStateInUseEffect( }, }; } + +function isInitFromHookCall(init: TSESTree.Expression | null) { + if (init?.type !== T.CallExpression) return false; + switch (init.callee.type) { + case T.Identifier: + return ER.isReactHookName(init.callee.name); + case T.MemberExpression: + return init.callee.property.type === T.Identifier + && ER.isReactHookName(init.callee.property.name); + default: + return false; + } +} + +function isVariableDeclaratorFromHookCall(node: TSESTree.Node): node is + & TSESTree.VariableDeclarator + & { init: TSESTree.VariableDeclarator["init"] & {} } +{ + if (node.type !== T.VariableDeclarator) return false; + if (node.id.type !== T.Identifier) return false; + return isInitFromHookCall(node.init); +} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-callback.md b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-callback.md similarity index 85% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-callback.md rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-callback.md index 7c97fd1781..5dc4e40d25 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-callback.md +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-callback.md @@ -2,8 +2,6 @@ title: no-unnecessary-use-callback --- -This rule is experimental and may change in the future or be removed. It is not recommended to use it in production code at this time. - **Full Name in `eslint-plugin-react-hooks-extra`** ```sh copy @@ -65,5 +63,5 @@ function MyComponent() { ## See Also -- [`no-unnecessary-use-memo`](./hooks-extra-no-unnecessary-use-memo)\ +- [`no-unnecessary-use-memo`](./no-unnecessary-use-memo)\ Disallows unnecessary usage of `useMemo`. diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-callback.spec.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-callback.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-callback.spec.ts rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-callback.spec.ts diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-callback.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-callback.ts new file mode 100644 index 0000000000..269888e24f --- /dev/null +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-callback.ts @@ -0,0 +1,136 @@ +import type { RuleContext, RuleFeature } from "@eslint-react/kit"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import type { CamelCase } from "string-ts"; +import * as AST from "@eslint-react/ast"; +import * as ER from "@eslint-react/core"; +import { identity } from "@eslint-react/eff"; +import { getSettingsFromContext } from "@eslint-react/shared"; +import * as VAR from "@eslint-react/var"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { match } from "ts-pattern"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "no-unnecessary-use-callback"; + +export const RULE_FEATURES = [ + "EXP", +] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + deprecated: { + deprecatedSince: "2.0.0", + replacedBy: [ + { + message: "Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead.", + plugin: { + name: "eslint-plugin-react-x", + url: "https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x", + }, + rule: { + name: "no-unnecessary-use-callback", + url: "https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-callback", + }, + }, + { + message: "Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead.", + plugin: { + name: "@eslint-react/eslint-plugin", + url: "https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin", + }, + rule: { + name: "no-unnecessary-use-callback", + url: "https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-callback", + }, + }, + ], + }, + docs: { + description: "Disallow unnecessary usage of `useCallback`.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + messages: { + noUnnecessaryUseCallback: + "An 'useCallback' with empty deps and no references to the component scope may be unnecessary.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +export function create(context: RuleContext): RuleListener { + if (!context.sourceCode.text.includes("use")) return {}; + const alias = getSettingsFromContext(context).additionalHooks.useCallback ?? []; + const isUseCallbackCall = ER.isReactHookCallWithNameAlias(context, "useCallback", alias); + return { + CallExpression(node) { + if (!ER.isReactHookCall(node)) { + return; + } + const initialScope = context.sourceCode.getScope(node); + if (!isUseCallbackCall(node)) { + return; + } + const scope = context.sourceCode.getScope(node); + const component = scope.block; + if (!AST.isFunction(component)) { + return; + } + const [arg0, arg1] = node.arguments; + if (arg0 == null || arg1 == null) { + return; + } + + const hasEmptyDeps = match(arg1) + .with({ type: T.ArrayExpression }, (n) => n.elements.length === 0) + .with({ type: T.Identifier }, (n) => { + const variable = VAR.findVariable(n.name, initialScope); + const variableNode = VAR.getVariableInitNode(variable, 0); + if (variableNode?.type !== T.ArrayExpression) { + return false; + } + return variableNode.elements.length === 0; + }) + .otherwise(() => false); + + if (!hasEmptyDeps) { + return; + } + const arg0Node = match(arg0) + .with({ type: T.ArrowFunctionExpression }, (n) => { + if (n.body.type === T.ArrowFunctionExpression) { + return n.body; + } + return n; + }) + .with({ type: T.FunctionExpression }, identity) + .with({ type: T.Identifier }, (n) => { + const variable = VAR.findVariable(n.name, initialScope); + const variableNode = VAR.getVariableInitNode(variable, 0); + if (variableNode?.type !== T.ArrowFunctionExpression && variableNode?.type !== T.FunctionExpression) { + return null; + } + return variableNode; + }) + .otherwise(() => null); + if (arg0Node == null) return; + + const arg0NodeScope = context.sourceCode.getScope(arg0Node); + const arg0NodeReferences = VAR.getChidScopes(arg0NodeScope).flatMap((x) => x.references); + const isReferencedToComponentScope = arg0NodeReferences.some((x) => x.resolved?.scope.block === component); + + if (!isReferencedToComponentScope) { + context.report({ + messageId: "noUnnecessaryUseCallback", + node, + }); + } + }, + }; +} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-memo.md b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-memo.md similarity index 86% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-memo.md rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-memo.md index 9318687886..4265b0f9b5 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-memo.md +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-memo.md @@ -2,8 +2,6 @@ title: no-unnecessary-use-memo --- -This rule is experimental and may change in the future or be removed. It is not recommended to use it in production code at this time. - **Full Name in `eslint-plugin-react-hooks-extra`** ```sh copy @@ -73,5 +71,5 @@ function MyComponent() { ## See Also -- [`no-unnecessary-use-callback`](./hooks-extra-no-unnecessary-use-callback)\ +- [`no-unnecessary-use-callback`](./no-unnecessary-use-callback)\ Disallows unnecessary usage of `useCallback`. diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-memo.spec.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-memo.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-memo.spec.ts rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-memo.spec.ts diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-memo.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-memo.ts new file mode 100644 index 0000000000..89f7fe6cc5 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-memo.ts @@ -0,0 +1,141 @@ +import type { RuleContext, RuleFeature } from "@eslint-react/kit"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import type { CamelCase } from "string-ts"; +import * as AST from "@eslint-react/ast"; +import * as ER from "@eslint-react/core"; +import { identity } from "@eslint-react/eff"; +import { getSettingsFromContext } from "@eslint-react/shared"; +import * as VAR from "@eslint-react/var"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { match } from "ts-pattern"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "no-unnecessary-use-memo"; + +export const RULE_FEATURES = [ + "EXP", +] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + deprecated: { + deprecatedSince: "2.0.0", + replacedBy: [ + { + message: "Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead.", + plugin: { + name: "eslint-plugin-react-x", + url: "https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x", + }, + rule: { + name: "no-unnecessary-use-memo", + url: "https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-memo", + }, + }, + { + message: "Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead.", + plugin: { + name: "@eslint-react/eslint-plugin", + url: "https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin", + }, + rule: { + name: "no-unnecessary-use-memo", + url: "https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-memo", + }, + }, + ], + }, + docs: { + description: "Disallow unnecessary usage of `useMemo`.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + messages: { + noUnnecessaryUseMemo: "An 'useMemo' with empty deps and no references to the component scope may be unnecessary.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +export function create(context: RuleContext): RuleListener { + if (!context.sourceCode.text.includes("use")) return {}; + const alias = getSettingsFromContext(context).additionalHooks.useMemo ?? []; + const isUseMemoCall = ER.isReactHookCallWithNameAlias(context, "useMemo", alias); + return { + CallExpression(node) { + if (!ER.isReactHookCall(node)) { + return; + } + const initialScope = context.sourceCode.getScope(node); + if (!isUseMemoCall(node)) { + return; + } + const scope = context.sourceCode.getScope(node); + const component = scope.block; + if (!AST.isFunction(component)) { + return; + } + const [arg0, arg1] = node.arguments; + if (arg0 == null || arg1 == null) { + return; + } + const hasCallInArg0 = AST.isFunction(arg0) + && [...AST.getNestedCallExpressions(arg0.body), ...AST.getNestedNewExpressions(arg0.body)].length > 0; + + if (hasCallInArg0) { + return; + } + + const hasEmptyDeps = match(arg1) + .with({ type: T.ArrayExpression }, (n) => n.elements.length === 0) + .with({ type: T.Identifier }, (n) => { + const variable = VAR.findVariable(n.name, initialScope); + const variableNode = VAR.getVariableInitNode(variable, 0); + if (variableNode?.type !== T.ArrayExpression) { + return false; + } + return variableNode.elements.length === 0; + }) + .otherwise(() => false); + + if (!hasEmptyDeps) { + return; + } + const arg0Node = match(arg0) + .with({ type: T.ArrowFunctionExpression }, (n) => { + if (n.body.type === T.ArrowFunctionExpression) { + return n.body; + } + return n; + }) + .with({ type: T.FunctionExpression }, identity) + .with({ type: T.Identifier }, (n) => { + const variable = VAR.findVariable(n.name, initialScope); + const variableNode = VAR.getVariableInitNode(variable, 0); + if (variableNode?.type !== T.ArrowFunctionExpression && variableNode?.type !== T.FunctionExpression) { + return null; + } + return variableNode; + }) + .otherwise(() => null); + if (arg0Node == null) return; + + const arg0NodeScope = context.sourceCode.getScope(arg0Node); + const arg0NodeReferences = VAR.getChidScopes(arg0NodeScope).flatMap((x) => x.references); + const isReferencedToComponentScope = arg0NodeReferences.some((x) => x.resolved?.scope.block === component); + + if (!isReferencedToComponentScope) { + context.report({ + messageId: "noUnnecessaryUseMemo", + node, + }); + } + }, + }; +} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.md b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-prefix.md similarity index 99% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.md rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-prefix.md index 7043db53fd..0c033b8304 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.md +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-prefix.md @@ -14,6 +14,10 @@ react-hooks-extra/no-unnecessary-use-prefix @eslint-react/hooks-extra/no-unnecessary-use-prefix ``` +**Features** + +`🧪` + **Presets** - `recommended` diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.spec.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-prefix.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.spec.ts rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-prefix.spec.ts diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-prefix.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-prefix.ts new file mode 100644 index 0000000000..9118415fcc --- /dev/null +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/no-unnecessary-use-prefix.ts @@ -0,0 +1,129 @@ +import type { RuleContext, RuleFeature } from "@eslint-react/kit"; +import type { TSESTree } from "@typescript-eslint/types"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import type { CamelCase } from "string-ts"; +import * as AST from "@eslint-react/ast"; +import * as ER from "@eslint-react/core"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "no-unnecessary-use-prefix"; + +export const RULE_FEATURES = [] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +const WELL_KNOWN_HOOKS = [ + "useMDXComponents", +]; + +function containsUseComments(context: RuleContext, node: TSESTree.Node) { + return context.sourceCode + .getCommentsInside(node) + .some(({ value }) => /use\([\s\S]*?\)/u.test(value) || /use[A-Z0-9]\w*\([\s\S]*?\)/u.test(value)); +} + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + deprecated: { + deprecatedSince: "2.0.0", + replacedBy: [ + { + message: "Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead.", + plugin: { + name: "eslint-plugin-react-x", + url: "https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x", + }, + rule: { + name: "no-unnecessary-use-prefix", + url: "https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-prefix", + }, + }, + { + message: "Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead.", + plugin: { + name: "@eslint-react/eslint-plugin", + url: "https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin", + }, + rule: { + name: "no-unnecessary-use-prefix", + url: "https://next.eslint-react.xyz/docs/rules/no-unnecessary-use-prefix", + }, + }, + ], + }, + docs: { + description: "Enforces that a function with the `use` prefix should use at least one Hook inside of it.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + messages: { + noUnnecessaryUsePrefix: + "If your function doesn't call any Hooks, avoid the 'use' prefix. Instead, write it as a regular function without the 'use' prefix.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +export function create(context: RuleContext): RuleListener { + const { ctx, listeners } = ER.useHookCollector(); + return { + ...listeners, + "Program:exit"(program) { + const allHooks = ctx.getAllHooks(program); + for (const { id, name, node, hookCalls } of allHooks.values()) { + // Skip well-known hooks + if (WELL_KNOWN_HOOKS.includes(name)) { + continue; + } + // Skip empty functions + if (AST.isEmptyFunction(node)) { + continue; + } + // Skip useful hooks + if (hookCalls.length > 0) { + continue; + } + // Skip hooks with comments that contain calls to other hooks + if (containsUseComments(context, node)) { + continue; + } + if (id != null) { + context.report({ + messageId: "noUnnecessaryUsePrefix", + data: { + name, + }, + loc: getPreferredLoc(context, id), + }); + continue; + } + context.report({ + messageId: "noUnnecessaryUsePrefix", + node, + data: { + name, + }, + }); + } + }, + }; +} + +function getPreferredLoc(context: RuleContext, id: TSESTree.Identifier) { + if (AST.isMultiLine(id)) return id.loc; + if (!context.sourceCode.getText(id).startsWith("use")) return id.loc; + return { + end: { + column: id.loc.start.column + 3, + line: id.loc.start.line, + }, + start: { + column: id.loc.start.column, + line: id.loc.start.line, + }, + }; +} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.md b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/prefer-use-state-lazy-initialization.md similarity index 94% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.md rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/prefer-use-state-lazy-initialization.md index 5d410332f8..0f40c40efd 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.md +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/prefer-use-state-lazy-initialization.md @@ -69,3 +69,4 @@ declare function generateTodos(): string[]; ## Further Reading - [React Docs: `useState` Hook](https://react.dev/reference/react/useState#setstate) + - # [Avoiding recreating the initial state](https://react.dev/reference/react/useState#avoiding-recreating-the-initial-state) diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.spec.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/prefer-use-state-lazy-initialization.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-hooks-extra/src/rules/prefer-use-state-lazy-initialization.spec.ts rename to packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/prefer-use-state-lazy-initialization.spec.ts diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/prefer-use-state-lazy-initialization.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/prefer-use-state-lazy-initialization.ts new file mode 100644 index 0000000000..178cf4a197 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules-removed/prefer-use-state-lazy-initialization.ts @@ -0,0 +1,108 @@ +// Ported from https://github.com/jsx-eslint/eslint-plugin-react/pull/3579/commits/ebb739a0fe99a2ee77055870bfda9f67a2691374 +import type { RuleContext, RuleFeature } from "@eslint-react/kit"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import type { CamelCase } from "string-ts"; +import * as AST from "@eslint-react/ast"; +import * as ER from "@eslint-react/core"; +import { getSettingsFromContext } from "@eslint-react/shared"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "prefer-use-state-lazy-initialization"; + +export const RULE_FEATURES = [ + "EXP", +] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +// identifier names for allowed function names +const ALLOW_LIST = [ + "Boolean", + "String", + "Number", +]; + +// rule takes inspiration from https://github.com/facebook/react/issues/26520 +export default createRule<[], MessageID>({ + meta: { + type: "problem", + deprecated: { + deprecatedSince: "2.0.0", + replacedBy: [ + { + message: "Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead.", + plugin: { + name: "eslint-plugin-react-x", + url: "https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x", + }, + rule: { + name: "prefer-use-state-lazy-initialization", + url: "https://next.eslint-react.xyz/docs/rules/prefer-use-state-lazy-initialization", + }, + }, + { + message: "Use the same rule from `eslint-plugin-react-x` or `@eslint-react/eslint-plugin` instead.", + plugin: { + name: "@eslint-react/eslint-plugin", + url: "https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin", + }, + rule: { + name: "prefer-use-state-lazy-initialization", + url: "https://next.eslint-react.xyz/docs/rules/prefer-use-state-lazy-initialization", + }, + }, + ], + }, + docs: { + description: "Enforces function calls made inside `useState` to be wrapped in an `initializer function`.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + messages: { + preferUseStateLazyInitialization: + "To prevent re-computation, consider using lazy initial state for useState calls that involve function calls. Ex: 'useState(() => getValue())'.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +export function create(context: RuleContext): RuleListener { + const alias = getSettingsFromContext(context).additionalHooks.useState ?? []; + const isUseStateCall = ER.isReactHookCallWithNameAlias(context, "useState", alias); + return { + CallExpression(node) { + if (!ER.isReactHookCall(node)) { + return; + } + if (!isUseStateCall(node)) { + return; + } + const [useStateInput] = node.arguments; + if (useStateInput == null) { + return; + } + for (const expr of AST.getNestedNewExpressions(useStateInput)) { + if (!("name" in expr.callee)) continue; + if (ALLOW_LIST.includes(expr.callee.name)) continue; + if (AST.findParentNode(expr, (n) => ER.isUseCall(context, n)) != null) continue; + context.report({ + messageId: "preferUseStateLazyInitialization", + node: expr, + }); + } + for (const expr of AST.getNestedCallExpressions(useStateInput)) { + if (!("name" in expr.callee)) continue; + if (ER.isReactHookName(expr.callee.name)) continue; + if (ALLOW_LIST.includes(expr.callee.name)) continue; + if (AST.findParentNode(expr, (n) => ER.isUseCall(context, n)) != null) continue; + context.report({ + messageId: "preferUseStateLazyInitialization", + node: expr, + }); + } + }, + }; +} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.md b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.md index 48a8de97f6..72ca9ba49d 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.md +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.md @@ -14,6 +14,10 @@ react-hooks-extra/no-direct-set-state-in-use-effect @eslint-react/hooks-extra/no-direct-set-state-in-use-effect ``` +**Features** + +`🧪` + **Presets** - `recommended` diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.spec.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.spec.ts index f36ef9ac1f..adf779d0db 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.spec.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.spec.ts @@ -782,6 +782,56 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + // React docs recommend to first update state in render instead of an effect. + // But then continue on to say that usually you can avoid the sync entirely by + // more wisely choosing your state. So we'll just always warn about chained state. + name: "Syncing prop changes to internal state", + code: tsx` + function List({ items }) { + const [selection, setSelection] = useState(); + + useEffect(() => { + setSelection(null); + }, [items]); + + return ( +
+ {items.map((item) => ( +
setSelection(item)}> + {item.name} +
+ ))} +
+ ) + } + `, + errors: [ + { + messageId: "noDirectSetStateInUseEffect", + }, + ], + }, + { + name: "Conditionally setting state from internal state", + code: tsx` + function Form() { + const [error, setError] = useState(); + const [result, setResult] = useState(); + + useEffect(() => { + if (result.data) { + setError(null); + } + }, [result]); + } + `, + errors: [ + { + messageId: "noDirectSetStateInUseEffect", + }, + ], + }, ], valid: [ ...allValid, @@ -996,5 +1046,34 @@ ruleTester.run(RULE_NAME, rule, { }, []) } `, + tsx` + import { useEffect, useState, useCallback } from "react"; + + function useCustomHook() { + useLayoutEffect(() => { + navigation.setOptions({ + headerLeft: () => , + headerRight: () => ( + + ) + }); + }, [loading, navigation, onBack, onPressSave, post, submitting, t]); + } + `, ], }); diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.ts index c5edf42bd5..79f842dc11 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-effect.ts @@ -2,7 +2,7 @@ import type { RuleContext, RuleFeature } from "@eslint-react/kit"; import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import { useNoDirectSetStateInUseEffect } from "../hooks/use-no-direct-set-state-in-use-effect"; +import { useNoDirectSetStateInUseEffect } from "../rules-hooks/use-no-direct-set-state-in-use-effect"; import { createRule } from "../utils"; export const RULE_NAME = "no-direct-set-state-in-use-effect"; diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-layout-effect.md b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-layout-effect.md index 118e6e3526..1f8b42073c 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-layout-effect.md +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-layout-effect.md @@ -2,8 +2,6 @@ title: no-direct-set-state-in-use-layout-effect --- -This rule is experimental and may change in the future or be removed. It is not recommended to use it in production code at this time. - **Full Name in `eslint-plugin-react-hooks-extra`** ```sh copy diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-layout-effect.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-layout-effect.ts index c34709635b..5c6a6c46ac 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-layout-effect.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-direct-set-state-in-use-layout-effect.ts @@ -2,7 +2,7 @@ import type { RuleContext, RuleFeature } from "@eslint-react/kit"; import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import { useNoDirectSetStateInUseEffect } from "../hooks/use-no-direct-set-state-in-use-effect"; +import { useNoDirectSetStateInUseEffect } from "../rules-hooks/use-no-direct-set-state-in-use-effect"; import { createRule } from "../utils"; export const RULE_NAME = "no-direct-set-state-in-use-layout-effect"; diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/index.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/index.ts index 8807643c60..69d92f43e3 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/index.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/index.ts @@ -1,7 +1 @@ export * from "./create-rule"; -export * from "./is-from-hook-call"; -export * from "./is-from-use-state-call"; -export * from "./is-react-hook-identifier"; -export * from "./is-set-function-call"; -export * from "./is-then-call"; -export * from "./is-variable-declarator-from-hook-call"; diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-from-hook-call.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-from-hook-call.ts deleted file mode 100644 index 15f340ccd4..0000000000 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-from-hook-call.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { RuleContext } from "@eslint-react/kit"; -import type { ESLintReactSettingsNormalized } from "@eslint-react/shared"; -import type { TSESTree } from "@typescript-eslint/types"; -import * as ER from "@eslint-react/core"; -import { constTrue } from "@eslint-react/eff"; -import * as VAR from "@eslint-react/var"; -import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; - -export const REACT_BUILD_IN_HOOKS = [ - "use", - "useActionState", - "useCallback", - "useContext", - "useDebugValue", - "useDeferredValue", - "useEffect", - "useFormStatus", - "useId", - "useImperativeHandle", - "useInsertionEffect", - "useLayoutEffect", - "useMemo", - "useOptimistic", - "useReducer", - "useRef", - "useState", - "useSyncExternalStore", - "useTransition", -] as const; - -export function isFromHookCall( - context: RuleContext, - name: (typeof REACT_BUILD_IN_HOOKS)[number], - settings: ESLintReactSettingsNormalized, - predicate: (topLevelId: TSESTree.Identifier, call: TSESTree.CallExpression) => boolean = constTrue, -) { - const hookAlias = settings.additionalHooks[name] ?? []; - // eslint-disable-next-line function/function-return-boolean - return (topLevelId: TSESTree.Identifier) => { - const variable = VAR.findVariable(topLevelId, context.sourceCode.getScope(topLevelId)); - const variableNode = VAR.getVariableInitNode(variable, 0); - if (variableNode == null) return false; - if (variableNode.type !== T.CallExpression) return false; - if (!ER.isReactHookCallWithNameAlias(context, name, hookAlias)(variableNode)) return false; - return predicate(topLevelId, variableNode); - }; -} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-from-use-state-call.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-from-use-state-call.ts deleted file mode 100644 index 14e265557f..0000000000 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-from-use-state-call.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { RuleContext } from "@eslint-react/kit"; -import type { ESLintReactSettingsNormalized } from "@eslint-react/shared"; -import type { TSESTree } from "@typescript-eslint/types"; -import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; - -import { isFromHookCall } from "./is-from-hook-call"; - -export function isFromUseStateCall(context: RuleContext, settings: ESLintReactSettingsNormalized) { - const predicate = (topLevelId: TSESTree.Identifier, call: TSESTree.CallExpression) => { - const { parent } = call; - if (!("id" in parent) || parent.id?.type !== T.ArrayPattern) { - return true; - } - return parent.id.elements.findIndex((e) => e?.type === T.Identifier && e.name === topLevelId.name) === 1; - }; - // eslint-disable-next-line function/function-return-boolean - return isFromHookCall(context, "useState", settings, predicate); -} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-set-function-call.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-set-function-call.ts deleted file mode 100644 index a29fcdf1e2..0000000000 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-set-function-call.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { RuleContext } from "@eslint-react/kit"; -import type { ESLintReactSettingsNormalized } from "@eslint-react/shared"; -import type { TSESTree } from "@typescript-eslint/types"; -import * as VAR from "@eslint-react/var"; -import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; - -import { isFromUseStateCall } from "./is-from-use-state-call"; - -export function isSetFunctionCall(context: RuleContext, settings: ESLintReactSettingsNormalized) { - const isIdFromUseStateCall = isFromUseStateCall(context, settings); - // eslint-disable-next-line function/function-return-boolean - return (node: TSESTree.CallExpression) => { - switch (node.callee.type) { - // const data = useState(); - // data.at(1)(); - case T.CallExpression: { - const { callee } = node.callee; - if (callee.type !== T.MemberExpression) { - return false; - } - if (!("name" in callee.object)) { - return false; - } - const isAt = callee.property.type === T.Identifier && callee.property.name === "at"; - const [index] = node.callee.arguments; - if (!isAt || index == null) { - return false; - } - const indexScope = context.sourceCode.getScope(node); - const indexValue = VAR.toStaticValue({ - kind: "lazy", - node: index, - initialScope: indexScope, - }).value; - return indexValue === 1 && isIdFromUseStateCall(callee.object); - } - // const [data, setData] = useState(); - // setData(); - case T.Identifier: { - return isIdFromUseStateCall(node.callee); - } - // const data = useState(); - // data[1](); - case T.MemberExpression: { - if (!("name" in node.callee.object)) { - return false; - } - const property = node.callee.property; - const propertyScope = context.sourceCode.getScope(node); - const propertyValue = VAR.toStaticValue({ - kind: "lazy", - node: property, - initialScope: propertyScope, - }).value; - return propertyValue === 1 && isIdFromUseStateCall(node.callee.object); - } - default: { - return false; - } - } - }; -} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-variable-declarator-from-hook-call.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-variable-declarator-from-hook-call.ts deleted file mode 100644 index 1f21146df4..0000000000 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/utils/is-variable-declarator-from-hook-call.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { TSESTree } from "@typescript-eslint/types"; -import * as ER from "@eslint-react/core"; -import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; - -export function isVariableDeclaratorFromHookCall(node: TSESTree.Node): node is - & { - id: TSESTree.Identifier; - init: - & { - callee: TSESTree.Identifier | TSESTree.MemberExpression; - } - & TSESTree.CallExpression; - } - & TSESTree.VariableDeclarator -{ - if (node.type !== T.VariableDeclarator) { - return false; - } - if (node.id.type !== T.Identifier) { - return false; - } - if (node.init?.type !== T.CallExpression) { - return false; - } - switch (node.init.callee.type) { - case T.Identifier: - return ER.isReactHookName(node.init.callee.name); - case T.MemberExpression: - return node.init.callee.property.type === T.Identifier - && ER.isReactHookName(node.init.callee.property.name); - default: - return false; - } -} diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/tsup.config.ts b/packages/plugins/eslint-plugin-react-hooks-extra/tsup.config.ts index a84cd44465..e037e267d9 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/tsup.config.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/tsup.config.ts @@ -5,12 +5,12 @@ export default { dts: true, entry: ["src/index.ts"], external: ["eslint", "typescript"], - format: ["cjs", "esm"], + format: ["esm"], minify: false, outDir: "dist", platform: "node", sourcemap: false, splitting: false, - target: "node18", + target: "node20", treeshake: true, } satisfies Options; diff --git a/packages/plugins/eslint-plugin-react-naming-convention/package.json b/packages/plugins/eslint-plugin-react-naming-convention/package.json index 04c7fa0494..5e1f04845b 100644 --- a/packages/plugins/eslint-plugin-react-naming-convention/package.json +++ b/packages/plugins/eslint-plugin-react-naming-convention/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-naming-convention", - "version": "1.52.3-next.6", + "version": "2.0.0-next.47", "description": "ESLint React's ESLint plugin for naming convention related rules.", "keywords": [ "react", @@ -22,22 +22,14 @@ "license": "MIT", "author": "Rel1cx", "sideEffects": false, + "type": "module", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./package.json": "./package.json" }, - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", "files": [ "dist", "./package.json" @@ -69,8 +61,8 @@ "tsup": "^8.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "^4.9.5 || ^5.3.3" + "eslint": "^9.29.0", + "typescript": "^4.9.5 || ^5.4.5" }, "peerDependenciesMeta": { "eslint": { @@ -82,7 +74,7 @@ }, "engines": { "bun": ">=1.0.15", - "node": ">=18.18.0" + "node": ">=20.19.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/component-name.ts b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/component-name.ts index cf293a77f2..c07389642a 100644 --- a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/component-name.ts +++ b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/component-name.ts @@ -15,16 +15,6 @@ type Options = readonly [ | Case | { allowAllCaps?: boolean; - /** - * @todo Remove in the next major version - * @deprecated Component names now need to start with an uppercase letter instead of a non-lowercase letter. This means `_Button` or `_component` are no longer valid. (@kassens) in https://github.com/facebook/react/pull/25162 - */ - allowLeadingUnderscore?: boolean; - /** - * @todo Remove in the next major version - * @deprecated This option has no actual effect on the rule - */ - allowNamespace?: boolean; excepts?: readonly string[]; rule?: Case; }, @@ -50,16 +40,6 @@ const schema = [ additionalProperties: false, properties: { allowAllCaps: { type: "boolean" }, - /** - * @todo Remove in the next major version - * @deprecated - */ - allowLeadingUnderscore: { type: "boolean" }, - /** - * @todo Remove in the next major version - * @deprecated - */ - allowNamespace: { type: "boolean" }, excepts: { type: "array", items: { type: "string", format: "regex" }, diff --git a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/filename.ts b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/filename.ts index d273a7d089..6967bf6e21 100644 --- a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/filename.ts +++ b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/filename.ts @@ -25,14 +25,7 @@ type Options = readonly [ | unit | Case | { - /** - * @deprecated Use ESLint's [files](https://eslint.org/docs/latest/use/configure/configuration-files#specifying-files-and-ignores) feature instead - */ excepts?: readonly string[]; - /** - * @deprecated Use ESLint's [files](https://eslint.org/docs/latest/use/configure/configuration-files#specifying-files-and-ignores) feature instead - */ - extensions?: readonly string[]; rule?: Case; }, ]; @@ -40,7 +33,6 @@ type Options = readonly [ const defaultOptions = [ { excepts: ["^index$"], - extensions: [".js", ".jsx", ".ts", ".tsx"], rule: "PascalCase", }, ] as const satisfies Options; @@ -100,12 +92,10 @@ export function create(context: RuleContext): RuleListener { : options.rule ?? "PascalCase"; const excepts = typeof options === "string" ? [] - // eslint-disable-next-line @typescript-eslint/no-deprecated - : options.excepts ?? []; + : (options.excepts ?? []).map((s) => RE.toRegExp(s)); - function validate(name: string, casing: Case = rule, ignores: readonly string[] = excepts) { + function validate(name: string, casing: Case = rule, ignores = excepts) { const shouldIgnore = ignores - .map((s) => RE.toRegExp(s)) .some((pattern) => pattern.test(name)); if (shouldIgnore) return true; diff --git a/packages/plugins/eslint-plugin-react-naming-convention/tsup.config.ts b/packages/plugins/eslint-plugin-react-naming-convention/tsup.config.ts index a84cd44465..e037e267d9 100644 --- a/packages/plugins/eslint-plugin-react-naming-convention/tsup.config.ts +++ b/packages/plugins/eslint-plugin-react-naming-convention/tsup.config.ts @@ -5,12 +5,12 @@ export default { dts: true, entry: ["src/index.ts"], external: ["eslint", "typescript"], - format: ["cjs", "esm"], + format: ["esm"], minify: false, outDir: "dist", platform: "node", sourcemap: false, splitting: false, - target: "node18", + target: "node20", treeshake: true, } satisfies Options; diff --git a/packages/plugins/eslint-plugin-react-web-api/package.json b/packages/plugins/eslint-plugin-react-web-api/package.json index c2ad3fffbe..cb36e4a47f 100644 --- a/packages/plugins/eslint-plugin-react-web-api/package.json +++ b/packages/plugins/eslint-plugin-react-web-api/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-web-api", - "version": "1.52.3-next.6", + "version": "2.0.0-next.47", "description": "ESLint React's ESLint plugin for interacting with Web APIs", "keywords": [ "react", @@ -22,22 +22,14 @@ "license": "MIT", "author": "Rel1cx", "sideEffects": false, + "type": "module", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./package.json": "./package.json" }, - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", "files": [ "dist", "./package.json" @@ -68,8 +60,8 @@ "tsup": "^8.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "^4.9.5 || ^5.3.3" + "eslint": "^9.29.0", + "typescript": "^4.9.5 || ^5.4.5" }, "peerDependenciesMeta": { "eslint": { @@ -81,7 +73,7 @@ }, "engines": { "bun": ">=1.0.15", - "node": ">=18.18.0" + "node": ">=20.19.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/eslint-plugin-react-web-api/src/utils/get-phase-kind-of-function.ts b/packages/plugins/eslint-plugin-react-web-api/src/utils/get-phase-kind-of-function.ts index 2bfa1b4c93..5e71fe7f34 100644 --- a/packages/plugins/eslint-plugin-react-web-api/src/utils/get-phase-kind-of-function.ts +++ b/packages/plugins/eslint-plugin-react-web-api/src/utils/get-phase-kind-of-function.ts @@ -1,12 +1,45 @@ -import type * as AST from "@eslint-react/ast"; +import type { unit } from "@eslint-react/eff"; +import type { TSESTree } from "@typescript-eslint/types"; +import * as AST from "@eslint-react/ast"; import * as ER from "@eslint-react/core"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; import { match } from "ts-pattern"; export function getPhaseKindOfFunction(node: AST.TSESTreeFunction) { return match(node) - .when(ER.isFunctionOfUseEffectSetup, () => "setup") - .when(ER.isFunctionOfUseEffectCleanup, () => "cleanup") - .when(ER.isFunctionOfComponentDidMount, () => "mount") - .when(ER.isFunctionOfComponentWillUnmount, () => "unmount") + .when(isFunctionOfUseEffectSetup, () => "setup") + .when(isFunctionOfUseEffectCleanup, () => "cleanup") + .when(isFunctionOfComponentDidMount, () => "mount") + .when(isFunctionOfComponentWillUnmount, () => "unmount") .otherwise(() => null); } + +export function isFunctionOfUseEffectSetup(node: TSESTree.Node | unit) { + if (node == null) return false; + return node.parent?.type === T.CallExpression + && node.parent.callee !== node + && node.parent.callee.type === T.Identifier + && node.parent.arguments.at(0) === node + && ER.isUseEffectCallLoose(node.parent); +} + +export function isFunctionOfUseEffectCleanup(node: TSESTree.Node | unit) { + if (node == null) return false; + const pReturn = AST.findParentNode(node, AST.is(T.ReturnStatement)); + const pFunction = AST.findParentNode(node, AST.isFunction); // Correctly named variable + const pFunctionOfReturn = AST.findParentNode(pReturn, AST.isFunction); + if (pFunction !== pFunctionOfReturn) return false; // Ensure consistent variable naming + return isFunctionOfUseEffectSetup(pFunction); +} + +export function isFunctionOfComponentDidMount(node: TSESTree.Node) { + return AST.isFunction(node) + && ER.isComponentDidMount(node.parent) + && node.parent.value === node; +} + +export function isFunctionOfComponentWillUnmount(node: TSESTree.Node) { + return AST.isFunction(node) + && ER.isComponentWillUnmount(node.parent) + && node.parent.value === node; +} diff --git a/packages/plugins/eslint-plugin-react-web-api/tsup.config.ts b/packages/plugins/eslint-plugin-react-web-api/tsup.config.ts index a84cd44465..e037e267d9 100644 --- a/packages/plugins/eslint-plugin-react-web-api/tsup.config.ts +++ b/packages/plugins/eslint-plugin-react-web-api/tsup.config.ts @@ -5,12 +5,12 @@ export default { dts: true, entry: ["src/index.ts"], external: ["eslint", "typescript"], - format: ["cjs", "esm"], + format: ["esm"], minify: false, outDir: "dist", platform: "node", sourcemap: false, splitting: false, - target: "node18", + target: "node20", treeshake: true, } satisfies Options; diff --git a/packages/plugins/eslint-plugin-react-x/package.json b/packages/plugins/eslint-plugin-react-x/package.json index ee40c5db4d..c30bff53d9 100644 --- a/packages/plugins/eslint-plugin-react-x/package.json +++ b/packages/plugins/eslint-plugin-react-x/package.json @@ -1,7 +1,7 @@ { "name": "eslint-plugin-react-x", - "version": "1.52.3-next.6", - "description": "4-7x faster composable ESLint rules for for libraries and frameworks that use React as a UI runtime.", + "version": "2.0.0-next.47", + "description": "A set of composable ESLint rules for for libraries and frameworks that use React as a UI runtime.", "keywords": [ "react", "eslint", @@ -21,22 +21,14 @@ "license": "MIT", "author": "Rel1cx", "sideEffects": false, + "type": "module", "exports": { ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./package.json": "./package.json" }, - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", "files": [ "dist", "./package.json" @@ -71,9 +63,9 @@ "tsup": "^8.5.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^9.29.0", "ts-api-utils": "^2.1.0", - "typescript": "^4.9.5 || ^5.3.3" + "typescript": "^4.9.5 || ^5.4.5" }, "peerDependenciesMeta": { "eslint": { @@ -88,7 +80,7 @@ }, "engines": { "bun": ">=1.0.15", - "node": ">=18.18.0" + "node": ">=20.19.0" }, "publishConfig": { "access": "public" 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 7e1deae045..59ff1f32f8 100644 --- a/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts +++ b/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts @@ -5,7 +5,9 @@ export const name = "react-x/recommended"; export const rules = { "react-x/jsx-key-before-spread": "warn", + "react-x/jsx-no-comment-textnodes": "warn", "react-x/jsx-no-duplicate-props": "warn", + // "react-x/jsx-no-undef": "error", "react-x/jsx-uses-react": "warn", "react-x/jsx-uses-vars": "warn", "react-x/no-access-state-in-setstate": "error", @@ -14,9 +16,10 @@ export const rules = { "react-x/no-children-for-each": "warn", "react-x/no-children-map": "warn", "react-x/no-children-only": "warn", + // "react-x/no-children-prop": "warn", "react-x/no-children-to-array": "warn", + // "react-x/no-class-component": "warn", "react-x/no-clone-element": "warn", - "react-x/no-comment-textnodes": "warn", "react-x/no-component-will-mount": "error", "react-x/no-component-will-receive-props": "error", "react-x/no-component-will-update": "error", @@ -24,11 +27,16 @@ export const rules = { "react-x/no-create-ref": "error", "react-x/no-default-props": "error", "react-x/no-direct-mutation-state": "error", + // "react-x/prefer-destructuring-assignment": "warn", + // "react-x/prefer-namespace-import": "warn", + // "react-x/prefer-read-only-props": "error", "react-x/no-duplicate-key": "warn", "react-x/no-forward-ref": "warn", "react-x/no-implicit-key": "warn", + // "react-x/no-missing-component-display-name": "warn", + // "react-x/no-missing-context-display-name": "warn", "react-x/no-missing-key": "error", - "react-x/no-misused-capture-owner-stack": "error", + // "react-x/no-misused-capture-owner-stack": "error", "react-x/no-nested-component-definitions": "error", "react-x/no-nested-lazy-component-declarations": "warn", "react-x/no-prop-types": "error", @@ -37,6 +45,7 @@ export const rules = { "react-x/no-set-state-in-component-did-update": "warn", "react-x/no-set-state-in-component-will-update": "warn", "react-x/no-string-refs": "error", + "react-x/no-unnecessary-use-prefix": "warn", "react-x/no-unsafe-component-will-mount": "warn", "react-x/no-unsafe-component-will-receive-props": "warn", "react-x/no-unsafe-component-will-update": "warn", @@ -46,6 +55,7 @@ export const rules = { "react-x/no-unused-state": "warn", "react-x/no-use-context": "warn", "react-x/no-useless-forward-ref": "warn", + "react-x/prefer-use-state-lazy-initialization": "warn", } as const satisfies RulePreset; export const settings = { diff --git a/packages/plugins/eslint-plugin-react-x/src/plugin.ts b/packages/plugins/eslint-plugin-react-x/src/plugin.ts index 6462f6bc31..29ceebec85 100644 --- a/packages/plugins/eslint-plugin-react-x/src/plugin.ts +++ b/packages/plugins/eslint-plugin-react-x/src/plugin.ts @@ -1,10 +1,12 @@ 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 jsxNoCommentTextnodes from "./rules/jsx-no-comment-textnodes"; import jsxNoDuplicateProps from "./rules/jsx-no-duplicate-props"; import jsxNoIife from "./rules/jsx-no-iife"; import jsxNoUndef from "./rules/jsx-no-undef"; +import jsxShorthandBoolean from "./rules/jsx-shorthand-boolean"; +import jsxShorthandFragment from "./rules/jsx-shorthand-fragment"; import jsxUsesReact from "./rules/jsx-uses-react"; import jsxUsesVars from "./rules/jsx-uses-vars"; import noAccessStateInSetstate from "./rules/no-access-state-in-setstate"; @@ -17,8 +19,6 @@ import noChildrenProp from "./rules/no-children-prop"; import noChildrenToArray from "./rules/no-children-to-array"; import noClassComponent from "./rules/no-class-component"; import noCloneElement from "./rules/no-clone-element"; -import noCommentTextnodes from "./rules/no-comment-textnodes"; -import noComplexConditionalRendering from "./rules/no-complex-conditional-rendering"; import noComponentWillMount from "./rules/no-component-will-mount"; import noComponentWillReceiveProps from "./rules/no-component-will-receive-props"; import noComponentWillUpdate from "./rules/no-component-will-update"; @@ -42,6 +42,9 @@ import noSetStateInComponentDidMount from "./rules/no-set-state-in-component-did import noSetStateInComponentDidUpdate from "./rules/no-set-state-in-component-did-update"; import noSetStateInComponentWillUpdate from "./rules/no-set-state-in-component-will-update"; import noStringRefs from "./rules/no-string-refs"; +import noUnnecessaryUseCallback from "./rules/no-unnecessary-use-callback"; +import noUnnecessaryUseMemo from "./rules/no-unnecessary-use-memo"; +import noUnnecessaryUsePrefix from "./rules/no-unnecessary-use-prefix"; import noUnsafeComponentWillMount from "./rules/no-unsafe-component-will-mount"; import noUnsafeComponentWillReceiveProps from "./rules/no-unsafe-component-will-receive-props"; import noUnsafeComponentWillUpdate from "./rules/no-unsafe-component-will-update"; @@ -53,10 +56,19 @@ import noUseContext from "./rules/no-use-context"; import noUselessForwardRef from "./rules/no-useless-forward-ref"; import noUselessFragment from "./rules/no-useless-fragment"; import preferDestructuringAssignment from "./rules/prefer-destructuring-assignment"; -import preferReactNamespaceImport from "./rules/prefer-react-namespace-import"; +import preferNamespaceImport from "./rules/prefer-namespace-import"; import preferReadOnlyProps from "./rules/prefer-read-only-props"; -import preferShorthandBoolean from "./rules/prefer-shorthand-boolean"; -import preferShorthandFragment from "./rules/prefer-shorthand-fragment"; +import preferUseStateLazyInitialization from "./rules/prefer-use-state-lazy-initialization"; + +/* eslint-disable perfectionist/sort-imports */ +import avoidShorthandBoolean from "./rules-removed/avoid-shorthand-boolean"; +import avoidShorthandFragment from "./rules-removed/avoid-shorthand-fragment"; +import preferShorthandBoolean from "./rules-removed/prefer-shorthand-boolean"; +import preferShorthandFragment from "./rules-removed/prefer-shorthand-fragment"; +import preferReactNamespaceImport from "./rules-removed/prefer-react-namespace-import"; +import noCommentTextnodes from "./rules-removed/no-comment-textnodes"; +import noComplexConditionalRendering from "./rules-removed/no-complex-conditional-rendering"; +/* eslint-enable perfectionist/sort-imports */ export const plugin = { meta: { @@ -64,8 +76,15 @@ export const plugin = { version, }, rules: { - "avoid-shorthand-boolean": avoidShorthandBoolean, - "avoid-shorthand-fragment": avoidShorthandFragment, + "jsx-key-before-spread": jsxKeyBeforeSpread, + "jsx-no-comment-textnodes": jsxNoCommentTextnodes, + "jsx-no-duplicate-props": jsxNoDuplicateProps, + "jsx-no-iife": jsxNoIife, + "jsx-no-undef": jsxNoUndef, + "jsx-shorthand-boolean": jsxShorthandBoolean, + "jsx-shorthand-fragment": jsxShorthandFragment, + "jsx-uses-react": jsxUsesReact, + "jsx-uses-vars": jsxUsesVars, "no-access-state-in-setstate": noAccessStateInSetstate, "no-array-index-key": noArrayIndexKey, "no-children-count": noChildrenCount, @@ -76,8 +95,6 @@ export const plugin = { "no-children-to-array": noChildrenToArray, "no-class-component": noClassComponent, "no-clone-element": noCloneElement, - "no-comment-textnodes": noCommentTextnodes, - "no-complex-conditional-rendering": noComplexConditionalRendering, "no-component-will-mount": noComponentWillMount, "no-component-will-receive-props": noComponentWillReceiveProps, "no-component-will-update": noComponentWillUpdate, @@ -101,6 +118,9 @@ export const plugin = { "no-set-state-in-component-did-update": noSetStateInComponentDidUpdate, "no-set-state-in-component-will-update": noSetStateInComponentWillUpdate, "no-string-refs": noStringRefs, + "no-unnecessary-use-callback": noUnnecessaryUseCallback, + "no-unnecessary-use-memo": noUnnecessaryUseMemo, + "no-unnecessary-use-prefix": noUnnecessaryUsePrefix, "no-unsafe-component-will-mount": noUnsafeComponentWillMount, "no-unsafe-component-will-receive-props": noUnsafeComponentWillReceiveProps, "no-unsafe-component-will-update": noUnsafeComponentWillUpdate, @@ -112,29 +132,17 @@ export const plugin = { "no-useless-forward-ref": noUselessForwardRef, "no-useless-fragment": noUselessFragment, "prefer-destructuring-assignment": preferDestructuringAssignment, - "prefer-react-namespace-import": preferReactNamespaceImport, + "prefer-namespace-import": preferNamespaceImport, "prefer-read-only-props": preferReadOnlyProps, + "prefer-use-state-lazy-initialization": preferUseStateLazyInitialization, + + // Removed rules + "avoid-shorthand-boolean": avoidShorthandBoolean, + "avoid-shorthand-fragment": avoidShorthandFragment, + "no-comment-textnodes": noCommentTextnodes, + "no-complex-conditional-rendering": noComplexConditionalRendering, + "prefer-react-namespace-import": preferReactNamespaceImport, "prefer-shorthand-boolean": preferShorthandBoolean, "prefer-shorthand-fragment": preferShorthandFragment, - - // Part: JSX only rules - "jsx-key-before-spread": jsxKeyBeforeSpread, - "jsx-no-duplicate-props": jsxNoDuplicateProps, - "jsx-no-iife": jsxNoIife, - "jsx-no-undef": jsxNoUndef, - "jsx-uses-react": jsxUsesReact, - "jsx-uses-vars": jsxUsesVars, - - // Part: deprecated rules - /** @deprecated Use `no-useless-forward-ref` instead */ - "ensure-forward-ref-using-ref": noUselessForwardRef, - /** @deprecated Use `no-complex-conditional-rendering` instead */ - "no-complicated-conditional-rendering": noComplexConditionalRendering, - /** @deprecated Use `jsx-no-duplicate-props` instead */ - "no-duplicate-jsx-props": jsxNoDuplicateProps, - /** @deprecated Use `no-nested-component-definitions` instead */ - "no-nested-components": noNestedComponentDefinitions, - /** @deprecated Use `jsx-uses-vars` instead */ - "use-jsx-vars": jsxUsesVars, }, } as const; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules-hooks/use-no-direct-set-state-in-use-effect.ts b/packages/plugins/eslint-plugin-react-x/src/rules-hooks/use-no-direct-set-state-in-use-effect.ts new file mode 100644 index 0000000000..b0d811719e --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules-hooks/use-no-direct-set-state-in-use-effect.ts @@ -0,0 +1,341 @@ +import type { RuleContext } from "@eslint-react/kit"; +import type { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import type { Scope } from "@typescript-eslint/utils/ts-eslint"; +import * as AST from "@eslint-react/ast"; +import * as ER from "@eslint-react/core"; +import { constVoid, getOrElseUpdate, not } from "@eslint-react/eff"; +import { getSettingsFromContext } from "@eslint-react/shared"; +import * as VAR from "@eslint-react/var"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; + +import { match } from "ts-pattern"; + +type CallKind = + | "useEffect" + | "useLayoutEffect" + | "useState" + | "setState" + | "then" + | "other"; + +type FunctionKind = + | "setup" + | "cleanup" + | "deferred" + | "immediate" + | "other"; + +export declare namespace useNoDirectSetStateInUseEffect { + type Options = { + onViolation: (context: Ctx, node: TSESTree.Node | TSESTree.Token, data: { name: string }) => void; + useEffectKind: "useEffect" | "useLayoutEffect"; + }; + type ReturnType = ESLintUtils.RuleListener; +} + +export function useNoDirectSetStateInUseEffect( + context: Ctx, + options: useNoDirectSetStateInUseEffect.Options, +): useNoDirectSetStateInUseEffect.ReturnType { + const { onViolation, useEffectKind } = options; + const settings = getSettingsFromContext(context); + const hooks = settings.additionalHooks; + const getText = (n: TSESTree.Node) => context.sourceCode.getText(n); + const isUseEffectLikeCall = ER.isReactHookCallWithNameAlias(context, useEffectKind, hooks[useEffectKind]); + const isUseStateCall = ER.isReactHookCallWithNameAlias(context, "useState", hooks.useState); + const isUseMemoCall = ER.isReactHookCallWithNameAlias(context, "useMemo", hooks.useMemo); + const isUseCallbackCall = ER.isReactHookCallWithNameAlias(context, "useCallback", hooks.useCallback); + + const functionEntries: { kind: FunctionKind; node: AST.TSESTreeFunction }[] = []; + const setupFunctionRef: { current: AST.TSESTreeFunction | null } = { current: null }; + const setupFunctionIdentifiers: TSESTree.Identifier[] = []; + + const indFunctionCalls: TSESTree.CallExpression[] = []; + const indSetStateCalls = new WeakMap(); + const indSetStateCallsInUseEffectArg0 = new WeakMap(); + const indSetStateCallsInUseEffectSetup = new Map(); + const indSetStateCallsInUseMemoOrCallback = new WeakMap(); + + const onSetupFunctionEnter = (node: AST.TSESTreeFunction) => { + setupFunctionRef.current = node; + }; + + const onSetupFunctionExit = (node: AST.TSESTreeFunction) => { + if (setupFunctionRef.current === node) { + setupFunctionRef.current = null; + } + }; + + function isFunctionOfUseEffectSetup(node: TSESTree.Node) { + return node.parent?.type === T.CallExpression + && node.parent.callee !== node + && isUseEffectLikeCall(node.parent); + } + + function getCallName(node: TSESTree.Node) { + if (node.type === T.CallExpression) { + return AST.toStringFormat(node.callee, getText); + } + return AST.toStringFormat(node, getText); + } + + function getCallKind(node: TSESTree.CallExpression) { + return match(node) + .when(isUseStateCall, () => "useState") + .when(isUseEffectLikeCall, () => useEffectKind) + .when(isSetStateCall, () => "setState") + .when(AST.isThenCall, () => "then") + .otherwise(() => "other"); + } + + function getFunctionKind(node: AST.TSESTreeFunction) { + const parent = AST.findParentNode(node, not(AST.isTypeExpression)) ?? node.parent; + switch (true) { + case node.async: + case parent.type === T.CallExpression + && AST.isThenCall(parent): + return "deferred"; + case node.type !== T.FunctionDeclaration + && parent.type === T.CallExpression + && parent.callee === node: + return "immediate"; + case isFunctionOfUseEffectSetup(node): + return "setup"; + default: + return "other"; + } + } + + function isIdFromUseStateCall(topLevelId: TSESTree.Identifier, at?: number) { + const variable = VAR.findVariable(topLevelId, context.sourceCode.getScope(topLevelId)); + const variableNode = VAR.getVariableInitNode(variable, 0); + if (variableNode == null) return false; + if (variableNode.type !== T.CallExpression) return false; + if (!ER.isReactHookCallWithNameAlias(context, "useState", hooks.useState)(variableNode)) return false; + const variableNodeParent = variableNode.parent; + if (!("id" in variableNodeParent) || variableNodeParent.id?.type !== T.ArrayPattern) { + return true; + } + return variableNodeParent + .id + .elements + .findIndex((e) => e?.type === T.Identifier && e.name === topLevelId.name) === at; + } + + function isSetStateCall(node: TSESTree.CallExpression) { + switch (node.callee.type) { + // const data = useState(); + // data.at(1)(); + case T.CallExpression: { + const { callee } = node.callee; + if (callee.type !== T.MemberExpression) { + return false; + } + if (!("name" in callee.object)) { + return false; + } + const isAt = callee.property.type === T.Identifier && callee.property.name === "at"; + const [index] = node.callee.arguments; + if (!isAt || index == null) { + return false; + } + const indexScope = context.sourceCode.getScope(node); + const indexValue = VAR.toStaticValue({ + kind: "lazy", + node: index, + initialScope: indexScope, + }).value; + return indexValue === 1 && isIdFromUseStateCall(callee.object); + } + // const [data, setData] = useState(); + // setData(); + case T.Identifier: { + return isIdFromUseStateCall(node.callee, 1); + } + // const data = useState(); + // data[1](); + case T.MemberExpression: { + if (!("name" in node.callee.object)) { + return false; + } + const property = node.callee.property; + const propertyScope = context.sourceCode.getScope(node); + const propertyValue = VAR.toStaticValue({ + kind: "lazy", + node: property, + initialScope: propertyScope, + }).value; + return propertyValue === 1 && isIdFromUseStateCall(node.callee.object, 1); + } + default: { + return false; + } + } + } + + return { + ":function"(node: AST.TSESTreeFunction) { + const kind = getFunctionKind(node); + functionEntries.push({ kind, node }); + if (kind === "setup") { + onSetupFunctionEnter(node); + } + }, + ":function:exit"(node: AST.TSESTreeFunction) { + const { kind } = functionEntries.at(-1) ?? {}; + if (kind === "setup") { + onSetupFunctionExit(node); + } + functionEntries.pop(); + }, + CallExpression(node) { + const setupFunction = setupFunctionRef.current; + const pEntry = functionEntries.at(-1); + if (pEntry == null || pEntry.node.async) { + return; + } + match(getCallKind(node)) + .with("setState", () => { + switch (true) { + case pEntry.kind === "deferred": + case pEntry.node.async: + // do nothing, this is a deferred setState call + break; + case pEntry.node === setupFunction: + case pEntry.kind === "immediate" + && AST.findParentNode(pEntry.node, AST.isFunction) === setupFunction: { + onViolation(context, node, { + name: context.sourceCode.getText(node.callee), + }); + return; + } + default: { + const vd = AST.findParentNode(node, isVariableDeclaratorFromHookCall); + if (vd == null) getOrElseUpdate(indSetStateCalls, pEntry.node, () => []).push(node); + else getOrElseUpdate(indSetStateCallsInUseMemoOrCallback, vd.init, () => []).push(node); + } + } + }) + .with(useEffectKind, () => { + if (AST.isFunction(node.arguments.at(0))) return; + setupFunctionIdentifiers.push(...AST.getNestedIdentifiers(node)); + }) + .with("other", () => { + if (pEntry.node !== setupFunction) return; + indFunctionCalls.push(node); + }) + .otherwise(constVoid); + }, + Identifier(node) { + if (node.parent.type === T.CallExpression && node.parent.callee === node) { + return; + } + if (!isIdFromUseStateCall(node, 1)) { + return; + } + switch (node.parent.type) { + case T.ArrowFunctionExpression: { + const parent = node.parent.parent; + if (parent.type !== T.CallExpression) { + break; + } + // const [state, setState] = useState(); + // const set = useMemo(() => setState, []); + // useEffect(set, []); + if (!isUseMemoCall(parent)) { + break; + } + const vd = AST.findParentNode(parent, isVariableDeclaratorFromHookCall); + if (vd != null) { + getOrElseUpdate(indSetStateCallsInUseEffectArg0, vd.init, () => []).push(node); + } + break; + } + case T.CallExpression: { + if (node !== node.parent.arguments.at(0)) { + break; + } + // const [state, setState] = useState(); + // const set = useCallback(setState, []); + // useEffect(set, []); + if (isUseCallbackCall(node.parent)) { + const vd = AST.findParentNode(node.parent, isVariableDeclaratorFromHookCall); + if (vd != null) { + getOrElseUpdate(indSetStateCallsInUseEffectArg0, vd.init, () => []).push(node); + } + break; + } + // const [state, setState] = useState(); + // useEffect(setState); + if (isUseEffectLikeCall(node.parent)) { + getOrElseUpdate(indSetStateCallsInUseEffectSetup, node.parent, () => []).push(node); + } + } + } + }, + "Program:exit"() { + const getSetStateCalls = ( + id: string | TSESTree.Identifier, + initialScope: Scope.Scope, + ): TSESTree.CallExpression[] | TSESTree.Identifier[] => { + const node = VAR.getVariableInitNode(VAR.findVariable(id, initialScope), 0); + switch (node?.type) { + case T.ArrowFunctionExpression: + case T.FunctionDeclaration: + case T.FunctionExpression: + return indSetStateCalls.get(node) ?? []; + case T.CallExpression: + return indSetStateCallsInUseMemoOrCallback.get(node) ?? indSetStateCallsInUseEffectArg0.get(node) ?? []; + } + return []; + }; + for (const [, calls] of indSetStateCallsInUseEffectSetup) { + for (const call of calls) { + onViolation(context, call, { name: call.name }); + } + } + for (const { callee } of indFunctionCalls) { + if (!("name" in callee)) { + continue; + } + const { name } = callee; + const setStateCalls = getSetStateCalls(name, context.sourceCode.getScope(callee)); + for (const setStateCall of setStateCalls) { + onViolation(context, setStateCall, { + name: getCallName(setStateCall), + }); + } + } + for (const id of setupFunctionIdentifiers) { + const setStateCalls = getSetStateCalls(id.name, context.sourceCode.getScope(id)); + for (const setStateCall of setStateCalls) { + onViolation(context, setStateCall, { + name: getCallName(setStateCall), + }); + } + } + }, + }; +} + +function isInitFromHookCall(init: TSESTree.Expression | null) { + if (init?.type !== T.CallExpression) return false; + switch (init.callee.type) { + case T.Identifier: + return ER.isReactHookName(init.callee.name); + case T.MemberExpression: + return init.callee.property.type === T.Identifier + && ER.isReactHookName(init.callee.property.name); + default: + return false; + } +} + +function isVariableDeclaratorFromHookCall(node: TSESTree.Node): node is + & TSESTree.VariableDeclarator + & { init: TSESTree.VariableDeclarator["init"] & {} } +{ + if (node.type !== T.VariableDeclarator) return false; + if (node.id.type !== T.Identifier) return false; + return isInitFromHookCall(node.init); +} diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-boolean.md b/packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-boolean.md similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-boolean.md rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-boolean.md diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-boolean.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-boolean.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-boolean.spec.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-boolean.spec.ts diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-boolean.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-boolean.ts similarity index 93% rename from packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-boolean.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-boolean.ts index 15bd7f4685..20412c1915 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-boolean.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-boolean.ts @@ -14,6 +14,7 @@ export type MessageID = CamelCase; export default createRule<[], MessageID>({ meta: { type: "problem", + deprecated: true, docs: { description: "Enforces explicit boolean values for boolean attributes.", [Symbol.for("rule_features")]: RULE_FEATURES, @@ -23,6 +24,9 @@ export default createRule<[], MessageID>({ avoidShorthandBoolean: "Avoid using shorthand boolean attribute '{{propName}}'. Use '{{propName}}={true}' instead.", }, + replacedBy: [ + "react-x/jsx-shorthand-boolean", + ], schema: [], }, name: RULE_NAME, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-fragment.md b/packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-fragment.md similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-fragment.md rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-fragment.md diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-fragment.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-fragment.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-fragment.spec.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-fragment.spec.ts diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-fragment.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-fragment.ts similarity index 94% rename from packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-fragment.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-fragment.ts index a18a72a05d..1f82d73926 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/avoid-shorthand-fragment.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules-removed/avoid-shorthand-fragment.ts @@ -13,6 +13,7 @@ export type MessageID = CamelCase; export default createRule<[], MessageID>({ meta: { type: "problem", + deprecated: true, docs: { description: "Enforces explicit `` components instead of the shorthand `<>` or `` syntax.", [Symbol.for("rule_features")]: RULE_FEATURES, @@ -20,6 +21,9 @@ export default createRule<[], MessageID>({ messages: { avoidShorthandFragment: "Avoid using shorthand fragment syntax. Use '{{jsxFragmentFactory}}' component instead.", }, + replacedBy: [ + "react-x/jsx-shorthand-fragment", + ], schema: [], }, name: RULE_NAME, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-comment-textnodes.md b/packages/plugins/eslint-plugin-react-x/src/rules-removed/no-comment-textnodes.md similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/no-comment-textnodes.md rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/no-comment-textnodes.md diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-comment-textnodes.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/no-comment-textnodes.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/no-comment-textnodes.spec.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/no-comment-textnodes.spec.ts diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-comment-textnodes.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/no-comment-textnodes.ts similarity index 95% rename from packages/plugins/eslint-plugin-react-x/src/rules/no-comment-textnodes.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/no-comment-textnodes.ts index 68303450a4..a990375c23 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-comment-textnodes.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules-removed/no-comment-textnodes.ts @@ -16,6 +16,7 @@ export type MessageID = CamelCase; export default createRule<[], MessageID>({ meta: { type: "problem", + deprecated: true, docs: { description: "Prevents comments from being inserted as text nodes.", [Symbol.for("rule_features")]: RULE_FEATURES, @@ -24,6 +25,9 @@ export default createRule<[], MessageID>({ noCommentTextnodes: "Possible misused comment in text node. Comments inside children section of tag should be placed inside braces.", }, + replacedBy: [ + "react-x/jsx-no-comment-textnodes", + ], schema: [], }, name: RULE_NAME, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-complex-conditional-rendering.md b/packages/plugins/eslint-plugin-react-x/src/rules-removed/no-complex-conditional-rendering.md similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/no-complex-conditional-rendering.md rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/no-complex-conditional-rendering.md diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-complex-conditional-rendering.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/no-complex-conditional-rendering.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/no-complex-conditional-rendering.spec.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/no-complex-conditional-rendering.spec.ts diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-complex-conditional-rendering.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/no-complex-conditional-rendering.ts similarity index 98% rename from packages/plugins/eslint-plugin-react-x/src/rules/no-complex-conditional-rendering.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/no-complex-conditional-rendering.ts index 23c48a11ff..09b2e092bf 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-complex-conditional-rendering.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules-removed/no-complex-conditional-rendering.ts @@ -18,6 +18,7 @@ export type MessageID = CamelCase; export default createRule<[], MessageID>({ meta: { type: "problem", + deprecated: true, docs: { description: "Disallow complex conditional rendering in JSX expressions.", [Symbol.for("rule_features")]: RULE_FEATURES, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.md b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-react-namespace-import.md similarity index 71% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.md rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-react-namespace-import.md index 18b65f79d0..f4d7086628 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.md +++ b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-react-namespace-import.md @@ -1,17 +1,17 @@ --- -title: prefer-react-namespace-import +title: prefer-namespace-import --- **Full Name in `eslint-plugin-react-x`** ```sh copy -react-x/prefer-react-namespace-import +react-x/prefer-namespace-import ``` **Full Name in `@eslint-react/eslint-plugin`** ```sh copy -@eslint-react/prefer-react-namespace-import +@eslint-react/prefer-namespace-import ``` **Features** @@ -50,5 +50,5 @@ import type { useState } from "react"; ## Implementation -- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.ts) -- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.spec.ts) +- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/prefer-namespace-import.ts) +- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/prefer-namespace-import.spec.ts) diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-react-namespace-import.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.spec.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-react-namespace-import.spec.ts diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-react-namespace-import.ts similarity index 96% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-react-namespace-import.ts index 2b9ba38ca9..a5f052a240 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-react-namespace-import.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-react-namespace-import.ts @@ -17,6 +17,7 @@ export type MessageID = CamelCase; export default createRule<[], MessageID>({ meta: { type: "problem", + deprecated: true, docs: { description: "Enforces React is imported via a namespace import.", [Symbol.for("rule_features")]: RULE_FEATURES, @@ -25,6 +26,9 @@ export default createRule<[], MessageID>({ messages: { preferReactNamespaceImport: "Prefer importing React as 'import * as React from \"{{importSource}}\"';", }, + replacedBy: [ + "react-x/prefer-namespace-import", + ], schema: [], }, name: RULE_NAME, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-boolean.md b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-boolean.md similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-boolean.md rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-boolean.md diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-boolean.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-boolean.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-boolean.spec.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-boolean.spec.ts diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-boolean.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-boolean.ts similarity index 95% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-boolean.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-boolean.ts index eeb04c2c8e..d432f52845 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-boolean.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-boolean.ts @@ -20,6 +20,7 @@ export type MessageID = CamelCase; export default createRule<[], MessageID>({ meta: { type: "problem", + deprecated: true, docs: { description: "Enforces shorthand syntax for boolean attributes.", [Symbol.for("rule_features")]: RULE_FEATURES, @@ -28,6 +29,9 @@ export default createRule<[], MessageID>({ messages: { preferShorthandBoolean: "Use shorthand boolean attribute '{{propName}}'.", }, + replacedBy: [ + "react-x/jsx-shorthand-boolean", + ], schema: [], }, name: RULE_NAME, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-fragment.md b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-fragment.md similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-fragment.md rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-fragment.md diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-fragment.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-fragment.spec.ts similarity index 100% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-fragment.spec.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-fragment.spec.ts diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-fragment.ts b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-fragment.ts similarity index 95% rename from packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-fragment.ts rename to packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-fragment.ts index 6dfe6ad480..49810aa191 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-shorthand-fragment.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules-removed/prefer-shorthand-fragment.ts @@ -17,6 +17,7 @@ export type MessageID = CamelCase; export default createRule<[], MessageID>({ meta: { type: "problem", + deprecated: true, docs: { description: "Enforces shorthand syntax for fragments.", [Symbol.for("rule_features")]: RULE_FEATURES, @@ -25,6 +26,9 @@ export default createRule<[], MessageID>({ messages: { preferShorthandFragment: "Use fragment shorthand syntax instead of 'Fragment' component.", }, + replacedBy: [ + "react-x/jsx-shorthand-fragment", + ], schema: [], }, name: RULE_NAME, 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 index 843075404d..b9ed49e9fe 100644 --- 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 @@ -38,6 +38,33 @@ ruleTester.run(RULE_NAME, rule, { { messageId: "jsxKeyBeforeSpread" }, ], }, + { + code: tsx` + + const App = (props) => { + return [ +
1
, + ] + }; + `, + errors: [ + { messageId: "jsxKeyBeforeSpread" }, + ], + }, + { + code: tsx` + + const App = (props) => { + return [ +
1
, + ] + }; + `, + errors: [ + { messageId: "jsxKeyBeforeSpread" }, + { messageId: "jsxKeyBeforeSpread" }, + ], + }, ], valid: [ ...allValid, 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 index 3776542fc0..a8d8861ba1 100644 --- 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 @@ -39,7 +39,10 @@ export function create(context: RuleContext): RuleListener { firstSpreadAttributeIndex ??= index; continue; } - if (attr.name.name === "key" && firstSpreadAttributeIndex != null && index > firstSpreadAttributeIndex) { + if (firstSpreadAttributeIndex == null) { + continue; + } + if (attr.name.name === "key" && index > firstSpreadAttributeIndex) { context.report({ messageId: "jsxKeyBeforeSpread", node: attr, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.md b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.md new file mode 100644 index 0000000000..075f34a10c --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.md @@ -0,0 +1,76 @@ +--- +title: jsx-no-comment-textnodes +--- + +**Full Name in `eslint-plugin-react-x`** + +```sh copy +react-x/jsx-no-comment-textnodes +``` + +**Full Name in `@eslint-react/eslint-plugin`** + +```sh copy +@eslint-react/jsx-no-comment-textnodes +``` + +**Presets** + +- `x` +- `recommended` +- `recommended-typescript` +- `recommended-type-checked` + +## Description + +Prevents comment strings (e.g. beginning with `//` or `/*`) from being accidentally inserted into the JSX element's textnodes. + +This could be a mistake during code editing or it could be a misunderstanding of how JSX works. Either way, it's probably not what you intended. + +## Examples + +### Failing + +```tsx +import React from "react"; + +function MyComponent1() { + return
// empty div
; + // ^^^^^^^^^^^^ + // - Possible misused comment in text node. Comments inside children section of tag should be placed inside braces. +} + +function MyComponent2() { + return
/* empty div */
; + // ^^^^^^^^^^^^^^^ + // - Possible misused comment in text node. Comments inside children section of tag should be placed inside braces. +} +``` + +### Passing + +```tsx +import React from "react"; + +function MyComponent() { + return
{/* empty div */}
; +} +``` + +### Legitimate uses + +It's possible you may want to legitimately output comment start characters (`//` or `/*`) in a JSX text node. In which case, you can do the following: + +```tsx +import React from "react"; + +function MyComponent() { + // 🟢 Good: This is a legitimate use of comment strings in JSX textnodes + return
{"/* This will be output as a text node */"}
; +} +``` + +## Implementation + +- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.ts) +- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.spec.ts) diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.spec.ts new file mode 100644 index 0000000000..d9765ca4ff --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.spec.ts @@ -0,0 +1,60 @@ +import tsx from "dedent"; + +import { allValid, ruleTester } from "../../../../../test"; +import rule, { RULE_NAME } from "./jsx-no-comment-textnodes"; + +ruleTester.run(RULE_NAME, rule, { + invalid: [ + { + code: tsx`
// invalid
`, + errors: [{ messageId: "jsxNoCommentTextnodes" }], + }, + { + code: tsx`<>// invalid`, + errors: [{ messageId: "jsxNoCommentTextnodes" }], + }, + { + code: tsx`
/* invalid */
`, + errors: [{ messageId: "jsxNoCommentTextnodes" }], + }, + { + code: tsx` +
+ // invalid +
+ `, + errors: [{ messageId: "jsxNoCommentTextnodes" }], + }, + { + code: tsx` +
+ abcdef + /* invalid */ + foo +
+ `, + errors: [{ messageId: "jsxNoCommentTextnodes" }], + }, + { + code: tsx` +
+ {'abcdef'} + // invalid + {'foo'} +
+ `, + errors: [{ messageId: "jsxNoCommentTextnodes" }], + }, + { + code: "/*", + errors: [{ messageId: "jsxNoCommentTextnodes" }], + }, + ], + valid: [ + ...allValid, + "{/* valid */}", + " https://www.eslint-react.xyz/attachment/download/1", + "", + "", + ], +}); diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.ts b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.ts new file mode 100644 index 0000000000..0991e3ed5a --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-no-comment-textnodes.ts @@ -0,0 +1,61 @@ +import type { RuleContext, RuleFeature } from "@eslint-react/kit"; +import type { TSESTree } from "@typescript-eslint/types"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; +import type { CamelCase } from "string-ts"; +import * as AST from "@eslint-react/ast"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; + +import { createRule } from "../utils"; + +export const RULE_NAME = "jsx-no-comment-textnodes"; + +export const RULE_FEATURES = [] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + docs: { + description: "Prevents comments from being inserted as text nodes.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + messages: { + jsxNoCommentTextnodes: + "Possible misused comment in text node. Comments inside children section of tag should be placed inside braces.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +export function create(context: RuleContext): RuleListener { + function hasCommentLike(node: TSESTree.JSXText | TSESTree.Literal) { + if (AST.isOneOf([T.JSXAttribute, T.JSXExpressionContainer])(node.parent)) { + return false; + } + const rawValue = context.sourceCode.getText(node); + return /^\s*\/(?:\/|\*)/mu.test(rawValue); + } + const visitorFunction = (node: TSESTree.JSXText | TSESTree.Literal): void => { + if (!AST.isOneOf([T.JSXElement, T.JSXFragment])(node.parent)) { + return; + } + if (!hasCommentLike(node)) { + return; + } + if (!node.parent.type.includes("JSX")) { + return; + } + context.report({ + messageId: "jsxNoCommentTextnodes", + node, + }); + }; + return { + JSXText: visitorFunction, + Literal: visitorFunction, + }; +} diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/jsx-shorthand-boolean.md b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-shorthand-boolean.md new file mode 100644 index 0000000000..4a7ec619c8 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/jsx-shorthand-boolean.md @@ -0,0 +1,59 @@ +--- +title: jsx-shorthand-boolean +--- + +**Full Name in `eslint-plugin-react-x`** + +```sh copy +react-x/jsx-shorthand-boolean +``` + +**Full Name in `@eslint-react/eslint-plugin`** + +```sh copy +@eslint-react/jsx-shorthand-boolean +``` + +**Features** + +`🔧` + +## Description + +Enforces the use of shorthand syntax for boolean attributes. + +## Examples + +### Failing + +```tsx +import React from "react"; + +function MyComponent() { + return