diff --git a/package.json b/package.json index eeb1471696..972ff638ab 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "vite-node": "^6.0.0", "vitest": "^4.1.10" }, - "packageManager": "pnpm@11.13.0", + "packageManager": "pnpm@11.13.1", "engines": { "node": ">=22.0.0" } diff --git a/packages/jsx/docs/README.md b/packages/jsx/docs/README.md index 40b65f87b2..7f4c0d6dcd 100644 --- a/packages/jsx/docs/README.md +++ b/packages/jsx/docs/README.md @@ -19,6 +19,7 @@ | [collapseMultilineText](functions/collapseMultilineText.md) | Collapse a multiline JSX text string following React's whitespace rules. | | [findAttribute](functions/findAttribute.md) | Find a JSX attribute (or spread attribute containing the property) by name on a given element. | | [findParentAttribute](functions/findParentAttribute.md) | Walk up the AST from `node` to find the nearest ancestor that is a `JSXAttribute` and (optionally) passes a predicate. | +| [findSpreadProperty](functions/findSpreadProperty.md) | Find the `Property` node that provides a given key inside a spread argument. | | [getAttributeName](functions/getAttributeName.md) | Get the stringified name of a `JSXAttribute` node. | | [getAttributeStaticValue](functions/getAttributeStaticValue.md) | Find an attribute by name on a JSX element and collapse its value to a plain JavaScript value in a single step. | | [getAttributeValue](functions/getAttributeValue.md) | Find an attribute by name on a JSX element and resolve its value in a single call. | @@ -33,6 +34,6 @@ | [isEmptyStringExpression](functions/isEmptyStringExpression.md) | Check whether a JSX child node is an empty string expression (`{""}`). | | [isFragmentElement](functions/isFragmentElement.md) | Check whether a node is a React Fragment element. | | [isHostElement](functions/isHostElement.md) | Check whether a node is a host (intrinsic / DOM) element. | -| [isWhitespace](functions/isWhitespace.md) | Check whether a JSX child node is whitespace padding that React would trim away during rendering. | +| [isPaddingWhitespace](functions/isPaddingWhitespace.md) | Check whether a JSX child node is whitespace padding that React would trim away during rendering. | | [isWhitespaceText](functions/isWhitespaceText.md) | Check whether a JSX child node is any whitespace-only text. | -| [resolveAttributeValue](functions/resolveAttributeValue.md) | Resolve the value of a JSX attribute (or spread attribute) into a AttributeValue descriptor that can be inspected further. | +| [resolveAttributeValue](functions/resolveAttributeValue.md) | Resolve the value of a JSX attribute (or spread attribute) into an AttributeValue descriptor that can be inspected further. | diff --git a/packages/jsx/docs/functions/findAttribute.md b/packages/jsx/docs/functions/findAttribute.md index a9e776f4cb..c987a9b60d 100644 --- a/packages/jsx/docs/functions/findAttribute.md +++ b/packages/jsx/docs/functions/findAttribute.md @@ -17,7 +17,8 @@ or `undefined` when the attribute is not present. Spread attributes are resolved when possible: if the spread argument is an identifier that resolves to an object expression, the object's properties are searched for a matching key. -Nested object expressions and nested spread identifiers are also resolved. +Nested object expressions and nested spread identifiers are also resolved +(see [findSpreadProperty](findSpreadProperty.md)). ## Parameters diff --git a/packages/jsx/docs/functions/findParentAttribute.md b/packages/jsx/docs/functions/findParentAttribute.md index 4b8a451d01..03c154fbcd 100644 --- a/packages/jsx/docs/functions/findParentAttribute.md +++ b/packages/jsx/docs/functions/findParentAttribute.md @@ -3,7 +3,7 @@ # Function: findParentAttribute() ```ts -function findParentAttribute(node: Node, test?: (node: JSXAttribute) => boolean): JSXAttribute | null; +function findParentAttribute(node: Node, test?: (node: JSXAttribute) => boolean): JSXAttribute | undefined; ``` Walk up the AST from `node` to find the nearest ancestor that is a `JSXAttribute` @@ -21,6 +21,6 @@ inside an expression container) and needs to know which JSX attribute it belongs ## Returns -`JSXAttribute` \| `null` +`JSXAttribute` \| `undefined` -The first matching `JSXAttribute` ancestor, or `null` if none is found before reaching the root. +The first matching `JSXAttribute` ancestor, or `undefined` if none is found before reaching the root. diff --git a/packages/jsx/docs/functions/findSpreadProperty.md b/packages/jsx/docs/functions/findSpreadProperty.md new file mode 100644 index 0000000000..f5a46255d6 --- /dev/null +++ b/packages/jsx/docs/functions/findSpreadProperty.md @@ -0,0 +1,43 @@ +[@eslint-react/jsx](../README.md) / findSpreadProperty + +# Function: findSpreadProperty() + +```ts +function findSpreadProperty( + context: RuleContext, + argument: Expression, + name: string, + seen?: Set, +): Property | undefined; +``` + +Find the `Property` node that provides a given key inside a spread argument. + +This is the single resolution routine shared by [findAttribute](findAttribute.md) (existence +checks) and the `spreadProps` variant of `resolveAttributeValue` (value extraction): + +- An `Identifier` argument is resolved to its initializer via variable + resolution, following alias chains (`const b = a`) like `getStaticValue` + does; an `ObjectExpression` argument is searched directly. +- Properties are walked **in reverse** so that later entries win, matching + JavaScript object semantics (`{ ...a, k: 1 }` -> the literal `k`). +- Nested `SpreadElement`s (identifiers or inline object expressions) are + searched recursively; a `seen` set guards against circular references. +- Plain identifier keys and string literal keys are matched directly; + computed keys are matched when they are statically evaluable + (ex: `{ ["class" + "Name"]: 1 }`). + +## Parameters + +| Parameter | Type | Description | +| ---------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `context` | `RuleContext` | The ESLint rule context (needed for variable resolution). | +| `argument` | `Expression` | The spread argument expression to search. | +| `name` | `string` | The property name to look for. | +| `seen` | [`Set`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Set)\<`Node`\> | Internal set of already-visited nodes (cycle guard). | + +## Returns + +`Property` \| `undefined` + +The matching `Property` node, or `undefined` when the key is not found. diff --git a/packages/jsx/docs/functions/getAttributeStaticValue.md b/packages/jsx/docs/functions/getAttributeStaticValue.md index 3177a5e2b0..f482320dfb 100644 --- a/packages/jsx/docs/functions/getAttributeStaticValue.md +++ b/packages/jsx/docs/functions/getAttributeStaticValue.md @@ -17,9 +17,9 @@ This is a convenience composition of [findAttribute](findAttribute.md) -> [resolveAttributeValue](resolveAttributeValue.md) -> `toStatic()`, with automatic handling of the `spreadProps` case (extracts the named property from the spread object). -Returns `null` when the attribute is absent, `undefined` when the value cannot -be statically determined (including empty expression containers), and the -resolved static value otherwise. +Returns `undefined` both when the attribute is absent and when its value +cannot be statically determined; use [findAttribute](findAttribute.md) or +[hasAttribute](hasAttribute.md) when presence itself matters. ## Parameters @@ -33,4 +33,4 @@ resolved static value otherwise. `unknown` -The static value of the attribute, `null` when absent, or `undefined` when indeterminate. +The static value of the attribute, or `undefined` when absent or indeterminate. diff --git a/packages/jsx/docs/functions/getAttributeValue.md b/packages/jsx/docs/functions/getAttributeValue.md index 6ee3af8e23..21ab4d5255 100644 --- a/packages/jsx/docs/functions/getAttributeValue.md +++ b/packages/jsx/docs/functions/getAttributeValue.md @@ -7,7 +7,7 @@ function getAttributeValue( context: RuleContext, element: JSXElement, name: string, -): AttributeValue | null; +): AttributeValue | undefined; ``` Find an attribute by name on a JSX element and resolve its value in a single call. @@ -26,6 +26,6 @@ pattern in lint rules. ## Returns -`AttributeValue` \| `null` +`AttributeValue` \| `undefined` -A JsxAttributeValue descriptor, or `null` when the attribute is not present on the element. +An AttributeValue descriptor, or `undefined` when the attribute is not present on the element. diff --git a/packages/jsx/docs/functions/getChildren.md b/packages/jsx/docs/functions/getChildren.md index 6b960e80db..5f1fa73397 100644 --- a/packages/jsx/docs/functions/getChildren.md +++ b/packages/jsx/docs/functions/getChildren.md @@ -13,7 +13,7 @@ Mirrors Babel's `buildChildren` helper: 1. Iterate over `element.children`. 2. Skip `JSXText` nodes that clean to nothing (padding whitespace). 3. Skip `JSXExpressionContainer` nodes whose expression is empty. -4. Skip `JSXEmptyExpression` nodes. +4. Skip empty string expressions (`{""}`), which produce no DOM node. 5. Collect everything else. ## Parameters diff --git a/packages/jsx/docs/functions/getElementFullType.md b/packages/jsx/docs/functions/getElementFullType.md index 935b1ba6d0..83b5754d75 100644 --- a/packages/jsx/docs/functions/getElementFullType.md +++ b/packages/jsx/docs/functions/getElementFullType.md @@ -11,6 +11,7 @@ Get the string representation of a JSX element's type. - `
` -> `"div"` - `` -> `"Foo.Bar"` - `` -> `"React.Fragment"` +- `` -> `"xml:space"` - `<>` -> `""`. ## Parameters diff --git a/packages/jsx/docs/functions/isWhitespace.md b/packages/jsx/docs/functions/isPaddingWhitespace.md similarity index 59% rename from packages/jsx/docs/functions/isWhitespace.md rename to packages/jsx/docs/functions/isPaddingWhitespace.md index 9b5196c776..5eddb55ae4 100644 --- a/packages/jsx/docs/functions/isWhitespace.md +++ b/packages/jsx/docs/functions/isPaddingWhitespace.md @@ -1,9 +1,9 @@ -[@eslint-react/jsx](../README.md) / isWhitespace +[@eslint-react/jsx](../README.md) / isPaddingWhitespace -# Function: isWhitespace() +# Function: isPaddingWhitespace() ```ts -function isWhitespace(node: JSXChild): boolean; +function isPaddingWhitespace(node: JSXChild): boolean; ``` Check whether a JSX child node is whitespace padding that React would @@ -12,8 +12,10 @@ trim away during rendering. A child is considered whitespace padding when it is a `JSXText` node whose content is empty after applying React's whitespace normalization (see [collapseMultilineText](collapseMultilineText.md), modelled after Babel's -`cleanJSXElementLiteralChild`). This is the whitespace that appears between -JSX tags purely for formatting. +`cleanJSXElementLiteralChild`) **and** it contains a newline. This is the +whitespace that appears between JSX tags purely for formatting. + +For the looser "any whitespace-only text" check, see [isWhitespaceText](isWhitespaceText.md). ## Parameters diff --git a/packages/jsx/docs/functions/isWhitespaceText.md b/packages/jsx/docs/functions/isWhitespaceText.md index d5d0ef7e65..f70406623f 100644 --- a/packages/jsx/docs/functions/isWhitespaceText.md +++ b/packages/jsx/docs/functions/isWhitespaceText.md @@ -8,7 +8,7 @@ function isWhitespaceText(node: JSXChild): boolean; Check whether a JSX child node is any whitespace-only text. -This is a looser variant of [isWhitespace](isWhitespace.md); it matches every +This is a looser variant of [isPaddingWhitespace](isPaddingWhitespace.md); it matches every `JSXText` node whose raw content is empty after trimming, regardless of whether it contains a newline. diff --git a/packages/jsx/docs/functions/resolveAttributeValue.md b/packages/jsx/docs/functions/resolveAttributeValue.md index 6d9c76cc00..43f52b78d5 100644 --- a/packages/jsx/docs/functions/resolveAttributeValue.md +++ b/packages/jsx/docs/functions/resolveAttributeValue.md @@ -3,22 +3,32 @@ # Function: resolveAttributeValue() ```ts -function resolveAttributeValue(context: RuleContext, attribute: TSESTreeJSXAttributeLike): AttributeValue; +function resolveAttributeValue( + context: RuleContext, + attribute: TSESTreeJSXAttributeLike, + name?: string, +): AttributeValue; ``` -Resolve the value of a JSX attribute (or spread attribute) into a +Resolve the value of a JSX attribute (or spread attribute) into an AttributeValue descriptor that can be inspected further. This is the low-level building block; it operates on a single attribute node that the caller has already located. For the higher-level "find by name and resolve" combo, see [getAttributeValue](getAttributeValue.md). +When the attribute is a `JSXSpreadAttribute`, passing `name` (typically the +same name the attribute was found by) makes `toStatic()` return the static +value of that named property, eliminating the need to branch on +`kind === "spreadProps"` at the call site. + ## Parameters -| Parameter | Type | Description | -| ----------- | -------------------------- | ---------------------------------------------------- | -| `context` | `RuleContext` | The ESLint rule context (needed for scope look-ups). | -| `attribute` | `TSESTreeJSXAttributeLike` | A `JSXAttribute` or `JSXSpreadAttribute` node. | +| Parameter | Type | Description | +| ----------- | -------------------------- | -------------------------------------------------------------------------- | +| `context` | `RuleContext` | The ESLint rule context (needed for scope look-ups). | +| `attribute` | `TSESTreeJSXAttributeLike` | A `JSXAttribute` or `JSXSpreadAttribute` node. | +| `name?` | `string` | Optional property name used to resolve `toStatic()` for spread attributes. | ## Returns diff --git a/packages/jsx/src/attribute-find.test.ts b/packages/jsx/src/attribute-find.test.ts new file mode 100644 index 0000000000..1ea7349ab7 --- /dev/null +++ b/packages/jsx/src/attribute-find.test.ts @@ -0,0 +1,234 @@ +/// + +import type { RuleContext } from "@eslint-react/eslint"; +import * as tsParser from "@typescript-eslint/parser"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { Linter } from "eslint"; +import { describe, expect, it } from "vitest"; + +import { findAttribute, findParentAttribute, findSpreadProperty } from "./attribute-find"; + +function parseJsxElement(code: string): { context: RuleContext; element: TSESTree.JSXElement } { + const found: { context: RuleContext | null; element: TSESTree.JSXElement | null } = { context: null, element: null }; + new Linter().verify(code, { + plugins: { + test: { + rules: { + "test-rule": { + meta: { type: "problem", messages: {}, schema: [] }, + create(ctx: unknown) { + found.context = ctx as never; + return { + JSXElement(node: TSESTree.JSXElement) { + found.element ??= node; + }, + }; + }, + }, + }, + }, + }, + rules: { "test/test-rule": "error" }, + languageOptions: { + parser: tsParser, + parserOptions: { jsx: true, ecmaFeatures: { jsx: true } }, + }, + }); + if (found.context == null || found.element == null) throw new Error("expected a JSX element in the code"); + return { context: found.context, element: found.element }; +} + +function getSpreadArgument(element: TSESTree.JSXElement, index = 0): TSESTree.Expression { + const attr = element.openingElement.attributes[index]; + if (attr?.type !== AST.JSXSpreadAttribute) { + throw new Error(`expected attribute ${index} to be a JSXSpreadAttribute, got ${attr?.type ?? "unknown"}`); + } + return attr.argument; +} + +describe("findAttribute", () => { + it("finds a direct attribute", () => { + const { context, element } = parseJsxElement('
;'); + const attr = findAttribute(context, element, "className"); + expect(attr?.type).toBe(AST.JSXAttribute); + }); + + it("finds a property inside a spread object expression", () => { + const { context, element } = parseJsxElement("
;"); + const attr = findAttribute(context, element, "className"); + expect(attr?.type).toBe(AST.JSXSpreadAttribute); + }); + + it("finds a property behind a string literal key in a spread object", () => { + const { context, element } = parseJsxElement('
;'); + const attr = findAttribute(context, element, "className"); + expect(attr?.type).toBe(AST.JSXSpreadAttribute); + }); + + it("finds a property inside a spread identifier resolving to an object expression", () => { + const { context, element } = parseJsxElement( + "const props = { className: 'x' };
;", + ); + const attr = findAttribute(context, element, "className"); + expect(attr?.type).toBe(AST.JSXSpreadAttribute); + }); + + it("finds a property nested in multiple object expression spreads", () => { + const { context, element } = parseJsxElement( + "
;", + ); + const attr = findAttribute(context, element, "className"); + expect(attr?.type).toBe(AST.JSXSpreadAttribute); + }); + + it("finds a property nested in multiple identifier spreads", () => { + const { context, element } = parseJsxElement( + "const inner = { className: 'x' };" + + "const middle = { ...inner };" + + "const props = { ...middle };" + + "
;", + ); + const attr = findAttribute(context, element, "className"); + expect(attr?.type).toBe(AST.JSXSpreadAttribute); + }); + + it("returns undefined when the attribute is not present", () => { + const { context, element } = parseJsxElement("
;"); + const attr = findAttribute(context, element, "className"); + expect(attr).toBeUndefined(); + }); + + it("returns undefined when the spread argument does not resolve to an object", () => { + const { context, element } = parseJsxElement("const props = null;
;"); + const attr = findAttribute(context, element, "className"); + expect(attr).toBeUndefined(); + }); + + it("respects later-props-win semantics", () => { + const { context, element } = parseJsxElement( + "const props = { className: 'first' };
;", + ); + const attr = findAttribute(context, element, "className"); + expect(attr?.type).toBe(AST.JSXAttribute); + }); + + it("returns the last matching attribute when the name is duplicated", () => { + const { context, element } = parseJsxElement('
;'); + const attr = findAttribute(context, element, "id"); + expect(attr).toBe(element.openingElement.attributes[1]); + }); + + it("returns the last matching spread attribute when the property is duplicated", () => { + const { context, element } = parseJsxElement( + "
;", + ); + const attr = findAttribute(context, element, "id"); + expect(attr).toBe(element.openingElement.attributes[1]); + }); + + it("does not infinite loop on circular spread references", () => { + const { context, element } = parseJsxElement( + "const a = {};" + + "const b = { ...a };" + + "Object.assign(a, { ...b });" + + "
;", + ); + expect(() => findAttribute(context, element, "className")).not.toThrow(); + }); +}); + +describe("findParentAttribute", () => { + it("finds the enclosing attribute from a nested expression", () => { + const { element } = parseJsxElement('
;'); + const attr = element.openingElement.attributes[0]; + if (attr?.type !== AST.JSXAttribute || attr.value?.type !== AST.JSXExpressionContainer) { + throw new Error("unexpected attribute shape"); + } + const found = findParentAttribute(attr.value.expression); + expect(found).toBe(attr); + }); + + it("filters candidates with the predicate", () => { + const { element } = parseJsxElement('
;'); + const attr = element.openingElement.attributes[0]; + if (attr?.type !== AST.JSXAttribute || attr.value?.type !== AST.JSXExpressionContainer) { + throw new Error("unexpected attribute shape"); + } + const found = findParentAttribute( + attr.value.expression, + (n) => n.name.type === AST.JSXIdentifier && n.name.name === "id", + ); + expect(found).toBeUndefined(); + }); + + it("returns undefined when no attribute ancestor exists", () => { + const { element } = parseJsxElement("
;"); + expect(findParentAttribute(element)).toBeUndefined(); + }); +}); + +describe("findSpreadProperty", () => { + it("finds a property in an inline object expression", () => { + const { context, element } = parseJsxElement("
;"); + const prop = findSpreadProperty(context, getSpreadArgument(element), "a"); + expect(prop?.value.type).toBe(AST.Literal); + if (prop?.value.type === AST.Literal) { + expect(prop.value.value).toBe(1); + } + }); + + it("later properties win over earlier ones", () => { + const { context, element } = parseJsxElement("
;"); + const prop = findSpreadProperty(context, getSpreadArgument(element), "a"); + expect(prop?.value.type).toBe(AST.Literal); + if (prop?.value.type === AST.Literal) { + expect(prop.value.value).toBe(2); + } + }); + + it("later spreads win over earlier properties", () => { + const { context, element } = parseJsxElement("
;"); + const prop = findSpreadProperty(context, getSpreadArgument(element), "a"); + expect(prop?.value.type).toBe(AST.Literal); + if (prop?.value.type === AST.Literal) { + expect(prop.value.value).toBe(1); + } + }); + + it("matches string literal keys", () => { + const { context, element } = parseJsxElement('
;'); + const prop = findSpreadProperty(context, getSpreadArgument(element), "a"); + expect(prop?.type).toBe(AST.Property); + }); + + it("matches statically evaluable computed keys", () => { + const { context, element } = parseJsxElement('
;'); + const prop = findSpreadProperty(context, getSpreadArgument(element), "ab"); + expect(prop?.type).toBe(AST.Property); + }); + + it("skips computed keys that are not statically evaluable", () => { + const { context, element } = parseJsxElement("
;"); + const prop = findSpreadProperty(context, getSpreadArgument(element), "foo"); + expect(prop).toBeUndefined(); + }); + + it("follows identifier aliases", () => { + const { context, element } = parseJsxElement( + "const a = { k: 1 }; const b = a;
;", + ); + const prop = findSpreadProperty(context, getSpreadArgument(element), "k"); + expect(prop?.value.type).toBe(AST.Literal); + if (prop?.value.type === AST.Literal) { + expect(prop.value.value).toBe(1); + } + }); + + it("does not infinite loop on circular aliases", () => { + const { context, element } = parseJsxElement( + "const a = b; const b = a;
;", + ); + expect(() => findSpreadProperty(context, getSpreadArgument(element), "k")).not.toThrow(); + expect(findSpreadProperty(context, getSpreadArgument(element), "k")).toBeUndefined(); + }); +}); diff --git a/packages/jsx/src/attribute-find.ts b/packages/jsx/src/attribute-find.ts new file mode 100644 index 0000000000..b1db246c5c --- /dev/null +++ b/packages/jsx/src/attribute-find.ts @@ -0,0 +1,114 @@ +import { type TSESTreeJSXAttributeLike, Traverse } from "@eslint-react/ast"; +import type { RuleContext } from "@eslint-react/eslint"; +import { resolve } from "@eslint-react/var"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { getStaticValue } from "@typescript-eslint/utils/ast-utils"; +import { getAttributeName } from "./attribute-name"; + +/** + * Find a JSX attribute (or spread attribute containing the property) by name on a given element. + * + * Returns the last matching attribute to mirror React's behavior where later props win, + * or `undefined` when the attribute is not present. + * + * Spread attributes are resolved when possible: if the spread argument is an identifier + * that resolves to an object expression, the object's properties are searched for a matching key. + * Nested object expressions and nested spread identifiers are also resolved + * (see {@link findSpreadProperty}). + * @param context The ESLint rule context (needed for variable resolution in spread attributes). + * @param element The `JSXElement` node to search. + * @param name The attribute name to look for (ex: "className"). + * @returns The matching `JSXAttribute` or `JSXSpreadAttribute`, or `undefined` when not found. + */ +export function findAttribute(context: RuleContext, element: TSESTree.JSXElement, name: string): TSESTreeJSXAttributeLike | undefined { + return element.openingElement.attributes.findLast((attr) => { + if (attr.type === AST.JSXAttribute) { + return getAttributeName(attr) === name; + } + return findSpreadProperty(context, attr.argument, name) != null; + }); +} + +/** + * Walk up the AST from `node` to find the nearest ancestor that is a `JSXAttribute` + * and (optionally) passes a predicate. + * + * This is useful when a rule visitor enters a deeply nested node (ex: a `Literal` + * inside an expression container) and needs to know which JSX attribute it belongs to. + * @param node The starting node for the upward search. + * @param test Optional predicate to filter candidate `JSXAttribute` nodes. When omitted every `JSXAttribute` ancestor matches. + * @returns The first matching `JSXAttribute` ancestor, or `undefined` if none is found before reaching the root. + */ +export function findParentAttribute(node: TSESTree.Node, test: (node: TSESTree.JSXAttribute) => boolean = () => true): TSESTree.JSXAttribute | undefined { + const guard = (n: TSESTree.Node): n is TSESTree.JSXAttribute => { + return n.type === AST.JSXAttribute && test(n); + }; + return Traverse.findParent(node, guard) ?? undefined; +} + +/** + * Find the `Property` node that provides a given key inside a spread argument. + * + * This is the single resolution routine shared by {@link findAttribute} (existence + * checks) and the `spreadProps` variant of `resolveAttributeValue` (value extraction): + * + * - An `Identifier` argument is resolved to its initializer via variable + * resolution, following alias chains (`const b = a`) like `getStaticValue` + * does; an `ObjectExpression` argument is searched directly. + * - Properties are walked **in reverse** so that later entries win, matching + * JavaScript object semantics (`{ ...a, k: 1 }` -> the literal `k`). + * - Nested `SpreadElement`s (identifiers or inline object expressions) are + * searched recursively; a `seen` set guards against circular references. + * - Plain identifier keys and string literal keys are matched directly; + * computed keys are matched when they are statically evaluable + * (ex: `{ ["class" + "Name"]: 1 }`). + * @param context The ESLint rule context (needed for variable resolution). + * @param argument The spread argument expression to search. + * @param name The property name to look for. + * @param seen Internal set of already-visited nodes (cycle guard). + * @returns The matching `Property` node, or `undefined` when the key is not found. + */ +export function findSpreadProperty( + context: RuleContext, + argument: TSESTree.Expression, + name: string, + seen: Set = new Set(), +): TSESTree.Property | undefined { + let objectExpression: TSESTree.ObjectExpression | undefined; + if (argument.type === AST.Identifier) { + // Follow identifier aliases (`const b = a`) until a non-identifier + // initializer is reached, mirroring `getStaticValue`'s identifier tracking. + let initNode: TSESTree.Node | null = resolve(context, argument); + while (initNode != null && initNode.type === AST.Identifier && !seen.has(initNode)) { + seen.add(initNode); + initNode = resolve(context, initNode); + } + if (initNode?.type === AST.ObjectExpression) { + objectExpression = initNode; + } + } else if (argument.type === AST.ObjectExpression) { + objectExpression = argument; + } + if (objectExpression == null || seen.has(objectExpression)) return undefined; + seen.add(objectExpression); + + const { properties } = objectExpression; + for (let i = properties.length - 1; i >= 0; i--) { + const property = properties[i]; + if (property == null) continue; + if (property.type === AST.Property) { + const { key } = property; + if (property.computed) { + const keyScope = context.sourceCode.getScope(key); + if (getStaticValue(key, keyScope)?.value === name) return property; + continue; + } + if (key.type === AST.Identifier && key.name === name) return property; + if (key.type === AST.Literal && key.value === name) return property; + continue; + } + const found = findSpreadProperty(context, property.argument, name, seen); + if (found != null) return found; + } + return undefined; +} diff --git a/packages/jsx/src/attribute-has.ts b/packages/jsx/src/attribute-has.ts new file mode 100644 index 0000000000..05117c0e6b --- /dev/null +++ b/packages/jsx/src/attribute-has.ts @@ -0,0 +1,52 @@ +import type { RuleContext } from "@eslint-react/eslint"; +import type { TSESTree } from "@typescript-eslint/types"; +import { findAttribute } from "./attribute-find"; + +/** + * Check whether a JSX element carries a given attribute (prop). + * + * This is a thin convenience wrapper around {@link findAttribute} for the + * common case where you only need a boolean answer. + * + * Spread attributes are taken into account: `` + * will report `true` for `"disabled"`. + * @param context The ESLint rule context (needed for variable resolution in spread attributes). + * @param element The `JSXElement` node to inspect. + * @param name The attribute name to look for (ex: "className"). + * @returns `true` when the attribute is present on the element. + */ +export function hasAttribute(context: RuleContext, element: TSESTree.JSXElement, name: string) { + return findAttribute(context, element, name) != null; +} + +/** + * Check whether a JSX element carries at least one of the given attributes. + * + * This is a batch variant of {@link hasAttribute} for the common pattern of + * short-circuiting on multiple prop names. + * + * Spread attributes are taken into account (see {@link findAttribute}). + * @param context The ESLint rule context (needed for variable resolution in spread attributes). + * @param element The `JSXElement` node to inspect. + * @param names The attribute names to look for. + * @returns `true` when at least one of the attributes is present. + */ +export function hasAnyAttribute(context: RuleContext, element: TSESTree.JSXElement, names: string[]): boolean { + return names.some((name) => findAttribute(context, element, name) != null); +} + +/** + * Check whether a JSX element carries all of the given attributes (props). + * + * This is a batch variant of {@link hasAttribute} for the common pattern + * where a rule needs to verify that a set of required props are all present. + * + * Spread attributes are taken into account (see {@link findAttribute}). + * @param context The ESLint rule context (needed for variable resolution in spread attributes). + * @param element The `JSXElement` node to inspect. + * @param names The attribute names to look for. + * @returns `true` when every name in `names` is present on the element. + */ +export function hasEveryAttribute(context: RuleContext, element: TSESTree.JSXElement, names: string[]) { + return names.every((name) => findAttribute(context, element, name) != null); +} diff --git a/packages/jsx/src/is-attribute.ts b/packages/jsx/src/attribute-name.ts similarity index 64% rename from packages/jsx/src/is-attribute.ts rename to packages/jsx/src/attribute-name.ts index 8a89856cef..fce08299ec 100644 --- a/packages/jsx/src/is-attribute.ts +++ b/packages/jsx/src/attribute-name.ts @@ -1,6 +1,23 @@ import { dual } from "@local/eff"; import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +/** + * Get the stringified name of a `JSXAttribute` node. + * + * Handles both simple identifiers and namespaced names: + * - `className` -> `"className"` + * - `aria-label` -> `"aria-label"` + * - `xml:space` -> `"xml:space"`. + * @param node A `JSXAttribute` AST node. + * @returns The attribute name as a plain string. + */ +export function getAttributeName(node: TSESTree.JSXAttribute): string { + if (node.name.type === AST.JSXIdentifier) { + return node.name.name; + } + return node.name.namespace.name + ":" + node.name.name.name; +} + /** * Check whether a node is a `JSXAttribute` with the given name. * diff --git a/packages/jsx/src/attribute-value.test.ts b/packages/jsx/src/attribute-value.test.ts new file mode 100644 index 0000000000..4eb73ac4b1 --- /dev/null +++ b/packages/jsx/src/attribute-value.test.ts @@ -0,0 +1,220 @@ +/// + +import type { RuleContext } from "@eslint-react/eslint"; +import * as tsParser from "@typescript-eslint/parser"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { Linter } from "eslint"; +import { describe, expect, it } from "vitest"; + +import { getAttributeStaticValue, getAttributeValue, resolveAttributeValue } from "./attribute-value"; + +function parseJsxElement(code: string): { context: RuleContext; element: TSESTree.JSXElement } { + const found: { context: RuleContext | null; element: TSESTree.JSXElement | null } = { context: null, element: null }; + new Linter().verify(code, { + plugins: { + test: { + rules: { + "test-rule": { + meta: { type: "problem", messages: {}, schema: [] }, + create(ctx: unknown) { + found.context = ctx as never; + return { + JSXElement(node: TSESTree.JSXElement) { + found.element ??= node; + }, + }; + }, + }, + }, + }, + }, + rules: { "test/test-rule": "error" }, + languageOptions: { + parser: tsParser, + parserOptions: { jsx: true, ecmaFeatures: { jsx: true } }, + }, + }); + if (found.context == null || found.element == null) throw new Error("expected a JSX element in the code"); + return { context: found.context, element: found.element }; +} + +function getAttribute(element: TSESTree.JSXElement, index = 0) { + const attr = element.openingElement.attributes[index]; + if (attr == null) throw new Error(`expected attribute ${index} to exist`); + return attr; +} + +describe("resolveAttributeValue", () => { + it("resolves a boolean attribute", () => { + const { context, element } = parseJsxElement(";"); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("boolean"); + expect(value.node).toBeNull(); + expect(value.toStatic()).toBe(true); + }); + + it("resolves a literal attribute", () => { + const { context, element } = parseJsxElement('
;'); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("literal"); + expect(value.node?.type).toBe(AST.Literal); + expect(value.toStatic()).toBe("x"); + }); + + it("resolves a statically evaluable expression", () => { + const { context, element } = parseJsxElement("
;"); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("unknown"); + expect(value.toStatic()).toBe(2); + }); + + it("resolves an expression referencing a constant", () => { + const { context, element } = parseJsxElement("const x = 5;
;"); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("unknown"); + expect(value.toStatic()).toBe(5); + }); + + it("returns undefined for a non-static expression", () => { + const { context, element } = parseJsxElement("
;"); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("unknown"); + expect(value.toStatic()).toBeUndefined(); + }); + + it("resolves an empty expression container as a missing value", () => { + const { context, element } = parseJsxElement("
;"); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("missing"); + expect(value.node?.type).toBe(AST.JSXEmptyExpression); + expect(value.toStatic()).toBeUndefined(); + }); + + it("resolves a JSX element value", () => { + const { context, element } = parseJsxElement("
/>;"); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("element"); + expect(value.node?.type).toBe(AST.JSXElement); + expect(value.toStatic()).toBeUndefined(); + }); + + it("resolves properties of an inline spread object", () => { + const { context, element } = parseJsxElement('
;'); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("spreadProps"); + if (value.kind !== "spreadProps") return; + expect(value.getProperty("className")).toBe("x"); + expect(value.getProperty("n")).toBeUndefined(); + expect(value.getProperty("missing")).toBeUndefined(); + }); + + it("resolves properties of a spread identifier", () => { + const { context, element } = parseJsxElement('const props = { className: "x" };
;'); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("spreadProps"); + if (value.kind !== "spreadProps") return; + expect(value.getProperty("className")).toBe("x"); + }); + + it("resolves properties through identifier aliases", () => { + const { context, element } = parseJsxElement('const a = { className: "x" }; const b = a;
;'); + const value = resolveAttributeValue(context, getAttribute(element)); + if (value.kind !== "spreadProps") throw new Error("expected spreadProps"); + expect(value.getProperty("className")).toBe("x"); + }); + + it("resolves properties behind statically evaluable computed keys", () => { + const { context, element } = parseJsxElement('
;'); + const value = resolveAttributeValue(context, getAttribute(element)); + if (value.kind !== "spreadProps") throw new Error("expected spreadProps"); + expect(value.getProperty("className")).toBe("x"); + }); + + it("applies later-props-win semantics inside spread objects", () => { + const { context, element } = parseJsxElement("
;"); + const value = resolveAttributeValue(context, getAttribute(element)); + if (value.kind !== "spreadProps") throw new Error("expected spread"); + expect(value.getProperty("a")).toBe(2); + }); + + it("toStatic() returns undefined for spread attributes without a name", () => { + const { context, element } = parseJsxElement('
;'); + const value = resolveAttributeValue(context, getAttribute(element)); + expect(value.kind).toBe("spreadProps"); + expect(value.toStatic()).toBeUndefined(); + }); + + it("toStatic() resolves the named property for spread attributes", () => { + const { context, element } = parseJsxElement('
;'); + const value = resolveAttributeValue(context, getAttribute(element), "className"); + expect(value.kind).toBe("spreadProps"); + expect(value.toStatic()).toBe("x"); + }); + + it("toStatic() ignores the name for plain attributes", () => { + const { context, element } = parseJsxElement('
;'); + const value = resolveAttributeValue(context, getAttribute(element), "className"); + expect(value.kind).toBe("literal"); + expect(value.toStatic()).toBe("x"); + }); +}); + +describe("getAttributeValue", () => { + it("returns undefined when the attribute is absent", () => { + const { context, element } = parseJsxElement("
;"); + expect(getAttributeValue(context, element, "className")).toBeUndefined(); + }); + + it("returns the resolved value descriptor when present", () => { + const { context, element } = parseJsxElement('
;'); + const value = getAttributeValue(context, element, "className"); + expect(value?.kind).toBe("literal"); + expect(value?.toStatic()).toBe("x"); + }); + + it("resolves the named property of a spread attribute", () => { + const { context, element } = parseJsxElement('const props = { className: "x" };
;'); + const value = getAttributeValue(context, element, "className"); + expect(value?.kind).toBe("spreadProps"); + expect(value?.toStatic()).toBe("x"); + }); +}); + +describe("getAttributeStaticValue", () => { + it("returns undefined when the attribute is absent", () => { + const { context, element } = parseJsxElement("
;"); + expect(getAttributeStaticValue(context, element, "className")).toBeUndefined(); + }); + + it("returns the literal value", () => { + const { context, element } = parseJsxElement('
;'); + expect(getAttributeStaticValue(context, element, "className")).toBe("x"); + }); + + it("returns true for a boolean attribute", () => { + const { context, element } = parseJsxElement(";"); + expect(getAttributeStaticValue(context, element, "disabled")).toBe(true); + }); + + it("returns undefined for a non-static expression", () => { + const { context, element } = parseJsxElement("
;"); + expect(getAttributeStaticValue(context, element, "id")).toBeUndefined(); + }); + + it("resolves the named property from a spread", () => { + const { context, element } = parseJsxElement('const props = { className: "x" };
;'); + expect(getAttributeStaticValue(context, element, "className")).toBe("x"); + }); + + it("respects later-props-win semantics", () => { + const { context, element } = parseJsxElement( + 'const props = { className: "first" };
;', + ); + expect(getAttributeStaticValue(context, element, "className")).toBe("second"); + }); + + it("returns the last value when the attribute is duplicated", () => { + const { context, element } = parseJsxElement('
;'); + expect(getAttributeStaticValue(context, element, "className")).toBe("b"); + }); +}); diff --git a/packages/jsx/src/attribute-value.ts b/packages/jsx/src/attribute-value.ts new file mode 100644 index 0000000000..b0e543e8f7 --- /dev/null +++ b/packages/jsx/src/attribute-value.ts @@ -0,0 +1,186 @@ +import { type TSESTreeJSXAttributeLike } from "@eslint-react/ast"; +import type { RuleContext } from "@eslint-react/eslint"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { getStaticValue } from "@typescript-eslint/utils/ast-utils"; +import { findAttribute, findSpreadProperty } from "./attribute-find"; + +/** + * Discriminated union representing the resolved value of a JSX attribute. + * + * Each variant carries the original AST `node` (where applicable — the + * `boolean` variant has no value node and reports `null`) and a `toStatic()` + * helper that attempts to collapse the value into a plain JavaScript value + * at analysis time. + * + * `toStatic()` returns `undefined` whenever no static value is available; + * structural information is carried by `kind`, value information by `toStatic()`. + */ +export type AttributeValue = + | { readonly kind: "boolean"; readonly node: null; toStatic(): true } + | { readonly kind: "literal"; readonly node: TSESTree.Literal; toStatic(): TSESTree.Literal["value"] } + | { readonly kind: "unknown"; readonly node: TSESTree.Expression; toStatic(): unknown } + | { readonly kind: "element"; readonly node: TSESTree.JSXElement; toStatic(): undefined } + | { readonly kind: "missing"; readonly node: TSESTree.JSXEmptyExpression; toStatic(): undefined } + | { readonly kind: "spreadChild"; readonly node: TSESTree.JSXSpreadChild; toStatic(): undefined } + | { readonly kind: "spreadProps"; getProperty(name: string): unknown; readonly node: TSESTree.JSXSpreadAttribute["argument"]; toStatic(): unknown }; + +/** + * Resolve the value of a JSX attribute (or spread attribute) into an + * {@link AttributeValue} descriptor that can be inspected further. + * + * This is the low-level building block; it operates on a single attribute + * node that the caller has already located. For the higher-level "find by + * name and resolve" combo, see {@link getAttributeValue}. + * + * When the attribute is a `JSXSpreadAttribute`, passing `name` (typically the + * same name the attribute was found by) makes `toStatic()` return the static + * value of that named property, eliminating the need to branch on + * `kind === "spreadProps"` at the call site. + * @param context The ESLint rule context (needed for scope look-ups). + * @param attribute A `JSXAttribute` or `JSXSpreadAttribute` node. + * @param name Optional property name used to resolve `toStatic()` for spread attributes. + * @returns A discriminated-union descriptor of the attribute's value. + */ +export function resolveAttributeValue( + context: RuleContext, + attribute: TSESTreeJSXAttributeLike, + name?: string, +): AttributeValue { + if (attribute.type === AST.JSXAttribute) { + return resolveJsxAttribute(context, attribute); + } + return resolveJsxSpreadAttribute(context, attribute, name); +} + +/** + * Find an attribute by name on a JSX element and resolve its value in a single call. + * + * This is a convenience composition of {@link findAttribute} and + * {@link resolveAttributeValue} that eliminates the most common two-step + * pattern in lint rules. + * @param context The ESLint rule context. + * @param element The `JSXElement` node to search. + * @param name The attribute name to look up (ex: "className"). + * @returns An {@link AttributeValue} descriptor, or `undefined` when the attribute is not present on the element. + */ +export function getAttributeValue(context: RuleContext, element: TSESTree.JSXElement, name: string): AttributeValue | undefined { + const attr = findAttribute(context, element, name); + if (attr == null) return undefined; + return resolveAttributeValue(context, attr, name); +} + +/** + * Find an attribute by name on a JSX element and collapse its value to a plain + * JavaScript value in a single step. + * + * This is a convenience composition of {@link findAttribute} -> + * {@link resolveAttributeValue} -> `toStatic()`, with automatic handling of the + * `spreadProps` case (extracts the named property from the spread object). + * + * Returns `undefined` both when the attribute is absent and when its value + * cannot be statically determined; use {@link findAttribute} or + * {@link hasAttribute} when presence itself matters. + * @param context The ESLint rule context. + * @param element The `JSXElement` node to inspect. + * @param name The attribute name to look up (ex: "className"). + * @returns The static value of the attribute, or `undefined` when absent or indeterminate. + */ +export function getAttributeStaticValue(context: RuleContext, element: TSESTree.JSXElement, name: string): unknown { + return getAttributeValue(context, element, name)?.toStatic(); +} + +// #region Internal Resolvers + +function resolveJsxAttribute(context: RuleContext, node: TSESTree.JSXAttribute): AttributeValue { + const scope = context.sourceCode.getScope(node); + + // Boolean attribute, no value means `true` (ex: ``). + if (node.value == null) { + return { + kind: "boolean", + node: null, + toStatic() { + return true; + }, + } as const satisfies AttributeValue; + } + + switch (node.value.type) { + case AST.Literal: { + const staticValue = node.value.value; + return { + kind: "literal", + node: node.value, + toStatic() { + return staticValue; + }, + } as const satisfies AttributeValue; + } + + case AST.JSXExpressionContainer: { + const expr = node.value.expression; + if (expr.type === AST.JSXEmptyExpression) { + return { + kind: "missing", + node: expr, + toStatic() { + return undefined; + }, + } as const satisfies AttributeValue; + } + return { + kind: "unknown", + node: expr, + toStatic() { + return getStaticValue(expr, scope)?.value; + }, + } as const satisfies AttributeValue; + } + + case AST.JSXElement: { + return { + kind: "element", + node: node.value, + toStatic() { + return undefined; + }, + } as const satisfies AttributeValue; + } + + // Not valid in attribute value position per the JSX spec and not produced + // by current parsers, but part of the TSESTree union; reported as its own + // kind so consumers can distinguish it from a plain expression container. + case AST.JSXSpreadChild: { + return { + kind: "spreadChild", + node: node.value, + toStatic() { + return undefined; + }, + } as const satisfies AttributeValue; + } + } +} + +function resolveJsxSpreadAttribute( + context: RuleContext, + node: TSESTree.JSXSpreadAttribute, + name?: string, +): AttributeValue { + const getProperty = (propertyName: string): unknown => { + const property = findSpreadProperty(context, node.argument, propertyName); + if (property == null) return undefined; + const propertyScope = context.sourceCode.getScope(property.value); + return getStaticValue(property.value, propertyScope)?.value; + }; + return { + kind: "spreadProps", + getProperty, + node: node.argument, + toStatic() { + return name == null ? undefined : getProperty(name); + }, + } as const satisfies AttributeValue; +} + +// #endregion diff --git a/packages/jsx/src/children.test.ts b/packages/jsx/src/children.test.ts new file mode 100644 index 0000000000..ada1f50b58 --- /dev/null +++ b/packages/jsx/src/children.test.ts @@ -0,0 +1,100 @@ +/// + +import type { TSESTreeJSXElementLike } from "@eslint-react/ast"; +import * as tsParser from "@typescript-eslint/parser"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { Linter } from "eslint"; +import { describe, expect, it } from "vitest"; + +import { getChildren, hasChildren } from "./children"; + +function parseJsx(code: string): TSESTreeJSXElementLike { + const found: { node: TSESTreeJSXElementLike | null } = { node: null }; + new Linter().verify(code, { + plugins: { + test: { + rules: { + "test-rule": { + meta: { type: "problem", messages: {}, schema: [] }, + create: () => ({ + JSXElement(node: TSESTree.JSXElement) { + found.node ??= node; + }, + JSXFragment(node: TSESTree.JSXFragment) { + found.node ??= node; + }, + }), + }, + }, + }, + }, + rules: { "test/test-rule": "error" }, + languageOptions: { + parser: tsParser, + parserOptions: { jsx: true, ecmaFeatures: { jsx: true } }, + }, + }); + if (found.node == null) throw new Error("expected JSX in the code"); + return found.node; +} + +describe("getChildren", () => { + it("filters out newline-containing whitespace padding", () => { + const children = getChildren(parseJsx("
\n \n
;")); + expect(children.length).toBe(1); + expect(children[0]?.type).toBe(AST.JSXElement); + }); + + it("keeps same-line whitespace text", () => { + const children = getChildren(parseJsx("
;")); + expect(children.length).toBe(1); + expect(children[0]?.type).toBe(AST.JSXText); + }); + + it("filters out empty expression containers", () => { + expect(getChildren(parseJsx("
{/* comment */}
;"))).toEqual([]); + }); + + it("filters out empty string expressions", () => { + expect(getChildren(parseJsx('
{""}
;'))).toEqual([]); + }); + + it("keeps meaningful children in order", () => { + const children = getChildren(parseJsx("
a{1}
;")); + expect(children.map((c) => c.type)).toEqual([ + AST.JSXText, + AST.JSXExpressionContainer, + AST.JSXElement, + ]); + }); + + it("works for fragments", () => { + const children = getChildren(parseJsx("<>\n \n;")); + expect(children.length).toBe(1); + expect(children[0]?.type).toBe(AST.JSXElement); + }); +}); + +describe("hasChildren", () => { + it("returns false for an empty element", () => { + expect(hasChildren(parseJsx("
;"))).toBe(false); + }); + + it("returns false for same-line whitespace-only content", () => { + expect(hasChildren(parseJsx("
;"))).toBe(false); + }); + + it("returns false for an empty string expression", () => { + expect(hasChildren(parseJsx('
{""}
;'))).toBe(false); + }); + + it("returns true for meaningful children", () => { + expect(hasChildren(parseJsx("
a
;"))).toBe(true); + }); + + it("differs from getChildren for same-line whitespace", () => { + const node = parseJsx("
;"); + expect(getChildren(node).length).toBe(1); + expect(hasChildren(node)).toBe(false); + }); +}); diff --git a/packages/jsx/src/has-children.ts b/packages/jsx/src/children.ts similarity index 54% rename from packages/jsx/src/has-children.ts rename to packages/jsx/src/children.ts index be80d1a359..b4ad6025ae 100644 --- a/packages/jsx/src/has-children.ts +++ b/packages/jsx/src/children.ts @@ -1,5 +1,37 @@ import type { TSESTreeJSXElementLike } from "@eslint-react/ast"; -import { isEmptyStringExpression, isWhitespaceText } from "./is-whitespace"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { isEmptyStringExpression, isPaddingWhitespace, isWhitespaceText } from "./text"; + +/** + * Get the meaningful children of a JSX element or fragment. + * + * Mirrors Babel's `buildChildren` helper: + * 1. Iterate over `element.children`. + * 2. Skip `JSXText` nodes that clean to nothing (padding whitespace). + * 3. Skip `JSXExpressionContainer` nodes whose expression is empty. + * 4. Skip empty string expressions (`{""}`), which produce no DOM node. + * 5. Collect everything else. + * @param element A `JSXElement` or `JSXFragment` node. + * @returns An array of children nodes that contribute to rendered output. + */ +export function getChildren(element: TSESTreeJSXElementLike): TSESTree.JSXChild[] { + const children: TSESTree.JSXChild[] = []; + + for (const child of element.children) { + // Padding whitespace (whitespace containing a newline) that React trims away. + if (isPaddingWhitespace(child)) continue; + + if (child.type === AST.JSXExpressionContainer) { + if (child.expression.type === AST.JSXEmptyExpression) continue; + // { "" } produces no DOM node. + if (isEmptyStringExpression(child)) continue; + } + + children.push(child); + } + + return children; +} /** * Check whether a JSX element (or fragment) has meaningful children, that is, diff --git a/packages/jsx/src/collapse-multiline-text.ts b/packages/jsx/src/collapse-multiline-text.ts deleted file mode 100644 index 44409dcb35..0000000000 --- a/packages/jsx/src/collapse-multiline-text.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Collapse a multiline JSX text string following React's whitespace rules. - * - * This mirrors Babel's `cleanJSXElementLiteralChild` algorithm: - * 1. Split the raw text into lines. - * 2. Find the last non-empty line. - * 3. Trim leading spaces on non-first lines and trailing spaces on non-last lines. - * 4. Collapse tabs into spaces. - * 5. Append a single space after each non-last non-empty line. - * @param text The raw JSX text string to collapse. - * @returns The collapsed string, or `null` if the text contains only whitespace. - * @see https://github.com/babel/babel/blob/main/packages/babel-types/src/utils/react/cleanJSXElementLiteralChild.ts - */ -export function collapseMultilineText(text: string): string | null { - const lines = text.split(/\r\n|\n|\r/); - - let lastNonEmptyLine = 0; - for (let i = 0; i < lines.length; i++) { - if (/[^ \t]/.exec(lines[i] ?? "") != null) { - lastNonEmptyLine = i; - } - } - - let str = ""; - for (let i = 0; i < lines.length; i++) { - const line = lines[i] ?? ""; - - const isFirstLine = i === 0; - const isLastLine = i === lines.length - 1; - const isLastNonEmptyLine = i === lastNonEmptyLine; - - // Replace rendered whitespace tabs with spaces - let trimmedLine = line.replace(/\t/g, " "); - - // Trim whitespace touching a newline - if (!isFirstLine) { - trimmedLine = trimmedLine.replace(/^ +/, ""); - } - if (!isLastLine) { - trimmedLine = trimmedLine.replace(/ +$/, ""); - } - - if (trimmedLine.length > 0) { - if (!isLastNonEmptyLine) { - trimmedLine += " "; - } - str += trimmedLine; - } - } - - return str === "" ? null : str; -} diff --git a/packages/jsx/src/element-is.ts b/packages/jsx/src/element-is.ts new file mode 100644 index 0000000000..327f93083e --- /dev/null +++ b/packages/jsx/src/element-is.ts @@ -0,0 +1,89 @@ +import type { TSESTreeJSXElementLike } from "@eslint-react/ast"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { getElementFullType } from "./element-type"; + +/** + * A test that determines whether a JSX element matches. + * + * - `string` matches against the full element type (ex: "div", "React.Fragment") + * - `string[]` matches when the element type equals any of the given strings + * - `function` receives the element type string and returns a boolean. + */ +export type ElementTest = + | string + | readonly string[] + | ((elementType: string, node: TSESTreeJSXElementLike) => boolean); + +/** + * Check whether a node is a `JSXElement` (or `JSXFragment`) and optionally + * matches a given test. + * + * Modelled after + * [`hast-util-is-element`](https://github.com/syntax-tree/hast-util-is-element): + * the `test` parameter controls what counts as a match. + * + * When called without a test, the function acts as a simple type-guard + * for `JSXElement | JSXFragment`. + * @param node The AST node to test. + * @param test Optional test to match the element type against. + * @returns `true` when the node is a matching JSX element. + */ +export function isElement(node: TSESTree.Node | null | undefined, test?: ElementTest): node is TSESTreeJSXElementLike { + if (node == null) return false; + if (node.type !== AST.JSXElement && node.type !== AST.JSXFragment) { + return false; + } + // No test, confirm that it is a JSX element / fragment. + if (test == null) return true; + const elementType = getElementFullType(node); + switch (typeof test) { + case "string": + return elementType === test; + case "function": + return test(elementType, node); + default: + return test.includes(elementType); + } +} + +/** + * Check whether a node is a React Fragment element. + * + * Recognizes both the shorthand `<>...` syntax (`JSXFragment`) and the + * explicit `` / `` form (`JSXElement`). + * + * The comparison is performed against the self name (last dot-separated + * segment) of both the node and the configured factory, so `` + * matches `"React.Fragment"` and `` matches `"Fragment"`. + * @param node The AST node to test. + * @param jsxFragmentFactory The configured fragment factory string (ex: "React.Fragment"). + * @returns `true` when the node represents a React Fragment. + */ +export function isFragmentElement(node: TSESTree.Node, jsxFragmentFactory = "React.Fragment"): node is TSESTreeJSXElementLike { + if (node.type === AST.JSXFragment) return true; + if (node.type !== AST.JSXElement) return false; + + const fragment = jsxFragmentFactory.split(".").at(-1) ?? "Fragment"; + return getElementFullType(node).split(".").at(-1) === fragment; +} + +/** + * Check whether a node is a host (intrinsic / DOM) element. + * + * A host element is a `JSXElement` whose tag name is a plain `JSXIdentifier` + * starting with a lowercase letter, the same heuristic React uses to + * distinguish `
` from ``. + * @param node The AST node to test. + * @returns `true` when the node is a `JSXElement` with a lowercase tag name. + */ +export function isHostElement(node: TSESTree.Node): node is TSESTree.JSXElement { + if (node.type !== AST.JSXElement) return false; + const name = node.openingElement.name; + if (name.type === AST.JSXIdentifier) { + return /^[a-z]/u.test(name.name); + } + if (name.type === AST.JSXNamespacedName) { + return /^[a-z]/u.test(name.name.name); + } + return false; +} diff --git a/packages/jsx/src/get-element-type.ts b/packages/jsx/src/element-type.ts similarity index 97% rename from packages/jsx/src/get-element-type.ts rename to packages/jsx/src/element-type.ts index 85574e381d..8744229a5e 100644 --- a/packages/jsx/src/get-element-type.ts +++ b/packages/jsx/src/element-type.ts @@ -7,6 +7,7 @@ import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; * - `
` -> `"div"` * - `` -> `"Foo.Bar"` * - `` -> `"React.Fragment"` + * - `` -> `"xml:space"` * - `<>` -> `""`. * @param node A `JSXElement` or `JSXFragment` node. * @returns The fully-qualified element type string. diff --git a/packages/jsx/src/element.test.ts b/packages/jsx/src/element.test.ts new file mode 100644 index 0000000000..78b0e11cf7 --- /dev/null +++ b/packages/jsx/src/element.test.ts @@ -0,0 +1,160 @@ +/// + +import type { TSESTreeJSXElementLike } from "@eslint-react/ast"; +import * as tsParser from "@typescript-eslint/parser"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { Linter } from "eslint"; +import { describe, expect, it } from "vitest"; + +import { isElement, isFragmentElement, isHostElement } from "./element-is"; +import { getElementFullType, getElementSelfType } from "./element-type"; + +function parseJsx(code: string): TSESTreeJSXElementLike { + const found: { node: TSESTreeJSXElementLike | null } = { node: null }; + new Linter().verify(code, { + plugins: { + test: { + rules: { + "test-rule": { + meta: { type: "problem", messages: {}, schema: [] }, + create: () => ({ + JSXElement(node: TSESTree.JSXElement) { + found.node ??= node; + }, + JSXFragment(node: TSESTree.JSXFragment) { + found.node ??= node; + }, + }), + }, + }, + }, + }, + rules: { "test/test-rule": "error" }, + languageOptions: { + parser: tsParser, + parserOptions: { jsx: true, ecmaFeatures: { jsx: true } }, + }, + }); + if (found.node == null) throw new Error("expected JSX in the code"); + return found.node; +} + +describe("getElementFullType", () => { + it("returns the tag name for simple elements", () => { + expect(getElementFullType(parseJsx("
;"))).toBe("div"); + }); + + it("returns the qualified name for member expressions", () => { + expect(getElementFullType(parseJsx(";"))).toBe("Foo.Bar.Baz"); + }); + + it("returns the qualified name for React.Fragment", () => { + expect(getElementFullType(parseJsx(";"))).toBe("React.Fragment"); + }); + + it("returns the namespaced name", () => { + expect(getElementFullType(parseJsx(";"))).toBe("xml:space"); + }); + + it("returns an empty string for fragments", () => { + expect(getElementFullType(parseJsx("<>;"))).toBe(""); + }); +}); + +describe("getElementSelfType", () => { + it("returns the last segment of a member expression", () => { + expect(getElementSelfType(parseJsx(";"))).toBe("Baz"); + }); + + it("returns the tag name for simple elements", () => { + expect(getElementSelfType(parseJsx("
;"))).toBe("div"); + }); + + it("returns an empty string for fragments", () => { + expect(getElementSelfType(parseJsx("<>;"))).toBe(""); + }); +}); + +describe("isElement", () => { + it("matches any JSX element without a test", () => { + expect(isElement(parseJsx("
;"))).toBe(true); + }); + + it("does not match null or non-JSX nodes", () => { + const node = parseJsx("
;"); + expect(isElement(null)).toBe(false); + expect(isElement(undefined)).toBe(false); + if (node.type !== AST.JSXElement) throw new Error("expected element"); + expect(isElement(node.openingElement)).toBe(false); + }); + + it("matches against a string test", () => { + const node = parseJsx("
;"); + expect(isElement(node, "div")).toBe(true); + expect(isElement(node, "span")).toBe(false); + }); + + it("matches against an array test", () => { + const node = parseJsx("
;"); + expect(isElement(node, ["span", "div"])).toBe(true); + expect(isElement(node, ["span", "a"])).toBe(false); + }); + + it("matches against a function test", () => { + const node = parseJsx(";"); + expect(isElement(node, (type) => type.startsWith("Foo"))).toBe(true); + expect(isElement(node, (type) => type === "div")).toBe(false); + }); + + it("matches fragments", () => { + const node = parseJsx("<>;"); + expect(isElement(node)).toBe(true); + expect(isElement(node, "")).toBe(true); + }); +}); + +describe("isFragmentElement", () => { + it("matches the shorthand fragment syntax", () => { + expect(isFragmentElement(parseJsx("<>;"))).toBe(true); + }); + + it("matches with the default factory", () => { + expect(isFragmentElement(parseJsx(";"))).toBe(true); + }); + + it("matches with the default factory", () => { + expect(isFragmentElement(parseJsx(";"))).toBe(true); + }); + + it("matches a custom factory", () => { + expect(isFragmentElement(parseJsx(";"), "Preact.Fragment")).toBe(true); + }); + + it("compares only the self name segment of the factory", () => { + // The heuristic matches any `<*.Fragment>` regardless of the qualifier. + expect(isFragmentElement(parseJsx(";"))).toBe(true); + expect(isFragmentElement(parseJsx(";"))).toBe(false); + }); +}); + +describe("isHostElement", () => { + it("matches lowercase tag names", () => { + expect(isHostElement(parseJsx("
;"))).toBe(true); + }); + + it("does not match capitalized components", () => { + expect(isHostElement(parseJsx(";"))).toBe(false); + }); + + it("does not match member expressions", () => { + expect(isHostElement(parseJsx(";"))).toBe(false); + }); + + it("matches lowercase namespaced names", () => { + expect(isHostElement(parseJsx(";"))).toBe(true); + }); + + it("does not match fragments", () => { + expect(isHostElement(parseJsx("<>;"))).toBe(false); + }); +}); diff --git a/packages/jsx/src/find-attribute.test.ts b/packages/jsx/src/find-attribute.test.ts deleted file mode 100644 index 704bb9bb11..0000000000 --- a/packages/jsx/src/find-attribute.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -/// - -import type { RuleContext } from "@eslint-react/eslint"; -import { parseForESLint } from "@typescript-eslint/parser"; -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; -import tsx from "dedent"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -import { getFixturesRootDir } from "../../../testing/helpers"; -import { findAttribute } from "./find-attribute"; - -function parse(code: string) { - return parseForESLint(code, { - disallowAutomaticSingleRunInference: true, - filePath: path.join(getFixturesRootDir(), "estree.tsx"), - }); -} - -function createContext(parsed: ReturnType): RuleContext { - const { scopeManager } = parsed; - return { - sourceCode: { - getScope(node: TSESTree.Node) { - const inner = node.type !== AST.Program; - for (let current: TSESTree.Node | undefined = node; current != null; current = current.parent) { - const scope = scopeManager.acquire(current, inner); - if (scope != null) { - return scope.type === "function-expression-name" - ? scope.childScopes[0] - : scope; - } - } - return scopeManager.scopes[0]; - }, - }, - } as unknown as RuleContext; -} - -function parseJsxElement(code: string): { context: RuleContext; element: TSESTree.JSXElement } { - const parsed = parse(code); - const last = parsed.ast.body.at(-1); - if (last?.type !== AST.ExpressionStatement || last.expression.type !== AST.JSXElement) { - throw new Error(`expected last statement to be a JSXElement, got ${last?.type ?? "unknown"}`); - } - return { context: createContext(parsed), element: last.expression }; -} - -describe("findAttribute", () => { - it("finds a direct attribute", () => { - const { context, element } = parseJsxElement('
;'); - const attr = findAttribute(context, element, "className"); - expect(attr?.type).toBe(AST.JSXAttribute); - }); - - it("finds a property inside a spread object expression", () => { - const { context, element } = parseJsxElement("
;"); - const attr = findAttribute(context, element, "className"); - expect(attr?.type).toBe(AST.JSXSpreadAttribute); - }); - - it("finds a property inside a spread identifier resolving to an object expression", () => { - const { context, element } = parseJsxElement( - "const props = { className: 'x' };
;", - ); - const attr = findAttribute(context, element, "className"); - expect(attr?.type).toBe(AST.JSXSpreadAttribute); - }); - - it("finds a property nested in multiple object expression spreads", () => { - const { context, element } = parseJsxElement( - "
;", - ); - const attr = findAttribute(context, element, "className"); - expect(attr?.type).toBe(AST.JSXSpreadAttribute); - }); - - it("finds a property nested in multiple identifier spreads", () => { - const { context, element } = parseJsxElement(tsx` - const inner = { className: 'x' }; - const middle = { ...inner }; - const props = { ...middle }; -
; - `); - const attr = findAttribute(context, element, "className"); - expect(attr?.type).toBe(AST.JSXSpreadAttribute); - }); - - it("returns undefined when the attribute is not present", () => { - const { context, element } = parseJsxElement("
;"); - const attr = findAttribute(context, element, "className"); - expect(attr).toBeUndefined(); - }); - - it("respects later-props-win semantics", () => { - const { context, element } = parseJsxElement( - "const props = { className: 'first' };
;", - ); - const attr = findAttribute(context, element, "className"); - expect(attr?.type).toBe(AST.JSXAttribute); - }); - - it("does not infinite loop on circular spread references", () => { - const { context, element } = parseJsxElement(tsx` - const a = {}; - const b = { ...a }; - Object.assign(a, { ...b }); -
; - `); - expect(() => findAttribute(context, element, "className")).not.toThrow(); - }); -}); diff --git a/packages/jsx/src/find-attribute.ts b/packages/jsx/src/find-attribute.ts deleted file mode 100644 index d43135035d..0000000000 --- a/packages/jsx/src/find-attribute.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { type TSESTreeJSXAttributeLike } from "@eslint-react/ast"; -import type { RuleContext } from "@eslint-react/eslint"; -import { resolve } from "@eslint-react/var"; -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; -import { getAttributeName } from "./get-attribute-name"; - -/** - * Find a JSX attribute (or spread attribute containing the property) by name on a given element. - * - * Returns the last matching attribute to mirror React's behavior where later props win, - * or `undefined` when the attribute is not present. - * - * Spread attributes are resolved when possible: if the spread argument is an identifier - * that resolves to an object expression, the object's properties are searched for a matching key. - * Nested object expressions and nested spread identifiers are also resolved. - * @param context The ESLint rule context (needed for variable resolution in spread attributes). - * @param element The `JSXElement` node to search. - * @param name The attribute name to look for (ex: "className"). - * @returns The matching `JSXAttribute` or `JSXSpreadAttribute`, or `undefined` when not found. - */ -export function findAttribute( - context: RuleContext, - element: TSESTree.JSXElement, - name: string, -): TSESTreeJSXAttributeLike | undefined { - function findProperty( - properties: TSESTree.ObjectLiteralElement[], - name: string, - seen: Set = new Set(), - ): TSESTree.Property | null { - for (const property of properties) { - if (property.type === AST.Property && !property.computed && property.key.type === AST.Identifier && property.key.name === name) { - return property; - } - if (property.type !== AST.SpreadElement) continue; - const argument = property.argument; - if (argument.type === AST.Identifier) { - const initNode = resolve(context, argument); - if (initNode?.type === AST.ObjectExpression) { - if (seen.has(initNode)) continue; - seen.add(initNode); - const found = findProperty(initNode.properties, name, seen); - if (found != null) return found; - } - continue; - } - if (argument.type === AST.ObjectExpression) { - if (seen.has(argument)) continue; - seen.add(argument); - const found = findProperty(argument.properties, name, seen); - if (found != null) return found; - } - } - return null; - } - return element.openingElement.attributes.findLast((attr) => { - if (attr.type === AST.JSXAttribute) { - return getAttributeName(attr) === name; - } - switch (attr.argument.type) { - case AST.Identifier: { - const initNode = resolve(context, attr.argument); - if (initNode?.type === AST.ObjectExpression) { - return findProperty(initNode.properties, name) != null; - } - return false; - } - case AST.ObjectExpression: - return findProperty(attr.argument.properties, name) != null; - } - return false; - }); -} diff --git a/packages/jsx/src/find-parent-attribute.ts b/packages/jsx/src/find-parent-attribute.ts deleted file mode 100644 index eb01cb376a..0000000000 --- a/packages/jsx/src/find-parent-attribute.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Traverse } from "@eslint-react/ast"; -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; - -/** - * Walk up the AST from `node` to find the nearest ancestor that is a `JSXAttribute` - * and (optionally) passes a predicate. - * - * This is useful when a rule visitor enters a deeply nested node (ex: a `Literal` - * inside an expression container) and needs to know which JSX attribute it belongs to. - * @param node The starting node for the upward search. - * @param test Optional predicate to filter candidate `JSXAttribute` nodes. When omitted every `JSXAttribute` ancestor matches. - * @returns The first matching `JSXAttribute` ancestor, or `null` if none is found before reaching the root. - */ -export function findParentAttribute( - node: TSESTree.Node, - test: (node: TSESTree.JSXAttribute) => boolean = () => true, -): TSESTree.JSXAttribute | null { - const guard = (n: TSESTree.Node): n is TSESTree.JSXAttribute => { - return n.type === AST.JSXAttribute && test(n); - }; - return Traverse.findParent(node, guard); -} diff --git a/packages/jsx/src/get-attribute-name.ts b/packages/jsx/src/get-attribute-name.ts deleted file mode 100644 index 4e56caa35c..0000000000 --- a/packages/jsx/src/get-attribute-name.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; - -/** - * Get the stringified name of a `JSXAttribute` node. - * - * Handles both simple identifiers and namespaced names: - * - `className` -> `"className"` - * - `aria-label` -> `"aria-label"` - * - `xml:space` -> `"xml:space"`. - * @param node A `JSXAttribute` AST node. - * @returns The attribute name as a plain string. - */ -export function getAttributeName(node: TSESTree.JSXAttribute): string { - if (node.name.type === AST.JSXIdentifier) { - return node.name.name; - } - return node.name.namespace.name + ":" + node.name.name.name; -} diff --git a/packages/jsx/src/get-attribute-static-value.ts b/packages/jsx/src/get-attribute-static-value.ts deleted file mode 100644 index ba7d5de898..0000000000 --- a/packages/jsx/src/get-attribute-static-value.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { RuleContext } from "@eslint-react/eslint"; -import type { TSESTree } from "@typescript-eslint/types"; -import { findAttribute } from "./find-attribute"; -import { resolveAttributeValue } from "./resolve-attribute-value"; - -/** - * Find an attribute by name on a JSX element and collapse its value to a plain - * JavaScript value in a single step. - * - * This is a convenience composition of {@link findAttribute} -> - * {@link resolveAttributeValue} -> `toStatic()`, with automatic handling of the - * `spreadProps` case (extracts the named property from the spread object). - * - * Returns `null` when the attribute is absent, `undefined` when the value cannot - * be statically determined (including empty expression containers), and the - * resolved static value otherwise. - * @param context The ESLint rule context. - * @param element The `JSXElement` node to inspect. - * @param name The attribute name to look up (ex: "className"). - * @returns The static value of the attribute, `null` when absent, or `undefined` when indeterminate. - */ -export function getAttributeStaticValue(context: RuleContext, element: TSESTree.JSXElement, name: string): unknown { - const attr = findAttribute(context, element, name); - if (attr == null) return null; - const resolved = resolveAttributeValue(context, attr); - if (resolved.kind === "spreadProps") { - return resolved.getProperty(name); - } - if (resolved.kind === "missing") { - return undefined; - } - return resolved.toStatic(); -} diff --git a/packages/jsx/src/get-attribute-value.ts b/packages/jsx/src/get-attribute-value.ts deleted file mode 100644 index 8613a043c1..0000000000 --- a/packages/jsx/src/get-attribute-value.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { RuleContext } from "@eslint-react/eslint"; -import type { TSESTree } from "@typescript-eslint/types"; -import { findAttribute } from "./find-attribute"; -import { resolveAttributeValue } from "./resolve-attribute-value"; - -/** - * Find an attribute by name on a JSX element and resolve its value in a single call. - * - * This is a convenience composition of {@link findAttribute} and - * {@link resolveAttributeValue} that eliminates the most common two-step - * pattern in lint rules. - * @param context The ESLint rule context. - * @param element The `JSXElement` node to search. - * @param name The attribute name to look up (ex: "className"). - * @returns A {@link JsxAttributeValue} descriptor, or `null` when the attribute is not present on the element. - */ -export function getAttributeValue(context: RuleContext, element: TSESTree.JSXElement, name: string) { - const attr = findAttribute(context, element, name); - if (attr == null) return null; - return resolveAttributeValue(context, attr); -} diff --git a/packages/jsx/src/get-children.ts b/packages/jsx/src/get-children.ts deleted file mode 100644 index 180002678a..0000000000 --- a/packages/jsx/src/get-children.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { TSESTreeJSXElementLike } from "@eslint-react/ast"; -import type { TSESTree } from "@typescript-eslint/types"; -import { AST_NODE_TYPES as AST } from "@typescript-eslint/types"; -import { collapseMultilineText } from "./collapse-multiline-text"; -import { isEmptyStringExpression } from "./is-whitespace"; - -/** - * Get the meaningful children of a JSX element or fragment. - * - * Mirrors Babel's `buildChildren` helper: - * 1. Iterate over `element.children`. - * 2. Skip `JSXText` nodes that clean to nothing (padding whitespace). - * 3. Skip `JSXExpressionContainer` nodes whose expression is empty. - * 4. Skip `JSXEmptyExpression` nodes. - * 5. Collect everything else. - * @param element A `JSXElement` or `JSXFragment` node. - * @returns An array of children nodes that contribute to rendered output. - */ -export function getChildren(element: TSESTreeJSXElementLike): TSESTree.JSXChild[] { - const elements: TSESTree.JSXChild[] = []; - - for (const child of element.children) { - if (child.type === AST.JSXText) { - // Padding whitespace (whitespace containing a newline) that React trims away. - if (collapseMultilineText(child.value) == null && child.value.includes("\n")) continue; - elements.push(child); - continue; - } - - if (child.type === AST.JSXExpressionContainer) { - const { expression } = child; - if (expression.type === AST.JSXEmptyExpression) continue; - // { "" } produces no DOM node. - if (isEmptyStringExpression(child)) continue; - elements.push(child); - continue; - } - - elements.push(child); - } - - return elements; -} diff --git a/packages/jsx/src/has-any-attribute.ts b/packages/jsx/src/has-any-attribute.ts deleted file mode 100644 index c315ee62eb..0000000000 --- a/packages/jsx/src/has-any-attribute.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RuleContext } from "@eslint-react/eslint"; -import type { TSESTree } from "@typescript-eslint/types"; -import { findAttribute } from "./find-attribute"; - -/** - * Check whether a JSX element carries at least one of the given attributes. - * - * This is a batch variant of {@link hasAttribute} for the common pattern of - * short-circuiting on multiple prop names. - * - * Spread attributes are taken into account (see {@link findAttribute}). - * @param context The ESLint rule context (needed for variable resolution in spread attributes). - * @param element The `JSXElement` node to inspect. - * @param names The attribute names to look for. - * @returns `true` when at least one of the attributes is present. - */ -export function hasAnyAttribute(context: RuleContext, element: TSESTree.JSXElement, names: string[]): boolean { - return names.some((name) => findAttribute(context, element, name) != null); -} diff --git a/packages/jsx/src/has-attribute.ts b/packages/jsx/src/has-attribute.ts deleted file mode 100644 index e5da6a48bd..0000000000 --- a/packages/jsx/src/has-attribute.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RuleContext } from "@eslint-react/eslint"; -import type { TSESTree } from "@typescript-eslint/types"; -import { findAttribute } from "./find-attribute"; - -/** - * Check whether a JSX element carries a given attribute (prop). - * - * This is a thin convenience wrapper around {@link findAttribute} for the - * common case where you only need a boolean answer. - * - * Spread attributes are taken into account: `` - * will report `true` for `"disabled"`. - * @param context The ESLint rule context (needed for variable resolution in spread attributes). - * @param element The `JSXElement` node to inspect. - * @param name The attribute name to look for (ex: "className"). - * @returns `true` when the attribute is present on the element. - */ -export function hasAttribute(context: RuleContext, element: TSESTree.JSXElement, name: string) { - return findAttribute(context, element, name) != null; -} diff --git a/packages/jsx/src/has-every-attribute.ts b/packages/jsx/src/has-every-attribute.ts deleted file mode 100644 index dfb5bd0c58..0000000000 --- a/packages/jsx/src/has-every-attribute.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RuleContext } from "@eslint-react/eslint"; -import type { TSESTree } from "@typescript-eslint/types"; -import { findAttribute } from "./find-attribute"; - -/** - * Check whether a JSX element carries all of the given attributes (props). - * - * This is a batch variant of {@link hasAttribute} for the common pattern - * where a rule needs to verify that a set of required props are all present. - * - * Spread attributes are taken into account (see {@link findAttribute}). - * @param context The ESLint rule context (needed for variable resolution in spread attributes). - * @param element The `JSXElement` node to inspect. - * @param names The attribute names to look for. - * @returns `true` when every name in `names` is present on the element. - */ -export function hasEveryAttribute(context: RuleContext, element: TSESTree.JSXElement, names: string[]) { - return names.every((name) => findAttribute(context, element, name) != null); -} diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index 3369b90fd2..a52fe8f7b7 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -1,18 +1,11 @@ -export * from "./collapse-multiline-text"; -export * from "./find-attribute"; -export * from "./find-parent-attribute"; -export * from "./get-attribute-name"; -export * from "./get-attribute-static-value"; -export * from "./get-attribute-value"; -export * from "./get-children"; -export * from "./get-element-type"; -export * from "./has-any-attribute"; -export * from "./has-attribute"; -export * from "./has-children"; -export * from "./has-every-attribute"; -export * from "./is-attribute"; -export * from "./is-element"; -export * from "./is-fragment-element"; -export * from "./is-host-element"; -export * from "./is-whitespace"; -export * from "./resolve-attribute-value"; +export { findAttribute, findParentAttribute, findSpreadProperty } from "./attribute-find"; +export { hasAnyAttribute, hasAttribute, hasEveryAttribute } from "./attribute-has"; +export { getAttributeName, isAttribute } from "./attribute-name"; +export { getAttributeStaticValue, getAttributeValue, resolveAttributeValue } from "./attribute-value"; + +export { getChildren, hasChildren } from "./children"; + +export { type ElementTest, isElement, isFragmentElement, isHostElement } from "./element-is"; +export { getElementFullType, getElementSelfType } from "./element-type"; + +export { collapseMultilineText, isEmptyStringExpression, isPaddingWhitespace, isWhitespaceText } from "./text"; diff --git a/packages/jsx/src/is-element.ts b/packages/jsx/src/is-element.ts deleted file mode 100644 index 83f160c903..0000000000 --- a/packages/jsx/src/is-element.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { TSESTreeJSXElementLike } from "@eslint-react/ast"; -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; -import { getElementFullType } from "./get-element-type"; - -/** - * A test that determines whether a JSX element matches. - * - * - `string` matches against the full element type (ex: "div", "React.Fragment") - * - `string[]` matches when the element type equals any of the given strings - * - `function` receives the element type string and returns a boolean. - */ -export type ElementTest = - | string - | readonly string[] - | ((elementType: string, node: TSESTreeJSXElementLike) => boolean); - -/** - * Check whether a node is a `JSXElement` (or `JSXFragment`) and optionally - * matches a given test. - * - * Modelled after - * [`hast-util-is-element`](https://github.com/syntax-tree/hast-util-is-element): - * the `test` parameter controls what counts as a match. - * - * When called without a test, the function acts as a simple type-guard - * for `JSXElement | JSXFragment`. - * @param node The AST node to test. - * @param test Optional test to match the element type against. - * @returns `true` when the node is a matching JSX element. - */ -export function isElement(node: TSESTree.Node | null | undefined, test?: ElementTest): node is TSESTreeJSXElementLike { - if (node == null) return false; - if (node.type !== AST.JSXElement && node.type !== AST.JSXFragment) { - return false; - } - // No test, confirm that it is a JSX element / fragment. - if (test == null) return true; - const elementType = getElementFullType(node); - switch (typeof test) { - case "string": - return elementType === test; - case "function": - return test(elementType, node); - default: - return test.includes(elementType); - } -} diff --git a/packages/jsx/src/is-fragment-element.ts b/packages/jsx/src/is-fragment-element.ts deleted file mode 100644 index 095eea5e87..0000000000 --- a/packages/jsx/src/is-fragment-element.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { TSESTreeJSXElementLike } from "@eslint-react/ast"; -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; -import { getElementFullType } from "./get-element-type"; - -/** - * Check whether a node is a React Fragment element. - * - * Recognizes both the shorthand `<>...` syntax (`JSXFragment`) and the - * explicit `` / `` form (`JSXElement`). - * - * The comparison is performed against the self name (last dot-separated - * segment) of both the node and the configured factory, so `` - * matches `"React.Fragment"` and `` matches `"Fragment"`. - * @param node The AST node to test. - * @param jsxFragmentFactory The configured fragment factory string (ex: "React.Fragment"). - * @returns `true` when the node represents a React Fragment. - */ -export function isFragmentElement( - node: TSESTree.Node, - jsxFragmentFactory = "React.Fragment", -): node is TSESTreeJSXElementLike { - if (node.type === AST.JSXFragment) return true; - if (node.type !== AST.JSXElement) return false; - - const fragment = jsxFragmentFactory.split(".").at(-1) ?? "Fragment"; - return getElementFullType(node).split(".").at(-1) === fragment; -} diff --git a/packages/jsx/src/is-host-element.ts b/packages/jsx/src/is-host-element.ts deleted file mode 100644 index 700794ed5a..0000000000 --- a/packages/jsx/src/is-host-element.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; - -/** - * Check whether a node is a host (intrinsic / DOM) element. - * - * A host element is a `JSXElement` whose tag name is a plain `JSXIdentifier` - * starting with a lowercase letter, the same heuristic React uses to - * distinguish `
` from ``. - * @param node The AST node to test. - * @returns `true` when the node is a `JSXElement` with a lowercase tag name. - */ -export function isHostElement(node: TSESTree.Node): node is TSESTree.JSXElement { - if (node.type !== AST.JSXElement) return false; - const name = node.openingElement.name; - if (name.type === AST.JSXIdentifier) { - return /^[a-z]/u.test(name.name); - } - if (name.type === AST.JSXNamespacedName) { - return /^[a-z]/u.test(name.name.name); - } - return false; -} diff --git a/packages/jsx/src/is-whitespace.ts b/packages/jsx/src/is-whitespace.ts deleted file mode 100644 index 95fa70765e..0000000000 --- a/packages/jsx/src/is-whitespace.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; -import { collapseMultilineText } from "./collapse-multiline-text"; - -/** - * Check whether a JSX child node is whitespace padding that React would - * trim away during rendering. - * - * A child is considered whitespace padding when it is a `JSXText` node whose - * content is empty after applying React's whitespace normalization - * (see {@link collapseMultilineText}, modelled after Babel's - * `cleanJSXElementLiteralChild`). This is the whitespace that appears between - * JSX tags purely for formatting. - * @param node A JSX child node. - * @returns `true` when the node is purely formatting whitespace. - */ -export function isWhitespace(node: TSESTree.JSXChild): boolean { - if (node.type !== AST.JSXText) return false; - return collapseMultilineText(node.value) == null && node.value.includes("\n"); -} - -/** - * Check whether a JSX child node is any whitespace-only text. - * - * This is a looser variant of {@link isWhitespace}; it matches every - * `JSXText` node whose raw content is empty after trimming, regardless of - * whether it contains a newline. - * @param node A JSX child node. - * @returns `true` when the node is a whitespace-only `JSXText`. - */ -export function isWhitespaceText(node: TSESTree.JSXChild): boolean { - if (node.type !== AST.JSXText) return false; - return node.raw.trim() === ""; -} - -/** - * Check whether a JSX child node is an empty string expression (`{""}`). - * - * React's reconciler and SSR renderer explicitly skip empty strings, - * producing no DOM node (see `ReactChildFiber.js` and `ReactFizzConfigDOM.js`). - * Such expressions are therefore treated as non-rendered children, in the same - * way as whitespace padding. - * @param node A JSX child node. - * @returns `true` when the node is a `{""}` expression container. - */ -export function isEmptyStringExpression(node: TSESTree.JSXChild): boolean { - if (node.type !== AST.JSXExpressionContainer) return false; - const expr = node.expression; - if (expr.type !== AST.Literal) return false; - return expr.value === ""; -} diff --git a/packages/jsx/src/resolve-attribute-value.ts b/packages/jsx/src/resolve-attribute-value.ts deleted file mode 100644 index 7ab34a1eae..0000000000 --- a/packages/jsx/src/resolve-attribute-value.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { TSESTreeJSXAttributeLike } from "@eslint-react/ast"; -import type { RuleContext } from "@eslint-react/eslint"; -import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; -import { getStaticValue } from "@typescript-eslint/utils/ast-utils"; -import { P, match } from "ts-pattern"; - -/** - * Discriminated union representing the resolved value of a JSX attribute. - * - * Each variant carries the original AST `node` (where applicable) and a - * `toStatic()` helper that attempts to collapse the value into a plain - * JavaScript value at analysis time. - */ -type AttributeValue = - | { readonly kind: "boolean"; readonly node: null; toStatic(): true } - | { readonly kind: "element"; readonly node: TSESTree.JSXElement; toStatic(): null } - | { readonly kind: "literal"; readonly node: TSESTree.Literal; toStatic(): TSESTree.Literal["value"] } - | { readonly kind: "unknown"; readonly node: TSESTree.JSXExpressionContainer["expression"]; toStatic(): unknown } - | { readonly kind: "missing"; readonly node: TSESTree.JSXEmptyExpression; toStatic(): null } - | { readonly kind: "spreadChild"; getChildren(): unknown; readonly node: TSESTree.JSXSpreadChild["expression"]; toStatic(): null } - | { readonly kind: "spreadProps"; getProperty(name: string): unknown; readonly node: TSESTree.JSXSpreadAttribute["argument"]; toStatic(): null }; - -/** - * Resolve the value of a JSX attribute (or spread attribute) into a - * {@link AttributeValue} descriptor that can be inspected further. - * - * This is the low-level building block; it operates on a single attribute - * node that the caller has already located. For the higher-level "find by - * name and resolve" combo, see {@link getAttributeValue}. - * @param context The ESLint rule context (needed for scope look-ups). - * @param attribute A `JSXAttribute` or `JSXSpreadAttribute` node. - * @returns A discriminated-union descriptor of the attribute's value. - */ -export function resolveAttributeValue(context: RuleContext, attribute: TSESTreeJSXAttributeLike): AttributeValue { - if (attribute.type === AST.JSXAttribute) { - return resolveJsxAttribute(context, attribute); - } - return resolveJsxSpreadAttribute(context, attribute); -} - -// #region Internal Resolvers - -function resolveJsxAttribute(context: RuleContext, node: TSESTree.JSXAttribute): AttributeValue { - const scope = context.sourceCode.getScope(node); - - // Boolean attribute, no value means `true` (ex: ``). - if (node.value == null) { - return { - kind: "boolean", - node: null, - toStatic() { - return true; - }, - } as const satisfies AttributeValue; - } - - switch (node.value.type) { - case AST.Literal: { - const staticValue = node.value.value; - return { - kind: "literal", - node: node.value, - toStatic() { - return staticValue; - }, - } as const satisfies AttributeValue; - } - - case AST.JSXExpressionContainer: { - const expr = node.value.expression; - - if (expr.type === AST.JSXEmptyExpression) { - return { - kind: "missing", - node: expr, - toStatic() { - return null; - }, - } as const satisfies AttributeValue; - } - - return { - kind: "unknown", - node: expr, - toStatic() { - return getStaticValue(expr, scope)?.value; - }, - } as const satisfies AttributeValue; - } - - case AST.JSXElement: { - return { - kind: "element", - node: node.value, - toStatic() { - return null; - }, - } as const satisfies AttributeValue; - } - - case AST.JSXSpreadChild: { - return { - kind: "spreadChild", - getChildren() { - return null; - }, - node: node.value.expression, - toStatic() { - return null; - }, - } as const satisfies AttributeValue; - } - } -} - -function resolveJsxSpreadAttribute(context: RuleContext, node: TSESTree.JSXSpreadAttribute): AttributeValue { - const scope = context.sourceCode.getScope(node); - - return { - kind: "spreadProps", - getProperty(name: string) { - return match(getStaticValue(node.argument, scope)?.value) - .with({ [name]: P.select(P.unknown) }, (v) => v) - .otherwise(() => null); - }, - node: node.argument, - toStatic() { - return null; - }, - } as const satisfies AttributeValue; -} - -// #endregion diff --git a/packages/jsx/src/text.test.ts b/packages/jsx/src/text.test.ts new file mode 100644 index 0000000000..de01e285ab --- /dev/null +++ b/packages/jsx/src/text.test.ts @@ -0,0 +1,121 @@ +/// + +import type { TSESTreeJSXElementLike } from "@eslint-react/ast"; +import * as tsParser from "@typescript-eslint/parser"; +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; +import { Linter } from "eslint"; +import { describe, expect, it } from "vitest"; + +import { collapseMultilineText, isEmptyStringExpression, isPaddingWhitespace, isWhitespaceText } from "./text"; + +function parseJsx(code: string): TSESTreeJSXElementLike { + const found: { node: TSESTreeJSXElementLike | null } = { node: null }; + new Linter().verify(code, { + plugins: { + test: { + rules: { + "test-rule": { + meta: { type: "problem", messages: {}, schema: [] }, + create: () => ({ + JSXElement(node: TSESTree.JSXElement) { + found.node ??= node; + }, + JSXFragment(node: TSESTree.JSXFragment) { + found.node ??= node; + }, + }), + }, + }, + }, + }, + rules: { "test/test-rule": "error" }, + languageOptions: { + parser: tsParser, + parserOptions: { jsx: true, ecmaFeatures: { jsx: true } }, + }, + }); + if (found.node == null) throw new Error("expected JSX in the code"); + return found.node; +} + +describe("collapseMultilineText", () => { + it("keeps single-line text untouched", () => { + expect(collapseMultilineText("foo")).toBe("foo"); + expect(collapseMultilineText(" foo ")).toBe(" foo "); + }); + + it("collapses multiline text following JSX whitespace rules", () => { + expect(collapseMultilineText("foo\n bar")).toBe("foo bar"); + expect(collapseMultilineText("\n foo\n bar\n")).toBe("foo bar"); + }); + + it("collapses tabs into spaces", () => { + expect(collapseMultilineText("\tfoo")).toBe(" foo"); + }); + + it("returns null for whitespace-only text", () => { + expect(collapseMultilineText("\n \n ")).toBeNull(); + expect(collapseMultilineText("")).toBeNull(); + }); +}); + +describe("isPaddingWhitespace", () => { + it("matches whitespace text containing a newline", () => { + const child = parseJsx("
\n \n
;").children[0]; + expect(child?.type).toBe(AST.JSXText); + if (child == null) return; + expect(isPaddingWhitespace(child)).toBe(true); + }); + + it("does not match same-line whitespace", () => { + const child = parseJsx("
;").children[0]; + if (child == null) throw new Error("expected child"); + expect(isPaddingWhitespace(child)).toBe(false); + }); + + it("does not match non-text nodes", () => { + const child = parseJsx("
;").children[0]; + if (child == null) throw new Error("expected child"); + expect(isPaddingWhitespace(child)).toBe(false); + }); +}); + +describe("isWhitespaceText", () => { + it("matches any whitespace-only text", () => { + const child = parseJsx("
;").children[0]; + if (child == null) throw new Error("expected child"); + expect(isWhitespaceText(child)).toBe(true); + }); + + it("matches newline-containing whitespace", () => { + const child = parseJsx("
\n \n
;").children[0]; + if (child == null) throw new Error("expected child"); + expect(isWhitespaceText(child)).toBe(true); + }); + + it("does not match meaningful text", () => { + const child = parseJsx("
x
;").children[0]; + if (child == null) throw new Error("expected child"); + expect(isWhitespaceText(child)).toBe(false); + }); +}); + +describe("isEmptyStringExpression", () => { + it("matches an empty string expression", () => { + const child = parseJsx('
{""}
;').children[0]; + if (child == null) throw new Error("expected child"); + expect(isEmptyStringExpression(child)).toBe(true); + }); + + it("does not match a non-empty string expression", () => { + const child = parseJsx('
{"x"}
;').children[0]; + if (child == null) throw new Error("expected child"); + expect(isEmptyStringExpression(child)).toBe(false); + }); + + it("does not match text nodes", () => { + const child = parseJsx("
x
;").children[0]; + if (child == null) throw new Error("expected child"); + expect(isEmptyStringExpression(child)).toBe(false); + }); +}); diff --git a/packages/jsx/src/text.ts b/packages/jsx/src/text.ts new file mode 100644 index 0000000000..4d0ea6d9b3 --- /dev/null +++ b/packages/jsx/src/text.ts @@ -0,0 +1,104 @@ +import { AST_NODE_TYPES as AST, type TSESTree } from "@typescript-eslint/types"; + +/** + * Collapse a multiline JSX text string following React's whitespace rules. + * + * This mirrors Babel's `cleanJSXElementLiteralChild` algorithm: + * 1. Split the raw text into lines. + * 2. Find the last non-empty line. + * 3. Trim leading spaces on non-first lines and trailing spaces on non-last lines. + * 4. Collapse tabs into spaces. + * 5. Append a single space after each non-last non-empty line. + * @param text The raw JSX text string to collapse. + * @returns The collapsed string, or `null` if the text contains only whitespace. + * @see https://github.com/babel/babel/blob/main/packages/babel-types/src/utils/react/cleanJSXElementLiteralChild.ts + */ +export function collapseMultilineText(text: string): string | null { + const lines = text.split(/\r\n|\n|\r/); + + let lastNonEmptyLine = 0; + for (let i = 0; i < lines.length; i++) { + if (/[^ \t]/.exec(lines[i] ?? "") != null) { + lastNonEmptyLine = i; + } + } + + let str = ""; + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + + const isFirstLine = i === 0; + const isLastLine = i === lines.length - 1; + const isLastNonEmptyLine = i === lastNonEmptyLine; + + // Replace rendered whitespace tabs with spaces + let trimmedLine = line.replace(/\t/g, " "); + + // Trim whitespace touching a newline + if (!isFirstLine) { + trimmedLine = trimmedLine.replace(/^ +/, ""); + } + if (!isLastLine) { + trimmedLine = trimmedLine.replace(/ +$/, ""); + } + + if (trimmedLine.length > 0) { + if (!isLastNonEmptyLine) { + trimmedLine += " "; + } + str += trimmedLine; + } + } + + return str === "" ? null : str; +} + +/** + * Check whether a JSX child node is whitespace padding that React would + * trim away during rendering. + * + * A child is considered whitespace padding when it is a `JSXText` node whose + * content is empty after applying React's whitespace normalization + * (see {@link collapseMultilineText}, modelled after Babel's + * `cleanJSXElementLiteralChild`) **and** it contains a newline. This is the + * whitespace that appears between JSX tags purely for formatting. + * + * For the looser "any whitespace-only text" check, see {@link isWhitespaceText}. + * @param node A JSX child node. + * @returns `true` when the node is purely formatting whitespace. + */ +export function isPaddingWhitespace(node: TSESTree.JSXChild): boolean { + if (node.type !== AST.JSXText) return false; + return collapseMultilineText(node.value) == null && node.value.includes("\n"); +} + +/** + * Check whether a JSX child node is any whitespace-only text. + * + * This is a looser variant of {@link isPaddingWhitespace}; it matches every + * `JSXText` node whose raw content is empty after trimming, regardless of + * whether it contains a newline. + * @param node A JSX child node. + * @returns `true` when the node is a whitespace-only `JSXText`. + */ +export function isWhitespaceText(node: TSESTree.JSXChild): boolean { + if (node.type !== AST.JSXText) return false; + return node.raw.trim() === ""; +} + +/** + * Check whether a JSX child node is an empty string expression (`{""}`). + * + * React's reconciler and SSR renderer explicitly skip empty strings, + * producing no DOM node (see `ReactChildFiber.js` and `ReactFizzConfigDOM.js`). + * Such expressions are therefore treated as non-rendered children, in the same + * way as whitespace padding. + * @param node A JSX child node. + * @returns `true` when the node is a `{""}` expression container. + */ +export function isEmptyStringExpression(node: TSESTree.JSXChild): boolean { + if (node.type !== AST.JSXExpressionContainer) return false; + const expr = node.expression; + if (expr.type !== AST.Literal) return false; + return expr.value === ""; +} diff --git a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/CHANGELOG.md b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/CHANGELOG.md index 412fbc3ee0..b15912f634 100644 --- a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/CHANGELOG.md +++ b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `react-dom/no-dangerously-set-innerhtml-with-children The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Spread props providing `children` or `dangerouslySetInnerHTML` through a string literal key, a statically evaluable computed key, or an identifier alias chain are now recognized. + ## [5.10.2] - 2026-07-03 ### Fixed diff --git a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/no-dangerously-set-innerhtml-with-children.spec.ts b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/no-dangerously-set-innerhtml-with-children.spec.ts index 3c2375e384..8ea614005c 100644 --- a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/no-dangerously-set-innerhtml-with-children.spec.ts +++ b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/no-dangerously-set-innerhtml-with-children.spec.ts @@ -27,6 +27,20 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ messageId: "default" }], }, + // Alias chains in spread props are followed + { + code: tsx` + const inner = { children: "Children", dangerouslySetInnerHTML: { __html: "HTML" } } + const props = inner + ;
+ `, + errors: [{ messageId: "default" }], + }, + // Statically evaluable computed keys in spread props are recognized + { + code: tsx`
`, + errors: [{ messageId: "default" }], + }, { code: tsx`Children`, errors: [{ messageId: "default" }], diff --git a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/no-dangerously-set-innerhtml-with-children.ts b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/no-dangerously-set-innerhtml-with-children.ts index a342b3d0be..6b57ffbef2 100644 --- a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/no-dangerously-set-innerhtml-with-children.ts +++ b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml-with-children/no-dangerously-set-innerhtml-with-children.ts @@ -1,6 +1,6 @@ import { createRule } from "@/utils/create-rule"; import { type RuleContext, type RuleFeature, type RuleListener } from "@eslint-react/eslint"; -import { findAttribute, hasAttribute, isWhitespace } from "@eslint-react/jsx"; +import { findAttribute, hasAttribute, isPaddingWhitespace } from "@eslint-react/jsx"; export const RULE_NAME = "no-dangerously-set-innerhtml-with-children"; @@ -34,7 +34,7 @@ export function create(context: RuleContext): RuleListener { if (!hasAttribute(context, node, "dangerouslySetInnerHTML")) return; // Check for a 'children' prop or actual child nodes that are not just whitespace const childrenPropOrNode = findAttribute(context, node, "children") - ?? node.children.find((child) => !isWhitespace(child)); + ?? node.children.find((child) => !isPaddingWhitespace(child)); // If no children are found, the rule passes if (childrenPropOrNode == null) return; // If both 'dangerouslySetInnerHTML' and children are present, report an error diff --git a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml/CHANGELOG.md b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml/CHANGELOG.md index d07e95c63f..f4fb0b5d87 100644 --- a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml/CHANGELOG.md +++ b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `react-dom/no-dangerously-set-innerhtml` rule will be The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Spread props providing `dangerouslySetInnerHTML` through a string literal key (e.g. `{...{ "dangerouslySetInnerHTML": … }}`), a statically evaluable computed key (e.g. `{...{ ["dangerouslySetInnerHTML"]: … }}`), or an identifier alias chain (e.g. `const b = a;
`) are now reported. This refines the exemption introduced in 5.14.9, which also covered keys that can be statically determined. + ## [5.14.9] - 2026-07-15 ### Changed diff --git a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml/no-dangerously-set-innerhtml.spec.ts b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml/no-dangerously-set-innerhtml.spec.ts index e9edd59459..cf05d81633 100644 --- a/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml/no-dangerously-set-innerhtml.spec.ts +++ b/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml/no-dangerously-set-innerhtml.spec.ts @@ -59,6 +59,25 @@ ruleTester.run(RULE_NAME, rule, { code: tsx`} />`, errors: [{ messageId: "default" }], }, + // Computed string literal keys in spread props are statically resolved + { + code: tsx`
`, + errors: [{ messageId: "default" }], + }, + // Alias chains in spread props are followed + { + code: tsx` + const inner = { dangerouslySetInnerHTML: { __html: "HTML" } }; + const props = inner; + const div =
; + `, + errors: [{ messageId: "default" }], + }, + // String literal keys in spread props are matched + { + code: tsx`
`, + errors: [{ messageId: "default" }], + }, ], valid: [ "
", @@ -70,7 +89,5 @@ ruleTester.run(RULE_NAME, rule, { declare const dangerouslySetInnerHTML: string; const div =
; `, - // Computed string literal keys in spread props are not statically resolved - tsx`
`, ], }); diff --git a/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox/CHANGELOG.md b/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox/CHANGELOG.md index 31d0365dd7..69c8bba229 100644 --- a/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox/CHANGELOG.md +++ b/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `react-dom/no-missing-iframe-sandbox` rule will be do The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Spread `sandbox` props provided through a string literal key, a statically evaluable computed key, or an identifier alias chain now count as providing `sandbox`, matching the behavior documented in 5.10.2. The static value is also resolved per property, so unrelated non-static properties in the same spread object no longer suppress it. + ## [5.10.2] - 2026-07-03 ### Fixed diff --git a/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox/no-missing-iframe-sandbox.spec.ts b/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox/no-missing-iframe-sandbox.spec.ts index cf76052055..78f6846143 100644 --- a/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox/no-missing-iframe-sandbox.spec.ts +++ b/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox/no-missing-iframe-sandbox.spec.ts @@ -185,6 +185,21 @@ ruleTester.run(RULE_NAME, rule, { } `, }, + // Alias chains in spread props are followed + { + code: tsx` + const inner = { sandbox: "allow-downloads" }; + const props = inner; + + function App() { + return