Skip to content

Commit f07308b

Browse files
committed
feat(no-useless-fragment): auto fix support
1 parent 41ee491 commit f07308b

File tree

2 files changed

+94
-5
lines changed

2 files changed

+94
-5
lines changed

packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,33 @@ ruleTester.run(RULE_NAME, rule, {
88
{
99
code: /* tsx */ `<></>`,
1010
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
11+
output: null,
1112
},
1213
{
1314
code: /* tsx */ `<p><>foo</></p>`,
1415
errors: [
1516
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
1617
{ type: T.JSXFragment, messageId: "noUselessFragment" },
1718
],
19+
output: /* tsx */ `<p>foo</p>`,
1820
},
1921
{
2022
code: /* tsx */ `<p>moo<>foo</></p>`,
2123
errors: [
2224
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
2325
{ type: T.JSXFragment, messageId: "noUselessFragment" },
2426
],
27+
output: "<p>moofoo</p>",
2528
},
2629
{
2730
code: /* tsx */ `<p><>{meow}</></p>`,
2831
errors: [{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" }],
32+
output: "<p>{meow}</p>",
2933
},
3034
{
3135
code: /* tsx */ `<><div/></>`,
3236
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
37+
output: /* tsx */ `<div/>`,
3338
},
3439
{
3540
code: /* tsx */ `
@@ -38,10 +43,15 @@ ruleTester.run(RULE_NAME, rule, {
3843
</>
3944
`,
4045
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
46+
47+
output: /* tsx */ `
48+
<div/>
49+
`,
4150
},
4251
{
4352
code: /* tsx */ `<Fragment />`,
4453
errors: [{ type: T.JSXElement, messageId: "noUselessFragment" }],
54+
output: null,
4555
},
4656
{
4757
code: /* tsx */ `
@@ -50,21 +60,27 @@ ruleTester.run(RULE_NAME, rule, {
5060
</React.Fragment>
5161
`,
5262
errors: [{ type: T.JSXElement, messageId: "noUselessFragment" }],
63+
output: /* tsx */ `
64+
<Foo />
65+
`,
5366
},
5467
{
5568
code: /* tsx */ `<Eeee><>foo</></Eeee>`,
5669
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
70+
output: null,
5771
},
5872
{
5973
code: /* tsx */ `<div><>foo</></div>`,
6074
errors: [
6175
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
6276
{ type: T.JSXFragment, messageId: "noUselessFragment" },
6377
],
78+
output: "<div>foo</div>",
6479
},
6580
{
6681
code: '<div><>{"a"}{"b"}</></div>',
6782
errors: [{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" }],
83+
output: '<div>{"a"}{"b"}</div>',
6884
},
6985
{
7086
code: /* tsx */ `
@@ -75,10 +91,18 @@ ruleTester.run(RULE_NAME, rule, {
7591
</section>
7692
`,
7793
errors: [{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" }],
94+
output: /* tsx */ `
95+
<section>
96+
<Eeee />
97+
<Eeee />
98+
{"a"}{"b"}
99+
</section>
100+
`,
78101
},
79102
{
80103
code: '<div><Fragment>{"a"}{"b"}</Fragment></div>',
81104
errors: [{ type: T.JSXElement, messageId: "noUselessFragmentInBuiltIn" }],
105+
output: '<div>{"a"}{"b"}</div>',
82106
},
83107
{
84108
// whitespace tricky case
@@ -95,10 +119,18 @@ ruleTester.run(RULE_NAME, rule, {
95119
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
96120
{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" },
97121
],
122+
output: /* tsx */ `
123+
<section>
124+
git<b>hub</b>.
125+
126+
git <b>hub</b>
127+
</section>
128+
`,
98129
},
99130
{
100131
code: '<div>a <>{""}{""}</> a</div>',
101132
errors: [{ type: T.JSXFragment, messageId: "noUselessFragmentInBuiltIn" }],
133+
output: '<div>a {""}{""} a</div>',
102134
},
103135
{
104136
code: /* tsx */ `
@@ -112,11 +144,20 @@ ruleTester.run(RULE_NAME, rule, {
112144
{ type: T.JSXElement, messageId: "noUselessFragmentInBuiltIn" },
113145
{ type: T.JSXElement, messageId: "noUselessFragment" },
114146
],
147+
// eslint-disable-next-line unicorn/template-indent
148+
output: /* tsx */ `
149+
const Comp = () => (
150+
<html>
151+
152+
</html>
153+
);
154+
`,
115155
},
116156
// Ensure allowExpressions still catches expected violations
117157
{
118158
code: /* tsx */ `<><Foo>{moo}</Foo></>`,
119159
errors: [{ type: T.JSXFragment, messageId: "noUselessFragment" }],
160+
output: /* tsx */ `<Foo>{moo}</Foo>`,
120161
},
121162
{
122163
code: /* tsx */ `<>{moo}</>`,
@@ -135,6 +176,7 @@ ruleTester.run(RULE_NAME, rule, {
135176
messageId: "noUselessFragment",
136177
}],
137178
options: [{ allowExpressions: false }],
179+
output: /* tsx */ `<>{moo}</>`,
138180
},
139181
{
140182
code: /* tsx */ `<Foo bar={<>baz</>}/>`,

packages/plugins/eslint-plugin-react-x/src/rules/no-useless-fragment.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as JSX from "@eslint-react/jsx";
33
import type { RuleContext, RuleFeature } from "@eslint-react/shared";
44
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
55
import type { TSESTree } from "@typescript-eslint/utils";
6+
import type { RuleFixer } from "@typescript-eslint/utils/ts-eslint";
67

78
import { createRule } from "../utils";
89

@@ -27,23 +28,68 @@ const defaultOptions = [{
2728
allowExpressions: true,
2829
}] as const satisfies Options;
2930

31+
function trimLikeReact(text: string) {
32+
const leadingSpaces = /^\s*/.exec(text)?.[0] ?? "";
33+
const trailingSpaces = /\s*$/.exec(text)?.[0] ?? "";
34+
35+
const start = leadingSpaces.includes("\n") ? leadingSpaces.length : 0;
36+
const end = trailingSpaces.includes("\n") ? text.length - trailingSpaces.length : text.length;
37+
38+
return text.slice(start, end);
39+
}
40+
3041
function checkAndReport(
3142
node: TSESTree.JSXElement | TSESTree.JSXFragment,
3243
context: RuleContext,
3344
allowExpressions: boolean,
3445
) {
46+
function fix(fixer: RuleFixer) {
47+
// Not safe to fix fragments without a jsx parent.
48+
if (!(node.parent.type === T.JSXElement || node.parent.type === T.JSXFragment)) {
49+
// const a = <></>
50+
if (node.children.length === 0) {
51+
return null;
52+
}
53+
54+
// const a = <>cat {meow}</>
55+
if (
56+
node.children.some(
57+
(child) =>
58+
(JSX.isLiteral(child) && !JSX.isWhiteSpace(child))
59+
|| AST.is(T.JSXExpressionContainer)(child),
60+
)
61+
) {
62+
return null;
63+
}
64+
}
65+
66+
// Not safe to fix `<Eeee><>foo</></Eeee>` because `Eeee` might require its children be a ReactElement.
67+
if (JSX.isUserDefinedElement(node.parent)) {
68+
return null;
69+
}
70+
71+
const opener = node.type === T.JSXFragment ? node.openingFragment : node.openingElement;
72+
const closer = node.type === T.JSXFragment ? node.closingFragment : node.closingElement;
73+
74+
const childrenText = opener.type === T.JSXOpeningElement && opener.selfClosing
75+
? ""
76+
: context.sourceCode.getText().slice(opener.range[1], closer?.range[0]);
77+
78+
return fixer.replaceText(node, trimLikeReact(childrenText));
79+
}
80+
3581
const initialScope = context.sourceCode.getScope(node);
3682
// return if the fragment is keyed (e.g. <Fragment key={key}>)
3783
if (JSX.isKeyedElement(node, initialScope)) {
3884
return;
3985
}
4086
// report if the fragment is placed inside a built-in component (e.g. <div><></></div>)
4187
if (JSX.isBuiltInElement(node.parent)) {
42-
context.report({ messageId: "noUselessFragmentInBuiltIn", node });
88+
context.report({ messageId: "noUselessFragmentInBuiltIn", node, fix });
4389
}
4490
// report and return if the fragment has no children (e.g. <></>)
4591
if (node.children.length === 0) {
46-
context.report({ messageId: "noUselessFragment", node });
92+
context.report({ messageId: "noUselessFragment", node, fix });
4793
return;
4894
}
4995
const isChildElement = AST.isOneOf([T.JSXElement, T.JSXFragment])(node.parent);
@@ -58,15 +104,15 @@ function checkAndReport(
58104
// <Foo><>hello, world</></Foo>
59105
case !allowExpressions
60106
&& isChildElement: {
61-
context.report({ messageId: "noUselessFragment", node });
107+
context.report({ messageId: "noUselessFragment", node, fix });
62108
return;
63109
}
64110
case !allowExpressions
65111
&& !isChildElement
66112
&& node.children.length === 1: {
67113
// const foo = <>{children}</>;
68114
// return <>{children}</>;
69-
context.report({ messageId: "noUselessFragment", node });
115+
context.report({ messageId: "noUselessFragment", node, fix });
70116
return;
71117
}
72118
}
@@ -76,7 +122,7 @@ function checkAndReport(
76122
case nonPaddingChildren.length === 0:
77123
case nonPaddingChildren.length === 1
78124
&& firstNonPaddingChild?.type !== T.JSXExpressionContainer: {
79-
context.report({ messageId: "noUselessFragment", node });
125+
context.report({ messageId: "noUselessFragment", node, fix });
80126
return;
81127
}
82128
}
@@ -90,6 +136,7 @@ export default createRule<Options, MessageID>({
90136
docs: {
91137
description: "disallow unnecessary fragments",
92138
},
139+
fixable: "code",
93140
messages: {
94141
noUselessFragment: "A fragment contains less than two children is unnecessary.",
95142
noUselessFragmentInBuiltIn: "A fragment placed inside a built-in component is unnecessary.",

0 commit comments

Comments
 (0)