| /** |
| * @fileoverview Disallow useless fragments |
| */ |
| |
| 'use strict'; |
| |
| const arrayIncludes = require('array-includes'); |
| |
| const pragmaUtil = require('../util/pragma'); |
| const jsxUtil = require('../util/jsx'); |
| const docsUrl = require('../util/docsUrl'); |
| |
| function isJSXText(node) { |
| return !!node && (node.type === 'JSXText' || node.type === 'Literal'); |
| } |
| |
| /** |
| * @param {string} text |
| * @returns {boolean} |
| */ |
| function isOnlyWhitespace(text) { |
| return text.trim().length === 0; |
| } |
| |
| /** |
| * @param {ASTNode} node |
| * @returns {boolean} |
| */ |
| function isNonspaceJSXTextOrJSXCurly(node) { |
| return (isJSXText(node) && !isOnlyWhitespace(node.raw)) || node.type === 'JSXExpressionContainer'; |
| } |
| |
| /** |
| * Somehow fragment like this is useful: <Foo content={<>ee eeee eeee ...</>} /> |
| * @param {ASTNode} node |
| * @returns {boolean} |
| */ |
| function isFragmentWithOnlyTextAndIsNotChild(node) { |
| return node.children.length === 1 && |
| isJSXText(node.children[0]) && |
| !(node.parent.type === 'JSXElement' || node.parent.type === 'JSXFragment'); |
| } |
| |
| /** |
| * @param {string} text |
| * @returns {string} |
| */ |
| function trimLikeReact(text) { |
| const leadingSpaces = /^\s*/.exec(text)[0]; |
| const trailingSpaces = /\s*$/.exec(text)[0]; |
| |
| const start = arrayIncludes(leadingSpaces, '\n') ? leadingSpaces.length : 0; |
| const end = arrayIncludes(trailingSpaces, '\n') ? text.length - trailingSpaces.length : text.length; |
| |
| return text.slice(start, end); |
| } |
| |
| /** |
| * Test if node is like `<Fragment key={_}>_</Fragment>` |
| * @param {JSXElement} node |
| * @returns {boolean} |
| */ |
| function isKeyedElement(node) { |
| return node.type === 'JSXElement' && |
| node.openingElement.attributes && |
| node.openingElement.attributes.some(jsxUtil.isJSXAttributeKey); |
| } |
| |
| module.exports = { |
| meta: { |
| type: 'suggestion', |
| fixable: 'code', |
| docs: { |
| description: 'Disallow unnecessary fragments', |
| category: 'Possible Errors', |
| recommended: false, |
| url: docsUrl('jsx-no-useless-fragment') |
| }, |
| messages: { |
| NeedsMoreChidren: 'Fragments should contain more than one child - otherwise, thereās no need for a Fragment at all.', |
| ChildOfHtmlElement: 'Passing a fragment to an HTML element is useless.' |
| } |
| }, |
| |
| create(context) { |
| const reactPragma = pragmaUtil.getFromContext(context); |
| const fragmentPragma = pragmaUtil.getFragmentFromContext(context); |
| |
| /** |
| * Test whether a node is an padding spaces trimmed by react runtime. |
| * @param {ASTNode} node |
| * @returns {boolean} |
| */ |
| function isPaddingSpaces(node) { |
| return isJSXText(node) && |
| isOnlyWhitespace(node.raw) && |
| arrayIncludes(node.raw, '\n'); |
| } |
| |
| /** |
| * Test whether a JSXElement has less than two children, excluding paddings spaces. |
| * @param {JSXElement|JSXFragment} node |
| * @returns {boolean} |
| */ |
| function hasLessThanTwoChildren(node) { |
| if (!node || !node.children || node.children.length < 2) { |
| return true; |
| } |
| |
| return ( |
| node.children.length - |
| (+isPaddingSpaces(node.children[0])) - |
| (+isPaddingSpaces(node.children[node.children.length - 1])) |
| ) < 2; |
| } |
| |
| /** |
| * @param {JSXElement|JSXFragment} node |
| * @returns {boolean} |
| */ |
| function isChildOfHtmlElement(node) { |
| return node.parent.type === 'JSXElement' && |
| node.parent.openingElement.name.type === 'JSXIdentifier' && |
| /^[a-z]+$/.test(node.parent.openingElement.name.name); |
| } |
| |
| /** |
| * @param {JSXElement|JSXFragment} node |
| * @return {boolean} |
| */ |
| function isChildOfComponentElement(node) { |
| return node.parent.type === 'JSXElement' && |
| !isChildOfHtmlElement(node) && |
| !jsxUtil.isFragment(node.parent, reactPragma, fragmentPragma); |
| } |
| |
| /** |
| * @param {ASTNode} node |
| * @returns {boolean} |
| */ |
| function canFix(node) { |
| // Not safe to fix fragments without a jsx parent. |
| if (!(node.parent.type === 'JSXElement' || node.parent.type === 'JSXFragment')) { |
| // const a = <></> |
| if (node.children.length === 0) { |
| return false; |
| } |
| |
| // const a = <>cat {meow}</> |
| if (node.children.some(isNonspaceJSXTextOrJSXCurly)) { |
| return false; |
| } |
| } |
| |
| // Not safe to fix `<Eeee><>foo</></Eeee>` because `Eeee` might require its children be a ReactElement. |
| if (isChildOfComponentElement(node)) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| /** |
| * @param {ASTNode} node |
| * @returns {Function | undefined} |
| */ |
| function getFix(node) { |
| if (!canFix(node)) { |
| return undefined; |
| } |
| |
| return function fix(fixer) { |
| const opener = node.type === 'JSXFragment' ? node.openingFragment : node.openingElement; |
| const closer = node.type === 'JSXFragment' ? node.closingFragment : node.closingElement; |
| const childrenText = context.getSourceCode().getText().slice(opener.range[1], closer.range[0]); |
| |
| return fixer.replaceText(node, trimLikeReact(childrenText)); |
| }; |
| } |
| |
| function checkNode(node) { |
| if (isKeyedElement(node)) { |
| return; |
| } |
| |
| if (hasLessThanTwoChildren(node) && !isFragmentWithOnlyTextAndIsNotChild(node)) { |
| context.report({ |
| node, |
| messageId: 'NeedsMoreChidren', |
| fix: getFix(node) |
| }); |
| } |
| |
| if (isChildOfHtmlElement(node)) { |
| context.report({ |
| node, |
| messageId: 'ChildOfHtmlElement', |
| fix: getFix(node) |
| }); |
| } |
| } |
| |
| return { |
| JSXElement(node) { |
| if (jsxUtil.isFragment(node, reactPragma, fragmentPragma)) { |
| checkNode(node); |
| } |
| }, |
| JSXFragment: checkNode |
| }; |
| } |
| }; |