diff --git a/apps/website/content/docs/rules/meta.json b/apps/website/content/docs/rules/meta.json index 9f2fc6b15c..3fe23e91c5 100644 --- a/apps/website/content/docs/rules/meta.json +++ b/apps/website/content/docs/rules/meta.json @@ -34,6 +34,7 @@ "no-missing-key", "no-misused-capture-owner-stack", "no-nested-component-definitions", + "no-nested-lazy-component-declarations", "no-prop-types", "no-redundant-should-component-update", "no-set-state-in-component-did-mount", diff --git a/apps/website/content/docs/rules/overview.mdx b/apps/website/content/docs/rules/overview.mdx index 36ec0d74f7..b1065282c0 100644 --- a/apps/website/content/docs/rules/overview.mdx +++ b/apps/website/content/docs/rules/overview.mdx @@ -57,6 +57,7 @@ Linter rules can have false positives, false negatives, and some rules are depen | [`no-missing-key`](./no-missing-key) | 2️⃣ | | Disallow missing `key` on items in list rendering | | | [`no-misused-capture-owner-stack`](./no-misused-capture-owner-stack) | 0️⃣ | `🧪` | Prevents incorrect usage of `captureOwnerStack` | | | [`no-nested-component-definitions`](./no-nested-component-definitions) | 2️⃣ | | Disallow nesting component definitions inside other components | | +| [`no-nested-lazy-component-declarations`](./no-nested-lazy-component-declarations) | 2️⃣ | | Disallow nesting lazy component declarations inside other components | | | [`no-prop-types`](./no-prop-types) | 2️⃣ | | Disallow `propTypes` in favor of TypeScript or another type-checking solution | | | [`no-redundant-should-component-update`](./no-redundant-should-component-update) | 2️⃣ | | Disallow `shouldComponentUpdate` when extending `React.PureComponent` | | | [`no-set-state-in-component-did-mount`](./no-set-state-in-component-did-mount) | 1️⃣ | | Disallow calling `this.setState` in `componentDidMount` outside of functions, such as callbacks | | diff --git a/packages/core/docs/README.md b/packages/core/docs/README.md index 85d666605f..5a22846634 100644 --- a/packages/core/docs/README.md +++ b/packages/core/docs/README.md @@ -60,6 +60,8 @@ - [isForwardRef](variables/isForwardRef.md) - [isForwardRefCall](variables/isForwardRefCall.md) - [isInversePhase](variables/isInversePhase.md) +- [isLazy](variables/isLazy.md) +- [isLazyCall](variables/isLazyCall.md) - [isMemo](variables/isMemo.md) - [isMemoCall](variables/isMemoCall.md) - [isUseActionStateCall](variables/isUseActionStateCall.md) diff --git a/packages/core/docs/variables/isLazy.md b/packages/core/docs/variables/isLazy.md new file mode 100644 index 0000000000..c2b88050ff --- /dev/null +++ b/packages/core/docs/variables/isLazy.md @@ -0,0 +1,9 @@ +[**@eslint-react/core**](../README.md) + +*** + +[@eslint-react/core](../README.md) / isLazy + +# Variable: isLazy + +> `const` **isLazy**: `ReturnType` diff --git a/packages/core/docs/variables/isLazyCall.md b/packages/core/docs/variables/isLazyCall.md new file mode 100644 index 0000000000..72980fde35 --- /dev/null +++ b/packages/core/docs/variables/isLazyCall.md @@ -0,0 +1,9 @@ +[**@eslint-react/core**](../README.md) + +*** + +[@eslint-react/core](../README.md) / isLazyCall + +# Variable: isLazyCall + +> `const` **isLazyCall**: `ReturnType` diff --git a/packages/core/src/component/component-definition.ts b/packages/core/src/component/component-definition.ts index 29e474d40d..d544d25c93 100644 --- a/packages/core/src/component/component-definition.ts +++ b/packages/core/src/component/component-definition.ts @@ -42,7 +42,7 @@ export function isValidComponentDefinition(context: RuleContext, node: AST.TSEST if (hint & ComponentDetectionHint.SkipArrayMapArgument && AST.isArrayMapCall(node.parent)) { return false; } - const boundaryNode = AST.findParentNode( + const significantParent = AST.findParentNode( node, AST.isOneOf([ T.JSXExpressionContainer, @@ -52,5 +52,5 @@ export function isValidComponentDefinition(context: RuleContext, node: AST.TSEST T.ClassBody, ]), ); - return boundaryNode == null || boundaryNode.type !== T.JSXExpressionContainer; + return significantParent == null || significantParent.type !== T.JSXExpressionContainer; } diff --git a/packages/core/src/utils/is-react-api.ts b/packages/core/src/utils/is-react-api.ts index bd4dbbeb91..4a4c6797f2 100644 --- a/packages/core/src/utils/is-react-api.ts +++ b/packages/core/src/utils/is-react-api.ts @@ -32,6 +32,7 @@ export const isCreateElement = isReactAPI("createElement"); export const isCreateRef = isReactAPI("createRef"); export const isForwardRef = isReactAPI("forwardRef"); export const isMemo = isReactAPI("memo"); +export const isLazy = isReactAPI("lazy"); export const isCaptureOwnerStackCall = isReactAPICall("captureOwnerStack"); export const isChildrenCountCall = isReactAPICall("Children", "count"); @@ -45,3 +46,4 @@ export const isCreateElementCall = isReactAPICall("createElement"); export const isCreateRefCall = isReactAPICall("createRef"); export const isForwardRefCall = isReactAPICall("forwardRef"); export const isMemoCall = isReactAPICall("memo"); +export const isLazyCall = isReactAPICall("lazy"); diff --git a/packages/plugins/eslint-plugin-react-debug/src/rules/class-component.ts b/packages/plugins/eslint-plugin-react-debug/src/rules/class-component.ts index 67e738d71d..e0539e1798 100644 --- a/packages/plugins/eslint-plugin-react-debug/src/rules/class-component.ts +++ b/packages/plugins/eslint-plugin-react-debug/src/rules/class-component.ts @@ -34,8 +34,8 @@ export function create(context: RuleContext): RuleListener { const { ctx, listeners } = ER.useComponentCollectorLegacy(); return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { name = "anonymous", node: component } of components.values()) { context.report({ messageId: "classComponent", diff --git a/packages/plugins/eslint-plugin-react-debug/src/rules/function-component.ts b/packages/plugins/eslint-plugin-react-debug/src/rules/function-component.ts index 427b39f83a..bdc068f50d 100644 --- a/packages/plugins/eslint-plugin-react-debug/src/rules/function-component.ts +++ b/packages/plugins/eslint-plugin-react-debug/src/rules/function-component.ts @@ -42,8 +42,8 @@ export function create(context: RuleContext): RuleListener { ); return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { name = "anonymous", node, displayName, flag, hookCalls } of components.values()) { context.report({ messageId: "functionComponent", diff --git a/packages/plugins/eslint-plugin-react-debug/src/rules/hook.ts b/packages/plugins/eslint-plugin-react-debug/src/rules/hook.ts index e96f6cc91b..e61f6dd521 100644 --- a/packages/plugins/eslint-plugin-react-debug/src/rules/hook.ts +++ b/packages/plugins/eslint-plugin-react-debug/src/rules/hook.ts @@ -35,8 +35,8 @@ export function create(context: RuleContext): RuleListener { return { ...listeners, - "Program:exit"(node) { - const allHooks = ctx.getAllHooks(node); + "Program:exit"(program) { + const allHooks = ctx.getAllHooks(program); for (const { name, node, hookCalls } of allHooks.values()) { context.report({ 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/no-unnecessary-use-callback.md index 405a853fb6..a034ad3adc 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/no-unnecessary-use-callback.md @@ -20,7 +20,7 @@ react-hooks-extra/no-unnecessary-use-callback ## Description -Disallows unnecessary usage of `useCallback`. +Disallow unnecessary usage of `useCallback`. React Hooks `useCallback` has empty dependencies array like what's in the examples, are unnecessary. The hook can be removed and it's value can be created in the component body or hoisted to the outer scope of the component. diff --git a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.ts b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.ts index 54f231ebd9..c767d8cbbd 100644 --- a/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.ts +++ b/packages/plugins/eslint-plugin-react-hooks-extra/src/rules/no-unnecessary-use-prefix.ts @@ -44,8 +44,8 @@ export function create(context: RuleContext): RuleListener { const { ctx, listeners } = ER.useHookCollector(); return { ...listeners, - "Program:exit"(node) { - const allHooks = ctx.getAllHooks(node); + "Program:exit"(program) { + const allHooks = ctx.getAllHooks(program); for (const { name, node, hookCalls } of allHooks.values()) { // Skip empty functions if (AST.isEmptyFunction(node)) { 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 7b58f395d8..3e8eae7bc8 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 @@ -108,9 +108,9 @@ export function create(context: RuleContext): RuleListener { return { ...collector.listeners, ...collectorLegacy.listeners, - "Program:exit"(node) { - const functionComponents = collector.ctx.getAllComponents(node); - const classComponents = collectorLegacy.ctx.getAllComponents(node); + "Program:exit"(program) { + const functionComponents = collector.ctx.getAllComponents(program); + const classComponents = collectorLegacy.ctx.getAllComponents(program); for (const { node: component } of functionComponents.values()) { const id = AST.getFunctionIdentifier(component); if (id?.name == null) continue; diff --git a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/filename-extension.ts b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/filename-extension.ts index bb40f065a6..29c8433994 100644 --- a/packages/plugins/eslint-plugin-react-naming-convention/src/rules/filename-extension.ts +++ b/packages/plugins/eslint-plugin-react-naming-convention/src/rules/filename-extension.ts @@ -101,13 +101,13 @@ export function create(context: RuleContext): RuleListener { JSXFragment() { hasJSXNode = true; }, - "Program:exit"(node) { + "Program:exit"(program) { const fileNameExt = filename.slice(filename.lastIndexOf(".")); const isJSXExt = extensions.includes(fileNameExt); if (hasJSXNode && !isJSXExt) { context.report({ messageId: "useJsxFileExtension", - node, + node: program, data: { extensions: extensionsString, }, @@ -115,7 +115,7 @@ export function create(context: RuleContext): RuleListener { return; } - const hasCode = node.body.length > 0; + const hasCode = program.body.length > 0; const ignoreFilesWithoutCode = isObject(options) && options.ignoreFilesWithoutCode === true; if (!hasCode && ignoreFilesWithoutCode) { return; @@ -127,7 +127,7 @@ export function create(context: RuleContext): RuleListener { ) { context.report({ messageId: "useNonJsxFileExtension", - node, + node: program, data: { extensions: extensionsString, }, 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 a7048c53f3..33f9165372 100644 --- a/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts +++ b/packages/plugins/eslint-plugin-react-x/src/configs/recommended.ts @@ -29,6 +29,7 @@ export const rules = { "react-x/no-missing-key": "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", "react-x/no-redundant-should-component-update": "error", "react-x/no-set-state-in-component-did-mount": "warn", diff --git a/packages/plugins/eslint-plugin-react-x/src/plugin.ts b/packages/plugins/eslint-plugin-react-x/src/plugin.ts index 1550666bc9..5d7bf3f09e 100644 --- a/packages/plugins/eslint-plugin-react-x/src/plugin.ts +++ b/packages/plugins/eslint-plugin-react-x/src/plugin.ts @@ -33,6 +33,7 @@ import noMissingContextDisplayName from "./rules/no-missing-context-display-name import noMissingKey from "./rules/no-missing-key"; import noMisusedCaptureOwnerStack from "./rules/no-misused-capture-owner-stack"; import noNestedComponentDefinitions from "./rules/no-nested-component-definitions"; +import noNestedLazyComponentDeclarations from "./rules/no-nested-lazy-component-declarations"; import noPropTypes from "./rules/no-prop-types"; import noRedundantShouldComponentUpdate from "./rules/no-redundant-should-component-update"; import noSetStateInComponentDidMount from "./rules/no-set-state-in-component-did-mount"; @@ -91,6 +92,7 @@ export const plugin = { "no-missing-key": noMissingKey, "no-misused-capture-owner-stack": noMisusedCaptureOwnerStack, "no-nested-component-definitions": noNestedComponentDefinitions, + "no-nested-lazy-component-declarations": noNestedLazyComponentDeclarations, "no-prop-types": noPropTypes, "no-redundant-should-component-update": noRedundantShouldComponentUpdate, "no-set-state-in-component-did-mount": noSetStateInComponentDidMount, diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-access-state-in-setstate.md b/packages/plugins/eslint-plugin-react-x/src/rules/no-access-state-in-setstate.md index 7eb95fafd7..4b312ec052 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-access-state-in-setstate.md +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-access-state-in-setstate.md @@ -23,7 +23,7 @@ react-x/no-access-state-in-setstate ## Description -Disallows accessing `this.state` inside `setState` calls. +Disallow accessing `this.state` inside `setState` calls. Usage of `this.state` inside `setState` calls might result in errors when two state calls are called in batch and thus referencing old state and not the current state. diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-class-component.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-class-component.ts index 869402351f..160ba8bac7 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-class-component.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-class-component.ts @@ -33,8 +33,8 @@ export function create(context: RuleContext): RuleListener { const { ctx, listeners } = ER.useComponentCollectorLegacy(); return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { name = "anonymous", node: component } of components.values()) { if (component.body.body.some((m) => ER.isComponentDidCatch(m) || ER.isGetDerivedStateFromError(m))) { continue; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-mount.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-mount.ts index 1da9c72873..99f6aa7648 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-mount.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-mount.ts @@ -37,8 +37,8 @@ export function create(context: RuleContext): RuleListener { return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { node: component } of components.values()) { const { body } = component.body; for (const member of body) { diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-receive-props.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-receive-props.ts index 101f72f1cf..54b2631e9f 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-receive-props.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-receive-props.ts @@ -36,8 +36,8 @@ export function create(context: RuleContext): RuleListener { const { ctx, listeners } = ER.useComponentCollectorLegacy(); return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { node: component } of components.values()) { const { body } = component.body; for (const member of body) { diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-update.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-update.ts index 20ac5b49ea..dfd555804b 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-update.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-component-will-update.ts @@ -37,8 +37,8 @@ export function create(context: RuleContext): RuleListener { return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { node: component } of components.values()) { const { body } = component.body; for (const member of body) { diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-missing-component-display-name.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-missing-component-display-name.ts index 6fb4e94fb5..efd086eb07 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-missing-component-display-name.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-missing-component-display-name.ts @@ -44,8 +44,8 @@ export function create(context: RuleContext): RuleListener { ); return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { node, displayName, flag } of components.values()) { const isMemoOrForwardRef = (flag & (ER.ComponentFlag.ForwardRef | ER.ComponentFlag.Memo)) > 0n; if (AST.getFunctionIdentifier(node) != null) { diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions.ts index e555183848..bc7853a8d5 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions.ts @@ -49,17 +49,17 @@ export function create(context: RuleContext): RuleListener { return { ...collector.listeners, ...collectorLegacy.listeners, - "Program:exit"(node) { + "Program:exit"(program) { const functionComponents = [ ...collector .ctx - .getAllComponents(node) + .getAllComponents(program) .values(), ]; const classComponents = [ ...collectorLegacy .ctx - .getAllComponents(node) + .getAllComponents(program) .values(), ]; const isFunctionComponent = (node: TSESTree.Node): node is AST.TSESTreeFunction => { diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.md b/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.md new file mode 100644 index 0000000000..7bfea4a820 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.md @@ -0,0 +1,66 @@ +--- +title: no-nested-lazy-component-declarations +--- + +**Full Name in `eslint-plugin-react-x@beta`** + +```plain copy +react-x/no-nested-lazy-component-declarations +``` + +**Full Name in `@eslint-react/eslint-plugin@beta`** + +```plain copy +@eslint-react/no-nested-lazy-component-declarations +``` + +**Presets** + +- `x` +- `recommended` +- `recommended-typescript` +- `recommended-type-checked` + +## Description + +Disallow nesting lazy component declarations inside other components. + +When a lazy component is declared inside another component, it will be re-created on every render of the parent component. This can lead to unexpected behavior, such as resetting the state of the lazy component. + +## Examples + +### Failing + +```tsx +import { lazy } from "react"; + +function Editor() { + // 🔴 Bad: This will cause all state to be reset on re-renders + const MarkdownPreview = lazy(() => import("./MarkdownPreview.js")); + // ^^^^^^^^^^^^^^^ + // - Do not declare lazy components inside other components. Instead, always declare them at the top level of your module. + // ... +} +``` + +### Passing + +```tsx +import { lazy } from "react"; + +// ✅ Good: Declare lazy components outside of your components +const MarkdownPreview = lazy(() => import("./MarkdownPreview.js")); + +function Editor() { + // ... +} +``` + +## Implementation + +- [Rule source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.ts) +- [Test source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.spec.ts) + +## Further Reading + +- [React: Nesting and organizing components](https://react.dev/learn/your-first-component#nesting-and-organizing-components) diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.spec.ts new file mode 100644 index 0000000000..76aee848c2 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.spec.ts @@ -0,0 +1,42 @@ +import tsx from "dedent"; + +import { allValid, ruleTester } from "../../../../../test"; +import rule, { RULE_NAME } from "./no-nested-lazy-component-declarations"; + +ruleTester.run(RULE_NAME, rule, { + invalid: [ + { + code: tsx` + import { lazy } from "react"; + + function Editor() { + // 🔴 Bad: This will cause all state to be reset on re-renders + const MarkdownPreview = lazy(() => import("./MarkdownPreview.js")); + // ^^^^^^^^^^^^^^^ + // - Do not declare lazy components inside other components. Instead, always declare them at the top level of your module. + // ... + + return null; + } + `, + errors: [ + { + messageId: "noNestedComponentDefinitions", + }, + ], + }, + ], + valid: [ + ...allValid, + tsx` + import { lazy } from "react"; + + // ✅ Good: Declare lazy components outside of your components + const MarkdownPreview = lazy(() => import("./MarkdownPreview.js")); + + function Editor() { + // ... + } + `, + ], +}); diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.ts new file mode 100644 index 0000000000..9f6a1d5d92 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-nested-lazy-component-declarations.ts @@ -0,0 +1,90 @@ +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 * as JSX from "@eslint-react/jsx"; + +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { createRule } from "../utils"; + +export const RULE_NAME = "no-nested-component-definitions"; + +export const RULE_FEATURES = [] as const satisfies RuleFeature[]; + +export type MessageID = CamelCase; + +export default createRule<[], MessageID>({ + meta: { + type: "problem", + docs: { + description: "Disallow nesting lazy component declarations inside other components.", + [Symbol.for("rule_features")]: RULE_FEATURES, + }, + messages: { + noNestedComponentDefinitions: + "Do not declare lazy components inside other components. Instead, always declare them at the top level of your module.", + }, + schema: [], + }, + name: RULE_NAME, + create, + defaultOptions: [], +}); + +export function create(context: RuleContext): RuleListener { + const hint = ER.ComponentDetectionHint.None; + const collector = ER.useComponentCollector(context, { hint }); + const collectorLegacy = ER.useComponentCollectorLegacy(); + + const lazyComponentDeclarations = new Set(); + + return { + ...collector.listeners, + ...collectorLegacy.listeners, + ImportExpression(node) { + const lazyCall = AST.findParentNode(node, (n) => ER.isLazyCall(context, n)); + if (lazyCall != null) { + lazyComponentDeclarations.add(lazyCall); + } + }, + "Program:exit"(program) { + const functionComponents = [ + ...collector + .ctx + .getAllComponents(program) + .values(), + ]; + + const classComponents = [ + ...collectorLegacy + .ctx + .getAllComponents(program) + .values(), + ]; + + for (const lazy of lazyComponentDeclarations) { + const significantParent = AST.findParentNode(lazy, (n) => { + if (JSX.isJSX(n)) return true; + if (n.type === T.CallExpression) { + return ER.isReactHookCall(n) || ER.isCreateElementCall(context, n) || ER.isCreateContextCall(context, n); + } + if (AST.isFunction(n)) { + return functionComponents.some((c) => c.node === n); + } + if (AST.isClass(n)) { + return classComponents.some((c) => c.node === n); + } + return false; + }); + if (significantParent != null) { + context.report({ + messageId: "noNestedComponentDefinitions", + node: lazy, + }); + } + } + }, + }; +} diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-redundant-should-component-update.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-redundant-should-component-update.ts index ef618bec82..e3323e7fc2 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-redundant-should-component-update.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-redundant-should-component-update.ts @@ -44,8 +44,8 @@ export function create(context: RuleContext): RuleListener { return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { name = "PureComponent", node: component, flag } of components.values()) { if ((flag & ER.ComponentFlag.PureComponent) === 0n) { diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-did-update.md b/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-did-update.md index a3fec9a9e6..7d37d41460 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-did-update.md +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-did-update.md @@ -23,7 +23,7 @@ react-x/no-set-state-in-component-did-update ## Description -Disallows calling `this.setState` in `componentDidUpdate` outside of functions, such as callbacks. +Disallow calling `this.setState` in `componentDidUpdate` outside of functions, such as callbacks. Updating the state after a component mount will trigger a second `render()` call and can lead to property/layout thrashing. diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-did-update.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-did-update.ts index d200c3564f..e824a4caa7 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-did-update.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-did-update.ts @@ -17,7 +17,7 @@ export default createRule<[], MessageID>({ meta: { type: "problem", docs: { - description: "Disallows calling `this.setState` in `componentDidUpdate` outside of functions, such as callbacks.", + description: "Disallow calling `this.setState` in `componentDidUpdate` outside of functions, such as callbacks.", [Symbol.for("rule_features")]: RULE_FEATURES, }, messages: { diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-will-update.md b/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-will-update.md index b068d88347..c93ccfe9b1 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-will-update.md +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-set-state-in-component-will-update.md @@ -23,7 +23,7 @@ react-x/no-set-state-in-component-will-update ## Description -Disallows calling `this.setState` in `componentWillUpdate` outside of functions, such as callbacks. +Disallow calling `this.setState` in `componentWillUpdate` outside of functions, such as callbacks. Updating the state after a component mount will trigger a second `render()` call and can lead to property/layout thrashing. diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-mount.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-mount.ts index 512342e74b..d40ecba6c9 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-mount.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-mount.ts @@ -43,8 +43,8 @@ export function create(context: RuleContext): RuleListener { return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { node: component } of components.values()) { const { body } = component.body; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-receive-props.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-receive-props.ts index 3ee0cf8912..aba4cbb2a4 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-receive-props.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-receive-props.ts @@ -45,8 +45,8 @@ export function create(context: RuleContext): RuleListener { return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { node: component } of components.values()) { const { body } = component.body; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-update.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-update.ts index 58f1f13fb1..8e2e74364b 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-update.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unsafe-component-will-update.ts @@ -43,8 +43,8 @@ export function create(context: RuleContext): RuleListener { return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { node: component } of components.values()) { const { body } = component.body; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts index db58da09fe..72642a38fd 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-context-value.ts @@ -69,8 +69,8 @@ export function create(context: RuleContext): RuleListener { } getOrUpdate(constructions, functionEntry.node, () => []).push(construction); }, - "Program:exit"(node) { - const components = ctx.getAllComponents(node).values(); + "Program:exit"(program) { + const components = ctx.getAllComponents(program).values(); for (const { node: component } of components) { for (const construction of constructions.get(component) ?? []) { const { kind, node: constructionNode } = construction; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.ts index 5ac9ed645c..b2c18eda4f 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-unstable-default-props.ts @@ -44,8 +44,8 @@ export function create(context: RuleContext): RuleListener { return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const { node: component } of components.values()) { const { params } = component; const [props] = params; diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-destructuring-assignment.ts b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-destructuring-assignment.ts index adc9063b27..3777f5de6d 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-destructuring-assignment.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-destructuring-assignment.ts @@ -52,10 +52,10 @@ export function create(context: RuleContext): RuleListener { } }, - "Program:exit"(node) { + "Program:exit"(program) { const components = [ ...ctx - .getAllComponents(node) + .getAllComponents(program) .values(), ]; function isFunctionComponent(block: TSESTree.Node): block is AST.TSESTreeFunction { diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts index 17784dc2d1..8cedcf112a 100644 --- a/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts +++ b/packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts @@ -40,8 +40,8 @@ export function create(context: RuleContext): RuleListener { const { ctx, listeners } = ER.useComponentCollector(context); return { ...listeners, - "Program:exit"(node) { - const components = ctx.getAllComponents(node); + "Program:exit"(program) { + const components = ctx.getAllComponents(program); for (const [, component] of components) { const [props] = component.node.params; if (props == null) { diff --git a/packages/plugins/eslint-plugin/src/configs/x.ts b/packages/plugins/eslint-plugin/src/configs/x.ts index a0600461c7..3b258967eb 100644 --- a/packages/plugins/eslint-plugin/src/configs/x.ts +++ b/packages/plugins/eslint-plugin/src/configs/x.ts @@ -30,6 +30,7 @@ export const rules = { "@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", diff --git a/packages/utilities/jsx/src/jsx-detection.ts b/packages/utilities/jsx/src/jsx-detection.ts index 78406c67c5..166d2bdc01 100644 --- a/packages/utilities/jsx/src/jsx-detection.ts +++ b/packages/utilities/jsx/src/jsx-detection.ts @@ -7,6 +7,40 @@ import * as VAR from "@eslint-react/var"; import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; import { DEFAULT_JSX_DETECTION_HINT, JSXDetectionHint } from "./jsx-detection-hint"; +export type TSESTreeJSX = + | TSESTree.JSXAttribute + | TSESTree.JSXClosingElement + | TSESTree.JSXClosingFragment + | TSESTree.JSXElement + | TSESTree.JSXEmptyExpression + | TSESTree.JSXExpressionContainer + | TSESTree.JSXFragment + | TSESTree.JSXIdentifier + | TSESTree.JSXMemberExpression + | TSESTree.JSXNamespacedName + | TSESTree.JSXOpeningElement + | TSESTree.JSXOpeningFragment + | TSESTree.JSXSpreadAttribute + | TSESTree.JSXSpreadChild + | TSESTree.JSXText; + +export const isJSX = AST.isOneOf([ + T.JSXAttribute, + T.JSXClosingElement, + T.JSXClosingFragment, + T.JSXElement, + T.JSXEmptyExpression, + T.JSXExpressionContainer, + T.JSXFragment, + T.JSXIdentifier, + T.JSXMemberExpression, + T.JSXNamespacedName, + T.JSXOpeningElement, + T.JSXOpeningFragment, + T.JSXSpreadAttribute, + T.JSXSpreadChild, + T.JSXText, +]); /** * Check if a node is a `JSXText` or a `Literal` node * @param node The AST node to check @@ -30,24 +64,9 @@ export function isJsxLike( node: TSESTree.Node | _ | null, hint: JSXDetectionHint = DEFAULT_JSX_DETECTION_HINT, ): boolean { - switch (node?.type) { - case T.JSXText: - case T.JSXElement: - case T.JSXFragment: - case T.JSXAttribute: - case T.JSXClosingElement: - case T.JSXClosingFragment: - case T.JSXEmptyExpression: - case T.JSXExpressionContainer: - case T.JSXIdentifier: - case T.JSXMemberExpression: - case T.JSXOpeningElement: - case T.JSXOpeningFragment: - case T.JSXSpreadAttribute: - case T.JSXSpreadChild: - case T.JSXNamespacedName: { - return true; - } + if (node == null) return false; + if (isJSX(node)) return true; + switch (node.type) { case T.Literal: { switch (typeof node.value) { case "boolean":