Skip to content

Commit 2f65e92

Browse files
Require key for conditionally rendered repeated components (#2280)
1 parent 4eb3f50 commit 2f65e92

File tree

6 files changed

+777
-5
lines changed

6 files changed

+777
-5
lines changed

docs/rules/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ For example:
277277
| [vue/sort-keys](./sort-keys.md) | enforce sort-keys in a manner that is compatible with order-in-components | | :hammer: |
278278
| [vue/static-class-names-order](./static-class-names-order.md) | enforce static class names order | :wrench: | :hammer: |
279279
| [vue/v-for-delimiter-style](./v-for-delimiter-style.md) | enforce `v-for` directive's delimiter style | :wrench: | :lipstick: |
280+
| [vue/v-if-else-key](./v-if-else-key.md) | require key attribute for conditionally rendered repeated components | :wrench: | :warning: |
280281
| [vue/v-on-handler-style](./v-on-handler-style.md) | enforce writing style for handlers in `v-on` directives | :wrench: | :hammer: |
281282
| [vue/valid-define-options](./valid-define-options.md) | enforce valid `defineOptions` compiler macro | | :warning: |
282283

docs/rules/require-v-for-key.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ title: vue/require-v-for-key
55
description: require `v-bind:key` with `v-for` directives
66
since: v3.0.0
77
---
8+
89
# vue/require-v-for-key
910

1011
> 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
2021
```vue
2122
<template>
2223
<!-- ✓ GOOD -->
23-
<div
24-
v-for="todo in todos"
25-
:key="todo.id"
26-
/>
24+
<div v-for="todo in todos" :key="todo.id" />
2725
<!-- ✗ BAD -->
28-
<div v-for="todo in todos"/>
26+
<div v-for="todo in todos" />
2927
</template>
3028
```
3129

@@ -43,8 +41,10 @@ Nothing.
4341
## :couple: Related Rules
4442

4543
- [vue/valid-v-for]
44+
- [vue/v-if-else-key]
4645

4746
[vue/valid-v-for]: ./valid-v-for.md
47+
[vue/v-if-else-key]: ./v-if-else-key.md
4848

4949
## :books: Further Reading
5050

docs/rules/v-if-else-key.md

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
pageClass: rule-details
3+
sidebarDepth: 0
4+
title: vue/v-if-else-key
5+
description: require key attribute for conditionally rendered repeated components
6+
---
7+
8+
# vue/v-if-else-key
9+
10+
> require key attribute for conditionally rendered repeated components
11+
12+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
13+
- :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.
14+
15+
## :book: Rule Details
16+
17+
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.
18+
19+
This rule is not required in Vue 3, as the key is automatically assigned to the elements.
20+
21+
<eslint-code-block fix :rules="{'vue/v-if-else-key': ['error']}">
22+
23+
```vue
24+
<template>
25+
<!-- ✓ GOOD -->
26+
<my-component v-if="condition1" :key="one" />
27+
<my-component v-else-if="condition2" :key="two" />
28+
<my-component v-else :key="three" />
29+
30+
<!-- ✗ BAD -->
31+
<my-component v-if="condition1" />
32+
<my-component v-else-if="condition2" />
33+
<my-component v-else />
34+
</template>
35+
```
36+
37+
</eslint-code-block>
38+
39+
## :wrench: Options
40+
41+
Nothing.
42+
43+
## :couple: Related Rules
44+
45+
- [vue/require-v-for-key]
46+
47+
[vue/require-v-for-key]: ./require-v-for-key.md
48+
49+
## :books: Further Reading
50+
51+
- [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)
52+
53+
## :mag: Implementation
54+
55+
- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/v-if-else-key.js)
56+
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/v-if-else-key.js)

lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ module.exports = {
216216
'use-v-on-exact': require('./rules/use-v-on-exact'),
217217
'v-bind-style': require('./rules/v-bind-style'),
218218
'v-for-delimiter-style': require('./rules/v-for-delimiter-style'),
219+
'v-if-else-key': require('./rules/v-if-else-key'),
219220
'v-on-event-hyphenation': require('./rules/v-on-event-hyphenation'),
220221
'v-on-function-call': require('./rules/v-on-function-call'),
221222
'v-on-handler-style': require('./rules/v-on-handler-style'),

lib/rules/v-if-else-key.js

+285
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* @author Felipe Melendez
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
'use strict'
6+
7+
// =============================================================================
8+
// Requirements
9+
// =============================================================================
10+
11+
const utils = require('../utils')
12+
const casing = require('../utils/casing')
13+
14+
// =============================================================================
15+
// Rule Helpers
16+
// =============================================================================
17+
18+
/**
19+
* A conditional family is made up of a group of repeated components that are conditionally rendered
20+
* using v-if, v-else-if, and v-else.
21+
*
22+
* @typedef {Object} ConditionalFamily
23+
* @property {VElement} if - The node associated with the 'v-if' directive.
24+
* @property {VElement[]} elseIf - An array of nodes associated with 'v-else-if' directives.
25+
* @property {VElement | null} else - The node associated with the 'v-else' directive, or null if there isn't one.
26+
*/
27+
28+
/**
29+
* Checks for the presence of a 'key' attribute in the given node. If the 'key' attribute is missing
30+
* and the node is part of a conditional family a report is generated.
31+
* The fix proposed adds a unique key based on the component's name and count,
32+
* following the format '${kebabCase(componentName)}-${componentCount}', e.g., 'some-component-2'.
33+
*
34+
* @param {VElement} node - The Vue component node to check for a 'key' attribute.
35+
* @param {RuleContext} context - The rule's context object, used for reporting.
36+
* @param {string} componentName - Name of the component.
37+
* @param {string} uniqueKey - A unique key for the repeated component, used for the fix.
38+
* @param {Map<VElement, ConditionalFamily>} conditionalFamilies - Map of conditionally rendered components and their respective conditional directives.
39+
*/
40+
const checkForKey = (
41+
node,
42+
context,
43+
componentName,
44+
uniqueKey,
45+
conditionalFamilies
46+
) => {
47+
if (node.parent && node.parent.type === 'VElement') {
48+
const conditionalFamily = conditionalFamilies.get(node.parent)
49+
50+
if (
51+
conditionalFamily &&
52+
(utils.hasDirective(node, 'bind', 'key') ||
53+
utils.hasAttribute(node, 'key') ||
54+
!hasConditionalDirective(node) ||
55+
!(conditionalFamily.else || conditionalFamily.elseIf.length > 0))
56+
) {
57+
return
58+
}
59+
60+
context.report({
61+
node: node.startTag,
62+
loc: node.startTag.loc,
63+
messageId: 'requireKey',
64+
data: {
65+
componentName
66+
},
67+
fix(fixer) {
68+
const afterComponentNamePosition =
69+
node.startTag.range[0] + componentName.length + 1
70+
return fixer.insertTextBeforeRange(
71+
[afterComponentNamePosition, afterComponentNamePosition],
72+
` key="${uniqueKey}"`
73+
)
74+
}
75+
})
76+
}
77+
}
78+
79+
/**
80+
* Checks for the presence of conditional directives in the given node.
81+
*
82+
* @param {VElement} node - The node to check for conditional directives.
83+
* @returns {boolean} Returns true if a conditional directive is found in the node or its parents,
84+
* false otherwise.
85+
*/
86+
const hasConditionalDirective = (node) =>
87+
utils.hasDirective(node, 'if') ||
88+
utils.hasDirective(node, 'else-if') ||
89+
utils.hasDirective(node, 'else')
90+
91+
// =============================================================================
92+
// Rule Definition
93+
// =============================================================================
94+
95+
/** @type {import('eslint').Rule.RuleModule} */
96+
module.exports = {
97+
meta: {
98+
type: 'problem',
99+
docs: {
100+
description:
101+
'require key attribute for conditionally rendered repeated components',
102+
categories: null,
103+
recommended: false,
104+
url: 'https://eslint.vuejs.org/rules/v-if-else-key.html'
105+
},
106+
fixable: 'code',
107+
schema: [],
108+
messages: {
109+
requireKey:
110+
"Conditionally rendered repeated component '{{componentName}}' expected to have a 'key' attribute."
111+
}
112+
},
113+
/**
114+
* Creates and returns a rule object which checks usage of repeated components. If a component
115+
* is used more than once, it checks for the presence of a key.
116+
*
117+
* @param {RuleContext} context - The context object.
118+
* @returns {Object} A dictionary of functions to be called on traversal of the template body by
119+
* the eslint parser.
120+
*/
121+
create(context) {
122+
/**
123+
* Map to store conditionally rendered components and their respective conditional directives.
124+
*
125+
* @type {Map<VElement, ConditionalFamily>}
126+
*/
127+
const conditionalFamilies = new Map()
128+
129+
/**
130+
* Array of Maps to keep track of components and their usage counts along with the first
131+
* node instance. Each Map represents a different scope level, and maps a component name to
132+
* an object containing the count and a reference to the first node.
133+
*/
134+
/** @type {Map<string, { count: number; firstNode: any }>[]} */
135+
const componentUsageStack = [new Map()]
136+
137+
/**
138+
* Checks if a given node represents a custom component without any conditional directives.
139+
*
140+
* @param {VElement} node - The AST node to check.
141+
* @returns {boolean} True if the node represents a custom component without any conditional directives, false otherwise.
142+
*/
143+
const isCustomComponentWithoutCondition = (node) =>
144+
node.type === 'VElement' &&
145+
utils.isCustomComponent(node) &&
146+
!hasConditionalDirective(node)
147+
148+
/** Set of built-in Vue components that are exempt from the rule. */
149+
/** @type {Set<string>} */
150+
const exemptTags = new Set(['component', 'slot', 'template'])
151+
152+
/** Set to keep track of nodes we've pushed to the stack. */
153+
/** @type {Set<any>} */
154+
const pushedNodes = new Set()
155+
156+
/**
157+
* Creates and returns an object representing a conditional family.
158+
*
159+
* @param {VElement} ifNode - The VElement associated with the 'v-if' directive.
160+
* @returns {ConditionalFamily}
161+
*/
162+
const createConditionalFamily = (ifNode) => ({
163+
if: ifNode,
164+
elseIf: [],
165+
else: null
166+
})
167+
168+
return utils.defineTemplateBodyVisitor(context, {
169+
/**
170+
* Callback to be executed when a Vue element is traversed. This function checks if the
171+
* element is a component, increments the usage count of the component in the
172+
* current scope, and checks for the key directive if the component is repeated.
173+
*
174+
* @param {VElement} node - The traversed Vue element.
175+
*/
176+
VElement(node) {
177+
if (exemptTags.has(node.rawName)) {
178+
return
179+
}
180+
181+
const condition =
182+
utils.getDirective(node, 'if') ||
183+
utils.getDirective(node, 'else-if') ||
184+
utils.getDirective(node, 'else')
185+
186+
if (condition) {
187+
const conditionType = condition.key.name.name
188+
189+
if (node.parent && node.parent.type === 'VElement') {
190+
let conditionalFamily = conditionalFamilies.get(node.parent)
191+
192+
if (conditionType === 'if' && !conditionalFamily) {
193+
conditionalFamily = createConditionalFamily(node)
194+
conditionalFamilies.set(node.parent, conditionalFamily)
195+
}
196+
197+
if (conditionalFamily) {
198+
switch (conditionType) {
199+
case 'else-if': {
200+
conditionalFamily.elseIf.push(node)
201+
break
202+
}
203+
case 'else': {
204+
conditionalFamily.else = node
205+
break
206+
}
207+
}
208+
}
209+
}
210+
}
211+
212+
if (isCustomComponentWithoutCondition(node)) {
213+
componentUsageStack.push(new Map())
214+
return
215+
}
216+
217+
if (!utils.isCustomComponent(node)) {
218+
return
219+
}
220+
221+
const componentName = node.rawName
222+
const currentScope = componentUsageStack[componentUsageStack.length - 1]
223+
const usageInfo = currentScope.get(componentName) || {
224+
count: 0,
225+
firstNode: null
226+
}
227+
228+
if (hasConditionalDirective(node)) {
229+
// Store the first node if this is the first occurrence
230+
if (usageInfo.count === 0) {
231+
usageInfo.firstNode = node
232+
}
233+
234+
if (usageInfo.count > 0) {
235+
const uniqueKey = `${casing.kebabCase(componentName)}-${
236+
usageInfo.count + 1
237+
}`
238+
checkForKey(
239+
node,
240+
context,
241+
componentName,
242+
uniqueKey,
243+
conditionalFamilies
244+
)
245+
246+
// If this is the second occurrence, also apply a fix to the first occurrence
247+
if (usageInfo.count === 1) {
248+
const uniqueKeyForFirstInstance = `${casing.kebabCase(
249+
componentName
250+
)}-1`
251+
checkForKey(
252+
usageInfo.firstNode,
253+
context,
254+
componentName,
255+
uniqueKeyForFirstInstance,
256+
conditionalFamilies
257+
)
258+
}
259+
}
260+
usageInfo.count += 1
261+
currentScope.set(componentName, usageInfo)
262+
}
263+
componentUsageStack.push(new Map())
264+
pushedNodes.add(node)
265+
},
266+
267+
'VElement:exit'(node) {
268+
if (exemptTags.has(node.rawName)) {
269+
return
270+
}
271+
if (isCustomComponentWithoutCondition(node)) {
272+
componentUsageStack.pop()
273+
return
274+
}
275+
if (!utils.isCustomComponent(node)) {
276+
return
277+
}
278+
if (pushedNodes.has(node)) {
279+
componentUsageStack.pop()
280+
pushedNodes.delete(node)
281+
}
282+
}
283+
})
284+
}
285+
}

0 commit comments

Comments
 (0)