diff --git a/packages/core/docs/README.md b/packages/core/docs/README.md index 46fb94fedf..52db3d985c 100644 --- a/packages/core/docs/README.md +++ b/packages/core/docs/README.md @@ -137,6 +137,10 @@ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | [~~getClassComponentCollector~~](functions/getClassComponentCollector.md) | Get an api and visitor object for the rule to collect class components. | | [getClassId](functions/getClassId.md) | Get the class identifier of a class node. | +| [getCreateElementChildrenArguments](functions/getCreateElementChildrenArguments.md) | Get the children arguments (the arguments after the props object) of a `createElement` call. | +| [getCreateElementProp](functions/getCreateElementProp.md) | Find a statically named property in the props object of a `createElement` call. | +| [getCreateElementPropsObject](functions/getCreateElementPropsObject.md) | Get the props object (the second argument) of a `createElement` call. | +| [getCreateElementTypeArgument](functions/getCreateElementTypeArgument.md) | Get the type argument (the first argument) of a `createElement` call. | | [getFullyQualifiedNameEx](functions/getFullyQualifiedNameEx.md) | Get the fully qualified name of a symbol, handling cases that `ts.TypeChecker.getFullyQualifiedName` does not handle (ex: `export as namespace preact`). | | [getFunctionComponentCollector](functions/getFunctionComponentCollector.md) | Get an api and visitor object for the rule to collect function components. | | [getFunctionDirectives](functions/getFunctionDirectives.md) | Get the directives of a function (ex: "use strict", "use client", "use server"). | @@ -151,6 +155,7 @@ | [~~isAssignmentToThisState~~](functions/isAssignmentToThisState.md) | Check if the assignment expression assigns to `this.state`. | | [isBooleanLiteralType](functions/isBooleanLiteralType.md) | Check if the type is a boolean literal type. | | [isClassComponent](functions/isClassComponent.md) | Check if the node is a class component (extends `Component` or `PureComponent`). | +| [isCreateElementChildrenArgument](functions/isCreateElementChildrenArgument.md) | Check if the node is passed as a children argument (the third argument or later) of a `createElement` call. | | [isFunctionComponentDefinition](functions/isFunctionComponentDefinition.md) | Check if the function node is a valid React component definition. | | [isFunctionComponentName](functions/isFunctionComponentName.md) | Check if a string matches the strict component name pattern. | | [isFunctionComponentNameLoose](functions/isFunctionComponentNameLoose.md) | Check if a string matches the loose component name pattern. | @@ -165,6 +170,7 @@ | [isHookId](functions/isHookId.md) | Checks if the given node is a hook identifier. | | [isHookName](functions/isHookName.md) | Check if the name is a hook name (starts with `use` followed by an uppercase letter or digit). | | [isHookTag](functions/isHookTag.md) | Checks if the given expression is a hook tag (callee / tagged template tag). | +| [isInsideCreateElementProps](functions/isInsideCreateElementProps.md) | Check if the node is inside the props object (the second argument) of a `createElement` call. | | [isJsxLike](functions/isJsxLike.md) | Check if the node represents JSX-like content based on heuristics. | | [~~isPureComponent~~](functions/isPureComponent.md) | Check if the node is a pure component (extends `PureComponent`). | | [isRenderMethodCallback](functions/isRenderMethodCallback.md) | Check if the function is a callback passed to a class component's render method. | diff --git a/packages/core/docs/functions/getCreateElementChildrenArguments.md b/packages/core/docs/functions/getCreateElementChildrenArguments.md new file mode 100644 index 0000000000..c011a04d69 --- /dev/null +++ b/packages/core/docs/functions/getCreateElementChildrenArguments.md @@ -0,0 +1,22 @@ +[@eslint-react/core](../README.md) / getCreateElementChildrenArguments + +# Function: getCreateElementChildrenArguments() + +```ts +function getCreateElementChildrenArguments(context: RuleContext, node: Node | null): CallExpressionArgument[]; +``` + +Get the children arguments (the arguments after the props object) of a `createElement` call. + +## Parameters + +| Parameter | Type | Description | +| --------- | ---------------- | ------------------------ | +| `context` | `RuleContext` | The ESLint rule context. | +| `node` | `Node` \| `null` | The node to inspect. | + +## Returns + +`CallExpressionArgument`[] + +The children arguments, or an empty array when the node is not a `createElement` call. diff --git a/packages/core/docs/functions/getCreateElementProp.md b/packages/core/docs/functions/getCreateElementProp.md new file mode 100644 index 0000000000..a03998f33d --- /dev/null +++ b/packages/core/docs/functions/getCreateElementProp.md @@ -0,0 +1,38 @@ +[@eslint-react/core](../README.md) / getCreateElementProp + +# Function: getCreateElementProp() + +```ts +function getCreateElementProp( + context: RuleContext, + node: Node | null, + name: string, +): Property | null; +``` + +Find a statically named property in the props object of a `createElement` call. + +Statically resolvable names include plain identifier keys as well as +string-literal and simple template-literal keys (computed or not). + +## Parameters + +| Parameter | Type | Description | +| --------- | ---------------- | ---------------------------------------------------------- | +| `context` | `RuleContext` | The ESLint rule context. | +| `node` | `Node` \| `null` | The node to inspect. | +| `name` | `string` | The property name to look for (ex: `"children"`, `"key"`). | + +## Returns + +`Property` \| `null` + +The matching `Property` node, or `null` when the call has no static property with that name. + +## Example + +```ts +import { getCreateElementProp } from "@eslint-react/core"; + +const childrenProp = getCreateElementProp(context, node, "children"); +``` diff --git a/packages/core/docs/functions/getCreateElementPropsObject.md b/packages/core/docs/functions/getCreateElementPropsObject.md new file mode 100644 index 0000000000..42d6032d5b --- /dev/null +++ b/packages/core/docs/functions/getCreateElementPropsObject.md @@ -0,0 +1,26 @@ +[@eslint-react/core](../README.md) / getCreateElementPropsObject + +# Function: getCreateElementPropsObject() + +```ts +function getCreateElementPropsObject(context: RuleContext, node: Node | null): ObjectExpression | null; +``` + +Get the props object (the second argument) of a `createElement` call. + +Type expressions and chain expressions wrapping the argument are unwrapped +before the object check; `null`, spread, or otherwise non-object props +arguments yield `null`. + +## Parameters + +| Parameter | Type | Description | +| --------- | ---------------- | ------------------------ | +| `context` | `RuleContext` | The ESLint rule context. | +| `node` | `Node` \| `null` | The node to inspect. | + +## Returns + +`ObjectExpression` \| `null` + +The props `ObjectExpression`, or `null` when absent or not statically an object literal. diff --git a/packages/core/docs/functions/getCreateElementTypeArgument.md b/packages/core/docs/functions/getCreateElementTypeArgument.md new file mode 100644 index 0000000000..de3602d98e --- /dev/null +++ b/packages/core/docs/functions/getCreateElementTypeArgument.md @@ -0,0 +1,22 @@ +[@eslint-react/core](../README.md) / getCreateElementTypeArgument + +# Function: getCreateElementTypeArgument() + +```ts +function getCreateElementTypeArgument(context: RuleContext, node: Node | null): CallExpressionArgument | null; +``` + +Get the type argument (the first argument) of a `createElement` call. + +## Parameters + +| Parameter | Type | Description | +| --------- | ---------------- | ------------------------ | +| `context` | `RuleContext` | The ESLint rule context. | +| `node` | `Node` \| `null` | The node to inspect. | + +## Returns + +`CallExpressionArgument` \| `null` + +The type argument, or `null` when the node is not a `createElement` call or has no arguments. diff --git a/packages/core/docs/functions/isCreateElementChildrenArgument.md b/packages/core/docs/functions/isCreateElementChildrenArgument.md new file mode 100644 index 0000000000..b8907a6d8c --- /dev/null +++ b/packages/core/docs/functions/isCreateElementChildrenArgument.md @@ -0,0 +1,23 @@ +[@eslint-react/core](../README.md) / isCreateElementChildrenArgument + +# Function: isCreateElementChildrenArgument() + +```ts +function isCreateElementChildrenArgument(context: RuleContext, node: Node): boolean; +``` + +Check if the node is passed as a children argument (the third argument or +later) of a `createElement` call. + +## Parameters + +| Parameter | Type | Description | +| --------- | ------------- | ------------------------ | +| `context` | `RuleContext` | The ESLint rule context. | +| `node` | `Node` | The node to check. | + +## Returns + +`boolean` + +`true` if the node is a direct children argument of a `createElement` call. diff --git a/packages/core/docs/functions/isInsideCreateElementProps.md b/packages/core/docs/functions/isInsideCreateElementProps.md new file mode 100644 index 0000000000..17c50df750 --- /dev/null +++ b/packages/core/docs/functions/isInsideCreateElementProps.md @@ -0,0 +1,22 @@ +[@eslint-react/core](../README.md) / isInsideCreateElementProps + +# Function: isInsideCreateElementProps() + +```ts +function isInsideCreateElementProps(context: RuleContext, node: Node): boolean; +``` + +Check if the node is inside the props object (the second argument) of a `createElement` call. + +## Parameters + +| Parameter | Type | Description | +| --------- | ------------- | ------------------------ | +| `context` | `RuleContext` | The ESLint rule context. | +| `node` | `Node` | The node to check. | + +## Returns + +`boolean` + +`true` if the node is inside `createElement`'s props object. diff --git a/packages/core/src/api.test.ts b/packages/core/src/api.test.ts index f2fdfb6ec0..1ae29188f7 100644 --- a/packages/core/src/api.test.ts +++ b/packages/core/src/api.test.ts @@ -4,7 +4,7 @@ import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; import { simpleTraverse } from "@typescript-eslint/typescript-estree"; import { describe, expect, it } from "vitest"; -import { isAPI } from "./api"; +import { isAPI, isAPICall, isCreateElementCall } from "./api"; /** * This function mirrors the core matching logic inside `isAPI` from @@ -267,3 +267,60 @@ describe("isAPI (actual export)", () => { testAPI("React.memo;", "createElement", false); }); }); + +describe("dual signature: curried form (context first)", () => { + function createMockContext(code: string): RuleContext { + return { + sourceCode: { + getText: (node: TSESTree.Node) => code.slice(node.range[0], node.range[1]), + getScope: () => ({}), + }, + } as unknown as RuleContext; + } + + function parseLastExpression(code: string) { + const parsed = parseCode(code); + const last = parsed.ast.body.at(-1); + if (last?.type !== AST.ExpressionStatement) { + throw new Error(`expected last statement to be an ExpressionStatement, got ${last?.type ?? "unknown"}`); + } + return { context: createMockContext(code), node: last.expression }; + } + + it("isAPI curried form agrees with the two-argument form", () => { + const { context, node } = parseLastExpression("React.createElement;"); + expect(isAPI("createElement")(context, node)).toBe(true); + expect(isAPI("createElement")(context)(node)).toBe(true); + }); + + it("isAPI curried form rejects non-matching nodes", () => { + const { context, node } = parseLastExpression("React.memo;"); + expect(isAPI("createElement")(context, node)).toBe(false); + expect(isAPI("createElement")(context)(node)).toBe(false); + }); + + it("isAPICall curried form agrees with the two-argument form", () => { + const { context, node } = parseLastExpression(`React.createElement("div", null);`); + expect(isAPICall("createElement")(context, node)).toBe(true); + expect(isAPICall("createElement")(context)(node)).toBe(true); + }); + + it("isAPICall curried form rejects non-matching calls", () => { + const { context, node } = parseLastExpression(`React.cloneElement(element);`); + expect(isAPICall("createElement")(context, node)).toBe(false); + expect(isAPICall("createElement")(context)(node)).toBe(false); + }); + + it("isAPICall curried form handles null and non-call nodes", () => { + const { context, node } = parseLastExpression(`React.createElement("div", null);`); + const predicate = isAPICall("createElement")(context); + expect(predicate(null)).toBe(false); + expect(predicate(node.type === AST.CallExpression ? node.arguments[0] as TSESTree.Node : node)).toBe(false); + }); + + it("derived predicates (ex: isCreateElementCall) work in curried form", () => { + const { context, node } = parseLastExpression(`createElement("div", null);`); + expect(isCreateElementCall(context, node)).toBe(true); + expect(isCreateElementCall(context)(node)).toBe(true); + }); +}); diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index 082e2475e5..67ad912b80 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -1,6 +1,5 @@ import { Extract } from "@eslint-react/ast"; import type { RuleContext } from "@eslint-react/eslint"; -import { dual } from "@local/eff"; import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; export declare namespace isAPI { @@ -29,7 +28,12 @@ export function isAPI(api: string): isAPI.ReturnType { if (name.endsWith(`.${api}`)) return true; return false; }; - return dual(2, func); + function dual(context: RuleContext, node: null | TSESTree.Node): boolean; + function dual(context: RuleContext): (node: null | TSESTree.Node) => boolean; + function dual(context: RuleContext, ...rest: [] | [null | TSESTree.Node]) { + return rest.length === 1 ? func(context, rest[0]) : (node: null | TSESTree.Node) => func(context, node); + } + return dual; } export declare namespace isAPICall { @@ -51,7 +55,12 @@ export function isAPICall(api: string): isAPICall.ReturnType { if (node.type !== AST.CallExpression) return false; return isAPI(api)(context, Extract.unwrap(node.callee)); }; - return dual(2, func); + function dual(context: RuleContext, node: null | TSESTree.Node): node is TSESTree.CallExpression; + function dual(context: RuleContext): (node: null | TSESTree.Node) => node is TSESTree.CallExpression; + function dual(context: RuleContext, ...rest: [] | [null | TSESTree.Node]) { + return rest.length === 1 ? func(context, rest[0]) : (node: null | TSESTree.Node): node is TSESTree.CallExpression => func(context, node); + } + return dual; } // React API checks diff --git a/packages/core/src/create-element.test.ts b/packages/core/src/create-element.test.ts new file mode 100644 index 0000000000..7e1f7addab --- /dev/null +++ b/packages/core/src/create-element.test.ts @@ -0,0 +1,184 @@ +import { collectNodes, createScopeContext, getFirstNodeOfType, parseCode } from "@local/testkit"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { describe, expect, it } from "vitest"; + +import { + getCreateElementChildrenArguments, + getCreateElementProp, + getCreateElementPropsObject, + getCreateElementTypeArgument, + isCreateElementChildrenArgument, + isInsideCreateElementProps, +} from "./create-element"; + +/** + * Parses `code` and returns the first `CallExpression` together with a + * scope-aware rule context. + */ +function parseCallExpression(code: string) { + const parsed = parseCode(code); + const context = createScopeContext(parsed); + return { context, node: getFirstNodeOfType(code, AST.CallExpression) }; +} + +function parseNode(code: string, type: T["type"]) { + const parsed = parseCode(code); + const context = createScopeContext(parsed); + return { context, node: getFirstNodeOfType(code, type) }; +} + +/** + * Reads the source text of `node` via its range (the mock context from + * `createScopeContext` does not implement `sourceCode.getText`). + */ +function textOf(code: string, node: null | TSESTree.Node): string | null { + return node == null ? null : code.slice(node.range[0], node.range[1]); +} + +describe("getCreateElementTypeArgument", () => { + it.each([ + [`React.createElement("div", null);`, `"div"`], + [`createElement(App, null);`, `App`], + ])("should return the first argument: %s", (code, expected) => { + const { context, node } = parseCallExpression(code); + expect(textOf(code, getCreateElementTypeArgument(context, node))).toBe(expected); + }); + + it.each([ + [`notCreateElement("div", null);`], + [`React.createElement();`], + ])("should return null: %s", (code) => { + const { context, node } = parseCallExpression(code); + expect(getCreateElementTypeArgument(context, node)).toBeNull(); + }); +}); + +describe("getCreateElementPropsObject", () => { + it("should return the props object expression", () => { + const code = `React.createElement("div", { id: "a" });`; + const { context, node } = parseCallExpression(code); + expect(textOf(code, getCreateElementPropsObject(context, node))).toBe(`{ id: "a" }`); + }); + + it("should unwrap type expressions around the props argument", () => { + const { context, node } = parseCallExpression(`React.createElement("div", { id: "a" } as const);`); + expect(getCreateElementPropsObject(context, node)?.type).toBe(AST.ObjectExpression); + }); + + it.each([ + [`React.createElement("div");`], + [`React.createElement("div", null);`], + [`React.createElement("div", props);`], + [`notCreateElement("div", {});`], + ])("should return null: %s", (code) => { + const { context, node } = parseCallExpression(code); + expect(getCreateElementPropsObject(context, node)).toBeNull(); + }); +}); + +describe("getCreateElementChildrenArguments", () => { + it("should return the arguments after the props object", () => { + const code = `React.createElement("div", null, "a", "b");`; + const { context, node } = parseCallExpression(code); + const children = getCreateElementChildrenArguments(context, node); + expect(children.map((arg) => textOf(code, arg))).toEqual([`"a"`, `"b"`]); + }); + + it.each([ + [`React.createElement("div");`], + [`React.createElement("div", null);`], + [`notCreateElement("div", null, "a");`], + ])("should return an empty array: %s", (code) => { + const { context, node } = parseCallExpression(code); + expect(getCreateElementChildrenArguments(context, node)).toEqual([]); + }); +}); + +describe("getCreateElementProp", () => { + it.each([ + // Plain identifier key + [`React.createElement("div", { children: "a" });`, `children: "a"`], + // String-literal key + [`React.createElement("div", { "children": "a" });`, `"children": "a"`], + // Computed keys with statically resolvable names also match + [`React.createElement("div", { ["children"]: "a" });`, `["children"]: "a"`], + ])("should find the statically named property: %s", (code, expected) => { + const { context, node } = parseCallExpression(code); + expect(textOf(code, getCreateElementProp(context, node, "children"))).toBe(expected); + }); + + it.each([ + // No props object + [`React.createElement("div");`], + // Property with a different name + [`React.createElement("div", { key: "a" });`], + // Computed keys without a statically resolvable name do not match + [`React.createElement("div", { [CHILDREN]: "a" });`], + // Spread properties have no static name + [`React.createElement("div", { ...props });`], + // Not a createElement call + [`notCreateElement("div", { children: "a" });`], + ])("should return null: %s", (code) => { + const { context, node } = parseCallExpression(code); + expect(getCreateElementProp(context, node, "children")).toBeNull(); + }); +}); + +describe("isCreateElementChildrenArgument", () => { + it("should return true for a function passed as a children argument", () => { + const { context, node } = parseNode( + `React.createElement("div", null, () => null);`, + AST.ArrowFunctionExpression, + ); + expect(isCreateElementChildrenArgument(context, node)).toBe(true); + }); + + it("should return true through wrapping type expressions", () => { + const code = `React.createElement("div", null, (child as any));`; + const parsed = parseCode(code); + const context = createScopeContext(parsed); + const node = collectNodes(code, AST.Identifier).find((id) => id.name === "child"); + expect(node).toBeDefined(); + expect(isCreateElementChildrenArgument(context, node!)).toBe(true); + }); + + it.each<[string, TSESTree.Node["type"]]>([ + // The type argument is not a children argument + [`createElement("div", null);`, AST.Literal], + // A function nested in the props object is not a children argument + [`React.createElement("div", { children: () => null });`, AST.ArrowFunctionExpression], + // Not a createElement call + [`notCreateElement("div", null, () => null);`, AST.ArrowFunctionExpression], + ])("should return false: %s", (code, type) => { + const { context, node } = parseNode(code, type); + expect(isCreateElementChildrenArgument(context, node)).toBe(false); + }); +}); + +describe("isInsideCreateElementProps", () => { + it("should return true for a node inside the props object", () => { + const { context, node } = parseNode( + `React.createElement("div", { render: () => null });`, + AST.ArrowFunctionExpression, + ); + expect(isInsideCreateElementProps(context, node)).toBe(true); + }); + + it.each<[string, TSESTree.Node["type"]]>([ + // Nodes in the children arguments are not inside the props object + [`React.createElement("div", null, () => null);`, AST.ArrowFunctionExpression], + // Not a createElement call + [`notCreateElement("div", { render: () => null });`, AST.ArrowFunctionExpression], + ])("should return false: %s", (code, type) => { + const { context, node } = parseNode(code, type); + expect(isInsideCreateElementProps(context, node)).toBe(false); + }); + + it("should return false for a node inside a nested object within the props object", () => { + const { context, node } = parseNode( + `React.createElement("div", { style: { getValue: () => null } });`, + AST.ArrowFunctionExpression, + ); + expect(isInsideCreateElementProps(context, node)).toBe(false); + }); +}); diff --git a/packages/core/src/create-element.ts b/packages/core/src/create-element.ts new file mode 100644 index 0000000000..65dab2014a --- /dev/null +++ b/packages/core/src/create-element.ts @@ -0,0 +1,111 @@ +import { Check, Extract, Traverse } from "@eslint-react/ast"; +import type { RuleContext } from "@eslint-react/eslint"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { isCreateElementCall } from "./api"; + +// #region Argument Extraction + +/** + * Get the type argument (the first argument) of a `createElement` call. + * @param context The ESLint rule context. + * @param node The node to inspect. + * @returns The type argument, or `null` when the node is not a `createElement` call or has no arguments. + */ +export function getCreateElementTypeArgument(context: RuleContext, node: null | TSESTree.Node): TSESTree.CallExpressionArgument | null { + if (!isCreateElementCall(context, node)) return null; + return node.arguments[0] ?? null; +} + +/** + * Get the props object (the second argument) of a `createElement` call. + * + * Type expressions and chain expressions wrapping the argument are unwrapped + * before the object check; `null`, spread, or otherwise non-object props + * arguments yield `null`. + * + * @param context The ESLint rule context. + * @param node The node to inspect. + * @returns The props `ObjectExpression`, or `null` when absent or not statically an object literal. + */ +export function getCreateElementPropsObject(context: RuleContext, node: null | TSESTree.Node): TSESTree.ObjectExpression | null { + if (!isCreateElementCall(context, node)) return null; + const propsArg = node.arguments[1]; + if (propsArg == null) return null; + const propsObject = Extract.unwrap(propsArg); + return propsObject.type === AST.ObjectExpression ? propsObject : null; +} + +/** + * Get the children arguments (the arguments after the props object) of a `createElement` call. + * @param context The ESLint rule context. + * @param node The node to inspect. + * @returns The children arguments, or an empty array when the node is not a `createElement` call. + */ +export function getCreateElementChildrenArguments(context: RuleContext, node: null | TSESTree.Node): TSESTree.CallExpressionArgument[] { + if (!isCreateElementCall(context, node)) return []; + return node.arguments.slice(2); +} + +/** + * Find a statically named property in the props object of a `createElement` call. + * + * Statically resolvable names include plain identifier keys as well as + * string-literal and simple template-literal keys (computed or not). + * @param context The ESLint rule context. + * @param node The node to inspect. + * @param name The property name to look for (ex: `"children"`, `"key"`). + * @returns The matching `Property` node, or `null` when the call has no static property with that name. + * + * @example + * ```ts + * import { getCreateElementProp } from "@eslint-react/core"; + * + * const childrenProp = getCreateElementProp(context, node, "children"); + * ``` + */ +export function getCreateElementProp(context: RuleContext, node: null | TSESTree.Node, name: string): TSESTree.Property | null { + const propsObject = getCreateElementPropsObject(context, node); + if (propsObject == null) return null; + for (const prop of propsObject.properties) { + if (prop.type === AST.Property && Extract.getPropertyName(prop, "max") === name) { + return prop; + } + } + return null; +} + +// #endregion + +// #region Contextual Predicates + +/** + * Check if the node is passed as a children argument (the third argument or + * later) of a `createElement` call. + * @param context The ESLint rule context. + * @param node The node to check. + * @returns `true` if the node is a direct children argument of a `createElement` call. + */ +export function isCreateElementChildrenArgument(context: RuleContext, node: TSESTree.Node): boolean { + let parent = node.parent; + while (Check.isTypeExpression(parent)) parent = parent.parent; + return parent?.type === AST.CallExpression + && isCreateElementCall(context, parent) + && parent.arguments.slice(2).some((arg) => Extract.unwrap(arg) === node); +} + +/** + * Check if the node is inside the props object (the second argument) of a `createElement` call. + * @param context The ESLint rule context. + * @param node The node to check. + * @returns `true` if the node is inside `createElement`'s props object. + */ +export function isInsideCreateElementProps(context: RuleContext, node: TSESTree.Node): boolean { + const call = Traverse.findParent(node, isCreateElementCall(context)); + if (call == null) return false; + // 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]; +} + +// #endregion diff --git a/packages/core/src/function-component.ts b/packages/core/src/function-component.ts index 8e6d3cf69b..c42e7c913a 100644 --- a/packages/core/src/function-component.ts +++ b/packages/core/src/function-component.ts @@ -6,6 +6,7 @@ import { RE_COMPONENT_NAME, RE_COMPONENT_NAME_LOOSE } from "@eslint-react/shared import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; import { isCreateElementCall, isForwardRefCall, isMemoCall } from "./api"; import { isRenderMethodCallback } from "./class-component"; +import { isCreateElementChildrenArgument } from "./create-element"; import { type FunctionID, type FunctionInitPath, getFunctionId, isFunctionHasCallInInitPath } from "./function"; import type { HookCall } from "./hook"; import { JsxDetectionHint } from "./jsx"; @@ -258,9 +259,7 @@ export function isFunctionComponentDefinition(context: RuleContext, node: TSESTr // 3. Check immediate contextual exclusions if (isRenderMethodCallback(node)) return false; - if (parent.type === AST.CallExpression && isCreateElementCall(context, parent) && parent.arguments.slice(2).some((arg) => Extract.unwrap(arg) === node)) { - return false; - } + if (isCreateElementChildrenArgument(context, node)) return false; // 4. Apply contextual exclusions via hints const [parentCallee, parentCalleeName] = parent.type === AST.CallExpression ? [Extract.unwrap(parent.callee), Extract.getCalleeName(parent)] : [null, null]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b374681725..78eb64f642 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export * from "./api"; export * from "./class"; export * from "./class-component"; export * from "./class-component-collector"; +export * from "./create-element"; export * from "./function"; export * from "./function-component"; export * from "./function-component-collector"; diff --git a/plugins/eslint-plugin-react-jsx/src/rules/no-children-prop-with-children/no-children-prop-with-children.ts b/plugins/eslint-plugin-react-jsx/src/rules/no-children-prop-with-children/no-children-prop-with-children.ts index 565214315d..40b7dbc421 100644 --- a/plugins/eslint-plugin-react-jsx/src/rules/no-children-prop-with-children/no-children-prop-with-children.ts +++ b/plugins/eslint-plugin-react-jsx/src/rules/no-children-prop-with-children/no-children-prop-with-children.ts @@ -1,6 +1,6 @@ import { createRule } from "@/utils/create-rule"; -import { findCreateElementChildrenProp } from "@/utils/find-create-element-children-prop"; import { removeJsxAttribute } from "@/utils/remove-jsx-attribute"; +import * as core from "@eslint-react/core"; import { type RuleContext, type RuleFeature, type RuleListener } from "@eslint-react/eslint"; import { findAttribute, hasChildren } from "@eslint-react/jsx"; import { AST_NODE_TYPES as AST } from "@typescript-eslint/types"; @@ -39,12 +39,12 @@ export default createRule<[], MessageID>({ export function create(context: RuleContext): RuleListener { return { CallExpression(node) { - const childrenProp = findCreateElementChildrenProp(context, node); + const childrenProp = core.getCreateElementProp(context, node, "children"); if (childrenProp == null) return; // `createElement(type, props, ...children)` treats arguments after the // props object as children content; without them there is no conflict - if (node.arguments[2] == null) return; + if (core.getCreateElementChildrenArguments(context, node).length === 0) return; context.report({ messageId: "default", diff --git a/plugins/eslint-plugin-react-jsx/src/rules/no-children-prop/no-children-prop.ts b/plugins/eslint-plugin-react-jsx/src/rules/no-children-prop/no-children-prop.ts index 662fbb5dd1..034b9661ab 100644 --- a/plugins/eslint-plugin-react-jsx/src/rules/no-children-prop/no-children-prop.ts +++ b/plugins/eslint-plugin-react-jsx/src/rules/no-children-prop/no-children-prop.ts @@ -1,7 +1,7 @@ import { createRule } from "@/utils/create-rule"; -import { findCreateElementChildrenProp } from "@/utils/find-create-element-children-prop"; import { removeJsxAttribute } from "@/utils/remove-jsx-attribute"; import { Check } from "@eslint-react/ast"; +import * as core from "@eslint-react/core"; import { type RuleContext, type RuleFeature, type RuleListener } from "@eslint-react/eslint"; import { findAttribute } from "@eslint-react/jsx"; import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; @@ -39,7 +39,7 @@ export default createRule<[], MessageID>({ export function create(context: RuleContext): RuleListener { return { CallExpression(node) { - const childrenProp = findCreateElementChildrenProp(context, node); + const childrenProp = core.getCreateElementProp(context, node, "children"); if (childrenProp == null) return; context.report({ diff --git a/plugins/eslint-plugin-react-jsx/src/utils/find-create-element-children-prop.ts b/plugins/eslint-plugin-react-jsx/src/utils/find-create-element-children-prop.ts deleted file mode 100644 index 0d6888f66e..0000000000 --- a/plugins/eslint-plugin-react-jsx/src/utils/find-create-element-children-prop.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Extract } from "@eslint-react/ast"; -import * as core from "@eslint-react/core"; -import type { RuleContext } from "@eslint-react/eslint"; -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; - -/** - * Finds the statically named `children` property in the props object of a `createElement` call. - * @param context The rule context - * @param node The `CallExpression` node to inspect - * @returns The `children` property node, or `null` when the call has no static `children` prop - */ -export function findCreateElementChildrenProp(context: RuleContext, node: TSESTree.CallExpression): TSESTree.Property | null { - if (!core.isCreateElementCall(context, node)) return null; - - const propsArg = node.arguments[1]; - if (propsArg == null) return null; - - const propsObject = Extract.unwrap(propsArg); - if (propsObject.type !== AST.ObjectExpression) return null; - - for (const prop of propsObject.properties) { - if (prop.type === AST.Property && Extract.getPropertyName(prop, "max") === "children") { - return prop; - } - } - return null; -} diff --git a/plugins/eslint-plugin-react-x/src/rules/no-array-index-key/no-array-index-key.spec.ts b/plugins/eslint-plugin-react-x/src/rules/no-array-index-key/no-array-index-key.spec.ts index c88f4f3b5e..7583914158 100644 --- a/plugins/eslint-plugin-react-x/src/rules/no-array-index-key/no-array-index-key.spec.ts +++ b/plugins/eslint-plugin-react-x/src/rules/no-array-index-key/no-array-index-key.spec.ts @@ -125,6 +125,11 @@ ruleTester.run(RULE_NAME, rule, { code: tsx`foo.map((bar, i) => React.createElement('Foo', { key: i }))`, errors: [{ messageId: "default" }], }, + { + name: "index as key in React.createElement props wrapped in a type expression", + code: tsx`foo.map((bar, i) => React.createElement('Foo', { key: i } as const))`, + errors: [{ messageId: "default" }], + }, { name: "index in template literal as key in React.createElement", code: tsx`foo.map((bar, i) => React.createElement('Foo', { key: \`foo-\${i}\` }))`, diff --git a/plugins/eslint-plugin-react-x/src/rules/no-array-index-key/no-array-index-key.ts b/plugins/eslint-plugin-react-x/src/rules/no-array-index-key/no-array-index-key.ts index c9276bdd56..eb4938dbed 100644 --- a/plugins/eslint-plugin-react-x/src/rules/no-array-index-key/no-array-index-key.ts +++ b/plugins/eslint-plugin-react-x/src/rules/no-array-index-key/no-array-index-key.ts @@ -51,9 +51,14 @@ export function create(context: RuleContext): RuleListener { return node.type === AST.Identifier && isArrayIndexReference(context, node); } - // Checks if a call expression is `React.createElement` or `React.cloneElement` - function isCreateOrCloneElementCall(node: TSESTree.Node): node is TSESTree.CallExpression { - return core.isCreateElementCall(context, node) || core.isCloneElementCall(context, node); + // Gets the props object of a `createElement` or `cloneElement` call + function getPropsObject(node: TSESTree.CallExpression): TSESTree.ObjectExpression | null { + const props = node.arguments[1]; + if (core.isCreateElementCall(context, node)) { + return core.getCreateElementPropsObject(context, node); + } + if (!core.isCloneElementCall(context, node)) return null; + return props?.type === AST.ObjectExpression ? props : null; } /** @@ -115,10 +120,9 @@ export function create(context: RuleContext): RuleListener { return { // Handles 'key' props in `createElement` and `cloneElement` calls CallExpression(node) { - if (!isCreateOrCloneElementCall(node)) return; - const [, props] = node.arguments; - if (props?.type !== AST.ObjectExpression) return; - for (const property of props.properties) { + const propsObject = getPropsObject(node); + if (propsObject == null) return; + for (const property of propsObject.properties) { const value = getKeyPropValue(property); if (value == null) continue; for (const desc of visitKeyExpression(value)) { 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 1016eaa7bb..6f2ba0a767 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 @@ -95,21 +95,6 @@ export function getWrapperCallBoundName(context: RuleContext, node: TSESTreeFunc return parent.id.name; } -/** - * 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; - // 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]; -} - /** * Check if the node is inside a JSX attribute value * @param node The AST node to check diff --git a/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/no-nested-component-definitions.ts b/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/no-nested-component-definitions.ts index 40eda24a32..e8db0c8f04 100644 --- a/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/no-nested-component-definitions.ts +++ b/plugins/eslint-plugin-react-x/src/rules/no-nested-component-definitions/no-nested-component-definitions.ts @@ -3,7 +3,7 @@ import { Check, Traverse } from "@eslint-react/ast"; import * as core from "@eslint-react/core"; import { type RuleContext, type RuleFeature, type RuleListener, merge } from "@eslint-react/eslint"; import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; -import { getWrapperCallBoundName, isInsideCreateElementProps, isInsideJSXAttributeValue, isInsideRenderMethod } from "./lib"; +import { getWrapperCallBoundName, isInsideJSXAttributeValue, isInsideRenderMethod } from "./lib"; export const RULE_NAME = "no-nested-component-definitions"; @@ -85,7 +85,7 @@ export function create(context: RuleContext): RuleListener { continue; } // Check if the component is defined inside the props of a `createElement` call - if (isInsideCreateElementProps(context, component)) { + if (core.isInsideCreateElementProps(context, component)) { context.report({ data: { name,