Skip to content

Commit 7ccff10

Browse files
golopotljharb
andcommitted
[New] add jsx-no-useless-fragment rule
Co-Authored-By: Jordan Harband <[email protected]>
1 parent 66725bc commit 7ccff10

File tree

7 files changed

+518
-1
lines changed

7 files changed

+518
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ Enable the rules that you would like to use.
170170
* [react/jsx-no-literals](docs/rules/jsx-no-literals.md): Prevent usage of unwrapped JSX strings
171171
* [react/jsx-no-target-blank](docs/rules/jsx-no-target-blank.md): Prevent usage of unsafe `target='_blank'`
172172
* [react/jsx-no-undef](docs/rules/jsx-no-undef.md): Disallow undeclared variables in JSX
173+
* [react/jsx-no-useless-fragment](docs/rules/jsx-no-useless-fragment.md): Disallow unnescessary fragments (fixable)
173174
* [react/jsx-one-expression-per-line](docs/rules/jsx-one-expression-per-line.md): Limit to one expression per line in JSX
174175
* [react/jsx-curly-brace-presence](docs/rules/jsx-curly-brace-presence.md): Enforce curly braces or disallow unnecessary curly braces in JSX
175176
* [react/jsx-fragments](docs/rules/jsx-fragments.md): Enforce shorthand or standard form for React fragments

