Skip to content

Commit 23f76e6

Browse files
authored
feat: add no-useless-fragment (#64)
1 parent e8b18ad commit 23f76e6

File tree

11 files changed

+706
-2
lines changed

11 files changed

+706
-2
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export default [
112112
| [jsx/no-missing-key](packages/eslint-plugin-jsx/src/rules/no-missing-key.md) | require `key` prop when rendering list |
113113
| [jsx/no-misused-comment-in-textnode](packages/eslint-plugin-jsx/src/rules/no-misused-comment-in-textnode.md) | disallow comments from being inserted as text nodes |
114114
| [jsx/no-script-url](packages/eslint-plugin-jsx/src/rules/no-script-url.md) | disallow `javascript:` URLs as JSX event handler prop's value |
115+
| [jsx/no-useless-fragment](packages/eslint-plugin-jsx/src/rules/no-useless-fragment.md) | disallow unnecessary fragments |
115116
| [jsx/prefer-shorthand-boolean](packages/eslint-plugin-jsx/src/rules/prefer-shorthand-boolean.md) | enforce boolean attributes notation in JSX |
116117

117118
### naming-convention
@@ -156,7 +157,7 @@ export default [
156157
- [x] `jsx/no-script-url`
157158
- [ ] `jsx/no-target-blank`
158159
- [ ] `jsx/no-unknown-property`
159-
- [ ] `jsx/no-useless-fragment`
160+
- [x] `jsx/no-useless-fragment`
160161
- [ ] `jsx/prefer-fragment-syntax`
161162
- [x] `jsx/prefer-shorthand-boolean`
162163
- [x] `naming-convention/filename-extension`

eslint-doc-generator.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export default {
3030
pathRuleList: "README.md",
3131
ruleDocSectionInclude: ["Rule Details"],
3232
ruleDocTitleFormat: "name",
33+
ruleListColumns: ["name", "description"],
3334
ruleListSplit(rules) {
3435
const record = rules.reduce<Record<string, RuleNamesAndRules>>((acc, [name, rule]) => {
3536
const title = /^([\w-]+)\/[\w-]+/iu.exec(name)?.[1] ?? defaultTitle;

packages/eslint-plugin-jsx/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import jsxNoLeakedConditionalRendering from "./rules/no-leaked-conditional-rende
77
import jsxNoMissingKey from "./rules/no-missing-key";
88
import jsxNoMisusedCommentInTextNode from "./rules/no-misused-comment-in-textnode";
99
import jsxNoScriptUrl from "./rules/no-script-url";
10+
import jsxNoUselessFragment from "./rules/no-useless-fragment";
1011
import jsxPreferShorthandJsxBoolean from "./rules/prefer-shorthand-boolean";
1112

1213
export { name } from "../package.json";
@@ -18,5 +19,6 @@ export const rules = {
1819
"no-missing-key": jsxNoMissingKey,
1920
"no-misused-comment-in-textnode": jsxNoMisusedCommentInTextNode,
2021
"no-script-url": jsxNoScriptUrl,
22+
"no-useless-fragment": jsxNoUselessFragment,
2123
"prefer-shorthand-boolean": jsxPreferShorthandJsxBoolean,
2224
} as const;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# jsx/no-useless-fragment
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
A fragment is redundant if it contains only one child, or if it is the child of a html element, and is not a [keyed fragment](https://react.dev/reference/react/Fragment#caveats).
8+
9+
## Rule Details
10+
11+
### ❌ Incorrect
12+
13+
```tsx
14+
<>{foo}</>
15+
16+
<><Foo /></>
17+
18+
<p><>foo</></p>
19+
20+
<></>
21+
22+
<Fragment>foo</Fragment>
23+
24+
<React.Fragment>foo</React.Fragment>
25+
26+
<section>
27+
<>
28+
<div />
29+
<div />
30+
</>
31+
</section>
32+
33+
{showFullName ? <>{fullName}</> : <>{firstName}</>}
34+
```
35+
36+
### ✅ Correct
37+
38+
```tsx
39+
{foo}
40+
41+
<Foo />
42+
43+
<>
44+
<Foo />
45+
<Bar />
46+
</>
47+
48+
<>foo {bar}</>
49+
50+
<> {foo}</>
51+
52+
const cat = <>meow</>
53+
54+
<SomeComponent>
55+
<>
56+
<div />
57+
<div />
58+
</>
59+
</SomeComponent>
60+
61+
<Fragment key={item.id}>{item.value}</Fragment>
62+
63+
{showFullName ? fullName : firstName}
64+
```
65+
66+
## Rule Options
67+
68+
### `allowExpressions`
69+
70+
When `true` single expressions in a fragment will be allowed. This is useful in
71+
places like Typescript where `string` does not satisfy the expected return type
72+
of `JSX.Element`. A common workaround is to wrap the variable holding a string
73+
in a fragment and expression.
74+
75+
Examples of **correct** code for the rule, when `"allowExpressions"` is `true`:
76+
77+
```jsx
78+
<>{foo}</>
79+
80+
<>
81+
{foo}
82+
</>
83+
```
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { allValid } from "@eslint-react/shared";
2+
import { AST_NODE_TYPES } from "@typescript-eslint/types";
3+
4+
import RuleTester, { getFixturesRootDir } from "../../../../test/rule-tester";
5+
import rule, { RULE_NAME } from "./no-useless-fragment";
6+
7+
const rootDir = getFixturesRootDir();
8+
9+
const ruleTester = new RuleTester({
10+
parser: "@typescript-eslint/parser",
11+
parserOptions: {
12+
ecmaFeatures: {
13+
jsx: true,
14+
},
15+
ecmaVersion: 2021,
16+
sourceType: "module",
17+
project: "./tsconfig.json",
18+
tsconfigRootDir: rootDir,
19+
},
20+
});
21+
22+
ruleTester.run(RULE_NAME, rule, {
23+
valid: [
24+
...allValid,
25+
"<><Foo /><Bar /></>",
26+
"<>foo<div /></>",
27+
"<> <div /></>",
28+
'<>{"moo"} </>',
29+
"<NotFragment />",
30+
"<React.NotFragment />",
31+
"<NotReact.Fragment />",
32+
"<Foo><><div /><div /></></Foo>",
33+
'<div p={<>{"a"}{"b"}</>} />',
34+
"<Fragment key={item.id}>{item.value}</Fragment>",
35+
"<Fooo content={<>eeee ee eeeeeee eeeeeeee</>} />",
36+
"<>{foos.map(foo => foo)}</>",
37+
{
38+
code: "<>{moo}</>",
39+
options: [{ allowExpressions: true }],
40+
},
41+
{
42+
code: `
43+
<>
44+
{moo}
45+
</>
46+
`,
47+
options: [{ allowExpressions: true }],
48+
},
49+
],
50+
invalid: [
51+
{
52+
code: "<></>",
53+
output: null,
54+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }],
55+
},
56+
{
57+
code: "<>{}</>",
58+
output: null,
59+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }],
60+
},
61+
{
62+
code: "<p>moo<>foo</></p>",
63+
output: "<p>moofoo</p>",
64+
errors: [
65+
{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment },
66+
{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment },
67+
],
68+
},
69+
{
70+
code: "<>{meow}</>",
71+
output: null,
72+
errors: [{ messageId: "NeedsMoreChildren" }],
73+
},
74+
{
75+
code: "<p><>{meow}</></p>",
76+
output: "<p>{meow}</p>",
77+
errors: [
78+
{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment },
79+
{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment },
80+
],
81+
},
82+
{
83+
code: "<><div/></>",
84+
output: "<div/>",
85+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }],
86+
},
87+
{
88+
code: `
89+
<>
90+
<div/>
91+
</>
92+
`,
93+
output: `
94+
<div/>
95+
`,
96+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }],
97+
},
98+
{
99+
code: "<Fragment />",
100+
output: null,
101+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXElement }],
102+
},
103+
{
104+
code: `
105+
<React.Fragment>
106+
<Foo />
107+
</React.Fragment>
108+
`,
109+
output: `
110+
<Foo />
111+
`,
112+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXElement }],
113+
},
114+
{
115+
code: `
116+
<SomeReact.SomeFragment>
117+
{foo}
118+
</SomeReact.SomeFragment>
119+
`,
120+
output: null,
121+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXElement }],
122+
settings: {
123+
react: {
124+
pragma: "SomeReact",
125+
fragment: "SomeFragment",
126+
},
127+
},
128+
},
129+
{
130+
// Not safe to fix this case because `Eeee` might require child be ReactElement
131+
code: "<Eeee><>foo</></Eeee>",
132+
output: null,
133+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }],
134+
},
135+
{
136+
code: "<div><>foo</></div>",
137+
output: "<div>foo</div>",
138+
errors: [
139+
{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment },
140+
{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment },
141+
],
142+
},
143+
{
144+
code: '<div><>{"a"}{"b"}</></div>',
145+
output: '<div>{"a"}{"b"}</div>',
146+
errors: [{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }],
147+
},
148+
{
149+
code: `
150+
<section>
151+
<Eeee />
152+
<Eeee />
153+
<>{"a"}{"b"}</>
154+
</section>`,
155+
output: `
156+
<section>
157+
<Eeee />
158+
<Eeee />
159+
{"a"}{"b"}
160+
</section>`,
161+
errors: [{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }],
162+
},
163+
{
164+
code: '<div><Fragment>{"a"}{"b"}</Fragment></div>',
165+
output: '<div>{"a"}{"b"}</div>',
166+
errors: [{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXElement }],
167+
},
168+
{
169+
// whitepace tricky case
170+
code: `
171+
<section>
172+
git<>
173+
<b>hub</b>.
174+
</>
175+
176+
git<> <b>hub</b></>
177+
</section>`,
178+
output: `
179+
<section>
180+
git<b>hub</b>.
181+
182+
git <b>hub</b>
183+
</section>`,
184+
errors: [
185+
{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment, line: 3 },
186+
{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment, line: 7 },
187+
],
188+
},
189+
{
190+
code: '<div>a <>{""}{""}</> a</div>',
191+
output: '<div>a {""}{""} a</div>',
192+
errors: [{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXFragment }],
193+
},
194+
{
195+
code: `
196+
const Comp = () => (
197+
<html>
198+
<React.Fragment />
199+
</html>
200+
);
201+
`,
202+
output: `
203+
const Comp = () => (
204+
<html>
205+
${/* dprint-ignore the trailing whitespace here is intentional */ ""}
206+
</html>
207+
);
208+
`,
209+
errors: [
210+
{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXElement, line: 4 },
211+
{ messageId: "ChildOfHtmlElement", type: AST_NODE_TYPES.JSXElement, line: 4 },
212+
],
213+
},
214+
// Ensure allowExpressions still catches expected violations
215+
{
216+
code: "<><Foo>{moo}</Foo></>",
217+
output: "<Foo>{moo}</Foo>",
218+
options: [{ allowExpressions: true }],
219+
errors: [{ messageId: "NeedsMoreChildren", type: AST_NODE_TYPES.JSXFragment }],
220+
},
221+
],
222+
});

0 commit comments

Comments
 (0)