/** * @fileoverview Prevent creating unstable components inside components * @author Ari Perkkiö */ 'use strict'; const Components = require('../util/Components'); const docsUrl = require('../util/docsUrl'); const isCreateElement = require('../util/isCreateElement'); const report = require('../util/report'); // ------------------------------------------------------------------------------ // Constants // ------------------------------------------------------------------------------ const COMPONENT_AS_PROPS_INFO = ' If you want to allow component creation in props, set allowAsProps option to true.'; const HOOK_REGEXP = /^use[A-Z0-9].*$/; // ------------------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------------------ /** * Generate error message with given parent component name * @param {String} parentName Name of the parent component, if known * @returns {String} Error message with parent component name */ function generateErrorMessageWithParentName(parentName) { return `Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component${parentName ? ` “${parentName}” ` : ' '}and pass data as props.`; } /** * Check whether given text starts with `render`. Comparison is case-sensitive. * @param {String} text Text to validate * @returns {Boolean} */ function startsWithRender(text) { return (text || '').startsWith('render'); } /** * Get closest parent matching given matcher * @param {ASTNode} node The AST node * @param {Context} context eslint context * @param {Function} matcher Method used to match the parent * @returns {ASTNode} The matching parent node, if any */ function getClosestMatchingParent(node, context, matcher) { if (!node || !node.parent || node.parent.type === 'Program') { return; } if (matcher(node.parent, context)) { return node.parent; } return getClosestMatchingParent(node.parent, context, matcher); } /** * Matcher used to check whether given node is a `createElement` call * @param {ASTNode} node The AST node * @param {Context} context eslint context * @returns {Boolean} True if node is a `createElement` call, false if not */ function isCreateElementMatcher(node, context) { return ( node && node.type === 'CallExpression' && isCreateElement(context, node) ); } /** * Matcher used to check whether given node is a `ObjectExpression` * @param {ASTNode} node The AST node * @returns {Boolean} True if node is a `ObjectExpression`, false if not */ function isObjectExpressionMatcher(node) { return node && node.type === 'ObjectExpression'; } /** * Matcher used to check whether given node is a `JSXExpressionContainer` * @param {ASTNode} node The AST node * @returns {Boolean} True if node is a `JSXExpressionContainer`, false if not */ function isJSXExpressionContainerMatcher(node) { return node && node.type === 'JSXExpressionContainer'; } /** * Matcher used to check whether given node is a `JSXAttribute` of `JSXExpressionContainer` * @param {ASTNode} node The AST node * @returns {Boolean} True if node is a `JSXAttribute` of `JSXExpressionContainer`, false if not */ function isJSXAttributeOfExpressionContainerMatcher(node) { return ( node && node.type === 'JSXAttribute' && node.value && node.value.type === 'JSXExpressionContainer' ); } /** * Matcher used to check whether given node is an object `Property` * @param {ASTNode} node The AST node * @returns {Boolean} True if node is a `Property`, false if not */ function isPropertyOfObjectExpressionMatcher(node) { return ( node && node.parent && node.parent.type === 'Property' ); } /** * Matcher used to check whether given node is a `CallExpression` * @param {ASTNode} node The AST node * @returns {Boolean} True if node is a `CallExpression`, false if not */ function isCallExpressionMatcher(node) { return node && node.type === 'CallExpression'; } /** * Check whether given node or its parent is directly inside `map` call * ```jsx * {items.map(item =>
  • )} * ``` * @param {ASTNode} node The AST node * @returns {Boolean} True if node is directly inside `map` call, false if not */ function isMapCall(node) { return ( node && node.callee && node.callee.property && node.callee.property.name === 'map' ); } /** * Check whether given node is `ReturnStatement` of a React hook * @param {ASTNode} node The AST node * @param {Context} context eslint context * @returns {Boolean} True if node is a `ReturnStatement` of a React hook, false if not */ function isReturnStatementOfHook(node, context) { if ( !node || !node.parent || node.parent.type !== 'ReturnStatement' ) { return false; } const callExpression = getClosestMatchingParent(node, context, isCallExpressionMatcher); return ( callExpression && callExpression.callee && HOOK_REGEXP.test(callExpression.callee.name) ); } /** * Check whether given node is declared inside a render prop * ```jsx *
    } /> * {() =>
    } * ``` * @param {ASTNode} node The AST node * @param {Context} context eslint context * @returns {Boolean} True if component is declared inside a render prop, false if not */ function isComponentInRenderProp(node, context) { if ( node && node.parent && node.parent.type === 'Property' && node.parent.key && startsWithRender(node.parent.key.name) ) { return true; } // Check whether component is a render prop used as direct children, e.g. {() =>
    } if ( node && node.parent && node.parent.type === 'JSXExpressionContainer' && node.parent.parent && node.parent.parent.type === 'JSXElement' ) { return true; } const jsxExpressionContainer = getClosestMatchingParent(node, context, isJSXExpressionContainerMatcher); // Check whether prop name indicates accepted patterns if ( jsxExpressionContainer && jsxExpressionContainer.parent && jsxExpressionContainer.parent.type === 'JSXAttribute' && jsxExpressionContainer.parent.name && jsxExpressionContainer.parent.name.type === 'JSXIdentifier' ) { const propName = jsxExpressionContainer.parent.name.name; // Starts with render, e.g.
    } /> if (startsWithRender(propName)) { return true; } // Uses children prop explicitly, e.g.
    } /> if (propName === 'children') { return true; } } return false; } /** * Check whether given node is declared directly inside a render property * ```jsx * const rows = { render: () =>
    } *
    }] } /> * ``` * @param {ASTNode} node The AST node * @returns {Boolean} True if component is declared inside a render property, false if not */ function isDirectValueOfRenderProperty(node) { return ( node && node.parent && node.parent.type === 'Property' && node.parent.key && node.parent.key.type === 'Identifier' && startsWithRender(node.parent.key.name) ); } /** * Resolve the component name of given node * @param {ASTNode} node The AST node of the component * @returns {String} Name of the component, if any */ function resolveComponentName(node) { const parentName = node.id && node.id.name; if (parentName) return parentName; return ( node.type === 'ArrowFunctionExpression' && node.parent && node.parent.id && node.parent.id.name ); } // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { docs: { description: 'Disallow creating unstable components inside components', category: 'Possible Errors', recommended: false, url: docsUrl('no-unstable-nested-components'), }, schema: [{ type: 'object', properties: { customValidators: { type: 'array', items: { type: 'string', }, }, allowAsProps: { type: 'boolean', }, }, additionalProperties: false, }], }, create: Components.detect((context, components, utils) => { const allowAsProps = context.options.some((option) => option && option.allowAsProps); /** * Check whether given node is declared inside class component's render block * ```jsx * class Component extends React.Component { * render() { * class NestedClassComponent extends React.Component { * ... * ``` * @param {ASTNode} node The AST node being checked * @returns {Boolean} True if node is inside class component's render block, false if not */ function isInsideRenderMethod(node) { const parentComponent = utils.getParentComponent(node); if (!parentComponent || parentComponent.type !== 'ClassDeclaration') { return false; } return ( node && node.parent && node.parent.type === 'MethodDefinition' && node.parent.key && node.parent.key.name === 'render' ); } /** * Check whether given node is a function component declared inside class component. * Util's component detection fails to detect function components inside class components. * ```jsx * class Component extends React.Component { * render() { * const NestedComponent = () =>
    ; * ... * ``` * @param {ASTNode} node The AST node being checked * @returns {Boolean} True if given node a function component declared inside class component, false if not */ function isFunctionComponentInsideClassComponent(node) { const parentComponent = utils.getParentComponent(node); const parentStatelessComponent = utils.getParentStatelessComponent(node); return ( parentComponent && parentStatelessComponent && parentComponent.type === 'ClassDeclaration' && utils.getStatelessComponent(parentStatelessComponent) && utils.isReturningJSX(node) ); } /** * Check whether given node is declared inside `createElement` call's props * ```js * React.createElement(Component, { * footer: () => React.createElement("div", null) * }) * ``` * @param {ASTNode} node The AST node * @returns {Boolean} True if node is declare inside `createElement` call's props, false if not */ function isComponentInsideCreateElementsProp(node) { if (!components.get(node)) { return false; } const createElementParent = getClosestMatchingParent(node, context, isCreateElementMatcher); return ( createElementParent && createElementParent.arguments && createElementParent.arguments[1] === getClosestMatchingParent(node, context, isObjectExpressionMatcher) ); } /** * Check whether given node is declared inside a component/object prop. * ```jsx *
    } /> * { footer: () =>
    } * ``` * @param {ASTNode} node The AST node being checked * @returns {Boolean} True if node is a component declared inside prop, false if not */ function isComponentInProp(node) { if (isPropertyOfObjectExpressionMatcher(node)) { return utils.isReturningJSX(node); } const jsxAttribute = getClosestMatchingParent(node, context, isJSXAttributeOfExpressionContainerMatcher); if (!jsxAttribute) { return isComponentInsideCreateElementsProp(node); } return utils.isReturningJSX(node); } /** * Check whether given node is a stateless component returning non-JSX * ```jsx * {{ a: () => null }} * ``` * @param {ASTNode} node The AST node being checked * @returns {Boolean} True if node is a stateless component returning non-JSX, false if not */ function isStatelessComponentReturningNull(node) { const component = utils.getStatelessComponent(node); return component && !utils.isReturningJSX(component); } /** * Check whether given node is a unstable nested component * @param {ASTNode} node The AST node being checked */ function validate(node) { if (!node || !node.parent) { return; } const isDeclaredInsideProps = isComponentInProp(node); if ( !components.get(node) && !isFunctionComponentInsideClassComponent(node) && !isDeclaredInsideProps) { return; } if ( // Support allowAsProps option (isDeclaredInsideProps && (allowAsProps || isComponentInRenderProp(node, context))) // Prevent reporting components created inside Array.map calls || isMapCall(node) || isMapCall(node.parent) // Do not mark components declared inside hooks (or falsy '() => null' clean-up methods) || isReturnStatementOfHook(node, context) // Do not mark objects containing render methods || isDirectValueOfRenderProperty(node) // Prevent reporting nested class components twice || isInsideRenderMethod(node) // Prevent falsely reporting detected "components" which do not return JSX || isStatelessComponentReturningNull(node) ) { return; } // Get the closest parent component const parentComponent = getClosestMatchingParent( node, context, (nodeToMatch) => components.get(nodeToMatch) ); if (parentComponent) { const parentName = resolveComponentName(parentComponent); // Exclude lowercase parents, e.g. function createTestComponent() // React-dom prevents creating lowercase components if (parentName && parentName[0] === parentName[0].toLowerCase()) { return; } let message = generateErrorMessageWithParentName(parentName); // Add information about allowAsProps option when component is declared inside prop if (isDeclaredInsideProps && !allowAsProps) { message += COMPONENT_AS_PROPS_INFO; } report(context, message, null, { node, }); } } // -------------------------------------------------------------------------- // Public // -------------------------------------------------------------------------- return { FunctionDeclaration(node) { validate(node); }, ArrowFunctionExpression(node) { validate(node); }, FunctionExpression(node) { validate(node); }, ClassDeclaration(node) { validate(node); }, CallExpression(node) { validate(node); }, }; }), };