From f07308bffcf92ac27b95988b3330263b162c9535 Mon Sep 17 00:00:00 2001
From: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Date: Thu, 23 Jan 2025 23:12:07 +0800
Subject: [PATCH 1/2] feat(no-useless-fragment): auto fix support
---
.../src/rules/no-useless-fragment.spec.ts | 42 ++++++++++++++
.../src/rules/no-useless-fragment.ts | 57 +++++++++++++++++--
2 files changed, 94 insertions(+), 5 deletions(-)
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts
index 1b7270a90d..e90e0eca0c 100644
--- a/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts
@@ -8,6 +8,7 @@ ruleTester.run(RULE_NAME, rule, {
{
code: /* tsx */ `<>>`,
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
+ output: null,
},
{
code: /* tsx */ `
<>foo>
`,
@@ -15,6 +16,7 @@ ruleTester.run(RULE_NAME, rule, {
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
{ type: T.JSXFragment, messageId: "noUselessFragment" },
],
+ output: /* tsx */ `foo
`,
},
{
code: /* tsx */ `moo<>foo>
`,
@@ -22,14 +24,17 @@ ruleTester.run(RULE_NAME, rule, {
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
{ type: T.JSXFragment, messageId: "noUselessFragment" },
],
+ output: "moofoo
",
},
{
code: /* tsx */ `<>{meow}>
`,
errors: [{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" }],
+ output: "{meow}
",
},
{
code: /* tsx */ `<>>`,
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
+ output: /* tsx */ ``,
},
{
code: /* tsx */ `
@@ -38,10 +43,15 @@ ruleTester.run(RULE_NAME, rule, {
>
`,
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
+
+ output: /* tsx */ `
+
+ `,
},
{
code: /* tsx */ ``,
errors: [{ type: T.JSXElement, messageId: "noUselessFragment" }],
+ output: null,
},
{
code: /* tsx */ `
@@ -50,10 +60,14 @@ ruleTester.run(RULE_NAME, rule, {
`,
errors: [{ type: T.JSXElement, messageId: "noUselessFragment" }],
+ output: /* tsx */ `
+
+ `,
},
{
code: /* tsx */ `<>foo>`,
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
+ output: null,
},
{
code: /* tsx */ `<>foo>
`,
@@ -61,10 +75,12 @@ ruleTester.run(RULE_NAME, rule, {
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
{ type: T.JSXFragment, messageId: "noUselessFragment" },
],
+ output: "foo
",
},
{
code: '<>{"a"}{"b"}>
',
errors: [{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" }],
+ output: '{"a"}{"b"}
',
},
{
code: /* tsx */ `
@@ -75,10 +91,18 @@ ruleTester.run(RULE_NAME, rule, {
`,
errors: [{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" }],
+ output: /* tsx */ `
+
+ `,
},
{
code: '{"a"}{"b"}
',
errors: [{ type: T.JSXElement, messageId: "noUselessFragmentInBuiltIn" }],
+ output: '{"a"}{"b"}
',
},
{
// whitespace tricky case
@@ -95,10 +119,18 @@ ruleTester.run(RULE_NAME, rule, {
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
],
+ output: /* tsx */ `
+
+ `,
},
{
code: 'a <>{""}{""}> a
',
errors: [{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" }],
+ output: 'a {""}{""} a
',
},
{
code: /* tsx */ `
@@ -112,11 +144,20 @@ ruleTester.run(RULE_NAME, rule, {
{ type: T.JSXElement, messageId: "noUselessFragmentInBuiltIn" },
{ type: T.JSXElement, messageId: "noUselessFragment" },
],
+ // eslint-disable-next-line unicorn/template-indent
+ output: /* tsx */ `
+ const Comp = () => (
+
+
+
+ );
+ `,
},
// Ensure allowExpressions still catches expected violations
{
code: /* tsx */ `<>{moo}>`,
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
+ output: /* tsx */ `{moo}`,
},
{
code: /* tsx */ `<>{moo}>`,
@@ -135,6 +176,7 @@ ruleTester.run(RULE_NAME, rule, {
messageId: "noUselessFragment",
}],
options: [{ allowExpressions: false }],
+ output: /* tsx */ `<>{moo}>`,
},
{
code: /* tsx */ `baz>}/>`,
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.ts
index f436ef7d1e..59219a2b7e 100644
--- a/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.ts
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.ts
@@ -3,6 +3,7 @@ import * as JSX from "@eslint-react/jsx";
import type { RuleContext, RuleFeature } from "@eslint-react/shared";
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
import type { TSESTree } from "@typescript-eslint/utils";
+import type { RuleFixer } from "@typescript-eslint/utils/ts-eslint";
import { createRule } from "../utils";
@@ -27,11 +28,56 @@ const defaultOptions = [{
allowExpressions: true,
}] as const satisfies Options;
+function trimLikeReact(text: string) {
+ const leadingSpaces = /^\s*/.exec(text)?.[0] ?? "";
+ const trailingSpaces = /\s*$/.exec(text)?.[0] ?? "";
+
+ const start = leadingSpaces.includes("\n") ? leadingSpaces.length : 0;
+ const end = trailingSpaces.includes("\n") ? text.length - trailingSpaces.length : text.length;
+
+ return text.slice(start, end);
+}
+
function checkAndReport(
node: TSESTree.JSXElement | TSESTree.JSXFragment,
context: RuleContext,
allowExpressions: boolean,
) {
+ function fix(fixer: RuleFixer) {
+ // Not safe to fix fragments without a jsx parent.
+ if (!(node.parent.type === T.JSXElement || node.parent.type === T.JSXFragment)) {
+ // const a = <>>
+ if (node.children.length === 0) {
+ return null;
+ }
+
+ // const a = <>cat {meow}>
+ if (
+ node.children.some(
+ (child) =>
+ (JSX.isLiteral(child) && !JSX.isWhiteSpace(child))
+ || AST.is(T.JSXExpressionContainer)(child),
+ )
+ ) {
+ return null;
+ }
+ }
+
+ // Not safe to fix `<>foo>` because `Eeee` might require its children be a ReactElement.
+ if (JSX.isUserDefinedElement(node.parent)) {
+ return null;
+ }
+
+ const opener = node.type === T.JSXFragment ? node.openingFragment : node.openingElement;
+ const closer = node.type === T.JSXFragment ? node.closingFragment : node.closingElement;
+
+ const childrenText = opener.type === T.JSXOpeningElement && opener.selfClosing
+ ? ""
+ : context.sourceCode.getText().slice(opener.range[1], closer?.range[0]);
+
+ return fixer.replaceText(node, trimLikeReact(childrenText));
+ }
+
const initialScope = context.sourceCode.getScope(node);
// return if the fragment is keyed (e.g. )
if (JSX.isKeyedElement(node, initialScope)) {
@@ -39,11 +85,11 @@ function checkAndReport(
}
// report if the fragment is placed inside a built-in component (e.g. <>>
)
if (JSX.isBuiltInElement(node.parent)) {
- context.report({ messageId: "noUselessFragmentInBuiltIn", node });
+ context.report({ messageId: "noUselessFragmentInBuiltIn", node, fix });
}
// report and return if the fragment has no children (e.g. <>>)
if (node.children.length === 0) {
- context.report({ messageId: "noUselessFragment", node });
+ context.report({ messageId: "noUselessFragment", node, fix });
return;
}
const isChildElement = AST.isOneOf([T.JSXElement, T.JSXFragment])(node.parent);
@@ -58,7 +104,7 @@ function checkAndReport(
// <>hello, world>
case !allowExpressions
&& isChildElement: {
- context.report({ messageId: "noUselessFragment", node });
+ context.report({ messageId: "noUselessFragment", node, fix });
return;
}
case !allowExpressions
@@ -66,7 +112,7 @@ function checkAndReport(
&& node.children.length === 1: {
// const foo = <>{children}>;
// return <>{children}>;
- context.report({ messageId: "noUselessFragment", node });
+ context.report({ messageId: "noUselessFragment", node, fix });
return;
}
}
@@ -76,7 +122,7 @@ function checkAndReport(
case nonPaddingChildren.length === 0:
case nonPaddingChildren.length === 1
&& firstNonPaddingChild?.type !== T.JSXExpressionContainer: {
- context.report({ messageId: "noUselessFragment", node });
+ context.report({ messageId: "noUselessFragment", node, fix });
return;
}
}
@@ -90,6 +136,7 @@ export default createRule({
docs: {
description: "disallow unnecessary fragments",
},
+ fixable: "code",
messages: {
noUselessFragment: "A fragment contains less than two children is unnecessary.",
noUselessFragmentInBuiltIn: "A fragment placed inside a built-in component is unnecessary.",
From f3fa3ee30b913073ab418050fbc9695e42e7cfaf Mon Sep 17 00:00:00 2001
From: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Date: Thu, 23 Jan 2025 23:14:14 +0800
Subject: [PATCH 2/2] chore: update
---
.../eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts b/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts
index e90e0eca0c..3fabe74a34 100644
--- a/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts
+++ b/packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts
@@ -43,7 +43,6 @@ ruleTester.run(RULE_NAME, rule, {
>
`,
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
-
output: /* tsx */ `
`,