123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468 |
- /**
- * @fileoverview Prevent creating unstable components inside components
- * @author Ari Perkkiö
- */
- 'use strict';
- const Components = require('../util/Components');
- const docsUrl = require('../util/docsUrl');
- // ------------------------------------------------------------------------------
- // Constants
- // ------------------------------------------------------------------------------
- const ERROR_MESSAGE_WITHOUT_NAME = 'Declare this component outside parent component or memoize it.';
- 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
- * @returns {String} Error message with parent component name
- */
- function generateErrorMessageWithParentName(parentName) {
- return `Declare this component outside parent component "${parentName}" or memoize it.`;
- }
- /**
- * 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 {Function} matcher Method used to match the parent
- * @returns {ASTNode} The matching parent node, if any
- */
- function getClosestMatchingParent(node, matcher) {
- if (!node || !node.parent || node.parent.type === 'Program') {
- return;
- }
- if (matcher(node.parent)) {
- return node.parent;
- }
- return getClosestMatchingParent(node.parent, matcher);
- }
- /**
- * Matcher used to check whether given node is a `createElement` call
- * @param {ASTNode} node The AST node
- * @returns {Boolean} True if node is a `createElement` call, false if not
- */
- function isCreateElementMatcher(node) {
- return (
- node
- && node.type === 'CallExpression'
- && node.callee
- && node.callee.property
- && node.callee.property.name === 'createElement'
- );
- }
- /**
- * 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 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 => <li />)}
- * ```
- * @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
- * @returns {Boolean} True if node is a `ReturnStatement` of a React hook, false if not
- */
- function isReturnStatementOfHook(node) {
- if (
- !node
- || !node.parent
- || node.parent.type !== 'ReturnStatement'
- ) {
- return false;
- }
- const callExpression = getClosestMatchingParent(node, isCallExpressionMatcher);
- return (
- callExpression
- && callExpression.callee
- && HOOK_REGEXP.test(callExpression.callee.name)
- );
- }
- /**
- * Check whether given node is declared inside a render prop
- * ```jsx
- * <Component renderFooter={() => <div />} />
- * <Component>{() => <div />}</Component>
- * ```
- * @param {ASTNode} node The AST node
- * @returns {Boolean} True if component is declared inside a render prop, false if not
- */
- function isComponentInRenderProp(node) {
- 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. <Component>{() => <div />}</Component>
- if (
- node
- && node.parent
- && node.parent.type === 'JSXExpressionContainer'
- && node.parent.parent
- && node.parent.parent.type === 'JSXElement'
- ) {
- return true;
- }
- const jsxExpressionContainer = getClosestMatchingParent(node, 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. <Component renderFooter={() => <div />} />
- if (startsWithRender(propName)) {
- return true;
- }
- // Uses children prop explicitly, e.g. <Component children={() => <div />} />
- if (propName === 'children') {
- return true;
- }
- }
- return false;
- }
- /**
- * Check whether given node is declared directly inside a render property
- * ```jsx
- * const rows = { render: () => <div /> }
- * <Component rows={ [{ render: () => <div /> }] } />
- * ```
- * @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
- // ------------------------------------------------------------------------------
- module.exports = {
- meta: {
- docs: {
- description: 'Prevent 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();
- 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 = () => <div />;
- * ...
- * ```
- * @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();
- const parentStatelessComponent = utils.getParentStatelessComponent();
- 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, isCreateElementMatcher);
- return (
- createElementParent
- && createElementParent.arguments
- && createElementParent.arguments[1] === getClosestMatchingParent(node, isObjectExpressionMatcher)
- );
- }
- /**
- * Check whether given node is declared inside a component prop.
- * ```jsx
- * <Component footer={() => <div />} />
- * ```
- * @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) {
- const jsxAttribute = getClosestMatchingParent(node, 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)))
- // Prevent reporting components created inside Array.map calls
- || isMapCall(node)
- || isMapCall(node.parent)
- // Do not mark components declared inside hooks (or falsly '() => null' clean-up methods)
- || isReturnStatementOfHook(node)
- // Do not mark objects containing render methods
- || isDirectValueOfRenderProperty(node)
- // Prevent reporting nested class components twice
- || isInsideRenderMethod(node)
- // Prevent falsely reporting deteceted "components" which do not return JSX
- || isStatelessComponentReturningNull(node)
- ) {
- return;
- }
- // Get the closest parent component
- const parentComponent = getClosestMatchingParent(
- node,
- (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 = parentName
- ? generateErrorMessageWithParentName(parentName)
- : ERROR_MESSAGE_WITHOUT_NAME;
- // Add information about allowAsProps option when component is declared inside prop
- if (isDeclaredInsideProps && !allowAsProps) {
- message += COMPONENT_AS_PROPS_INFO;
- }
- context.report({node, message});
- }
- }
- // --------------------------------------------------------------------------
- // Public
- // --------------------------------------------------------------------------
- return {
- FunctionDeclaration(node) { validate(node); },
- ArrowFunctionExpression(node) { validate(node); },
- FunctionExpression(node) { validate(node); },
- ClassDeclaration(node) { validate(node); }
- };
- })
- };
|