{
- // Get the initial scope from the node's context
- const initialScope = context.sourceCode.getScope(node);
- switch (node.type) {
- case T.JSXAttribute:
- // Case 1: Literal value (e.g., className="container")
- if (node.value?.type === T.Literal) {
+export type AttributeValue =
+ | { kind: "boolean"; toStatic(): true } // Boolean attributes (e.g., disabled)
+ | { kind: "element"; node: TSESTree.JSXElement; toStatic(): unknown } // JSX element as value (e.g., />)
+ | { kind: "literal"; node: TSESTree.Literal; toStatic(): TSESTree.Literal["value"] } // Literal values
+ | { kind: "expression"; node: TSESTree.JSXExpressionContainer["expression"]; toStatic(): unknown } // Expression attributes (e.g., {value}, {...props})
+ | { kind: "spreadProps"; node: TSESTree.JSXSpreadAttribute["argument"]; toStatic(name?: string): unknown } // Spread props (e.g., {...props})
+ | { kind: "spreadChild"; node: TSESTree.JSXSpreadChild["expression"]; toStatic(): unknown }; // Spread children (e.g., {...["Hello", " ", "spread", " ", "children"]})
+
+export function resolveAttributeValue(context: RuleContext, attribute: AST.TSESTreeJSXAttributeLike) {
+ const initialScope = context.sourceCode.getScope(attribute);
+ function handleJsxAttribute(node: TSESTree.JSXAttribute) {
+ // Case 1: Boolean attribute with no value (e.g., disabled)
+ if (node.value == null) {
+ return {
+ kind: "boolean",
+ toStatic() {
+ return true;
+ },
+ } as const satisfies AttributeValue;
+ }
+ switch (node.value.type) {
+ // Case 2: Literal value (e.g., className="container")
+ case T.Literal: {
+ const staticValue = node.value.value;
return {
- kind: "some",
+ kind: "literal",
node: node.value,
- initialScope,
- value: node.value.value,
- } as const;
+ toStatic() {
+ return staticValue;
+ },
+ } as const satisfies AttributeValue;
}
- // Case 2: Expression container (e.g., className={variable})
- if (node.value?.type === T.JSXExpressionContainer) {
- return VAR.toStaticValue({
- kind: "lazy",
- node: node.value.expression,
- initialScope,
- });
- }
- // Case 3: Boolean attribute with no value (e.g., disabled)
- return { kind: "none", node, initialScope } as const;
- case T.JSXSpreadAttribute: {
- // For spread attributes (e.g., {...props}), try to extract static value
- const staticValue = VAR.toStaticValue({
- kind: "lazy",
- node: node.argument,
- initialScope,
- });
- // If can't extract static value, return none
- if (staticValue.kind === "none") {
- return staticValue;
+ // Case 3: Expression container (e.g., className={variable})
+ case T.JSXExpressionContainer: {
+ const expr = node.value.expression;
+ return {
+ kind: "expression",
+ node: expr,
+ toStatic() {
+ return getStaticValue(expr, initialScope)?.value;
+ },
+ } as const satisfies AttributeValue;
}
- // If spread object contains the named property, extract its value
- return match(staticValue.value)
- .with({ [name]: P.select(P.any) }, (value) => ({
- kind: "some",
- node: node.argument,
- initialScope,
- value,
- } as const))
- .otherwise(() => ({ kind: "none", node, initialScope } as const));
+ // Case 4: JSX Element as value (e.g., element=)
+ case T.JSXElement:
+ return {
+ kind: "element",
+ node: node.value,
+ toStatic() {
+ return unit;
+ },
+ } as const satisfies AttributeValue;
+ // Case 5: JSX spread children (e.g., {...["Hello", " ", "spread", " ", "children"]}
)
+ case T.JSXSpreadChild:
+ return {
+ kind: "spreadChild",
+ node: node.value.expression,
+ toStatic() {
+ return unit;
+ },
+ } as const satisfies AttributeValue;
}
- default:
- // Fallback case for unknown node types
- return { kind: "none", node, initialScope } as const;
+ }
+
+ function handleJsxSpreadAttribute(node: TSESTree.JSXSpreadAttribute) {
+ // For spread attributes (e.g., {...props}), try to extract static value
+ return {
+ kind: "spreadProps",
+ node: node.argument,
+ toStatic(name?: string) {
+ if (name == null) return unit;
+ // If spread object contains the named property, extract its value
+ return match(getStaticValue(node.argument, initialScope)?.value)
+ .with({ [name]: P.select(P.any) }, identity)
+ .otherwise(() => unit);
+ },
+ } as const satisfies AttributeValue;
+ }
+ switch (attribute.type) {
+ case T.JSXAttribute:
+ return handleJsxAttribute(attribute);
+ case T.JSXSpreadAttribute:
+ return handleJsxSpreadAttribute(attribute);
}
}
diff --git a/packages/core/src/jsx/jsx-attribute.ts b/packages/core/src/jsx/jsx-attribute.ts
index 4daac7fb73..1c74673d37 100644
--- a/packages/core/src/jsx/jsx-attribute.ts
+++ b/packages/core/src/jsx/jsx-attribute.ts
@@ -1,51 +1,35 @@
-import type { unit } from "@eslint-react/eff";
+import type * as AST from "@eslint-react/ast";
import type { RuleContext } from "@eslint-react/kit";
import * as VAR from "@eslint-react/var";
import type { Scope } from "@typescript-eslint/scope-manager";
-import type { TSESTree } from "@typescript-eslint/utils";
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
import { getAttributeName } from "./jsx-attribute-name";
-/**
- * Searches for a specific JSX attribute by name in a list of attributes
- * Returns the last matching attribute (rightmost in JSX)
- *
- * @param context - ESLint rule context
- * @param name - The name of the attribute to find
- * @param attributes - Array of JSX attributes to search through
- * @param initialScope - Optional scope for resolving variables
- * @returns The found attribute or undefined
- */
-export function getAttribute(
- context: RuleContext,
- name: string,
- attributes: (TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute)[],
- initialScope?: Scope,
-): TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute | unit {
- return attributes.findLast((attr) => {
- // Case 1: Direct JSX attribute (e.g., className="value")
- if (attr.type === T.JSXAttribute) {
- return getAttributeName(context, attr) === name;
- }
-
- // For spread attributes, we need a scope to resolve variables
- if (initialScope == null) return false;
-
- switch (attr.argument.type) {
- // Case 2: Spread from variable (e.g., {...props})
- case T.Identifier: {
- const variable = VAR.findVariable(attr.argument.name, initialScope);
- const variableNode = VAR.getVariableDefinitionNode(variable, 0);
- if (variableNode?.type === T.ObjectExpression) {
- return VAR.findProperty(name, variableNode.properties, initialScope) != null;
+export function getAttribute(context: RuleContext, attributes: AST.TSESTreeJSXAttributeLike[], initialScope?: Scope) {
+ return (name: string) => {
+ return attributes.findLast((attr) => {
+ // Case 1: Direct JSX attribute (e.g., className="value")
+ if (attr.type === T.JSXAttribute) {
+ return getAttributeName(context, attr) === name;
+ }
+ // For spread attributes, we need a scope to resolve variables
+ if (initialScope == null) return false;
+ switch (attr.argument.type) {
+ // Case 2: Spread from variable (e.g., {...props})
+ case T.Identifier: {
+ const variable = VAR.findVariable(attr.argument.name, initialScope);
+ const variableNode = VAR.getVariableDefinitionNode(variable, 0);
+ if (variableNode?.type === T.ObjectExpression) {
+ return VAR.findProperty(name, variableNode.properties, initialScope) != null;
+ }
+ return false;
}
- return false;
+ // Case 3: Spread from object literal (e.g., {{...{prop: value}}})
+ case T.ObjectExpression:
+ return VAR.findProperty(name, attr.argument.properties, initialScope) != null;
}
- // Case 3: Spread from object literal (e.g., {{...{prop: value}}})
- case T.ObjectExpression:
- return VAR.findProperty(name, attr.argument.properties, initialScope) != null;
- }
- return false;
- });
+ return false;
+ });
+ };
}
diff --git a/packages/core/src/jsx/jsx-has.ts b/packages/core/src/jsx/jsx-has.ts
index 087572c06a..d26cd8940a 100644
--- a/packages/core/src/jsx/jsx-has.ts
+++ b/packages/core/src/jsx/jsx-has.ts
@@ -18,7 +18,7 @@ export function hasAttribute(
attributes: TSESTree.JSXOpeningElement["attributes"],
initialScope?: Scope,
) {
- return getAttribute(context, name, attributes, initialScope) != null;
+ return getAttribute(context, attributes, initialScope)(name) != null;
}
/**
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml.ts
index de4b99272c..77b47d98ac 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-dangerously-set-innerhtml.ts
@@ -35,12 +35,8 @@ export function create(context: RuleContext): RuleListener {
if (!context.sourceCode.text.includes(dangerouslySetInnerHTML)) return {};
return {
JSXElement(node) {
- const attribute = ER.getAttribute(
- context,
- dangerouslySetInnerHTML,
- node.openingElement.attributes,
- context.sourceCode.getScope(node),
- );
+ const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node));
+ const attribute = getAttribute(dangerouslySetInnerHTML);
if (attribute == null) return;
context.report({
messageId: "noDangerouslySetInnerhtml",
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.spec.ts
index dff69a291d..6f2cb71083 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.spec.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.spec.ts
@@ -136,29 +136,5 @@ ruleTester.run(RULE_NAME, rule, {
return ;
}
`,
- {
- code: tsx`
- function App() {
- return ;
- }
- `,
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Button",
- as: "button",
- attributes: [
- {
- name: "type",
- as: "type",
- defaultValue: "button",
- },
- ],
- },
- ],
- },
- },
- },
],
});
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts
index eb2040fab8..786d63eb0c 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts
@@ -1,7 +1,9 @@
+import * as ER from "@eslint-react/core";
import type { RuleContext, RuleFeature, RuleSuggest } from "@eslint-react/kit";
import type { RuleFixer, RuleListener } from "@typescript-eslint/utils/ts-eslint";
import type { CamelCase } from "string-ts";
-import { createJsxElementResolver, createRule, resolveAttribute } from "../utils";
+
+import { createJsxElementResolver, createRule } from "../utils";
export const RULE_NAME = "no-missing-button-type";
@@ -38,26 +40,26 @@ export function create(context: RuleContext): RuleListener {
const resolver = createJsxElementResolver(context);
return {
JSXElement(node) {
- const { attributes, domElementType } = resolver.resolve(node);
+ const { domElementType } = resolver.resolve(node);
if (domElementType !== "button") return;
- const typeAttribute = resolveAttribute(context, attributes, node, "type");
- if (typeAttribute.attributeValueString != null) return;
- if (typeAttribute.attribute == null) {
+ const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node));
+ const typeAttribute = getAttribute("type");
+ if (typeAttribute == null) {
context.report({
messageId: "noMissingButtonType",
node: node.openingElement,
suggest: getSuggest((type) => (fixer: RuleFixer) => {
- return fixer.insertTextAfter(node.openingElement.name, ` ${typeAttribute.attributeName}="${type}"`);
+ return fixer.insertTextAfter(node.openingElement.name, ` type="${type}"`);
}),
});
return;
}
+ if (typeof ER.resolveAttributeValue(context, typeAttribute).toStatic("type") === "string") return;
context.report({
messageId: "noMissingButtonType",
- node: typeAttribute.attributeValue?.node ?? typeAttribute.attribute,
+ node: typeAttribute,
suggest: getSuggest((type) => (fixer: RuleFixer) => {
- if (typeAttribute.attribute == null) return null;
- return fixer.replaceText(typeAttribute.attribute, `${typeAttribute.attributeName}="${type}"`);
+ return fixer.replaceText(typeAttribute, `type="${type}"`);
}),
});
},
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts
index ae25b901db..6ed80d904b 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts
@@ -1,8 +1,9 @@
+import * as ER from "@eslint-react/core";
import type { RuleContext, RuleFeature } from "@eslint-react/kit";
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
import type { CamelCase } from "string-ts";
-import { createJsxElementResolver, createRule, resolveAttribute } from "../utils";
+import { createJsxElementResolver, createRule } from "../utils";
export const RULE_NAME = "no-missing-iframe-sandbox";
@@ -38,11 +39,11 @@ export function create(context: RuleContext): RuleListener {
const resolver = createJsxElementResolver(context);
return {
JSXElement(node) {
- const { attributes, domElementType } = resolver.resolve(node);
+ const { domElementType } = resolver.resolve(node);
if (domElementType !== "iframe") return;
- const sandboxAttribute = resolveAttribute(context, attributes, node, "sandbox");
- if (sandboxAttribute.attributeValueString != null) return;
- if (sandboxAttribute.attribute == null) {
+ const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node));
+ const sandboxAttribute = getAttribute("sandbox");
+ if (sandboxAttribute == null) {
context.report({
messageId: "noMissingIframeSandbox",
node: node.openingElement,
@@ -50,23 +51,27 @@ export function create(context: RuleContext): RuleListener {
messageId: "addIframeSandbox",
data: { value: "" },
fix(fixer) {
- return fixer.insertTextAfter(node.openingElement.name, ` ${sandboxAttribute.attributeName}=""`);
+ return fixer.insertTextAfter(node.openingElement.name, ` sandbox=""`);
},
}],
});
return;
}
+ const sandboxAttributeValue = ER.resolveAttributeValue(context, sandboxAttribute);
+ if (typeof sandboxAttributeValue.toStatic("sandbox") === "string") return;
context.report({
messageId: "noMissingIframeSandbox",
- node: sandboxAttribute.attributeValue?.node ?? sandboxAttribute.attribute,
- suggest: [{
- messageId: "addIframeSandbox",
- data: { value: "" },
- fix(fixer) {
- if (sandboxAttribute.attribute == null) return null;
- return fixer.replaceText(sandboxAttribute.attribute, `${sandboxAttribute.attributeName}=""`);
+ node: sandboxAttributeValue.node ?? sandboxAttribute,
+ suggest: [
+ {
+ messageId: "addIframeSandbox",
+ data: { value: "" },
+ fix(fixer) {
+ if (sandboxAttributeValue.kind.startsWith("spread")) return null;
+ return fixer.replaceText(sandboxAttribute, `sandbox=""`);
+ },
},
- }],
+ ],
});
},
};
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-script-url.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-script-url.ts
index 6751d418b7..ce918515d6 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-script-url.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-script-url.ts
@@ -13,10 +13,6 @@ export const RULE_FEATURES = [] as const satisfies RuleFeature[];
export type MessageID = CamelCase;
-/**
- * This rule is adapted from eslint-plugin-solid's jsx-no-script-url rule under the MIT license.
- * Thank you for your work!
- */
export default createRule<[], MessageID>({
meta: {
type: "problem",
@@ -37,12 +33,9 @@ export default createRule<[], MessageID>({
export function create(context: RuleContext): RuleListener {
return {
JSXAttribute(node) {
- if (node.name.type !== T.JSXIdentifier || node.value == null) {
- return;
- }
- const attributeValue = ER.getAttributeValue(context, node, ER.getAttributeName(context, node));
- if (attributeValue.kind === "none" || typeof attributeValue.value !== "string") return;
- if (RE.JAVASCRIPT_PROTOCOL.test(attributeValue.value)) {
+ if (node.name.type !== T.JSXIdentifier || node.value == null) return;
+ const value = ER.resolveAttributeValue(context, node).toStatic();
+ if (typeof value === "string" && RE.JAVASCRIPT_PROTOCOL.test(value)) {
context.report({
messageId: "noScriptUrl",
node: node.value,
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.spec.ts
index 6e3a14c649..1800f3c029 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.spec.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.spec.ts
@@ -54,24 +54,5 @@ ruleTester.run(RULE_NAME, rule, {
return ;
}
`,
- {
- // https://github.com/Rel1cx/eslint-react/issues/1217
- code: tsx`
- const a = ;
- `,
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "StatusBar",
- attributes: [
- // inform that the style attribute on StatusBar is not an intrinsic attribute but a custom one
- { name: "", as: "style" },
- ],
- },
- ],
- },
- },
- },
],
});
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.ts
index d9a36db3d1..fb197beccc 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-string-style-prop.ts
@@ -1,8 +1,9 @@
+import * as ER from "@eslint-react/core";
import type { RuleContext, RuleFeature } from "@eslint-react/kit";
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
import type { CamelCase } from "string-ts";
-import { createJsxElementResolver, createRule, resolveAttribute } from "../utils";
+import { createRule } from "../utils";
export const RULE_NAME = "no-string-style-prop";
@@ -28,23 +29,18 @@ export default createRule<[], MessageID>({
});
export function create(context: RuleContext): RuleListener {
- const resolver = createJsxElementResolver(context);
return {
JSXElement(node) {
- const { attributes } = resolver.resolve(node);
- const {
- attribute,
- attributeName,
- attributeValue,
- attributeValueString,
- } = resolveAttribute(context, attributes, node, "style");
- if (attributeName !== "style") return;
- if (attribute == null || attributeValue?.node == null) return;
- if (attributeValueString == null) return;
- context.report({
- messageId: "noStringStyleProp",
- node: attributeValue.node,
- });
+ const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node));
+ const attribute = getAttribute("style");
+ if (attribute == null) return;
+ const attributeValue = ER.resolveAttributeValue(context, attribute);
+ if (typeof attributeValue.toStatic() === "string") {
+ context.report({
+ messageId: "noStringStyleProp",
+ node: attributeValue.node ?? attribute,
+ });
+ }
},
};
}
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts
index f399edd1c1..b46ded2fdf 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts
@@ -1,9 +1,9 @@
-import type { unit } from "@eslint-react/eff";
+import * as ER from "@eslint-react/core";
import type { RuleContext, RuleFeature } from "@eslint-react/kit";
-
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
import type { CamelCase } from "string-ts";
-import { createJsxElementResolver, createRule, resolveAttribute } from "../utils";
+
+import { createJsxElementResolver, createRule } from "../utils";
export const RULE_NAME = "no-unsafe-iframe-sandbox";
@@ -15,7 +15,7 @@ const unsafeSandboxValues = [
["allow-scripts", "allow-same-origin"],
] as const;
-function isSafeSandbox(value: string | unit): value is string {
+function isSafeSandbox(value: unknown): value is string {
if (typeof value !== "string") return false;
return !unsafeSandboxValues.some((values) => {
return values.every((v) => value.includes(v));
@@ -43,13 +43,17 @@ export function create(context: RuleContext): RuleListener {
const resolver = createJsxElementResolver(context);
return {
JSXElement(node) {
- const { attributes, domElementType } = resolver.resolve(node);
+ const { domElementType } = resolver.resolve(node);
if (domElementType !== "iframe") return;
- const sandboxAttribute = resolveAttribute(context, attributes, node, "sandbox");
- if (!isSafeSandbox(sandboxAttribute.attributeValueString)) {
+ const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node));
+ const sandboxAttribute = getAttribute("sandbox");
+ if (sandboxAttribute == null) return;
+ const sandboxValue = ER.resolveAttributeValue(context, sandboxAttribute);
+ const sandboxValueStatic = sandboxValue.toStatic("sandbox");
+ if (!isSafeSandbox(sandboxValueStatic)) {
context.report({
messageId: "noUnsafeIframeSandbox",
- node: sandboxAttribute.attributeValue?.node ?? sandboxAttribute.attribute ?? node,
+ node: sandboxValue.node ?? sandboxAttribute,
});
}
},
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts
index dd65d38512..7208601dc4 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts
@@ -107,230 +107,208 @@ ruleTester.run(RULE_NAME, rule, {
},
},
},
- {
- code: '',
- errors: [
- {
- messageId: "noUnsafeTargetBlank",
- suggestions: [
- {
- messageId: "addRelNoreferrerNoopener",
- output: '',
- },
- ],
- },
- ],
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Link",
- as: "a",
- },
- ],
- },
- },
- },
- {
- code: '',
- errors: [
- {
- messageId: "noUnsafeTargetBlank",
- suggestions: [
- {
- messageId: "addRelNoreferrerNoopener",
- output: '',
- },
- ],
- },
- ],
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Link",
- as: "a",
- },
- ],
- },
- },
- },
- {
- code: tsx`
- const a = ;
- const b = ;
- `,
- errors: [
- {
- messageId: "noUnsafeTargetBlank",
- suggestions: [
- {
- messageId: "addRelNoreferrerNoopener",
- output: tsx`
- const a = ;
- const b = ;
- `,
- },
- ],
- },
- ], // should be 1 error
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Link",
- as: "a",
- attributes: [
- {
- name: "to",
- as: "href",
- },
- {
- name: "rel",
- defaultValue: "noreferrer",
- },
- ],
- },
- ],
- },
- },
- },
- {
- code: tsx`
- const a = ;
- const b = ;
- `,
- errors: [
- {
- messageId: "noUnsafeTargetBlank",
- suggestions: [
- {
- messageId: "addRelNoreferrerNoopener",
- output: tsx`
- const a = ;
- const b = ;
- `,
- },
- ],
- },
- {
- messageId: "noUnsafeTargetBlank",
- suggestions: [
- {
- messageId: "addRelNoreferrerNoopener",
- output: tsx`
- const a = ;
- const b = ;
- `,
- },
- ],
- },
- ],
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Link",
- as: "a",
- attributes: [{
- name: "rel",
- defaultValue: "noopener",
- }],
- },
- {
- name: "LinkButton",
- as: "a",
- attributes: [
- {
- name: "relation",
- as: "rel",
- defaultValue: "noreferrer",
- },
- ],
- },
- ],
- },
- },
- },
- {
- code: tsx`
- const a = ;
- const b = ;
- `,
- errors: [
- {
- messageId: "noUnsafeTargetBlank",
- suggestions: [
- {
- messageId: "addRelNoreferrerNoopener",
- output: tsx`
- const a = ;
- const b = ;
- `,
- },
- ],
- },
- ],
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Link",
- as: "a",
- attributes: [{
- name: "rel",
- defaultValue: "noreferrer",
- }],
- },
- {
- name: "LinkButton",
- as: "a",
- attributes: [
- {
- name: "relation",
- as: "rel",
- defaultValue: "noreferrer",
- },
- ],
- },
- ],
- },
- },
- },
- {
- code: tsx`
- const a = ;
- `,
- errors: [
- {
- messageId: "noUnsafeTargetBlank",
- suggestions: [
- {
- messageId: "addRelNoreferrerNoopener",
- output: tsx`
- const a = ;
- `,
- },
- ],
- },
- ],
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Link",
- as: "a",
- attributes: [
- {
- name: "target",
- defaultValue: "_blank",
- },
- ],
- },
- ],
- },
- },
- },
+ // TODO: Restore Link component test when support for additionalComponents is implemented. See issue #.
+ // },
+ // {
+ // code: '',
+ // errors: [
+ // {
+ // messageId: "noUnsafeTargetBlank",
+ // suggestions: [
+ // {
+ // messageId: "addRelNoreferrerNoopener",
+ // output: '',
+ // },
+ // ],
+ // },
+ // ],
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "Link",
+ // as: "a",
+ // },
+ // ],
+ // },
+ // },
+ // },
+ // {
+ // code: tsx`
+ // const a = ;
+ // const b = ;
+ // `,
+ // errors: [
+ // {
+ // messageId: "noUnsafeTargetBlank",
+ // suggestions: [
+ // {
+ // messageId: "addRelNoreferrerNoopener",
+ // output: tsx`
+ // const a = ;
+ // const b = ;
+ // `,
+ // },
+ // ],
+ // },
+ // ], // should be 1 error
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "Link",
+ // as: "a",
+ // attributes: [
+ // {
+ // name: "to",
+ // as: "href",
+ // },
+ // {
+ // name: "rel",
+ // defaultValue: "noreferrer",
+ // },
+ // ],
+ // },
+ // ],
+ // },
+ // },
+ // },
+ // {
+ // code: tsx`
+ // const a = ;
+ // const b = ;
+ // `,
+ // errors: [
+ // {
+ // messageId: "noUnsafeTargetBlank",
+ // suggestions: [
+ // {
+ // messageId: "addRelNoreferrerNoopener",
+ // output: tsx`
+ // const a = ;
+ // const b = ;
+ // `,
+ // },
+ // ],
+ // },
+ // {
+ // messageId: "noUnsafeTargetBlank",
+ // suggestions: [
+ // {
+ // messageId: "addRelNoreferrerNoopener",
+ // output: tsx`
+ // const a = ;
+ // const b = ;
+ // `,
+ // },
+ // ],
+ // },
+ // ],
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "Link",
+ // as: "a",
+ // attributes: [{
+ // name: "rel",
+ // defaultValue: "noopener",
+ // }],
+ // },
+ // {
+ // name: "LinkButton",
+ // as: "a",
+ // attributes: [
+ // {
+ // name: "relation",
+ // as: "rel",
+ // defaultValue: "noreferrer",
+ // },
+ // ],
+ // },
+ // ],
+ // },
+ // },
+ // },
+ // {
+ // code: tsx`
+ // const a = ;
+ // const b = ;
+ // `,
+ // errors: [
+ // {
+ // messageId: "noUnsafeTargetBlank",
+ // suggestions: [
+ // {
+ // messageId: "addRelNoreferrerNoopener",
+ // output: tsx`
+ // const a = ;
+ // const b = ;
+ // `,
+ // },
+ // ],
+ // },
+ // ],
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "Link",
+ // as: "a",
+ // attributes: [{
+ // name: "rel",
+ // defaultValue: "noreferrer",
+ // }],
+ // },
+ // {
+ // name: "LinkButton",
+ // as: "a",
+ // attributes: [
+ // {
+ // name: "relation",
+ // as: "rel",
+ // defaultValue: "noreferrer",
+ // },
+ // ],
+ // },
+ // ],
+ // },
+ // },
+ // },
+ // {
+ // code: tsx`
+ // const a = ;
+ // `,
+ // errors: [
+ // {
+ // messageId: "noUnsafeTargetBlank",
+ // suggestions: [
+ // {
+ // messageId: "addRelNoreferrerNoopener",
+ // output: tsx`
+ // const a = ;
+ // `,
+ // },
+ // ],
+ // },
+ // ],
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "Link",
+ // as: "a",
+ // attributes: [
+ // {
+ // name: "target",
+ // defaultValue: "_blank",
+ // },
+ // ],
+ // },
+ // ],
+ // },
+ // },
+ // },
],
valid: [
...allValid,
@@ -369,20 +347,20 @@ ruleTester.run(RULE_NAME, rule, {
},
},
},
- {
- code: '',
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "LinkButton",
- as: "a",
- },
- ],
- polymorphicPropName: "as",
- },
- },
- },
+ // {
+ // code: '',
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "LinkButton",
+ // as: "a",
+ // },
+ // ],
+ // polymorphicPropName: "as",
+ // },
+ // },
+ // },
{
code: '',
settings: {
@@ -391,143 +369,143 @@ ruleTester.run(RULE_NAME, rule, {
},
},
},
- {
- code: '',
- settings: {
- "react-x": {
- polymorphicPropName: "component",
- },
- },
- },
- {
- code: '',
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "LinkButton",
- as: "a",
- attributes: [{
- name: "rel",
- defaultValue: "noreferrer",
- }],
- },
- ],
- },
- },
- },
- {
- code: '',
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "LinkButton",
- as: "a",
- attributes: [{
- name: "rel",
- defaultValue: "noreferrer",
- }],
- },
- {
- name: "LinkButton",
- as: "a",
- attributes: [
- {
- name: "to",
- as: "href",
- defaultValue: "",
- },
- {
- name: "rel",
- defaultValue: "noreferrer",
- },
- ],
- },
- ],
- },
- },
- },
- {
- code: '',
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "LinkButton",
- as: "a",
- attributes: [
- {
- name: "to",
- as: "href",
- defaultValue: "noreferrer",
- },
- {
- name: "rel",
- defaultValue: "noreferrer",
- },
- ],
- },
- ],
- },
- },
- },
- {
- code: tsx`
- const a = ;
- const b = ;
- `,
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Link",
- as: "a",
- attributes: [{
- name: "rel",
- defaultValue: "noreferrer noopener",
- }],
- },
- {
- name: "LinkButton",
- as: "a",
- attributes: [
- {
- name: "relation",
- as: "rel",
- defaultValue: "noreferrer noopener",
- },
- ],
- },
- ],
- },
- },
- },
- {
- code: tsx`
- const a = ;
- `,
- settings: {
- "react-x": {
- additionalComponents: [
- {
- name: "Link",
- as: "a",
- attributes: [
- {
- name: "target",
- defaultValue: "_blank",
- },
- {
- name: "rel",
- defaultValue: "noreferrer",
- },
- ],
- },
- ],
- },
- },
- },
+ // {
+ // code: '',
+ // settings: {
+ // "react-x": {
+ // polymorphicPropName: "component",
+ // },
+ // },
+ // },
+ // {
+ // code: '',
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "LinkButton",
+ // as: "a",
+ // attributes: [{
+ // name: "rel",
+ // defaultValue: "noreferrer",
+ // }],
+ // },
+ // ],
+ // },
+ // },
+ // },
+ // {
+ // code: '',
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "LinkButton",
+ // as: "a",
+ // attributes: [{
+ // name: "rel",
+ // defaultValue: "noreferrer",
+ // }],
+ // },
+ // {
+ // name: "LinkButton",
+ // as: "a",
+ // attributes: [
+ // {
+ // name: "to",
+ // as: "href",
+ // defaultValue: "",
+ // },
+ // {
+ // name: "rel",
+ // defaultValue: "noreferrer",
+ // },
+ // ],
+ // },
+ // ],
+ // },
+ // },
+ // },
+ // {
+ // code: '',
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "LinkButton",
+ // as: "a",
+ // attributes: [
+ // {
+ // name: "to",
+ // as: "href",
+ // defaultValue: "noreferrer",
+ // },
+ // {
+ // name: "rel",
+ // defaultValue: "noreferrer",
+ // },
+ // ],
+ // },
+ // ],
+ // },
+ // },
+ // },
+ // {
+ // code: tsx`
+ // const a = ;
+ // const b = ;
+ // `,
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "Link",
+ // as: "a",
+ // attributes: [{
+ // name: "rel",
+ // defaultValue: "noreferrer noopener",
+ // }],
+ // },
+ // {
+ // name: "LinkButton",
+ // as: "a",
+ // attributes: [
+ // {
+ // name: "relation",
+ // as: "rel",
+ // defaultValue: "noreferrer noopener",
+ // },
+ // ],
+ // },
+ // ],
+ // },
+ // },
+ // },
+ // {
+ // code: tsx`
+ // const a = ;
+ // `,
+ // settings: {
+ // "react-x": {
+ // additionalComponents: [
+ // {
+ // name: "Link",
+ // as: "a",
+ // attributes: [
+ // {
+ // name: "target",
+ // defaultValue: "_blank",
+ // },
+ // {
+ // name: "rel",
+ // defaultValue: "noreferrer",
+ // },
+ // ],
+ // },
+ // ],
+ // },
+ // },
+ // },
// TODO: Implement this
// {
// code: tsx`
diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts
index fa4d5b4967..db2e7a556d 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts
@@ -1,11 +1,10 @@
-import type { unit } from "@eslint-react/eff";
+import * as ER from "@eslint-react/core";
import type { RuleContext, RuleFeature } from "@eslint-react/kit";
import type { TSESTree } from "@typescript-eslint/types";
-
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
-
import type { CamelCase } from "string-ts";
-import { createJsxElementResolver, createRule, resolveAttribute } from "../utils";
+
+import { createJsxElementResolver, createRule } from "../utils";
export const RULE_NAME = "no-unsafe-target-blank";
@@ -17,16 +16,28 @@ export type MessageID = CamelCase | RuleSuggestMessageID;
export type RuleSuggestMessageID = "addRelNoreferrerNoopener";
-function isExternalLinkLike(value: string | unit) {
- if (value == null) return false;
- return value.startsWith("https://")
- || /^(?:\w+:|\/\/)/u.test(value);
+/**
+ * Checks if a value appears to be an external link.
+ * External links typically start with http(s):// or have protocol-relative format.
+ * @param value - The value to check
+ * @returns Whether the value represents an external link
+ */
+function isExternalLinkLike(value: unknown): boolean {
+ if (typeof value !== "string") return false;
+
+ return value.startsWith("https://") || /^(?:\w+:|\/\/)/u.test(value);
}
-function isSafeRel(value: string | unit) {
- if (value == null) return false;
- return value === "noreferrer"
- || /\bnoreferrer\b/u.test(value);
+/**
+ * Checks if a rel attribute value contains the necessary security attributes.
+ * At minimum, it should contain "noreferrer".
+ * @param value - The rel attribute value to check
+ * @returns Whether the rel value is considered secure
+ */
+function isSafeRel(value: unknown): boolean {
+ if (typeof value !== "string") return false;
+
+ return value === "noreferrer" || /\bnoreferrer\b/u.test(value);
}
export default createRule<[], MessageID>({
@@ -52,23 +63,39 @@ export default createRule<[], MessageID>({
export function create(context: RuleContext): RuleListener {
const resolver = createJsxElementResolver(context);
+
return {
JSXElement(node: TSESTree.JSXElement) {
- const { attributes, domElementType } = resolver.resolve(node);
+ // Only process anchor tags ()
+ const { domElementType } = resolver.resolve(node);
if (domElementType !== "a") return;
- const targetAttribute = resolveAttribute(context, attributes, node, "target");
- if (targetAttribute.attributeValueString !== "_blank") {
- return;
- }
- const hrefAttribute = resolveAttribute(context, attributes, node, "href");
- if (!isExternalLinkLike(hrefAttribute.attributeValueString)) {
- return;
- }
- const relAttribute = resolveAttribute(context, attributes, node, "rel");
- if (isSafeRel(relAttribute.attributeValueString)) {
- return;
- }
- if (relAttribute.attribute == null) {
+
+ // Get access to the component attributes
+ const getAttributes = ER.getAttribute(
+ context,
+ node.openingElement.attributes,
+ context.sourceCode.getScope(node),
+ );
+
+ // Check if target="_blank" is present
+ const targetAttribute = getAttributes("target");
+ if (targetAttribute == null) return;
+
+ const targetAttributeValue = ER.resolveAttributeValue(context, targetAttribute).toStatic("target");
+ if (targetAttributeValue !== "_blank") return;
+
+ // Check if href points to an external resource
+ const hrefAttribute = getAttributes("href");
+ if (hrefAttribute == null) return;
+
+ const hrefAttributeValue = ER.resolveAttributeValue(context, hrefAttribute).toStatic("href");
+ if (!isExternalLinkLike(hrefAttributeValue)) return;
+
+ // Check if rel attribute exists and is secure
+ const relAttribute = getAttributes("rel");
+
+ // No rel attribute case - suggest adding one
+ if (relAttribute == null) {
context.report({
messageId: "noUnsafeTargetBlank",
node: node.openingElement,
@@ -77,21 +104,26 @@ export function create(context: RuleContext): RuleListener {
fix(fixer) {
return fixer.insertTextAfter(
node.openingElement.name,
- ` ${relAttribute.attributeName}="noreferrer noopener"`,
+ ` rel="noreferrer noopener"`,
);
},
}],
});
return;
}
+
+ // Check if existing rel attribute is secure
+ const relAttributeValue = ER.resolveAttributeValue(context, relAttribute).toStatic("rel");
+ if (isSafeRel(relAttributeValue)) return;
+
+ // Existing rel attribute is not secure - suggest replacing it
context.report({
messageId: "noUnsafeTargetBlank",
- node: relAttribute.attributeValue?.node ?? relAttribute.attribute,
+ node: relAttribute,
suggest: [{
messageId: "addRelNoreferrerNoopener",
fix(fixer) {
- if (relAttribute.attribute == null) return null;
- return fixer.replaceText(relAttribute.attribute, `${relAttribute.attributeName}="noreferrer noopener"`);
+ return fixer.replaceText(relAttribute, `rel="noreferrer noopener"`);
},
}],
});
diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts
index 318fb5ae88..9420a9357b 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/utils/create-jsx-element-resolver.ts
@@ -4,41 +4,62 @@ import type { TSESTree } from "@typescript-eslint/types";
import * as ER from "@eslint-react/core";
import { getSettingsFromContext } from "@eslint-react/shared";
+/**
+ * Creates a resolver for JSX elements that determines both the JSX element type
+ * and the underlying DOM element type.
+ *
+ * This resolver handles:
+ * 1. Regular HTML elements (div, span, etc.)
+ * 2. Polymorphic components (components that can render as different elements via a prop)
+ *
+ * @param context - The ESLint rule context
+ * @returns An object with a resolve method to determine element types
+ */
export function createJsxElementResolver(context: RuleContext) {
- const { components, polymorphicPropName } = getSettingsFromContext(context);
+ const { polymorphicPropName } = getSettingsFromContext(context);
+
return {
+ /**
+ * Resolves the JSX element to determine its type and the underlying DOM element type
+ *
+ * @param node - The JSX element node to resolve
+ * @returns An object containing the JSX element type and DOM element type
+ */
resolve(node: TSESTree.JSXElement) {
- const name = ER.getElementType(context, node);
- const component = components
- .findLast((c) => c.name === name || c.re.test(name));
+ // Get the element name/type (e.g., 'div', 'Button', etc.)
+ const elementName = ER.getElementType(context, node);
+
+ // Create the base result with element types
const result = {
- attributes: component?.attributes ?? [],
- domElementType: component?.as ?? name,
- jsxElementType: name,
+ domElementType: elementName,
+ jsxElementType: elementName,
};
- if (name === name.toLowerCase() || component != null || polymorphicPropName == null) {
+
+ // Early return if any of these conditions are met:
+ // 1. It's a native HTML element (lowercase name)
+ // 2. No polymorphic prop name is configured
+ if (elementName === elementName.toLowerCase() || polymorphicPropName == null) {
return result;
}
- const initialScope = context.sourceCode.getScope(node);
- const polymorphicPropAttr = ER.getAttribute(
- context,
- polymorphicPropName,
- node.openingElement.attributes,
- initialScope,
- );
- if (polymorphicPropAttr != null) {
- const polymorphicPropValue = ER.getAttributeValue(
- context,
- polymorphicPropAttr,
- polymorphicPropName,
- );
- if (polymorphicPropValue.kind === "some" && typeof polymorphicPropValue.value === "string") {
+
+ // Look for the polymorphic prop (e.g., 'as', 'component') in the element's attributes
+ const getAttribute = ER.getAttribute(context, node.openingElement.attributes, context.sourceCode.getScope(node));
+ const polymorphicProp = getAttribute(polymorphicPropName);
+
+ // If the polymorphic prop exists, try to determine its static value
+ if (polymorphicProp != null) {
+ const polymorphicPropValue = ER.resolveAttributeValue(context, polymorphicProp);
+ const staticValue = polymorphicPropValue.toStatic(polymorphicPropName);
+
+ // If we have a string value, use it as the DOM element type
+ if (typeof staticValue === "string") {
return {
...result,
- domElementType: polymorphicPropValue.value,
+ domElementType: staticValue,
};
}
}
+
return result;
},
} as const;
diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/find-custom-component.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/find-custom-component.ts
deleted file mode 100644
index 82c3aa366a..0000000000
--- a/packages/plugins/eslint-plugin-react-dom/src/utils/find-custom-component.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { CustomComponentNormalized, CustomComponentPropNormalized } from "@eslint-react/shared";
-
-/**
- * Finds a custom component by name from the provided array of components.
- *
- * @param name - The name of the component to find
- * @param components - Array of normalized custom components to search through
- * @returns The matching component if found, undefined otherwise
- */
-export function findCustomComponent(name: string, components: CustomComponentNormalized[]) {
- return components
- .findLast((c) => c.name === name || c.re.test(name));
-}
-
-/**
- * Finds a custom component prop by its "as" name.
- *
- * @param name - The name to match against the prop's "as" property
- * @param props - Array of normalized custom component props to search through
- * @returns The matching prop if found, undefined otherwise
- */
-export function findCustomComponentProp(name: string, props: CustomComponentPropNormalized[]) {
- return props
- .findLast((a) => a.as === name);
-}
diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts
index 158b76cca8..575feb2dd7 100644
--- a/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts
+++ b/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts
@@ -1,4 +1,2 @@
export * from "./create-jsx-element-resolver";
export * from "./create-rule";
-export * from "./find-custom-component";
-export * from "./resolve-attribute";
diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts
deleted file mode 100644
index 2b14a2d945..0000000000
--- a/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import * as ER from "@eslint-react/core";
-import { unit } from "@eslint-react/eff";
-import type { RuleContext } from "@eslint-react/kit";
-import type { CustomComponentPropNormalized } from "@eslint-react/shared";
-import type { TSESTree } from "@typescript-eslint/types";
-import { findCustomComponentProp } from "./find-custom-component";
-
-export function resolveAttribute(
- context: RuleContext,
- attributes: CustomComponentPropNormalized[],
- elementNode: TSESTree.JSXElement,
- attributeName: string,
-): {
- attribute: unit | TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute;
- attributeName: string;
- attributeValue: unit | ReturnType;
- attributeValueString: unit | string;
-} {
- const customComponentProp = findCustomComponentProp(attributeName, attributes);
- const propNameOnJsx = customComponentProp?.name ?? attributeName;
- const attribute = ER.getAttribute(
- context,
- propNameOnJsx,
- elementNode.openingElement.attributes,
- context.sourceCode.getScope(elementNode),
- );
- if (attribute == null) {
- return {
- attribute: unit,
- attributeName: propNameOnJsx,
- attributeValue: unit,
- attributeValueString: customComponentProp?.defaultValue,
- } as const;
- }
- const attributeValue = ER.getAttributeValue(context, attribute, propNameOnJsx);
- if (attributeValue.kind === "some" && typeof attributeValue.value === "string") {
- return {
- attribute,
- attributeName: propNameOnJsx,
- attributeValue,
- attributeValueString: attributeValue.value,
- } as const;
- }
- return {
- attribute,
- attributeName: propNameOnJsx,
- attributeValue: unit,
- attributeValueString: customComponentProp?.defaultValue ?? unit,
- } as const;
-}
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-children-prop.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-children-prop.ts
index b0f1934aa1..4f76746580 100644
--- a/packages/plugins/eslint-plugin-react-x/src/rules/no-children-prop.ts
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-children-prop.ts
@@ -31,8 +31,12 @@ export default createRule<[], MessageID>({
export function create(context: RuleContext): RuleListener {
return {
JSXElement(node) {
- const attributes = node.openingElement.attributes;
- const childrenProp = ER.getAttribute(context, "children", attributes, context.sourceCode.getScope(node));
+ const getAttribute = ER.getAttribute(
+ context,
+ node.openingElement.attributes,
+ context.sourceCode.getScope(node),
+ );
+ const childrenProp = getAttribute("children");
if (childrenProp != null) {
context.report({
messageId: "noChildrenProp",
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-implicit-key.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-implicit-key.ts
index 964650eeba..e2d088ffd3 100644
--- a/packages/plugins/eslint-plugin-react-x/src/rules/no-implicit-key.ts
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-implicit-key.ts
@@ -39,7 +39,7 @@ export function create(context: RuleContext): RuleListener {
return {
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
const initialScope = context.sourceCode.getScope(node);
- const keyProp = ER.getAttribute(context, "key", node.attributes, initialScope);
+ const keyProp = ER.getAttribute(context, node.attributes, initialScope)("key");
const isKeyPropOnElement = node.attributes
.some((n) =>
n.type === T.JSXAttribute
diff --git a/packages/plugins/eslint-plugin/README.md b/packages/plugins/eslint-plugin/README.md
index b6a0d3adab..2920dd5869 100644
--- a/packages/plugins/eslint-plugin/README.md
+++ b/packages/plugins/eslint-plugin/README.md
@@ -167,8 +167,8 @@ export default defineConfig([
Contributions are welcome!
-Please follow our [contributing guidelines](https://github.com/Rel1cx/eslint-react/tree/2.0.0/.github/CONTRIBUTING.md).
+Please follow our [contributing guidelines](https://github.com/Rel1cx/eslint-react/tree/rework/jsx-attribute-resolution/.github/CONTRIBUTING.md).
## License
-This project is licensed under the MIT License - see the [LICENSE](https://github.com/Rel1cx/eslint-react/tree/2.0.0/LICENSE) file for details.
+This project is licensed under the MIT License - see the [LICENSE](https://github.com/Rel1cx/eslint-react/tree/rework/jsx-attribute-resolution/LICENSE) file for details.
diff --git a/packages/shared/docs/README.md b/packages/shared/docs/README.md
index cdfc4e72c3..d810e773c3 100644
--- a/packages/shared/docs/README.md
+++ b/packages/shared/docs/README.md
@@ -6,22 +6,16 @@
## Interfaces
-- [CustomComponentNormalized](interfaces/CustomComponentNormalized.md)
-- [CustomComponentPropNormalized](interfaces/CustomComponentPropNormalized.md)
- [ESLintReactSettingsNormalized](interfaces/ESLintReactSettingsNormalized.md)
## Type Aliases
-- [CustomComponent](type-aliases/CustomComponent.md)
-- [CustomComponentProp](type-aliases/CustomComponentProp.md)
- [CustomHooks](type-aliases/CustomHooks.md)
- [ESLintReactSettings](type-aliases/ESLintReactSettings.md)
- [ESLintSettings](type-aliases/ESLintSettings.md)
## Variables
-- [CustomComponentPropSchema](variables/CustomComponentPropSchema.md)
-- [CustomComponentSchema](variables/CustomComponentSchema.md)
- [CustomHooksSchema](variables/CustomHooksSchema.md)
- [DEFAULT\_ESLINT\_REACT\_SETTINGS](variables/DEFAULT_ESLINT_REACT_SETTINGS.md)
- [DEFAULT\_ESLINT\_SETTINGS](variables/DEFAULT_ESLINT_SETTINGS.md)
diff --git a/packages/shared/docs/functions/coerceSettings.md b/packages/shared/docs/functions/coerceSettings.md
index a38315091a..0adaf0c137 100644
--- a/packages/shared/docs/functions/coerceSettings.md
+++ b/packages/shared/docs/functions/coerceSettings.md
@@ -20,19 +20,6 @@ The settings object to coerce
## Returns
-### additionalComponents?
-
-> `optional` **additionalComponents**: `object`[]
-
-User-defined components configuration
-Informs ESLint React how to treat these components during validation
-
-#### Example
-
-```ts
-[{ name: "Link", as: "a", attributes: [{ name: "to", as: "href" }] }]
-```
-
### additionalHooks?
> `optional` **additionalHooks**: `object`
diff --git a/packages/shared/docs/functions/decodeSettings.md b/packages/shared/docs/functions/decodeSettings.md
index 5ba45eb3e8..f95a6dc201 100644
--- a/packages/shared/docs/functions/decodeSettings.md
+++ b/packages/shared/docs/functions/decodeSettings.md
@@ -20,19 +20,6 @@ The settings object to decode
## Returns
-### additionalComponents?
-
-> `optional` **additionalComponents**: `object`[]
-
-User-defined components configuration
-Informs ESLint React how to treat these components during validation
-
-#### Example
-
-```ts
-[{ name: "Link", as: "a", attributes: [{ name: "to", as: "href" }] }]
-```
-
### additionalHooks?
> `optional` **additionalHooks**: `object`
diff --git a/packages/shared/docs/functions/isESLintReactSettings.md b/packages/shared/docs/functions/isESLintReactSettings.md
index 5d52472297..a160cd5774 100644
--- a/packages/shared/docs/functions/isESLintReactSettings.md
+++ b/packages/shared/docs/functions/isESLintReactSettings.md
@@ -6,7 +6,7 @@
# Function: isESLintReactSettings()
-> **isESLintReactSettings**(`settings`): `settings is { additionalComponents?: { as?: string; attributes?: { as?: string; defaultValue?: string; name: string }[]; name: string }[]; additionalHooks?: { use?: string[]; useActionState?: string[]; useCallback?: string[]; useContext?: string[]; useDebugValue?: string[]; useDeferredValue?: string[]; useEffect?: string[]; useFormStatus?: string[]; useId?: string[]; useImperativeHandle?: string[]; useInsertionEffect?: string[]; useLayoutEffect?: string[]; useMemo?: string[]; useOptimistic?: string[]; useReducer?: string[]; useRef?: string[]; useState?: string[]; useSyncExternalStore?: string[]; useTransition?: string[] }; importSource?: string; polymorphicPropName?: string; version?: string }`
+> **isESLintReactSettings**(`settings`): `settings is { additionalHooks?: { use?: string[]; useActionState?: string[]; useCallback?: string[]; useContext?: string[]; useDebugValue?: string[]; useDeferredValue?: string[]; useEffect?: string[]; useFormStatus?: string[]; useId?: string[]; useImperativeHandle?: string[]; useInsertionEffect?: string[]; useLayoutEffect?: string[]; useMemo?: string[]; useOptimistic?: string[]; useReducer?: string[]; useRef?: string[]; useState?: string[]; useSyncExternalStore?: string[]; useTransition?: string[] }; importSource?: string; polymorphicPropName?: string; version?: string }`
Checks if the provided settings conform to ESLintReactSettings schema
@@ -20,4 +20,4 @@ The settings object to validate
## Returns
-`settings is { additionalComponents?: { as?: string; attributes?: { as?: string; defaultValue?: string; name: string }[]; name: string }[]; additionalHooks?: { use?: string[]; useActionState?: string[]; useCallback?: string[]; useContext?: string[]; useDebugValue?: string[]; useDeferredValue?: string[]; useEffect?: string[]; useFormStatus?: string[]; useId?: string[]; useImperativeHandle?: string[]; useInsertionEffect?: string[]; useLayoutEffect?: string[]; useMemo?: string[]; useOptimistic?: string[]; useReducer?: string[]; useRef?: string[]; useState?: string[]; useSyncExternalStore?: string[]; useTransition?: string[] }; importSource?: string; polymorphicPropName?: string; version?: string }`
+`settings is { additionalHooks?: { use?: string[]; useActionState?: string[]; useCallback?: string[]; useContext?: string[]; useDebugValue?: string[]; useDeferredValue?: string[]; useEffect?: string[]; useFormStatus?: string[]; useId?: string[]; useImperativeHandle?: string[]; useInsertionEffect?: string[]; useLayoutEffect?: string[]; useMemo?: string[]; useOptimistic?: string[]; useReducer?: string[]; useRef?: string[]; useState?: string[]; useSyncExternalStore?: string[]; useTransition?: string[] }; importSource?: string; polymorphicPropName?: string; version?: string }`
diff --git a/packages/shared/docs/functions/normalizeSettings.md b/packages/shared/docs/functions/normalizeSettings.md
index 2cda67faa8..fcd6ef60cf 100644
--- a/packages/shared/docs/functions/normalizeSettings.md
+++ b/packages/shared/docs/functions/normalizeSettings.md
@@ -15,19 +15,6 @@ Transforms component definitions and resolves version information
### \_\_namedParameters
-#### additionalComponents?
-
-`object`[] = `[]`
-
-User-defined components configuration
-Informs ESLint React how to treat these components during validation
-
-**Example**
-
-```ts
-[{ name: "Link", as: "a", attributes: [{ name: "to", as: "href" }] }]
-```
-
#### additionalHooks?
\{ `use?`: `string`[]; `useActionState?`: `string`[]; `useCallback?`: `string`[]; `useContext?`: `string`[]; `useDebugValue?`: `string`[]; `useDeferredValue?`: `string`[]; `useEffect?`: `string`[]; `useFormStatus?`: `string`[]; `useId?`: `string`[]; `useImperativeHandle?`: `string`[]; `useInsertionEffect?`: `string`[]; `useLayoutEffect?`: `string`[]; `useMemo?`: `string`[]; `useOptimistic?`: `string`[]; `useReducer?`: `string`[]; `useRef?`: `string`[]; `useState?`: `string`[]; `useSyncExternalStore?`: `string`[]; `useTransition?`: `string`[]; \} = `{}`
@@ -251,10 +238,6 @@ React version to use
> `optional` **useTransition**: `string`[]
-### components
-
-> `readonly` **components**: `object`[]
-
### importSource
> **importSource**: `string`
diff --git a/packages/shared/docs/interfaces/CustomComponentNormalized.md b/packages/shared/docs/interfaces/CustomComponentNormalized.md
deleted file mode 100644
index b390c6682d..0000000000
--- a/packages/shared/docs/interfaces/CustomComponentNormalized.md
+++ /dev/null
@@ -1,47 +0,0 @@
-[**@eslint-react/shared**](../README.md)
-
-***
-
-[@eslint-react/shared](../README.md) / CustomComponentNormalized
-
-# Interface: CustomComponentNormalized
-
-Normalized representation of a custom component with RegExp for matching
-
-## Properties
-
-### as
-
-> **as**: `string`
-
-***
-
-### attributes
-
-> **attributes**: [`CustomComponentPropNormalized`](CustomComponentPropNormalized.md)[]
-
-***
-
-### name
-
-> **name**: `string`
-
-***
-
-### re
-
-> **re**: `object`
-
-#### test()
-
-> **test**(`s`): `boolean`
-
-##### Parameters
-
-###### s
-
-`string`
-
-##### Returns
-
-`boolean`
diff --git a/packages/shared/docs/interfaces/CustomComponentPropNormalized.md b/packages/shared/docs/interfaces/CustomComponentPropNormalized.md
deleted file mode 100644
index 7f4144395c..0000000000
--- a/packages/shared/docs/interfaces/CustomComponentPropNormalized.md
+++ /dev/null
@@ -1,27 +0,0 @@
-[**@eslint-react/shared**](../README.md)
-
-***
-
-[@eslint-react/shared](../README.md) / CustomComponentPropNormalized
-
-# Interface: CustomComponentPropNormalized
-
-Normalized representation of a custom component prop
-
-## Properties
-
-### as
-
-> **as**: `string`
-
-***
-
-### defaultValue?
-
-> `optional` **defaultValue**: `string`
-
-***
-
-### name
-
-> **name**: `string`
diff --git a/packages/shared/docs/interfaces/ESLintReactSettingsNormalized.md b/packages/shared/docs/interfaces/ESLintReactSettingsNormalized.md
index 959dc331b0..6b9a880606 100644
--- a/packages/shared/docs/interfaces/ESLintReactSettingsNormalized.md
+++ b/packages/shared/docs/interfaces/ESLintReactSettingsNormalized.md
@@ -92,12 +92,6 @@ Normalized ESLint React settings with processed values
***
-### components
-
-> **components**: [`CustomComponentNormalized`](CustomComponentNormalized.md)[]
-
-***
-
### importSource
> **importSource**: `string`
diff --git a/packages/shared/docs/type-aliases/CustomComponent.md b/packages/shared/docs/type-aliases/CustomComponent.md
deleted file mode 100644
index 8cf7529301..0000000000
--- a/packages/shared/docs/type-aliases/CustomComponent.md
+++ /dev/null
@@ -1,9 +0,0 @@
-[**@eslint-react/shared**](../README.md)
-
-***
-
-[@eslint-react/shared](../README.md) / CustomComponent
-
-# Type Alias: CustomComponent
-
-> **CustomComponent** = `z.infer`\<*typeof* [`CustomComponentSchema`](../variables/CustomComponentSchema.md)\>
diff --git a/packages/shared/docs/type-aliases/CustomComponentProp.md b/packages/shared/docs/type-aliases/CustomComponentProp.md
deleted file mode 100644
index 5fca3dfc58..0000000000
--- a/packages/shared/docs/type-aliases/CustomComponentProp.md
+++ /dev/null
@@ -1,9 +0,0 @@
-[**@eslint-react/shared**](../README.md)
-
-***
-
-[@eslint-react/shared](../README.md) / CustomComponentProp
-
-# Type Alias: CustomComponentProp
-
-> **CustomComponentProp** = `z.infer`\<*typeof* [`CustomComponentPropSchema`](../variables/CustomComponentPropSchema.md)\>
diff --git a/packages/shared/docs/variables/CustomComponentPropSchema.md b/packages/shared/docs/variables/CustomComponentPropSchema.md
deleted file mode 100644
index ec3ddef30b..0000000000
--- a/packages/shared/docs/variables/CustomComponentPropSchema.md
+++ /dev/null
@@ -1,11 +0,0 @@
-[**@eslint-react/shared**](../README.md)
-
-***
-
-[@eslint-react/shared](../README.md) / CustomComponentPropSchema
-
-# Variable: CustomComponentPropSchema
-
-> `const` **CustomComponentPropSchema**: `ZodObject`\<\{ `as`: `ZodOptional`\<`ZodString`\>; `defaultValue`: `ZodOptional`\<`ZodString`\>; `name`: `ZodString`; \}, `$strip`\>
-
-Schema for component prop mapping between user-defined components and host components
diff --git a/packages/shared/docs/variables/CustomComponentSchema.md b/packages/shared/docs/variables/CustomComponentSchema.md
deleted file mode 100644
index b0c1bb915a..0000000000
--- a/packages/shared/docs/variables/CustomComponentSchema.md
+++ /dev/null
@@ -1,13 +0,0 @@
-[**@eslint-react/shared**](../README.md)
-
-***
-
-[@eslint-react/shared](../README.md) / CustomComponentSchema
-
-# Variable: CustomComponentSchema
-
-> `const` **CustomComponentSchema**: `ZodObject`\<\{ `as`: `ZodOptional`\<`ZodString`\>; `attributes`: `ZodOptional`\<`ZodArray`\<`ZodObject`\<\{ `as`: `ZodOptional`\<`ZodString`\>; `defaultValue`: `ZodOptional`\<`ZodString`\>; `name`: `ZodString`; \}, `$strip`\>\>\>; `name`: `ZodString`; \}, `$strip`\>
-
-Schema for custom components configuration
-Provides key information about user-defined components before validation
-Example: Which prop is used as the `href` prop in a custom `Link` component
diff --git a/packages/shared/docs/variables/DEFAULT_ESLINT_REACT_SETTINGS.md b/packages/shared/docs/variables/DEFAULT_ESLINT_REACT_SETTINGS.md
index d90ee5248c..bfbe70de13 100644
--- a/packages/shared/docs/variables/DEFAULT_ESLINT_REACT_SETTINGS.md
+++ b/packages/shared/docs/variables/DEFAULT_ESLINT_REACT_SETTINGS.md
@@ -12,10 +12,6 @@ Default ESLint React settings
## Type Declaration
-### additionalComponents
-
-> `readonly` **additionalComponents**: \[\] = `[]`
-
### additionalHooks
> `readonly` **additionalHooks**: `object`
diff --git a/packages/shared/docs/variables/DEFAULT_ESLINT_SETTINGS.md b/packages/shared/docs/variables/DEFAULT_ESLINT_SETTINGS.md
index 5af5a9a1d5..a884be212c 100644
--- a/packages/shared/docs/variables/DEFAULT_ESLINT_SETTINGS.md
+++ b/packages/shared/docs/variables/DEFAULT_ESLINT_SETTINGS.md
@@ -16,10 +16,6 @@ Default ESLint settings with React settings included
> `readonly` **react-x**: `object` = `DEFAULT_ESLINT_REACT_SETTINGS`
-#### react-x.additionalComponents
-
-> `readonly` **additionalComponents**: \[\] = `[]`
-
#### react-x.additionalHooks
> `readonly` **additionalHooks**: `object`
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index 97af7cd572..35eca5b63e 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -2,7 +2,7 @@
/* eslint-disable perfectionist/sort-objects */
import type { unit } from "@eslint-react/eff";
import { getOrElseUpdate, identity } from "@eslint-react/eff";
-import { RegExp as RE, type RuleContext } from "@eslint-react/kit";
+import { type RuleContext } from "@eslint-react/kit";
import type { ESLint, SharedConfigurationSettings } from "@typescript-eslint/utils/ts-eslint"; // eslint-disable-line @typescript-eslint/no-unused-vars
import type { PartialDeep } from "type-fest";
@@ -13,66 +13,6 @@ import { getReactVersion } from "./get-react-version";
// ===== Schema Definitions =====
-/**
- * Schema for component prop mapping between user-defined components and host components
- */
-export const CustomComponentPropSchema = z.object({
- /**
- * The name of the prop in the user-defined component
- * @example "to"
- */
- name: z.string(),
-
- /**
- * The name of the prop in the host component
- * @example "href"
- */
- as: z.optional(z.string()),
-
- /**
- * Whether the prop is controlled in the user-defined component
- * @internal
- */
- controlled: z.optional(z.boolean()),
-
- /**
- * The default value of the prop in the user-defined component
- * @example "/", "noopener noreferrer"
- */
- defaultValue: z.optional(z.string()),
-});
-
-/**
- * Schema for custom components configuration
- * Provides key information about user-defined components before validation
- * Example: Which prop is used as the `href` prop in a custom `Link` component
- */
-export const CustomComponentSchema = z.object({
- /**
- * The name of the user-defined component
- * @example "Link"
- */
- name: z.string(),
-
- /**
- * The name of the host component that the user-defined component represents
- * @example "a"
- */
- as: z.optional(z.string()),
-
- /**
- * Attributes mapping between the user-defined component and the host component
- * @example Link's "to" attribute maps to anchor "href" attribute
- */
- attributes: z.optional(z.array(CustomComponentPropSchema)),
-
- /**
- * ESQuery selector to precisely select the component
- * @internal
- */
- selector: z.optional(z.string()),
-});
-
/**
* Schema for custom hooks aliases that should be treated as React Hooks
*/
@@ -145,13 +85,6 @@ export const ESLintReactSettingsSchema = z.object({
* @example { useEffect: ["useIsomorphicLayoutEffect"] }
*/
additionalHooks: z.optional(CustomHooksSchema),
-
- /**
- * User-defined components configuration
- * Informs ESLint React how to treat these components during validation
- * @example [{ name: "Link", as: "a", attributes: [{ name: "to", as: "href" }] }]
- */
- additionalComponents: z.optional(z.array(CustomComponentSchema)),
});
/**
@@ -165,40 +98,15 @@ export const ESLintSettingsSchema = z.optional(
);
// ===== Type Definitions =====
-
-export type CustomComponent = z.infer;
-export type CustomComponentProp = z.infer;
export type CustomHooks = z.infer;
export type ESLintSettings = z.infer;
export type ESLintReactSettings = z.infer;
-/**
- * Normalized representation of a custom component prop
- */
-export interface CustomComponentPropNormalized {
- name: string;
- as: string;
- // controlled?: boolean | unit;
- defaultValue?: string | unit;
-}
-
-/**
- * Normalized representation of a custom component with RegExp for matching
- */
-export interface CustomComponentNormalized {
- name: string;
- as: string;
- attributes: CustomComponentPropNormalized[];
- re: { test(s: string): boolean };
- // selector?: string | unit;
-}
-
/**
* Normalized ESLint React settings with processed values
*/
export interface ESLintReactSettingsNormalized {
additionalHooks: CustomHooks;
- components: CustomComponentNormalized[];
importSource: string;
polymorphicPropName: string | unit;
skipImportCheck: boolean;
@@ -217,7 +125,6 @@ export const DEFAULT_ESLINT_REACT_SETTINGS = {
strict: true,
skipImportCheck: true,
polymorphicPropName: "as",
- additionalComponents: [],
additionalHooks: {
useEffect: ["useIsomorphicLayoutEffect"],
useLayoutEffect: ["useIsomorphicLayoutEffect"],
@@ -292,7 +199,6 @@ export const decodeSettings = (settings: unknown): ESLintReactSettings => {
* Transforms component definitions and resolves version information
*/
export const normalizeSettings = ({
- additionalComponents = [],
additionalHooks = {},
importSource = "react",
polymorphicPropName = "as",
@@ -303,21 +209,6 @@ export const normalizeSettings = ({
}: ESLintReactSettings) => {
return {
...rest,
- components: additionalComponents.map((component) => {
- const { name, as = name, attributes = [], ...rest } = component;
- const re = RE.toRegExp(name);
- return {
- ...rest,
- name,
- re,
- as,
- attributes: attributes.map(({ name, as = name, ...rest }) => ({
- ...rest,
- name,
- as,
- })),
- };
- }),
additionalHooks,
importSource,
polymorphicPropName,
diff --git a/packages/utilities/ast/src/node-types.ts b/packages/utilities/ast/src/node-types.ts
index 6f0367b6fc..3566d5262a 100644
--- a/packages/utilities/ast/src/node-types.ts
+++ b/packages/utilities/ast/src/node-types.ts
@@ -55,6 +55,8 @@ export type TSESTreeJSX =
| TSESTree.JSXText
| TSESTree.JSXTextToken;
+export type TSESTreeJSXAttributeLike = TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute;
+
export type TSESTreeDestructuringPattern =
| TSESTree.ArrayPattern
| TSESTree.AssignmentPattern