|
|
/** * @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); }
/** * @param {ASTNode} node * @returns {boolean} */ function containsCallExpression(node) { return node && node.type === 'JSXExpressionContainer' && node.expression && node.expression.type === 'CallExpression'; }
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) { return true; }
/** @type {ASTNode[]} */ const nonPaddingChildren = node.children.filter( (child) => !isPaddingSpaces(child) );
if (nonPaddingChildren.length < 2) { return !containsCallExpression(nonPaddingChildren[0]); } }
/** * @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 = opener.selfClosing ? '' : 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 }; } };
|