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 */ ` +
+ + + {"a"}{"b"} +
+ `, }, { 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 */ ` +
+ github. + + git hub +
+ `, }, { 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 */ `
`,