You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
249 lines
6.6 KiB
249 lines
6.6 KiB
/** |
|
* @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'); |
|
const report = require('../util/report'); |
|
|
|
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'; |
|
} |
|
|
|
const messages = { |
|
NeedsMoreChildren: '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.', |
|
}; |
|
|
|
module.exports = { |
|
meta: { |
|
type: 'suggestion', |
|
fixable: 'code', |
|
docs: { |
|
description: 'Disallow unnecessary fragments', |
|
category: 'Possible Errors', |
|
recommended: false, |
|
url: docsUrl('jsx-no-useless-fragment'), |
|
}, |
|
messages, |
|
}, |
|
|
|
create(context) { |
|
const config = context.options[0] || {}; |
|
const allowExpressions = config.allowExpressions || false; |
|
|
|
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'); |
|
} |
|
|
|
function isFragmentWithSingleExpression(node) { |
|
const children = node && node.children.filter((child) => !isPaddingSpaces(child)); |
|
return ( |
|
children |
|
&& children.length === 1 |
|
&& children[0].type === 'JSXExpressionContainer' |
|
); |
|
} |
|
|
|
/** |
|
* 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; |
|
} |
|
|
|
// old TS parser can't handle this one |
|
if (node.type === 'JSXFragment' && (!node.openingFragment || !node.closingFragment)) { |
|
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) |
|
&& !(allowExpressions && isFragmentWithSingleExpression(node)) |
|
) { |
|
report(context, messages.NeedsMoreChildren, 'NeedsMoreChildren', { |
|
node, |
|
fix: getFix(node), |
|
}); |
|
} |
|
|
|
if (isChildOfHtmlElement(node)) { |
|
report(context, messages.ChildOfHtmlElement, 'ChildOfHtmlElement', { |
|
node, |
|
fix: getFix(node), |
|
}); |
|
} |
|
} |
|
|
|
return { |
|
JSXElement(node) { |
|
if (jsxUtil.isFragment(node, reactPragma, fragmentPragma)) { |
|
checkNode(node); |
|
} |
|
}, |
|
JSXFragment: checkNode, |
|
}; |
|
}, |
|
};
|
|
|