diff --git a/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-21-23-57.json b/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-21-23-57.json new file mode 100644 index 0000000000..304d7b58a1 --- /dev/null +++ b/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-21-23-57.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-plugin", + "comment": "Introduce a `@rushstack/import-requires-chunk-name` rule. This rule requires that dynamic imports include a Webpack chunk name magic comment.", + "type": "minor" + } + ], + "packageName": "@rushstack/eslint-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-21-23-58.json b/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-21-23-58.json new file mode 100644 index 0000000000..6e28479f5e --- /dev/null +++ b/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-21-23-58.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-plugin", + "comment": "Introduce a `@rushstack/pair-react-dom-render-unmount` rule. This rule requires that every React DOM `render` call has a matching `unmountComponentAtNode` call.", + "type": "minor" + } + ], + "packageName": "@rushstack/eslint-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-22-00-00.json b/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-22-00-00.json new file mode 100644 index 0000000000..2522f8a94f --- /dev/null +++ b/common/changes/@rushstack/eslint-plugin/new-eslint-rules_2025-10-22-00-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-plugin", + "comment": "Include missing rule documentation.", + "type": "patch" + } + ], + "packageName": "@rushstack/eslint-plugin" +} \ No newline at end of file diff --git a/eslint/eslint-plugin/README.md b/eslint/eslint-plugin/README.md index 0e920881e4..037045492d 100644 --- a/eslint/eslint-plugin/README.md +++ b/eslint/eslint-plugin/README.md @@ -55,78 +55,166 @@ let y: typeof import('./file'); jest.mock('./file'); // okay ``` -## `@rushstack/typedef-var` +## `@rushstack/import-requires-chunk-name` -Require explicit type annotations for top-level variable declarations, while exempting local variables within function or method scopes. +Require each dynamic `import()` used for code splitting to specify exactly one Webpack chunk name via a magic comment. #### Rule Details -This rule is implemented to supplement the deprecated `@typescript-eslint/typedef` rule. The `@typescript-eslint/typedef` rule was deprecated based on the judgment that "unnecessary type annotations, where type inference is sufficient, can be cumbersome to maintain and generally reduce code readability." +When using dynamic `import()` to create separately loaded chunks, Webpack (and compatible bundlers such as Rspack) can assign a deterministic name if a `/* webpackChunkName: 'my-chunk' */` or `// webpackChunkName: "my-chunk"` magic comment is provided. Without an explicit name, the bundler falls back to autogenerated identifiers (often numeric or hashed), which: -However, we prioritize code reading and maintenance over code authorship. That is, even when the compiler can infer a type, this rule enforces explicit type annotations to ensure that a code reviewer (e.g., when viewing a GitHub Diff) does not have to rely entirely on inference and can immediately ascertain a variable's type. This approach makes writing code harder but significantly improves the more crucial activity of reading and reviewing code. +- Are less stable across refactors, hurting long‑term caching +- Make bundle analysis and performance troubleshooting harder +- Can produce confusing diffs during code reviews -Therefore, the `@rushstack/typedef-var` rule enforces type annotations for all variable declarations outside of local function or class method scopes. This includes the module's top-level scope and any block scopes that do not belong to a function or method. +This rule enforces that: -To balance this strictness with code authoring convenience, the rule deliberately relaxes the type annotation requirement for the following local variable declarations: +1. Every `import()` expression used for code splitting includes a chunk name magic comment inside its parentheses. +2. Exactly one chunk name is declared. Multiple chunk name comments (or a single comment containing multiple comma‑separated `webpackChunkName` entries) are flagged. -- Variable declarations within a function body. -- Variable declarations within a class method. -- Variables declared via object or array destructuring assignments. +The chunk name must appear in either a block or line comment inside the `import()` call. Accepted forms: + +```ts +import(/* webpackChunkName: 'feature-settings' */ './feature/settings'); +import( + /* webpackChunkName: "feature-settings" */ + './feature/settings' +); +import( + // webpackChunkName: "feature-settings" + './feature/settings' +); +``` + +No options are currently supported. + +For background on magic comments, see: https://webpack.js.org/api/module-methods/#magic-comments #### Examples -The following patterns are considered problems when `@rushstack/typedef-var` is enabled: +The following patterns are considered problems when `@rushstack/import-requires-chunk-name` is enabled: ```ts -// Top-level declarations lack explicit type annotations -const x = 123; // error - -let x = 123; // error +// Missing chunk name +import('./feature/settings'); // error +``` -var x = 123; // error +```ts +// Multiple chunk name comments +import( + /* webpackChunkName: 'feature-settings' */ + /* webpackChunkName: 'feature-settings-alt' */ + './feature/settings' +); // error ``` ```ts -// Declaration within a non-function block scope -{ - const x = 123; // error -} +// Multiple chunk names in a single comment (comma separated) +import( + /* webpackChunkName: 'feature-settings', webpackChunkName: 'feature-settings-alt' */ + './feature/settings' +); // error ``` The following patterns are NOT considered problems: ```ts -// Local variables inside function expressions are exempt -function f() { const x = 123; } // passes +// Single block comment with one chunk name +import(/* webpackChunkName: 'feature-settings' */ './feature/settings'); +``` -const f = () => { const x = 123; }; // passes +```ts +// Multiline formatting with a block comment +import( + /* webpackChunkName: 'feature-settings' */ + './feature/settings' +); +``` -const f = function() { const x = 123; } // passes +```ts +// Line comment form +import( + // webpackChunkName: 'feature-settings' + './feature/settings' +); ``` +#### Notes + +- If your bundler does not understand Webpack magic comments (e.g. plain Node ESM loader), disable this rule for that project. +- Choose stable, descriptive chunk names—avoid including hashes, timestamps, or environment‑specific tokens. +- Chunk names share a global namespace in the final bundle; avoid collisions to keep analysis clear. + +#### Rationale + +Explicit chunk naming improves cache hit rates, observability, and maintainability. Enforcing the practice via an ESLint rule prevents missing or duplicate declarations that could lead to unpredictable bundle naming. + +## `@rushstack/no-backslash-imports` + +Prevent import and export specifiers from using Windows-style backslashes in module paths. + +#### Rule Details + +JavaScript module specifiers always use POSIX forward slashes. Using backslashes (e.g. `import './src\utils'`) can lead to inconsistent behavior across tools, and may break resolution in some environments. This rule flags any import or export whose source contains a `\` character and provides an autofix that replaces backslashes with `/`. + +#### Examples + +The following patterns are considered problems when `@rushstack/no-backslash-imports` is enabled: + ```ts -// Local variables inside class methods are exempt -class C { - public m(): void { - const x = 123; // passes - } -} +import helper from './lib\\helper'; // error (autofix -> './lib/helper') +export * from './data\\items'; // error +``` -class C { - public m = (): void => { - const x = 123; // passes - } -} +The following patterns are NOT considered problems: + +```ts +import helper from './lib/helper'; +export * from '../data/items'; ``` +#### Notes + +- Works for `import`, dynamic `import()`, and `export ... from` forms. +- Loader/query strings (e.g. `raw-loader!./file`) are preserved during the fix; only path separators are changed. + +#### Rationale + +Forward slashes are portable and avoid subtle cross-platform inconsistencies. Autofixing reduces churn and enforces a predictable style. + +## `@rushstack/no-external-local-imports` + +Prevent relative imports that reach outside the configured TypeScript `rootDir` (if specified) or outside the package boundary. + +#### Rule Details + +Local relative imports should refer only to files that are part of the compiling unit: either under the package directory or (when a `rootDir` is configured) under that root. Reaching outside can accidentally couple a package to sibling projects, untracked build inputs, or files excluded from type checking. This rule resolves each relative import/ export source and ensures the target is contained within the effective root. If not, it is flagged. + +#### Examples + +Assume `rootDir` is `src` and the package folder is `/repo/packages/example`: + ```ts -// Array and Object Destructuring assignments are exempt -let { a, b } = { // passes - a: 123, - b: 234 -} +// In /repo/packages/example/src/components/Button.ts +import '../utils/file'; // error if '../utils/file' is outside src +import '../../../other-package/src/index'; // error (outside package root) +``` + +```ts +// In /repo/packages/example/src/index.ts +import './utils/file'; // passes (inside rootDir) ``` +#### Notes + +- Only relative specifiers are checked. Package specifiers (`react`, `lodash`) are ignored. +- If no `rootDir` is defined, the package directory acts as the boundary. +- Useful for enforcing project isolation in monorepos. + +#### Rationale + +Prevents accidental dependencies on files that aren’t part of the compilation or publishing surface, improving encapsulation and build reproducibility. + ## `@rushstack/no-new-null` Prevent usage of the JavaScript `null` value, while allowing code to access existing APIs that @@ -156,7 +244,6 @@ suppressing the lint rule, you can use a specialized [JsonNull](https://api.rushstack.io/pages/node-core-library.jsonnull/) type as provided by [@rushstack/node-core-library](https://www.npmjs.com/package/@rushstack/node-core-library). - #### Examples The following patterns are considered problems when `@rushstack/no-new-null` is enabled: @@ -251,6 +338,47 @@ if (x === null) { // comparisons are okay } ``` +## `@rushstack/no-transitive-dependency-imports` + +Prevent importing modules from transitive dependencies that are not declared in the package’s direct dependency list. + +#### Rule Details + +Packages should only import modules from their own direct dependencies. Importing a transitive dependency (available only because another dependency pulled it in) creates hidden coupling and can break when versions change. This rule detects any import path containing multiple `node_modules` segments (for relative paths) or any direct reference to a nested `node_modules` folder for package specifiers, flagging such usages. + +Allowed exception: a single relative traversal into `node_modules` (e.g. `import '../node_modules/some-pkg/dist/index.js'`) is tolerated to support bypassing package `exports` fields intentionally. Additional traversals are disallowed. + +#### Examples + +The following patterns are considered problems when `@rushstack/no-transitive-dependency-imports` is enabled: + +```ts +// Transitive dependency via deep relative path +import '../../node_modules/some-pkg/node_modules/other-pkg/lib/internal'; // error (multiple node_modules segments) + +// Direct package import that resolves into nested node_modules (caught via parsing) +import 'other-pkg/node_modules/inner-pkg'; // error +``` + +The following patterns are NOT considered problems: + +```ts +// Direct dependency +import 'react'; + +// Single bypass to reach a file export +import '../node_modules/some-pkg/dist/index.js'; +``` + +#### Notes + +- Encourages declaring needed dependencies explicitly in `package.json`. +- Reduces breakage due to indirect version changes. + +#### Rationale + +Explicit declarations keep dependency graphs understandable and maintainable; avoiding transitive imports prevents fragile build outcomes. + ## `@rushstack/no-untyped-underscore` (Opt-in) Prevent TypeScript code from accessing legacy JavaScript members whose name has an underscore prefix. @@ -297,6 +425,196 @@ enum E { let e: E._PrivateMember = E._PrivateMember; // okay, because _PrivateMember is declared by E ``` +## `@rushstack/normalized-imports` + +Require relative import paths to be written in a normalized minimal form and autofix unnecessary directory traversals. + +#### Rule Details + +Developers sometimes write relative paths with redundant traversals (e.g. `import '../module'` when already in the parent, or `import '././utils'`). This rule computes the shortest relative path between the importing file and target, rewrites it using POSIX separators, and ensures a leading `./` is present when needed. Non-relative (package) imports are ignored. + +If the provided path differs from the normalized form, the rule reports it and autofixes to the canonical specifier while preserving loader/query suffixes. + +#### Examples + +The following patterns are considered problems when `@rushstack/normalized-imports` is enabled: + +```ts +// Redundant parent traversal +import '../currentDir/utils'; // error (autofix -> './utils') + +// Repeated ./ segments +import '././components/Button'; // error (autofix -> './components/Button') +``` + +The following patterns are NOT considered problems: + +```ts +import './utils'; +import '../shared/types'; +``` + +#### Notes + +- Only relative paths (`./` or `../`) are normalized. +- Helps produce deterministic diff noise and cleaner refactors. + +#### Rationale + +Consistent relative paths improve readability and make large-scale moves/renames less error-prone. + +## `@rushstack/pair-react-dom-render-unmount` + +Require ReactDOM (legacy) render trees created in a file to be explicitly unmounted in that same file to avoid memory leaks. + +#### Rule Details + +React 18 introduced `ReactDOM.createRoot()` and `root.unmount()`, but many codebases still use the legacy APIs: + +- `ReactDOM.render(element, container)` +- `ReactDOM.unmountComponentAtNode(container)` + +If a component tree is rendered and the container node is later discarded without an explicit unmount, detached DOM nodes and event handlers may remain in memory. This rule enforces a simple pairing discipline: the total number of render calls in a file must match the total number of unmount calls. If they differ, every render and unmount in the file is flagged so the developer can reconcile them. + +The rule detects both namespace invocations (e.g. `ReactDOM.render(...)`) and separately imported named functions (e.g. `import { render, unmountComponentAtNode } from 'react-dom'`). Default or namespace imports (e.g. `import * as ReactDOM from 'react-dom'` or `import ReactDOM from 'react-dom'`) are supported. + +No configuration options are currently supported. + +#### Examples + +The following patterns are considered problems when `@rushstack/pair-react-dom-render-unmount` is enabled: + +```ts +import * as ReactDOM from 'react-dom'; +ReactDOM.render( , document.getElementById('root')); +// Missing matching unmount +``` + +```ts +import { render } from 'react-dom'; +render( , document.getElementById('root')); +// Missing matching unmountComponentAtNode +``` + +```ts +import { unmountComponentAtNode } from 'react-dom'; +// Unmount without a corresponding render in this file +unmountComponentAtNode(document.getElementById('root')!); +``` + +```ts +import { render, unmountComponentAtNode } from 'react-dom'; +render( , a); +render( , b); +// Only one unmount +unmountComponentAtNode(a); +// "b"'s render is not paired +``` + +The following patterns are NOT considered problems: + +```ts +import * as ReactDOM from 'react-dom'; +const rootEl = document.getElementById('root'); +ReactDOM.render( , rootEl); +ReactDOM.unmountComponentAtNode(rootEl!); +``` + +```ts +import { render, unmountComponentAtNode } from 'react-dom'; +render( , a); +render( , b); +unmountComponentAtNode(a); +unmountComponentAtNode(b); +// All renders paired +``` + +```ts +// No legacy ReactDOM render/unmount usage in this file +// (e.g. uses React 18 createRoot API or just defines components) — rule passes +``` + +#### Notes + +- The rule does not attempt dataflow analysis to verify the same container node is passed; it only enforces count parity. +- Modern React apps using `createRoot()` should migrate to pairing `root.unmount()`. This legacy rule helps older code until migration is complete. +- Multiple files can coordinate unmounting (e.g. via a shared cleanup utility); in that case this rule will flag the imbalance—consider colocating the unmount or disabling the rule for that file. + +#### Rationale + +Unpaired legacy renders are a common cause of memory leaks and test pollution. A lightweight count-based heuristic catches most oversights without requiring complex static analysis. + +## `@rushstack/typedef-var` + +Require explicit type annotations for top-level variable declarations, while exempting local variables within function or method scopes. + +#### Rule Details + +This rule is implemented to supplement the deprecated `@typescript-eslint/typedef` rule. The `@typescript-eslint/typedef` rule was deprecated based on the judgment that "unnecessary type annotations, where type inference is sufficient, can be cumbersome to maintain and generally reduce code readability." + +However, we prioritize code reading and maintenance over code authorship. That is, even when the compiler can infer a type, this rule enforces explicit type annotations to ensure that a code reviewer (e.g., when viewing a GitHub Diff) does not have to rely entirely on inference and can immediately ascertain a variable's type. This approach makes writing code harder but significantly improves the more crucial activity of reading and reviewing code. + +Therefore, the `@rushstack/typedef-var` rule enforces type annotations for all variable declarations outside of local function or class method scopes. This includes the module's top-level scope and any block scopes that do not belong to a function or method. + +To balance this strictness with code authoring convenience, the rule deliberately relaxes the type annotation requirement for the following local variable declarations: + +- Variable declarations within a function body. +- Variable declarations within a class method. +- Variables declared via object or array destructuring assignments. + +#### Examples + +The following patterns are considered problems when `@rushstack/typedef-var` is enabled: + +```ts +// Top-level declarations lack explicit type annotations +const x = 123; // error + +let x = 123; // error + +var x = 123; // error +``` + +```ts +// Declaration within a non-function block scope +{ + const x = 123; // error +} +``` + +The following patterns are NOT considered problems: + +```ts +// Local variables inside function expressions are exempt +function f() { const x = 123; } // passes + +const f = () => { const x = 123; }; // passes + +const f = function() { const x = 123; } // passes +``` + +```ts +// Local variables inside class methods are exempt +class C { + public m(): void { + const x = 123; // passes + } +} + +class C { + public m = (): void => { + const x = 123; // passes + } +} +``` + +```ts +// Array and Object Destructuring assignments are exempt +let { a, b } = { // passes + a: 123, + b: 234 +} +``` ## Links diff --git a/eslint/eslint-plugin/src/import-requires-chunk-name.ts b/eslint/eslint-plugin/src/import-requires-chunk-name.ts new file mode 100644 index 0000000000..8b9bc6b9ef --- /dev/null +++ b/eslint/eslint-plugin/src/import-requires-chunk-name.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { TSESTree, TSESLint } from '@typescript-eslint/utils'; + +export const MESSAGE_ID_CHUNK_NAME: 'error-import-requires-chunk-name' = 'error-import-requires-chunk-name'; +export const MESSAGE_ID_SINGLE_CHUNK_NAME: 'error-import-requires-single-chunk-name' = + 'error-import-requires-single-chunk-name'; +type RuleModule = TSESLint.RuleModule; +type RuleContext = TSESLint.RuleContext< + typeof MESSAGE_ID_CHUNK_NAME | typeof MESSAGE_ID_SINGLE_CHUNK_NAME, + [] +>; + +const importRequiresChunkNameRule: RuleModule = { + defaultOptions: [], + meta: { + type: 'problem', + messages: { + [MESSAGE_ID_CHUNK_NAME]: + 'Usage of "import(...)" for code splitting requires a /* webpackChunkName: \'...\' */ comment', + [MESSAGE_ID_SINGLE_CHUNK_NAME]: + 'Usage of "import(...)" for code splitting cannot specify multiple /* webpackChunkName: \'...\' */ comments' + }, + schema: [], + docs: { + description: 'Requires that calls to "import(...)" for code splitting include a Webpack chunk name', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin' + } + }, + create: (context: RuleContext) => { + const sourceCode: Readonly = context.sourceCode; + const webpackChunkNameRegex: RegExp = /^webpackChunkName\s*:\s*('[^']+'|"[^"]+")$/; + + return { + ImportExpression: (node: TSESTree.ImportExpression) => { + const nodeComments: TSESTree.Comment[] = sourceCode.getCommentsInside(node); + const webpackChunkNameEntries: string[] = []; + for (const comment of nodeComments) { + const webpackChunkNameMatches: string[] = comment.value + .split(',') + .map((c) => c.trim()) + .filter((c) => !!c.match(webpackChunkNameRegex)); + webpackChunkNameEntries.push(...webpackChunkNameMatches); + } + + if (webpackChunkNameEntries.length === 0) { + context.report({ node, messageId: MESSAGE_ID_CHUNK_NAME }); + } else if (webpackChunkNameEntries.length !== 1) { + context.report({ node, messageId: MESSAGE_ID_SINGLE_CHUNK_NAME }); + } + } + }; + } +}; + +export { importRequiresChunkNameRule }; diff --git a/eslint/eslint-plugin/src/index.ts b/eslint/eslint-plugin/src/index.ts index 7aee5411dc..61f0c64f23 100644 --- a/eslint/eslint-plugin/src/index.ts +++ b/eslint/eslint-plugin/src/index.ts @@ -12,6 +12,8 @@ import { noTransitiveDependencyImportsRule } from './no-transitive-dependency-im import { noUntypedUnderscoreRule } from './no-untyped-underscore'; import { normalizedImportsRule } from './normalized-imports'; import { typedefVar } from './typedef-var'; +import { importRequiresChunkNameRule } from './import-requires-chunk-name'; +import { pairReactDomRenderUnmountRule } from './pair-react-dom-render-unmount'; interface IPlugin { rules: { [ruleName: string]: TSESLint.RuleModule }; @@ -44,7 +46,13 @@ const plugin: IPlugin = { 'normalized-imports': normalizedImportsRule, // Full name: "@rushstack/typedef-var" - 'typedef-var': typedefVar + 'typedef-var': typedefVar, + + // Full name: "@rushstack/import-requires-chunk-name" + 'import-requires-chunk-name': importRequiresChunkNameRule, + + // Full name: "@rushstack/pair-react-dom-render-unmount" + 'pair-react-dom-render-unmount': pairReactDomRenderUnmountRule } }; diff --git a/eslint/eslint-plugin/src/pair-react-dom-render-unmount.ts b/eslint/eslint-plugin/src/pair-react-dom-render-unmount.ts new file mode 100644 index 0000000000..7c4647b549 --- /dev/null +++ b/eslint/eslint-plugin/src/pair-react-dom-render-unmount.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TSESTree, type TSESLint } from '@typescript-eslint/utils'; + +export const MESSAGE_ID: 'error-pair-react-dom-render-unmount' = 'error-pair-react-dom-render-unmount'; +type RuleModule = TSESLint.RuleModule; +type RuleContext = TSESLint.RuleContext; + +const pairReactDomRenderUnmountRule: RuleModule = { + defaultOptions: [], + meta: { + type: 'problem', + messages: { + [MESSAGE_ID]: 'Pair the render and unmount calls to avoid memory leaks.' + }, + schema: [], + docs: { + description: + 'Pair ReactDOM "render" and "unmount" calls in one file.' + + ' If a ReactDOM render tree is not unmounted when disposed, it will cause a memory leak.', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin' + } + }, + create: (context: RuleContext) => { + const renderCallExpressions: TSESTree.CallExpression[] = []; + const unmountCallExpressions: TSESTree.CallExpression[] = []; + + let reactDomImportNamespaceName: string | undefined; + let reactDomRenderFunctionName: string | undefined; + let reactDomUnmountFunctionName: string | undefined; + + const isFunctionCallExpression: ( + node: TSESTree.CallExpression, + methodName: string | undefined + ) => boolean = (node: TSESTree.CallExpression, methodName: string | undefined) => { + return node.callee.type === TSESTree.AST_NODE_TYPES.Identifier && node.callee.name === methodName; + }; + + const isNamespaceCallExpression: ( + node: TSESTree.CallExpression, + namespaceName: string | undefined, + methodName: string | undefined + ) => boolean = ( + node: TSESTree.CallExpression, + namespaceName: string | undefined, + methodName: string | undefined + ) => { + if (node.callee.type === TSESTree.AST_NODE_TYPES.MemberExpression) { + const { object, property } = node.callee; + if (object.type === TSESTree.AST_NODE_TYPES.Identifier && object.name === namespaceName) { + return ( + (property.type === TSESTree.AST_NODE_TYPES.Identifier && property.name === methodName) || + (property.type === TSESTree.AST_NODE_TYPES.Literal && property.value === methodName) + ); + } + } + return false; + }; + + return { + ImportDeclaration: (node: TSESTree.ImportDeclaration) => { + // Extract the name for the 'react-dom' namespace import + if (node.source.value === 'react-dom') { + if (!reactDomImportNamespaceName) { + const namespaceSpecifier: TSESTree.ImportClause | undefined = node.specifiers.find( + (s) => s.type === TSESTree.AST_NODE_TYPES.ImportNamespaceSpecifier + ); + if (namespaceSpecifier) { + reactDomImportNamespaceName = namespaceSpecifier.local.name; + } else { + const defaultSpecifier: TSESTree.ImportClause | undefined = node.specifiers.find( + (s) => s.type === TSESTree.AST_NODE_TYPES.ImportDefaultSpecifier + ); + if (defaultSpecifier) { + reactDomImportNamespaceName = defaultSpecifier.local.name; + } + } + } + + if (!reactDomRenderFunctionName || !reactDomUnmountFunctionName) { + const importSpecifiers: TSESTree.ImportSpecifier[] = node.specifiers.filter( + (s) => s.type === TSESTree.AST_NODE_TYPES.ImportSpecifier + ) as TSESTree.ImportSpecifier[]; + for (const importSpecifier of importSpecifiers) { + const name: string | undefined = + 'name' in importSpecifier.imported ? importSpecifier.imported.name : undefined; + if (name === 'render') { + reactDomRenderFunctionName = importSpecifier.local.name; + } else if (name === 'unmountComponentAtNode') { + reactDomUnmountFunctionName = importSpecifier.local.name; + } + } + } + } + }, + CallExpression: (node: TSESTree.CallExpression) => { + if ( + isNamespaceCallExpression(node, reactDomImportNamespaceName, 'render') || + isFunctionCallExpression(node, reactDomRenderFunctionName) + ) { + renderCallExpressions.push(node); + } else if ( + isNamespaceCallExpression(node, reactDomImportNamespaceName, 'unmountComponentAtNode') || + isFunctionCallExpression(node, reactDomUnmountFunctionName) + ) { + unmountCallExpressions.push(node); + } + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Program:exit': (node: TSESTree.Program) => { + if (renderCallExpressions.length !== unmountCallExpressions.length) { + renderCallExpressions.concat(unmountCallExpressions).forEach((callExpression) => { + context.report({ node: callExpression, messageId: MESSAGE_ID }); + }); + } + } + }; + } +}; + +export { pairReactDomRenderUnmountRule }; diff --git a/eslint/eslint-plugin/src/test/import-requires-chunk-name.test.ts b/eslint/eslint-plugin/src/test/import-requires-chunk-name.test.ts new file mode 100644 index 0000000000..c06d477130 --- /dev/null +++ b/eslint/eslint-plugin/src/test/import-requires-chunk-name.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RuleTester } from '@typescript-eslint/rule-tester'; + +import { getRuleTesterWithProject } from './ruleTester'; +import { importRequiresChunkNameRule } from '../import-requires-chunk-name'; + +const ruleTester: RuleTester = getRuleTesterWithProject(); + +ruleTester.run('import-requires-chunk-name', importRequiresChunkNameRule, { + invalid: [ + { + code: [ + 'import(', + ' /* webpackChunkName: "my-chunk-name" */', + ' /* webpackChunkName: "my-chunk-name2" */', + " 'module'", + ')' + ].join('\n'), + errors: [{ messageId: 'error-import-requires-single-chunk-name' }] + }, + { + code: [ + 'import(', + ' // webpackChunkName: "my-chunk-name"', + ' // webpackChunkName: "my-chunk-name2"', + " 'module'", + ')' + ].join('\n'), + errors: [{ messageId: 'error-import-requires-single-chunk-name' }] + }, + { + code: "import('module')", + errors: [{ messageId: 'error-import-requires-chunk-name' }] + } + ], + valid: [ + { + code: 'import(/* webpackChunkName: "my-chunk-name" */\'module\')' + }, + { + code: ['import(', ' /* webpackChunkName: "my-chunk-name" */', " 'module'", ')'].join('\n') + }, + { + code: ['import(', ' // webpackChunkName: "my-chunk-name"', " 'module'", ')'].join('\n') + } + ] +}); diff --git a/eslint/eslint-plugin/src/test/pair-react-dom-render-unmount.test.ts b/eslint/eslint-plugin/src/test/pair-react-dom-render-unmount.test.ts new file mode 100644 index 0000000000..a5027f0780 --- /dev/null +++ b/eslint/eslint-plugin/src/test/pair-react-dom-render-unmount.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RuleTester } from '@typescript-eslint/rule-tester'; + +import { getRuleTesterWithProject } from './ruleTester'; +import { pairReactDomRenderUnmountRule } from '../pair-react-dom-render-unmount'; + +const ruleTester: RuleTester = getRuleTesterWithProject(); + +ruleTester.run('pair-react-dom-render-unmount', pairReactDomRenderUnmountRule, { + invalid: [ + { + code: [ + "import ReactDOM from 'react-dom';", + 'ReactDOM.render();', + 'ReactDOM.render();', + 'ReactDOM.render();', + 'ReactDOM.unmountComponentAtNode();', + 'ReactDOM.unmountComponentAtNode();' + ].join('\n'), + errors: [ + { messageId: 'error-pair-react-dom-render-unmount', line: 2 }, + { messageId: 'error-pair-react-dom-render-unmount', line: 3 }, + { messageId: 'error-pair-react-dom-render-unmount', line: 4 }, + { messageId: 'error-pair-react-dom-render-unmount', line: 5 }, + { messageId: 'error-pair-react-dom-render-unmount', line: 6 } + ] + }, + { + code: ["import * as ReactDOM from 'react-dom';", 'ReactDOM.render();'].join('\n'), + errors: [{ messageId: 'error-pair-react-dom-render-unmount', line: 2 }] + }, + { + code: ["import ReactDOM from 'react-dom';", 'ReactDOM.unmountComponentAtNode();'].join('\n'), + errors: [{ messageId: 'error-pair-react-dom-render-unmount', line: 2 }] + }, + { + code: [ + "import { render, unmountComponentAtNode } from 'react-dom';", + 'render();', + 'unmountComponentAtNode();', + 'unmountComponentAtNode();' + ].join('\n'), + errors: [ + { messageId: 'error-pair-react-dom-render-unmount', line: 2 }, + { messageId: 'error-pair-react-dom-render-unmount', line: 3 }, + { messageId: 'error-pair-react-dom-render-unmount', line: 4 } + ] + }, + { + code: [ + "import { render as ReactRender, unmountComponentAtNode as ReactUnmount } from 'react-dom';", + 'ReactRender();', + 'ReactUnmount();', + 'ReactUnmount();' + ].join('\n'), + errors: [ + { messageId: 'error-pair-react-dom-render-unmount', line: 2 }, + { messageId: 'error-pair-react-dom-render-unmount', line: 3 }, + { messageId: 'error-pair-react-dom-render-unmount', line: 4 } + ] + } + ], + valid: [ + { + code: [ + "import ReactDOM from 'react-dom';", + 'ReactDOM.render();', + 'ReactDOM.render();', + 'ReactDOM.render();', + 'ReactDOM.unmountComponentAtNode();', + 'ReactDOM.unmountComponentAtNode();', + 'ReactDOM.unmountComponentAtNode();' + ].join('\n') + }, + { + code: [ + "import * as ReactDOM from 'react-dom';", + 'ReactDOM.render();', + 'ReactDOM.unmountComponentAtNode();' + ].join('\n') + }, + { + code: [ + "import ReactDOM from 'react-dom';", + 'ReactDOM.render();', + 'ReactDOM.unmountComponentAtNode();' + ].join('\n') + }, + { + code: [ + "import { render, unmountComponentAtNode } from 'react-dom';", + 'render();', + 'unmountComponentAtNode();' + ].join('\n') + }, + { + code: [ + "import { render as ReactRender, unmountComponentAtNode as ReactUnmount } from 'react-dom';", + 'ReactRender();', + 'ReactUnmount();' + ].join('\n') + } + ] +});