diff --git a/packages/ast/docs/@eslint-react/namespaces/Extract/README.md b/packages/ast/docs/@eslint-react/namespaces/Extract/README.md index 1e4360ee4..df32798e9 100644 --- a/packages/ast/docs/@eslint-react/namespaces/Extract/README.md +++ b/packages/ast/docs/@eslint-react/namespaces/Extract/README.md @@ -6,11 +6,12 @@ Helpers for extracting information from `TSESTree` nodes. ## Functions -| Function | Description | -| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| [findProperty](functions/findProperty.md) | Find a property by name in a list of object literal properties, recursing into spread object expressions. | -| [getCalleeName](functions/getCalleeName.md) | Get the name of the callee of a call expression. | -| [getFullyQualifiedName](functions/getFullyQualifiedName.md) | Get the fully qualified name of a node (ex: `React.useState`), falling back to source text when needed. | -| [getIdentifierAt](functions/getIdentifierAt.md) | Get the identifier at a given position in a member expression chain (ex: position `0` in `a.b.c` is `a`). | -| [getPropertyName](functions/getPropertyName.md) | Get the static name of an object property's key. | -| [unwrap](functions/unwrap.md) | Recursively unwrap TypeScript type expressions and chain expressions to get the underlying expression. | +| Function | Description | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [findProperty](functions/findProperty.md) | Find a property by name in a list of object literal properties, recursing into spread object expressions. | +| [getCalleeName](functions/getCalleeName.md) | Get the name of the callee of a call expression. | +| [getFullyQualifiedName](functions/getFullyQualifiedName.md) | Get the fully qualified name of a node (ex: `React.useState`), falling back to source text when needed. | +| [getIdentifierAt](functions/getIdentifierAt.md) | Get the identifier at a given position in a member expression chain (ex: position `0` in `a.b.c` is `a`). | +| [getInnermostCall](functions/getInnermostCall.md) | Unwrap curried call wrappers like `connect(...)(Component)` to get the innermost call expression. Type expressions and chain expressions around each callee are unwrapped along the way. | +| [getPropertyName](functions/getPropertyName.md) | Get the static name of an object property's key. | +| [unwrap](functions/unwrap.md) | Recursively unwrap TypeScript type expressions and chain expressions to get the underlying expression. | diff --git a/packages/ast/docs/@eslint-react/namespaces/Extract/functions/getInnermostCall.md b/packages/ast/docs/@eslint-react/namespaces/Extract/functions/getInnermostCall.md new file mode 100644 index 000000000..b78aef396 --- /dev/null +++ b/packages/ast/docs/@eslint-react/namespaces/Extract/functions/getInnermostCall.md @@ -0,0 +1,22 @@ +[@eslint-react/ast](../../../../README.md) / [Extract](../README.md) / getInnermostCall + +# Function: getInnermostCall() + +```ts +function getInnermostCall(node: CallExpression): CallExpression; +``` + +Unwrap curried call wrappers like `connect(...)(Component)` to get the innermost call expression. +Type expressions and chain expressions around each callee are unwrapped along the way. + +## Parameters + +| Parameter | Type | Description | +| --------- | ---------------- | ----------------------------------------- | +| `node` | `CallExpression` | The outermost call expression to inspect. | + +## Returns + +`CallExpression` + +The innermost call expression, whose callee is not itself a call expression. diff --git a/packages/ast/src/extract.test.ts b/packages/ast/src/extract.test.ts index 97b47ac8f..3a70fb437 100644 --- a/packages/ast/src/extract.test.ts +++ b/packages/ast/src/extract.test.ts @@ -2,7 +2,7 @@ import { getFirstNodeOfType } from "@local/testkit"; import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; import { describe, expect, it } from "vitest"; -import { findProperty, getCalleeName, getFullyQualifiedName, getIdentifierAt, getPropertyName, unwrap } from "./extract"; +import { findProperty, getCalleeName, getFullyQualifiedName, getIdentifierAt, getInnermostCall, getPropertyName, unwrap } from "./extract"; function getFirstCallExpression(code: string): TSESTree.CallExpression { return getFirstNodeOfType(code, AST.CallExpression); @@ -187,6 +187,35 @@ describe("getFullyQualifiedName", () => { }); }); +describe("getInnermostCall", () => { + it("should return the call unchanged when the callee is not a call", () => { + const node = getFirstCallExpression("memo(Component);"); + expect(getInnermostCall(node)).toBe(node); + }); + + it("should unwrap a curried wrapper call", () => { + const code = "connect(mapStateToProps)(Component);"; + const node = getFirstCallExpression(code); + const inner = getInnermostCall(node); + expect(inner).not.toBe(node); + expect(code.slice(inner.range[0], inner.range[1])).toBe("connect(mapStateToProps)"); + }); + + it("should unwrap nested curried wrapper calls", () => { + const code = "withState(a)(withHandlers(b))(Component);"; + const node = getFirstCallExpression(code); + const inner = getInnermostCall(node); + expect(code.slice(inner.range[0], inner.range[1])).toBe("withState(a)"); + }); + + it("should unwrap type and chain expressions around callees", () => { + const code = "(connect(mapStateToProps) as any)(Component);"; + const node = getFirstCallExpression(code); + const inner = getInnermostCall(node); + expect(code.slice(inner.range[0], inner.range[1])).toBe("connect(mapStateToProps)"); + }); +}); + describe("getPropertyName", () => { describe('"min" effort', () => { it("should return the name of a non-computed identifier property by default", () => { diff --git a/packages/ast/src/extract.ts b/packages/ast/src/extract.ts index 44464a447..1bc697300 100644 --- a/packages/ast/src/extract.ts +++ b/packages/ast/src/extract.ts @@ -49,6 +49,22 @@ export function getCalleeName(node: TSESTree.CallExpression): string | null { return null; } +/** + * Unwrap curried call wrappers like `connect(...)(Component)` to get the innermost call expression. + * Type expressions and chain expressions around each callee are unwrapped along the way. + * @param node The outermost call expression to inspect. + * @returns The innermost call expression, whose callee is not itself a call expression. + */ +export function getInnermostCall(node: TSESTree.CallExpression): TSESTree.CallExpression { + let call = node; + let callee = unwrap(call.callee); + while (callee.type === AST.CallExpression) { + call = callee; + callee = unwrap(call.callee); + } + return call; +} + /** * Get the static name of an object property's key. * @param property The property to inspect. diff --git a/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/CHANGELOG.md b/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/CHANGELOG.md index 30747a0c9..51dbe6902 100644 --- a/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/CHANGELOG.md +++ b/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Narrowed the `with*` HOC detection in the fallback name resolution to well-known wrappers only: react-router v5's `withRouter`, Formik's `withFormik`, and recompose's `withProps`, `withState`, `withHandlers` and `withLifecycle`. Custom HOCs following the `with*` naming convention are no longer treated as component wrappers. + ## [5.18.2] - 2026-08-05 ### Fixed diff --git a/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/lib.ts b/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/lib.ts index 0b6f7d89d..1016eaa7b 100644 --- a/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/lib.ts +++ b/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/lib.ts @@ -4,51 +4,41 @@ import { type RuleContext } from "@eslint-react/eslint"; import { findParentAttribute } from "@eslint-react/jsx"; import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; -/** - * Well-known component wrapper function names, matched by exact or `.`-suffixed fully - * qualified name (e.g. `memo`, `React.memo`, `mobx.observer`, `connect` from react-redux, - * or Relay's `create*Container` helpers). Only wrappers whose argument is a render function - * are listed; wrappers taking a component identifier (`styled`, `motion`) or a loader - * (`lazy`, `dynamic`) are irrelevant for name resolution. - */ -const WELL_KNOWN_COMPONENT_WRAPPERS = [ - "connect", - "createFragmentContainer", - "createPaginationContainer", - "createRefetchContainer", - "forwardRef", - "graphql", - "memo", - "observer", - "useCallback", -] as const; - -/** Matches the HOC naming convention shared by recompose, react-router v5, Formik and custom HOCs (e.g. `withProps`, `withRouter`, `withFormik`, `withAuth`). */ -const RE_HOC_WRAPPER_NAME = /^with[A-Z]/; +// Well-known component wrapper call checks; only wrappers whose argument is a render function are listed. +/** Check if the node is a call expression to react-redux's `connect`. */ +const isConnectCall = core.isAPICall("connect"); +/** Check if the node is a call expression to Relay's `createFragmentContainer`. */ +const isCreateFragmentContainerCall = core.isAPICall("createFragmentContainer"); +/** Check if the node is a call expression to Relay's `createPaginationContainer`. */ +const isCreatePaginationContainerCall = core.isAPICall("createPaginationContainer"); +/** Check if the node is a call expression to Relay's `createRefetchContainer`. */ +const isCreateRefetchContainerCall = core.isAPICall("createRefetchContainer"); +/** Check if the node is a call expression to React's `forwardRef`. */ +const isForwardRefCall = core.isAPICall("forwardRef"); +/** Check if the node is a call expression to a `graphql` tag function (e.g. from Relay or Apollo). */ +const isGraphqlCall = core.isAPICall("graphql"); +/** Check if the node is a call expression to React's `memo`. */ +const isMemoCall = core.isAPICall("memo"); +/** Check if the node is a call expression to MobX's `observer`. */ +const isObserverCall = core.isAPICall("observer"); +/** Check if the node is a call expression to React's `useCallback`. */ +const isUseCallbackCall = core.isAPICall("useCallback"); +/** Check if the node is a call expression to Formik's `withFormik`. */ +const isWithFormikCall = core.isAPICall("withFormik"); +/** Check if the node is a call expression to recompose's `withHandlers`. */ +const isWithHandlersCall = core.isAPICall("withHandlers"); +/** Check if the node is a call expression to recompose's `withLifecycle`. */ +const isWithLifecycleCall = core.isAPICall("withLifecycle"); +/** Check if the node is a call expression to recompose's `withProps`. */ +const isWithPropsCall = core.isAPICall("withProps"); +/** Check if the node is a call expression to react-router v5's `withRouter`. */ +const isWithRouterCall = core.isAPICall("withRouter"); +/** Check if the node is a call expression to recompose's `withState`. */ +const isWithStateCall = core.isAPICall("withState"); /** - * Check if a call expression is a well-known component wrapper call. - * Only calls whose callee's fully qualified name is (or ends with) a well-known wrapper - * name (e.g. `memo`, `React.memo`, `React.useCallback`, `mobx.observer`) are treated as - * wrappers; anything else, including member calls on data objects (e.g. `items.map`, - * `Array.from`), is not, so array method callbacks are never mistaken for wrapped components. - * Curried wrappers like `connect(...)(Component)` or `withFormik(...)(Component)` are - * recognized by unwrapping nested callee call expressions. - * @param context The rule context - * @param node The call expression to check - * @returns `true` if the call is a well-known component wrapper call - */ -export function isWellKnownComponentWrapperCall(context: RuleContext, node: TSESTree.CallExpression) { - let callee = Extract.unwrap(node.callee); - // Unwrap curried wrappers like `connect(...)(Component)` - while (callee.type === AST.CallExpression) callee = Extract.unwrap(callee.callee); - const name = Extract.getFullyQualifiedName(callee, (n) => context.sourceCode.getText(n)); - const baseName = name.slice(name.lastIndexOf(".") + 1); - return RE_HOC_WRAPPER_NAME.test(baseName) || WELL_KNOWN_COMPONENT_WRAPPERS.some((wrapper) => name === wrapper || name.endsWith(`.${wrapper}`)); -} - -/** - * Check if a call expression is a component wrapper call for name resolution purposes. + * Check if a call is a well-known component wrapper call (e.g. `memo`, `connect`, `withFormik`). + * Curried forms like `connect(...)(Component)` are matched via the innermost callee call. * @param context The rule context * @param call The call expression to check * @param arg The function node passed to the call @@ -57,15 +47,31 @@ export function isWellKnownComponentWrapperCall(context: RuleContext, node: TSES function isComponentWrapperCall(context: RuleContext, call: TSESTree.CallExpression, arg: TSESTree.Node) { // The function is the callee (e.g. an IIFE), not an argument if (Extract.unwrap(call.callee) === arg) return false; - return isWellKnownComponentWrapperCall(context, call); + // Unwrap curried wrappers like `connect(...)(Component)` + call = Extract.getInnermostCall(call); + if (isConnectCall(context, call)) return true; + if (isCreateFragmentContainerCall(context, call)) return true; + if (isCreatePaginationContainerCall(context, call)) return true; + if (isCreateRefetchContainerCall(context, call)) return true; + if (isForwardRefCall(context, call)) return true; + if (isGraphqlCall(context, call)) return true; + if (isMemoCall(context, call)) return true; + if (isObserverCall(context, call)) return true; + if (isUseCallbackCall(context, call)) return true; + if (isWithFormikCall(context, call)) return true; + if (isWithHandlersCall(context, call)) return true; + if (isWithLifecycleCall(context, call)) return true; + if (isWithPropsCall(context, call)) return true; + if (isWithRouterCall(context, call)) return true; + if (isWithStateCall(context, call)) return true; + return false; } /** - * Resolve the name a function is bound to through a chain of wrapping call expressions, - * e.g. `const Component = useCallback(() =>
, [])` resolves to `Component`. + * Resolve the name a function is bound to through wrapping calls (e.g. `const C = useCallback(...)` → `C`). * @param context The rule context * @param node The function node to resolve the bound name for - * @returns The bound name if the call chain ends at a variable declarator with an identifier, `null` otherwise + * @returns The bound name, or `null` if the call chain does not end at an identifier declarator */ export function getWrapperCallBoundName(context: RuleContext, node: TSESTreeFunction) { let current: TSESTree.Node = node; @@ -90,7 +96,7 @@ export function getWrapperCallBoundName(context: RuleContext, node: TSESTreeFunc } /** - * Determine whether the node is inside `createElement`'s props argument + * Check if the node is inside `createElement`'s props argument * @param context The rule context * @param node The AST node to check * @returns `true` if the node is inside `createElement`'s props @@ -98,25 +104,25 @@ export function getWrapperCallBoundName(context: RuleContext, node: TSESTreeFunc export function isInsideCreateElementProps(context: RuleContext, node: TSESTree.Node) { const call = Traverse.findParent(node, core.isCreateElementCall(context)); if (call == null) return false; - // Check if the node is within an object expression that is the second argument (props) of createElement + // The props object is the second argument of createElement const prop = Traverse.findParent(node, Check.is(AST.ObjectExpression)); if (prop == null) return false; return prop === call.arguments[1]; } /** - * Determine whether the node is inside JSX attribute value + * Check if the node is inside a JSX attribute value * @param node The AST node to check - * @returns `true` if the node is inside JSX attribute value + * @returns `true` if the node is inside a JSX attribute value */ export function isInsideJSXAttributeValue(node: TSESTreeFunction) { return node.parent.type === AST.JSXAttribute || findParentAttribute(node, (n) => n.value?.type === AST.JSXExpressionContainer) != null; } /** - * Check whether a given node is declared inside a class component's render block + * Check if the node is declared inside a class component's render block * Ex: class C extends React.Component { render() { const Nested = () =>
; } } - * @param node The AST node being checked + * @param node The AST node to check * @returns `true` if the node is inside a class component's render block */ export function isInsideRenderMethod(node: TSESTree.Node) { diff --git a/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/no-nested-component-definitions.spec.ts b/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/no-nested-component-definitions.spec.ts index 9f235e1e5..1ad3bca9b 100644 --- a/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/no-nested-component-definitions.spec.ts +++ b/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/no-nested-component-definitions.spec.ts @@ -978,18 +978,17 @@ ruleTester.run(RULE_NAME, rule, { ], }, { - // Custom HOCs following the `with*` naming convention are also component wrappers code: tsx` function ParentComponent() { - const AuthedNestedComponent = withAuth((props) =>
); + const PropsNestedComponent = withProps({ className: "nested" })((props) =>
); - return ; + return ; } `, errors: [ { data: { - name: "AuthedNestedComponent", + name: "PropsNestedComponent", suggestion: "Move it to the top level.", }, messageId: "default", @@ -1036,6 +1035,14 @@ ruleTester.run(RULE_NAME, rule, { }, ], valid: [ + // Custom HOCs following the `with*` naming convention are not well-known wrappers + tsx` + function ParentComponent() { + const AuthedNestedComponent = withAuth((props) =>
); + + return ; + } + `, tsx` function ParentComponent() { return (