diff --git a/docs/rules/index.md b/docs/rules/index.md
index 4e2c26dee..71a05d42e 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -277,6 +277,7 @@ For example:
| [vue/sort-keys](./sort-keys.md) | enforce sort-keys in a manner that is compatible with order-in-components | | :hammer: |
| [vue/static-class-names-order](./static-class-names-order.md) | enforce static class names order | :wrench: | :hammer: |
| [vue/v-for-delimiter-style](./v-for-delimiter-style.md) | enforce `v-for` directive's delimiter style | :wrench: | :lipstick: |
+| [vue/v-if-else-key](./v-if-else-key.md) | require key attribute for conditionally rendered repeated components | :wrench: | :warning: |
| [vue/v-on-handler-style](./v-on-handler-style.md) | enforce writing style for handlers in `v-on` directives | :wrench: | :hammer: |
| [vue/valid-define-options](./valid-define-options.md) | enforce valid `defineOptions` compiler macro | | :warning: |
diff --git a/docs/rules/require-v-for-key.md b/docs/rules/require-v-for-key.md
index c8f894ff5..7699e1b30 100644
--- a/docs/rules/require-v-for-key.md
+++ b/docs/rules/require-v-for-key.md
@@ -5,6 +5,7 @@ title: vue/require-v-for-key
description: require `v-bind:key` with `v-for` directives
since: v3.0.0
---
+
# vue/require-v-for-key
> require `v-bind:key` with `v-for` directives
@@ -20,12 +21,9 @@ This rule reports the elements which have `v-for` and do not have `v-bind:key` w
```vue
-
+
-
+
```
@@ -43,8 +41,10 @@ Nothing.
## :couple: Related Rules
- [vue/valid-v-for]
+- [vue/v-if-else-key]
[vue/valid-v-for]: ./valid-v-for.md
+[vue/v-if-else-key]: ./v-if-else-key.md
## :books: Further Reading
diff --git a/docs/rules/v-if-else-key.md b/docs/rules/v-if-else-key.md
new file mode 100644
index 000000000..11c8c642a
--- /dev/null
+++ b/docs/rules/v-if-else-key.md
@@ -0,0 +1,56 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/v-if-else-key
+description: require key attribute for conditionally rendered repeated components
+---
+
+# vue/v-if-else-key
+
+> require key attribute for conditionally rendered repeated components
+
+- :exclamation: ***This rule has not been released yet.***
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+## :book: Rule Details
+
+This rule checks for components that are both repeated and conditionally rendered within the same scope. If such a component is found, the rule then checks for the presence of a 'key' directive. If the 'key' directive is missing, the rule issues a warning and offers a fix.
+
+This rule is not required in Vue 3, as the key is automatically assigned to the elements.
+
+
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :couple: Related Rules
+
+- [vue/require-v-for-key]
+
+[vue/require-v-for-key]: ./require-v-for-key.md
+
+## :books: Further Reading
+
+- [Guide (for v2) - v-if without key](https://v2.vuejs.org/v2/style-guide/#v-if-v-else-if-v-else-without-key-use-with-caution)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/v-if-else-key.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/v-if-else-key.js)
diff --git a/lib/index.js b/lib/index.js
index 0f3dcba27..3497a7b4f 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -216,6 +216,7 @@ module.exports = {
'use-v-on-exact': require('./rules/use-v-on-exact'),
'v-bind-style': require('./rules/v-bind-style'),
'v-for-delimiter-style': require('./rules/v-for-delimiter-style'),
+ 'v-if-else-key': require('./rules/v-if-else-key'),
'v-on-event-hyphenation': require('./rules/v-on-event-hyphenation'),
'v-on-function-call': require('./rules/v-on-function-call'),
'v-on-handler-style': require('./rules/v-on-handler-style'),
diff --git a/lib/rules/v-if-else-key.js b/lib/rules/v-if-else-key.js
new file mode 100644
index 000000000..0a6ec9cfe
--- /dev/null
+++ b/lib/rules/v-if-else-key.js
@@ -0,0 +1,285 @@
+/**
+ * @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 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') {
+ const conditionalFamily = conditionalFamilies.get(node.parent)
+
+ if (
+ conditionalFamily &&
+ (utils.hasDirective(node, 'bind', 'key') ||
+ utils.hasAttribute(node, 'key') ||
+ !hasConditionalDirective(node) ||
+ !(conditionalFamily.else || conditionalFamily.elseIf.length > 0))
+ ) {
+ return
+ }
+
+ 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'
+ },
+ 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 (conditionType === 'if' && !conditionalFamily) {
+ conditionalFamily = createConditionalFamily(node)
+ conditionalFamilies.set(node.parent, conditionalFamily)
+ }
+
+ if (conditionalFamily) {
+ switch (conditionType) {
+ 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)
+ }
+ }
+ })
+ }
+}
diff --git a/tests/lib/rules/v-if-else-key.js b/tests/lib/rules/v-if-else-key.js
new file mode 100644
index 000000000..4aab257d0
--- /dev/null
+++ b/tests/lib/rules/v-if-else-key.js
@@ -0,0 +1,429 @@
+/**
+ * @author Felipe Melendez
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/v-if-else-key')
+
+const tester = new RuleTester({
+ parser: require.resolve('vue-eslint-parser'),
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: 'module'
+ }
+})
+
+tester.run('v-if-else-key', rule, {
+ valid: [
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `
+ }
+ ],
+ invalid: [
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ output: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ errors: [
+ {
+ message:
+ "Conditionally rendered repeated component 'OuterComponent' expected to have a 'key' attribute.",
+ line: 4
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'InnerComponent' expected to have a 'key' attribute.",
+ line: 5
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'InnerComponent' expected to have a 'key' attribute.",
+ line: 6
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'OuterComponent' expected to have a 'key' attribute.",
+ line: 8
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ output: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ errors: [
+ {
+ message:
+ "Conditionally rendered repeated component 'OuterComponent' expected to have a 'key' attribute.",
+ line: 4
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'InnerComponent' expected to have a 'key' attribute.",
+ line: 5
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'InnerComponent' expected to have a 'key' attribute.",
+ line: 8
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'OuterComponent' expected to have a 'key' attribute.",
+ line: 10
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+
+
+
+ `,
+ output: `
+
+
+
+
+
+
+
+
+
+
+ `,
+ errors: [
+ {
+ message:
+ "Conditionally rendered repeated component 'InnerComponent' expected to have a 'key' attribute.",
+ line: 5
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'InnerComponent' expected to have a 'key' attribute.",
+ line: 6
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+
+ `,
+ output: `
+
+
+
+
+
+
+
+
+ `,
+ errors: [
+ {
+ message:
+ "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+ line: 4
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+ line: 5
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+
+ `,
+ output: `
+
+
+
+
+
+
+
+
+ `,
+ errors: [
+ {
+ message:
+ "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+ line: 4
+ },
+ {
+ message:
+ "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+ line: 6
+ }
+ ]
+ }
+ ]
+})