/** * @author Felipe Melendez * See LICENSE file in root directory for full license. */ 'use strict' // ============================================================================= // Requirements // ============================================================================= const utils = require('../utils') const casing = require('../utils/casing') // ============================================================================= // Rule Helpers // ============================================================================= /** * A conditional family is made up of a group of repeated components that are conditionally rendered * using v-if, v-else-if, and v-else. * * @typedef {Object} ConditionalFamily * @property {VElement} if - The node associated with the 'v-if' directive. * @property {VElement[]} elseIf - An array of nodes associated with 'v-else-if' directives. * @property {VElement | null} else - The node associated with the 'v-else' directive, or null if there isn't one. */ /** * Checks if a given node has sibling nodes of the same type that are also conditionally rendered. * This is used to determine if multiple instances of the same component are being conditionally * rendered within the same parent scope. * * @param {VElement} node - The Vue component node to check for conditional rendering siblings. * @param {string} componentName - The name of the component to check for sibling instances. * @returns {boolean} True if there are sibling nodes of the same type and conditionally rendered, false otherwise. */ const hasConditionalRenderedSiblings = (node, componentName) => { if (!node.parent || node.parent.type !== 'VElement') { return false } return node.parent.children.some( (sibling) => sibling !== node && sibling.type === 'VElement' && sibling.rawName === componentName && hasConditionalDirective(sibling) ) } /** * Checks for the presence of a 'key' attribute in the given node. If the 'key' attribute is missing * and the node is part of a conditional family a report is generated. * The fix proposed adds a unique key based on the component's name and count, * following the format '${kebabCase(componentName)}-${componentCount}', e.g., 'some-component-2'. * * @param {VElement} node - The Vue component node to check for a 'key' attribute. * @param {RuleContext} context - The rule's context object, used for reporting. * @param {string} componentName - Name of the component. * @param {string} uniqueKey - A unique key for the repeated component, used for the fix. * @param {Map} conditionalFamilies - Map of conditionally rendered components and their respective conditional directives. */ const checkForKey = ( node, context, componentName, uniqueKey, conditionalFamilies ) => { if ( !node.parent || node.parent.type !== 'VElement' || !hasConditionalRenderedSiblings(node, componentName) ) { return } const conditionalFamily = conditionalFamilies.get(node.parent) if (!conditionalFamily || utils.hasAttribute(node, 'key')) { return } const needsKey = conditionalFamily.if === node || conditionalFamily.else === node || conditionalFamily.elseIf.includes(node) if (needsKey) { context.report({ node: node.startTag, loc: node.startTag.loc, messageId: 'requireKey', data: { componentName }, fix(fixer) { const afterComponentNamePosition = node.startTag.range[0] + componentName.length + 1 return fixer.insertTextBeforeRange( [afterComponentNamePosition, afterComponentNamePosition], ` key="${uniqueKey}"` ) } }) } } /** * Checks for the presence of conditional directives in the given node. * * @param {VElement} node - The node to check for conditional directives. * @returns {boolean} Returns true if a conditional directive is found in the node or its parents, * false otherwise. */ const hasConditionalDirective = (node) => utils.hasDirective(node, 'if') || utils.hasDirective(node, 'else-if') || utils.hasDirective(node, 'else') // ============================================================================= // Rule Definition // ============================================================================= /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'problem', docs: { description: 'require key attribute for conditionally rendered repeated components', categories: null, recommended: false, url: 'https://eslint.vuejs.org/rules/v-if-else-key.html' }, // eslint-disable-next-line eslint-plugin/require-meta-fixable -- fixer is not recognized fixable: 'code', schema: [], messages: { requireKey: "Conditionally rendered repeated component '{{componentName}}' expected to have a 'key' attribute." } }, /** * Creates and returns a rule object which checks usage of repeated components. If a component * is used more than once, it checks for the presence of a key. * * @param {RuleContext} context - The context object. * @returns {Object} A dictionary of functions to be called on traversal of the template body by * the eslint parser. */ create(context) { /** * Map to store conditionally rendered components and their respective conditional directives. * * @type {Map} */ const conditionalFamilies = new Map() /** * Array of Maps to keep track of components and their usage counts along with the first * node instance. Each Map represents a different scope level, and maps a component name to * an object containing the count and a reference to the first node. */ /** @type {Map[]} */ const componentUsageStack = [new Map()] /** * Checks if a given node represents a custom component without any conditional directives. * * @param {VElement} node - The AST node to check. * @returns {boolean} True if the node represents a custom component without any conditional directives, false otherwise. */ const isCustomComponentWithoutCondition = (node) => node.type === 'VElement' && utils.isCustomComponent(node) && !hasConditionalDirective(node) /** Set of built-in Vue components that are exempt from the rule. */ /** @type {Set} */ const exemptTags = new Set(['component', 'slot', 'template']) /** Set to keep track of nodes we've pushed to the stack. */ /** @type {Set} */ const pushedNodes = new Set() /** * Creates and returns an object representing a conditional family. * * @param {VElement} ifNode - The VElement associated with the 'v-if' directive. * @returns {ConditionalFamily} */ const createConditionalFamily = (ifNode) => ({ if: ifNode, elseIf: [], else: null }) return utils.defineTemplateBodyVisitor(context, { /** * Callback to be executed when a Vue element is traversed. This function checks if the * element is a component, increments the usage count of the component in the * current scope, and checks for the key directive if the component is repeated. * * @param {VElement} node - The traversed Vue element. */ VElement(node) { if (exemptTags.has(node.rawName)) { return } const condition = utils.getDirective(node, 'if') || utils.getDirective(node, 'else-if') || utils.getDirective(node, 'else') if (condition) { const conditionType = condition.key.name.name if (node.parent && node.parent.type === 'VElement') { let conditionalFamily = conditionalFamilies.get(node.parent) if (!conditionalFamily) { conditionalFamily = createConditionalFamily(node) conditionalFamilies.set(node.parent, conditionalFamily) } if (conditionalFamily) { switch (conditionType) { case 'if': { conditionalFamily = createConditionalFamily(node) conditionalFamilies.set(node.parent, conditionalFamily) break } case 'else-if': { conditionalFamily.elseIf.push(node) break } case 'else': { conditionalFamily.else = node break } } } } } if (isCustomComponentWithoutCondition(node)) { componentUsageStack.push(new Map()) return } if (!utils.isCustomComponent(node)) { return } const componentName = node.rawName const currentScope = componentUsageStack[componentUsageStack.length - 1] const usageInfo = currentScope.get(componentName) || { count: 0, firstNode: null } if (hasConditionalDirective(node)) { // Store the first node if this is the first occurrence if (usageInfo.count === 0) { usageInfo.firstNode = node } if (usageInfo.count > 0) { const uniqueKey = `${casing.kebabCase(componentName)}-${ usageInfo.count + 1 }` checkForKey( node, context, componentName, uniqueKey, conditionalFamilies ) // If this is the second occurrence, also apply a fix to the first occurrence if (usageInfo.count === 1) { const uniqueKeyForFirstInstance = `${casing.kebabCase( componentName )}-1` checkForKey( usageInfo.firstNode, context, componentName, uniqueKeyForFirstInstance, conditionalFamilies ) } } usageInfo.count += 1 currentScope.set(componentName, usageInfo) } componentUsageStack.push(new Map()) pushedNodes.add(node) }, 'VElement:exit'(node) { if (exemptTags.has(node.rawName)) { return } if (isCustomComponentWithoutCondition(node)) { componentUsageStack.pop() return } if (!utils.isCustomComponent(node)) { return } if (pushedNodes.has(node)) { componentUsageStack.pop() pushedNodes.delete(node) } } }) } }