123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318 |
- /**
- * @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<VElement, ConditionalFamily>} 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<VElement, ConditionalFamily>}
- */
- 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<string, { count: number; firstNode: any }>[]} */
- 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<string>} */
- const exemptTags = new Set(['component', 'slot', 'template'])
- /** Set to keep track of nodes we've pushed to the stack. */
- /** @type {Set<any>} */
- 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)
- }
- }
- })
- }
- }
|