jsx-no-useless-fragment.js 6.9 KB

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