/** * @fileoverview Prevent missing displayName in a React component definition * @author Yannick Croissant */ 'use strict'; const values = require('object.values'); const filter = require('es-iterator-helpers/Iterator.prototype.filter'); const forEach = require('es-iterator-helpers/Iterator.prototype.forEach'); const Components = require('../util/Components'); const isCreateContext = require('../util/isCreateContext'); const astUtil = require('../util/ast'); const componentUtil = require('../util/componentUtil'); const docsUrl = require('../util/docsUrl'); const testReactVersion = require('../util/version').testReactVersion; const propsUtil = require('../util/props'); const report = require('../util/report'); // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ const messages = { noDisplayName: 'Component definition is missing display name', noContextDisplayName: 'Context definition is missing display name', }; /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { docs: { description: 'Disallow missing displayName in a React component definition', category: 'Best Practices', recommended: true, url: docsUrl('display-name'), }, messages, schema: [{ type: 'object', properties: { ignoreTranspilerName: { type: 'boolean', }, checkContextObjects: { type: 'boolean', }, }, additionalProperties: false, }], }, create: Components.detect((context, components, utils) => { const config = context.options[0] || {}; const ignoreTranspilerName = config.ignoreTranspilerName || false; const checkContextObjects = (config.checkContextObjects || false) && testReactVersion(context, '>= 16.3.0'); const contextObjects = new Map(); /** * Mark a prop type as declared * @param {ASTNode} node The AST node being checked. */ function markDisplayNameAsDeclared(node) { components.set(node, { hasDisplayName: true, }); } /** * Checks if React.forwardRef is nested inside React.memo * @param {ASTNode} node The AST node being checked. * @returns {Boolean} True if React.forwardRef is nested inside React.memo, false if not. */ function isNestedMemo(node) { const argumentIsCallExpression = node.arguments && node.arguments[0] && node.arguments[0].type === 'CallExpression'; return node.type === 'CallExpression' && argumentIsCallExpression && utils.isPragmaComponentWrapper(node); } /** * Reports missing display name for a given component * @param {Object} component The component to process */ function reportMissingDisplayName(component) { if ( testReactVersion(context, '^0.14.10 || ^15.7.0 || >= 16.12.0') && isNestedMemo(component.node) ) { return; } report(context, messages.noDisplayName, 'noDisplayName', { node: component.node, }); } /** * Reports missing display name for a given context object * @param {Object} contextObj The context object to process */ function reportMissingContextDisplayName(contextObj) { report(context, messages.noContextDisplayName, 'noContextDisplayName', { node: contextObj.node, }); } /** * Checks if the component have a name set by the transpiler * @param {ASTNode} node The AST node being checked. * @returns {Boolean} True if component has a name, false if not. */ function hasTranspilerName(node) { const namedObjectAssignment = ( node.type === 'ObjectExpression' && node.parent && node.parent.parent && node.parent.parent.type === 'AssignmentExpression' && ( !node.parent.parent.left.object || node.parent.parent.left.object.name !== 'module' || node.parent.parent.left.property.name !== 'exports' ) ); const namedObjectDeclaration = ( node.type === 'ObjectExpression' && node.parent && node.parent.parent && node.parent.parent.type === 'VariableDeclarator' ); const namedClass = ( (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') && node.id && !!node.id.name ); const namedFunctionDeclaration = ( (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression') && node.id && !!node.id.name ); const namedFunctionExpression = ( astUtil.isFunctionLikeExpression(node) && node.parent && (node.parent.type === 'VariableDeclarator' || node.parent.type === 'Property' || node.parent.method === true) && (!node.parent.parent || !componentUtil.isES5Component(node.parent.parent, context)) ); if ( namedObjectAssignment || namedObjectDeclaration || namedClass || namedFunctionDeclaration || namedFunctionExpression ) { return true; } return false; } // -------------------------------------------------------------------------- // Public // -------------------------------------------------------------------------- return { ExpressionStatement(node) { if (checkContextObjects && isCreateContext(node)) { contextObjects.set(node.expression.left.name, { node, hasDisplayName: false }); } }, VariableDeclarator(node) { if (checkContextObjects && isCreateContext(node)) { contextObjects.set(node.id.name, { node, hasDisplayName: false }); } }, 'ClassProperty, PropertyDefinition'(node) { if (!propsUtil.isDisplayNameDeclaration(node)) { return; } markDisplayNameAsDeclared(node); }, MemberExpression(node) { if (!propsUtil.isDisplayNameDeclaration(node.property)) { return; } if ( checkContextObjects && node.object && node.object.name && contextObjects.has(node.object.name) ) { contextObjects.get(node.object.name).hasDisplayName = true; } const component = utils.getRelatedComponent(node); if (!component) { return; } markDisplayNameAsDeclared(component.node.type === 'TSAsExpression' ? component.node.expression : component.node); }, 'FunctionExpression, FunctionDeclaration, ArrowFunctionExpression'(node) { if (ignoreTranspilerName || !hasTranspilerName(node)) { return; } if (components.get(node)) { markDisplayNameAsDeclared(node); } }, MethodDefinition(node) { if (!propsUtil.isDisplayNameDeclaration(node.key)) { return; } markDisplayNameAsDeclared(node); }, 'ClassExpression, ClassDeclaration'(node) { if (ignoreTranspilerName || !hasTranspilerName(node)) { return; } markDisplayNameAsDeclared(node); }, ObjectExpression(node) { if (!componentUtil.isES5Component(node, context)) { return; } if (ignoreTranspilerName || !hasTranspilerName(node)) { // Search for the displayName declaration node.properties.forEach((property) => { if (!property.key || !propsUtil.isDisplayNameDeclaration(property.key)) { return; } markDisplayNameAsDeclared(node); }); return; } markDisplayNameAsDeclared(node); }, CallExpression(node) { if (!utils.isPragmaComponentWrapper(node)) { return; } if (node.arguments.length > 0 && astUtil.isFunctionLikeExpression(node.arguments[0])) { // Skip over React.forwardRef declarations that are embedded within // a React.memo i.e. React.memo(React.forwardRef(/* ... */)) // This means that we raise a single error for the call to React.memo // instead of one for React.memo and one for React.forwardRef const isWrappedInAnotherPragma = utils.getPragmaComponentWrapper(node); if ( !isWrappedInAnotherPragma && (ignoreTranspilerName || !hasTranspilerName(node.arguments[0])) ) { return; } if (components.get(node)) { markDisplayNameAsDeclared(node); } } }, 'Program:exit'() { const list = components.list(); // Report missing display name for all components values(list).filter((component) => !component.hasDisplayName).forEach((component) => { reportMissingDisplayName(component); }); if (checkContextObjects) { // Report missing display name for all context objects forEach( filter(contextObjects.values(), (v) => !v.hasDisplayName), (contextObj) => reportMissingContextDisplayName(contextObj) ); } }, }; }), };