docs/rules/jsx-no-useless-fragment.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Disallow unnecessary fragments (react/jsx-no-useless-fragment)
2+
3+
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://reactjs.org/docs/fragments.html#keyed-fragments).
4+
5+
**Fixable:** This rule is sometimes automatically fixable using the `--fix` flag on the command line.
6+
7+
## Rule Details
8+
9+
The following patterns are considered warnings:
10+
11+
```jsx
12+
<>{foo}</>
13+
14+
<><Foo /></>
15+
16+
<p><>foo</></p>
17+
18+
<></>
19+
20+
<Fragment>foo</Fragment>
21+
22+
<React.Fragment>foo</React.Fragment>
23+
24+
<section>
25+
<>
26+
<div />
27+
<div />
28+
</>
29+
</section>
30+
```
31+
32+
The following patterns are **not** considered warnings:
33+
34+
```jsx
35+
<>
36+
<Foo />
37+
<Bar />
38+
</>
39+
40+
<>foo {bar}</>
41+
42+
<> {foo}</>
43+
44+
const cat = <>meow</>
45+
46+
<SomeComponent>
47+
<>
48+
<div />
49+
<div />
50+
</>
51+
</SomeComponent>
52+
53+
<Fragment key={item.id}>{item.value}</Fragment>
54+
```

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const allRules = {
3535
'jsx-no-duplicate-props': require('./lib/rules/jsx-no-duplicate-props'),
3636
'jsx-no-literals': require('./lib/rules/jsx-no-literals'),
3737
'jsx-no-target-blank': require('./lib/rules/jsx-no-target-blank'),
38+
'jsx-no-useless-fragment': require('./lib/rules/jsx-no-useless-fragment'),
3839
'jsx-one-expression-per-line': require('./lib/rules/jsx-one-expression-per-line'),
3940
'jsx-no-undef': require('./lib/rules/jsx-no-undef'),
4041
'jsx-curly-brace-presence': require('./lib/rules/jsx-curly-brace-presence'),

lib/rules/jsx-no-useless-fragment.js

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* @fileoverview Disallow useless fragments
3+
*/
4+
5+
'use strict';
6+
7+
const pragmaUtil = require('../util/pragma');
8+
const jsxUtil = require('../util/jsx');
9+
const docsUrl = require('../util/docsUrl');
10+
11+
function isJSXText(node) {
12+
return !!node && (node.type === 'JSXText' || node.type === 'Literal');
13+
}
14+
15+
/**
16+
* @param {string} text
17+
* @returns {boolean}
18+
*/
19+
function isOnlyWhitespace(text) {
20+
return text.trim().length === 0;
21+
}
22+
23+
/**
24+
* @param {ASTNode} node
25+
* @returns {boolean}
26+
*/
27+
function isNonspaceJSXTextOrJSXCurly(node) {
28+
return (isJSXText(node) && !isOnlyWhitespace(node.raw)) || node.type === 'JSXExpressionContainer';
29+
}
30+
31+
/**
32+
* Somehow fragment like this is useful: <Foo content={<>ee eeee eeee ...</>} />
33+
* @param {ASTNode} node
34+
* @returns {boolean}
35+
*/
36+
function isFragmentWithOnlyTextAndIsNotChild(node) {
37+
return node.children.length === 1 &&
38+
isJSXText(node.children[0]) &&
39+
!(node.parent.type === 'JSXElement' || node.parent.type === 'JSXFragment');
40+
}
41+
42+
/**
43+
* @param {string} text
44+
* @returns {string}
45+
*/
46+
function trimLikeReact(text) {
47+
const leadingSpaces = /^\s*/.exec(text)[0];
48+
const trailingSpaces = /\s*$/.exec(text)[0];
49+
50+
const start = leadingSpaces.includes('\n') ? leadingSpaces.length : 0;
51+
const end = trailingSpaces.includes('\n') ? text.length - trailingSpaces.length : text.length;
52+
53+
return text.slice(start, end);
54+
}
55+
56+
/**
57+
* Test if node is like `<Fragment key={_}>_</Fragment>`
58+
* @param {JSXElement} node
59+
* @returns {boolean}
60+
*/
61+
function isKeyedElement(node) {
62+
return node.type === 'JSXElement' &&
63+
node.openingElement.attributes &&
64+
node.openingElement.attributes.some(jsxUtil.isJSXAttributeKey);
65+
}
66+
67+
module.exports = {
68+
meta: {
69+
type: 'suggestion',
70+
fixable: 'code',
71+
docs: {
72+
description: 'Disallow unnecessary fragments',
73+
category: 'Possible Errors',
74+
recommended: false,
75+
url: docsUrl('jsx-no-useless-fragment')
76+
},
77+
messages: {
78+
NeedsMoreChidren: 'Fragments should contain more than one child - otherwise, there‘s no need for a Fragment at all.',
79+
ChildOfHtmlElement: 'Passing a fragment to an HTML element is useless.'
80+
}
81+
},
82+
83+
create(context) {
84+
const reactPragma = pragmaUtil.getFromContext(context);
85+
const fragmentPragma = pragmaUtil.getFragmentFromContext(context);
86+
87+
/**
88+
* Test whether a node is an padding spaces trimmed by react runtime.
89+
* @param {ASTNode} node
90+
* @returns {boolean}
91+
*/
92+
function isPaddingSpaces(node) {
93+
return isJSXText(node) &&
94+
isOnlyWhitespace(node.raw) &&
95+
node.raw.includes('\n');
96+
}
97+
98+
/**
99+
* Test whether a JSXElement has less than two children, excluding paddings spaces.
100+
* @param {JSXElement|JSXFragment} node
101+
* @returns {boolean}
102+
*/
103+
function hasLessThanTwoChildren(node) {
104+
if (!node || !node.children || node.children.length < 2) {
105+
return true;
106+
}
107+
108+
return (
109+
node.children.length -
110+
(+isPaddingSpaces(node.children[0])) -
111+
(+isPaddingSpaces(node.children[node.children.length - 1]))
112+
) < 2;
113+
}
114+
115+
/**
116+
* @param {JSXElement|JSXFragment} node
117+
* @returns {boolean}
118+
*/
119+
function isChildOfHtmlElement(node) {
120+
return node.parent.type === 'JSXElement' &&
121+
node.parent.openingElement.name.type === 'JSXIdentifier' &&
122+
/^[a-z]+$/.test(node.parent.openingElement.name.name);
123+
}
124+
125+
/**
126+
* @param {JSXElement|JSXFragment} node
127+
* @return {boolean}
128+
*/
129+
function isChildOfComponentElement(node) {
130+
return node.parent.type === 'JSXElement' &&
131+
!isChildOfHtmlElement(node) &&
132+
!jsxUtil.isFragment(node.parent, reactPragma, fragmentPragma);
133+
}
134+
135+
/**
136+
* @param {ASTNode} node
137+
* @returns {boolean}
138+
*/
139+
function canFix(node) {
140+
// Not safe to fix fragments without a jsx parent.
141+
if (!(node.parent.type === 'JSXElement' || node.parent.type === 'JSXFragment')) {
142+
// const a = <></>
143+
if (node.children.length === 0) {
144+
return false;
145+
}
146+
147+
// const a = <>cat {meow}</>
148+
if (node.children.some(isNonspaceJSXTextOrJSXCurly)) {
149+
return false;
150+
}
151+
}
152+
153+
// Not safe to fix `<Eeee><>foo</></Eeee>` because `Eeee` might require its children be a ReactElement.
154+
if (isChildOfComponentElement(node)) {
155+
return false;
156+
}
157+
158+
return true;
159+
}
160+
161+
/**
162+
* @param {ASTNode} node
163+
* @returns {Function | undefined}
164+
*/
165+
function getFix(node) {
166+
if (!canFix(node)) {
167+
return undefined;
168+
}
169+
170+
return function fix(fixer) {
171+
const opener = node.type === 'JSXFragment' ? node.openingFragment : node.openingElement;
172+
const closer = node.type === 'JSXFragment' ? node.closingFragment : node.closingElement;
173+
const childrenText = context.getSourceCode().getText().slice(opener.range[1], closer.range[0]);
174+
175+
return fixer.replaceText(node, trimLikeReact(childrenText));
176+
};
177+
}
178+
179+
function checkNode(node) {
180+
if (isKeyedElement(node)) {
181+
return;
182+
}
183+
184+
if (hasLessThanTwoChildren(node) && !isFragmentWithOnlyTextAndIsNotChild(node)) {
185+
context.report({
186+
node,
187+
messageId: 'NeedsMoreChidren',
188+
fix: getFix(node)
189+
});
190+
}
191+
192+
if (isChildOfHtmlElement(node)) {
193+
context.report({
194+
node,
195+
messageId: 'ChildOfHtmlElement',
196+
fix: getFix(node)
197+
});
198+
}
199+
}
200+
201+
return {
202+
JSXElement(node) {
203+
if (jsxUtil.isFragment(node, reactPragma, fragmentPragma)) {
204+
checkNode(node);
205+
}
206+
},
207+
JSXFragment: checkNode
208+
};
209+
}
210+
};

lib/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ declare global {
99
type Token = eslint.AST.Token;
1010
type Fixer = eslint.Rule.RuleFixer;
1111
type JSXAttribute = ASTNode;
12+
type JSXElement = ASTNode;
13+
type JSXFragment = ASTNode;
1214
type JSXSpreadAttribute = ASTNode;
1315

1416
interface Context extends eslint.SourceCode {

lib/util/jsx.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,35 @@ function isDOMComponent(node) {
2626
return COMPAT_TAG_REGEX.test(name);
2727
}
2828

29+
/**
30+
* Test whether a JSXElement is a fragment
31+
* @param {JSXElement} node
32+
* @param {string} reactPragma
33+
* @param {string} fragmentPragma
34+
* @returns {boolean}
35+
*/
36+
function isFragment(node, reactPragma, fragmentPragma) {
37+
const name = node.openingElement.name;
38+
39+
// <Fragment>
40+
if (name.type === 'JSXIdentifier' && name.name === fragmentPragma) {
41+
return true;
42+
}
43+
44+
// <React.Fragment>
45+
if (
46+
name.type === 'JSXMemberExpression' &&
47+
name.object.type === 'JSXIdentifier' &&
48+
name.object.name === reactPragma &&
49+
name.property.type === 'JSXIdentifier' &&
50+
name.property.name === fragmentPragma
51+
) {
52+
return true;
53+
}
54+
55+
return false;
56+
}
57+
2958
/**
3059
* Checks if a node represents a JSX element or fragment.
3160
* @param {object} node - node to check.
@@ -35,7 +64,21 @@ function isJSX(node) {
3564
return node && ['JSXElement', 'JSXFragment'].indexOf(node.type) >= 0;
3665
}
3766

67+
/**
68+
* Check if node is like `key={...}` as in `<Foo key={...} />`
69+
* @param {ASTNode} node
70+
* @returns {boolean}
71+
*/
72+
function isJSXAttributeKey(node) {
73+
return node.type === 'JSXAttribute' &&
74+
node.name &&
75+
node.name.type === 'JSXIdentifier' &&
76+
node.name.name === 'key';
77+
}
78+
3879
module.exports = {
3980
isDOMComponent,
40-
isJSX
81+
isFragment,
82+
isJSX,
83+
isJSXAttributeKey
4184
};

0 commit comments

Comments
 (0)