Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions packages/ast/docs/@eslint-react/namespaces/Extract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 30 additions & 1 deletion packages/ast/src/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSESTree.CallExpression>(code, AST.CallExpression);
Expand Down Expand Up @@ -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", () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/ast/src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(() => <div />, [])` 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;
Expand All @@ -90,33 +96,33 @@ 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
*/
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 = () => <div />; } }
* @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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <div />);
const PropsNestedComponent = withProps({ className: "nested" })((props) => <div />);

return <AuthedNestedComponent />;
return <PropsNestedComponent />;
}
`,
errors: [
{
data: {
name: "AuthedNestedComponent",
name: "PropsNestedComponent",
suggestion: "Move it to the top level.",
},
messageId: "default",
Expand Down Expand Up @@ -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) => <div />);

return <AuthedNestedComponent />;
}
`,
tsx`
function ParentComponent() {
return (
Expand Down
Loading