Skip to content

Commit 8d478b6

Browse files
authored
refactor(core): add createElement helper APIs and reuse them in rules (#1937)
1 parent 419e0b2 commit 8d478b6

20 files changed

Lines changed: 550 additions & 63 deletions

packages/core/docs/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@
137137
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
138138
| [~~getClassComponentCollector~~](functions/getClassComponentCollector.md) | Get an api and visitor object for the rule to collect class components. |
139139
| [getClassId](functions/getClassId.md) | Get the class identifier of a class node. |
140+
| [getCreateElementChildrenArguments](functions/getCreateElementChildrenArguments.md) | Get the children arguments (the arguments after the props object) of a `createElement` call. |
141+
| [getCreateElementProp](functions/getCreateElementProp.md) | Find a statically named property in the props object of a `createElement` call. |
142+
| [getCreateElementPropsObject](functions/getCreateElementPropsObject.md) | Get the props object (the second argument) of a `createElement` call. |
143+
| [getCreateElementTypeArgument](functions/getCreateElementTypeArgument.md) | Get the type argument (the first argument) of a `createElement` call. |
140144
| [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`). |
141145
| [getFunctionComponentCollector](functions/getFunctionComponentCollector.md) | Get an api and visitor object for the rule to collect function components. |
142146
| [getFunctionDirectives](functions/getFunctionDirectives.md) | Get the directives of a function (ex: "use strict", "use client", "use server"). |
@@ -151,6 +155,7 @@
151155
| [~~isAssignmentToThisState~~](functions/isAssignmentToThisState.md) | Check if the assignment expression assigns to `this.state`. |
152156
| [isBooleanLiteralType](functions/isBooleanLiteralType.md) | Check if the type is a boolean literal type. |
153157
| [isClassComponent](functions/isClassComponent.md) | Check if the node is a class component (extends `Component` or `PureComponent`). |
158+
| [isCreateElementChildrenArgument](functions/isCreateElementChildrenArgument.md) | Check if the node is passed as a children argument (the third argument or later) of a `createElement` call. |
154159
| [isFunctionComponentDefinition](functions/isFunctionComponentDefinition.md) | Check if the function node is a valid React component definition. |
155160
| [isFunctionComponentName](functions/isFunctionComponentName.md) | Check if a string matches the strict component name pattern. |
156161
| [isFunctionComponentNameLoose](functions/isFunctionComponentNameLoose.md) | Check if a string matches the loose component name pattern. |
@@ -165,6 +170,7 @@
165170
| [isHookId](functions/isHookId.md) | Checks if the given node is a hook identifier. |
166171
| [isHookName](functions/isHookName.md) | Check if the name is a hook name (starts with `use` followed by an uppercase letter or digit). |
167172
| [isHookTag](functions/isHookTag.md) | Checks if the given expression is a hook tag (callee / tagged template tag). |
173+
| [isInsideCreateElementProps](functions/isInsideCreateElementProps.md) | Check if the node is inside the props object (the second argument) of a `createElement` call. |
168174
| [isJsxLike](functions/isJsxLike.md) | Check if the node represents JSX-like content based on heuristics. |
169175
| [~~isPureComponent~~](functions/isPureComponent.md) | Check if the node is a pure component (extends `PureComponent`). |
170176
| [isRenderMethodCallback](functions/isRenderMethodCallback.md) | Check if the function is a callback passed to a class component's render method. |
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[@eslint-react/core](../README.md) / getCreateElementChildrenArguments
2+
3+
# Function: getCreateElementChildrenArguments()
4+
5+
```ts
6+
function getCreateElementChildrenArguments(context: RuleContext, node: Node | null): CallExpressionArgument[];
7+
```
8+
9+
Get the children arguments (the arguments after the props object) of a `createElement` call.
10+
11+
## Parameters
12+
13+
| Parameter | Type | Description |
14+
| --------- | ---------------- | ------------------------ |
15+
| `context` | `RuleContext` | The ESLint rule context. |
16+
| `node` | `Node` \| `null` | The node to inspect. |
17+
18+
## Returns
19+
20+
`CallExpressionArgument`[]
21+
22+
The children arguments, or an empty array when the node is not a `createElement` call.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[@eslint-react/core](../README.md) / getCreateElementProp
2+
3+
# Function: getCreateElementProp()
4+
5+
```ts
6+
function getCreateElementProp(
7+
context: RuleContext,
8+
node: Node | null,
9+
name: string,
10+
): Property | null;
11+
```
12+
13+
Find a statically named property in the props object of a `createElement` call.
14+
15+
Statically resolvable names include plain identifier keys as well as
16+
string-literal and simple template-literal keys (computed or not).
17+
18+
## Parameters
19+
20+
| Parameter | Type | Description |
21+
| --------- | ---------------- | ---------------------------------------------------------- |
22+
| `context` | `RuleContext` | The ESLint rule context. |
23+
| `node` | `Node` \| `null` | The node to inspect. |
24+
| `name` | `string` | The property name to look for (ex: `"children"`, `"key"`). |
25+
26+
## Returns
27+
28+
`Property` \| `null`
29+
30+
The matching `Property` node, or `null` when the call has no static property with that name.
31+
32+
## Example
33+
34+
```ts
35+
import { getCreateElementProp } from "@eslint-react/core";
36+
37+
const childrenProp = getCreateElementProp(context, node, "children");
38+
```
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[@eslint-react/core](../README.md) / getCreateElementPropsObject
2+
3+
# Function: getCreateElementPropsObject()
4+
5+
```ts
6+
function getCreateElementPropsObject(context: RuleContext, node: Node | null): ObjectExpression | null;
7+
```
8+
9+
Get the props object (the second argument) of a `createElement` call.
10+
11+
Type expressions and chain expressions wrapping the argument are unwrapped
12+
before the object check; `null`, spread, or otherwise non-object props
13+
arguments yield `null`.
14+
15+
## Parameters
16+
17+
| Parameter | Type | Description |
18+
| --------- | ---------------- | ------------------------ |
19+
| `context` | `RuleContext` | The ESLint rule context. |
20+
| `node` | `Node` \| `null` | The node to inspect. |
21+
22+
## Returns
23+
24+
`ObjectExpression` \| `null`
25+
26+
The props `ObjectExpression`, or `null` when absent or not statically an object literal.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[@eslint-react/core](../README.md) / getCreateElementTypeArgument
2+
3+
# Function: getCreateElementTypeArgument()
4+
5+
```ts
6+
function getCreateElementTypeArgument(context: RuleContext, node: Node | null): CallExpressionArgument | null;
7+
```
8+
9+
Get the type argument (the first argument) of a `createElement` call.
10+
11+
## Parameters
12+
13+
| Parameter | Type | Description |
14+
| --------- | ---------------- | ------------------------ |
15+
| `context` | `RuleContext` | The ESLint rule context. |
16+
| `node` | `Node` \| `null` | The node to inspect. |
17+
18+
## Returns
19+
20+
`CallExpressionArgument` \| `null`
21+
22+
The type argument, or `null` when the node is not a `createElement` call or has no arguments.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[@eslint-react/core](../README.md) / isCreateElementChildrenArgument
2+
3+
# Function: isCreateElementChildrenArgument()
4+
5+
```ts
6+
function isCreateElementChildrenArgument(context: RuleContext, node: Node): boolean;
7+
```
8+
9+
Check if the node is passed as a children argument (the third argument or
10+
later) of a `createElement` call.
11+
12+
## Parameters
13+
14+
| Parameter | Type | Description |
15+
| --------- | ------------- | ------------------------ |
16+
| `context` | `RuleContext` | The ESLint rule context. |
17+
| `node` | `Node` | The node to check. |
18+
19+
## Returns
20+
21+
`boolean`
22+
23+
`true` if the node is a direct children argument of a `createElement` call.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[@eslint-react/core](../README.md) / isInsideCreateElementProps
2+
3+
# Function: isInsideCreateElementProps()
4+
5+
```ts
6+
function isInsideCreateElementProps(context: RuleContext, node: Node): boolean;
7+
```
8+
9+
Check if the node is inside the props object (the second argument) of a `createElement` call.
10+
11+
## Parameters
12+
13+
| Parameter | Type | Description |
14+
| --------- | ------------- | ------------------------ |
15+
| `context` | `RuleContext` | The ESLint rule context. |
16+
| `node` | `Node` | The node to check. |
17+
18+
## Returns
19+
20+
`boolean`
21+
22+
`true` if the node is inside `createElement`'s props object.

packages/core/src/api.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types";
44
import { simpleTraverse } from "@typescript-eslint/typescript-estree";
55
import { describe, expect, it } from "vitest";
66

7-
import { isAPI } from "./api";
7+
import { isAPI, isAPICall, isCreateElementCall } from "./api";
88

99
/**
1010
* This function mirrors the core matching logic inside `isAPI` from
@@ -267,3 +267,60 @@ describe("isAPI (actual export)", () => {
267267
testAPI("React.memo;", "createElement", false);
268268
});
269269
});
270+
271+
describe("dual signature: curried form (context first)", () => {
272+
function createMockContext(code: string): RuleContext {
273+
return {
274+
sourceCode: {
275+
getText: (node: TSESTree.Node) => code.slice(node.range[0], node.range[1]),
276+
getScope: () => ({}),
277+
},
278+
} as unknown as RuleContext;
279+
}
280+
281+
function parseLastExpression(code: string) {
282+
const parsed = parseCode(code);
283+
const last = parsed.ast.body.at(-1);
284+
if (last?.type !== AST.ExpressionStatement) {
285+
throw new Error(`expected last statement to be an ExpressionStatement, got ${last?.type ?? "unknown"}`);
286+
}
287+
return { context: createMockContext(code), node: last.expression };
288+
}
289+
290+
it("isAPI curried form agrees with the two-argument form", () => {
291+
const { context, node } = parseLastExpression("React.createElement;");
292+
expect(isAPI("createElement")(context, node)).toBe(true);
293+
expect(isAPI("createElement")(context)(node)).toBe(true);
294+
});
295+
296+
it("isAPI curried form rejects non-matching nodes", () => {
297+
const { context, node } = parseLastExpression("React.memo;");
298+
expect(isAPI("createElement")(context, node)).toBe(false);
299+
expect(isAPI("createElement")(context)(node)).toBe(false);
300+
});
301+
302+
it("isAPICall curried form agrees with the two-argument form", () => {
303+
const { context, node } = parseLastExpression(`React.createElement("div", null);`);
304+
expect(isAPICall("createElement")(context, node)).toBe(true);
305+
expect(isAPICall("createElement")(context)(node)).toBe(true);
306+
});
307+
308+
it("isAPICall curried form rejects non-matching calls", () => {
309+
const { context, node } = parseLastExpression(`React.cloneElement(element);`);
310+
expect(isAPICall("createElement")(context, node)).toBe(false);
311+
expect(isAPICall("createElement")(context)(node)).toBe(false);
312+
});
313+
314+
it("isAPICall curried form handles null and non-call nodes", () => {
315+
const { context, node } = parseLastExpression(`React.createElement("div", null);`);
316+
const predicate = isAPICall("createElement")(context);
317+
expect(predicate(null)).toBe(false);
318+
expect(predicate(node.type === AST.CallExpression ? node.arguments[0] as TSESTree.Node : node)).toBe(false);
319+
});
320+
321+
it("derived predicates (ex: isCreateElementCall) work in curried form", () => {
322+
const { context, node } = parseLastExpression(`createElement("div", null);`);
323+
expect(isCreateElementCall(context, node)).toBe(true);
324+
expect(isCreateElementCall(context)(node)).toBe(true);
325+
});
326+
});

packages/core/src/api.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Extract } from "@eslint-react/ast";
22
import type { RuleContext } from "@eslint-react/eslint";
3-
import { dual } from "@local/eff";
43
import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types";
54

65
export declare namespace isAPI {
@@ -29,7 +28,12 @@ export function isAPI(api: string): isAPI.ReturnType {
2928
if (name.endsWith(`.${api}`)) return true;
3029
return false;
3130
};
32-
return dual(2, func);
31+
function dual(context: RuleContext, node: null | TSESTree.Node): boolean;
32+
function dual(context: RuleContext): (node: null | TSESTree.Node) => boolean;
33+
function dual(context: RuleContext, ...rest: [] | [null | TSESTree.Node]) {
34+
return rest.length === 1 ? func(context, rest[0]) : (node: null | TSESTree.Node) => func(context, node);
35+
}
36+
return dual;
3337
}
3438

3539
export declare namespace isAPICall {
@@ -51,7 +55,12 @@ export function isAPICall(api: string): isAPICall.ReturnType {
5155
if (node.type !== AST.CallExpression) return false;
5256
return isAPI(api)(context, Extract.unwrap(node.callee));
5357
};
54-
return dual(2, func);
58+
function dual(context: RuleContext, node: null | TSESTree.Node): node is TSESTree.CallExpression;
59+
function dual(context: RuleContext): (node: null | TSESTree.Node) => node is TSESTree.CallExpression;
60+
function dual(context: RuleContext, ...rest: [] | [null | TSESTree.Node]) {
61+
return rest.length === 1 ? func(context, rest[0]) : (node: null | TSESTree.Node): node is TSESTree.CallExpression => func(context, node);
62+
}
63+
return dual;
5564
}
5665

5766
// React API checks

0 commit comments

Comments
 (0)