UNPKG

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