jsx-no-useless-fragment.js 6.8 KB

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