Skip to content

Commit bd4abe5

Browse files
authored
fix(vue/no-duplicate-class-names): improve non-intersecting conditions and combining parents (#2980)
1 parent 2ac139a commit bd4abe5

File tree

3 files changed

+116
-18
lines changed

3 files changed

+116
-18
lines changed

.changeset/major-planes-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-vue': patch
3+
---
4+
5+
Fixed false positives in non-intersecting conditions in `vue/no-duplicate-class-names` and correctly detect duplicates in combining expressions

lib/rules/no-duplicate-class-names.js

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ const utils = require('../utils')
1111
* @param {VDirective} node
1212
* @param {Expression} [expression]
1313
* @param {boolean} [unconditional=true] whether the expression is unconditional
14-
* @return {IterableIterator<{ node: Literal | TemplateElement, unconditional: boolean }>}
14+
* @param {Expression} [parentExpr] parent expression for context
15+
* @return {IterableIterator<{ node: Literal | TemplateElement, unconditional: boolean, parentExpr?: Expression }>}
1516
*/
16-
function* extractClassNodes(node, expression, unconditional = true) {
17+
function* extractClassNodes(
18+
node,
19+
expression,
20+
unconditional = true,
21+
parentExpr
22+
) {
1723
const nodeExpression = expression ?? node.value?.expression
1824
if (!nodeExpression) return
1925

2026
switch (nodeExpression.type) {
2127
case 'Literal': {
22-
yield { node: nodeExpression, unconditional }
28+
yield { node: nodeExpression, unconditional, parentExpr }
2329
break
2430
}
2531
case 'ObjectExpression': {
@@ -29,42 +35,76 @@ function* extractClassNodes(node, expression, unconditional = true) {
2935
prop.key?.type === 'Literal' &&
3036
typeof prop.key.value === 'string'
3137
) {
32-
yield { node: prop.key, unconditional: false }
38+
yield {
39+
node: prop.key,
40+
unconditional: false,
41+
parentExpr: nodeExpression
42+
}
3343
}
3444
}
3545
break
3646
}
3747
case 'ArrayExpression': {
3848
for (const element of nodeExpression.elements) {
3949
if (!element || element.type === 'SpreadElement') continue
40-
yield* extractClassNodes(node, element, unconditional)
50+
yield* extractClassNodes(node, element, unconditional, nodeExpression)
4151
}
4252
break
4353
}
4454
case 'ConditionalExpression': {
45-
yield* extractClassNodes(node, nodeExpression.consequent, false)
46-
yield* extractClassNodes(node, nodeExpression.alternate, false)
55+
yield* extractClassNodes(
56+
node,
57+
nodeExpression.consequent,
58+
false,
59+
nodeExpression
60+
)
61+
yield* extractClassNodes(
62+
node,
63+
nodeExpression.alternate,
64+
false,
65+
nodeExpression
66+
)
4767
break
4868
}
4969
case 'TemplateLiteral': {
5070
for (const quasi of nodeExpression.quasis) {
51-
yield { node: quasi, unconditional }
71+
yield { node: quasi, unconditional, parentExpr: nodeExpression }
5272
}
5373
for (const expr of nodeExpression.expressions) {
54-
yield* extractClassNodes(node, expr, unconditional)
74+
yield* extractClassNodes(node, expr, unconditional, nodeExpression)
5575
}
5676
break
5777
}
5878
case 'BinaryExpression': {
5979
if (nodeExpression.operator === '+') {
60-
yield* extractClassNodes(node, nodeExpression.left, unconditional)
61-
yield* extractClassNodes(node, nodeExpression.right, unconditional)
80+
yield* extractClassNodes(
81+
node,
82+
nodeExpression.left,
83+
unconditional,
84+
nodeExpression
85+
)
86+
yield* extractClassNodes(
87+
node,
88+
nodeExpression.right,
89+
unconditional,
90+
nodeExpression
91+
)
6292
}
6393
break
6494
}
6595
case 'LogicalExpression': {
66-
yield* extractClassNodes(node, nodeExpression.left, unconditional)
67-
yield* extractClassNodes(node, nodeExpression.right, unconditional)
96+
yield* extractClassNodes(
97+
node,
98+
nodeExpression.left,
99+
unconditional,
100+
nodeExpression
101+
)
102+
yield* extractClassNodes(
103+
node,
104+
nodeExpression.right,
105+
false,
106+
nodeExpression
107+
)
68108
break
69109
}
70110
}
@@ -250,11 +290,15 @@ module.exports = {
250290
/** @type {Map<string, ASTNode>} */
251291
const seen = new Map()
252292

253-
/** @type {Map<string, {node: ASTNode, unconditional: boolean}>} */
293+
/** @type {Map<string, {node: ASTNode, unconditional: boolean, parentExpr?: Expression}>} */
254294
const collected = new Map()
255295

256296
const classNodes = extractClassNodes(node)
257-
for (const { node: reportNode, unconditional } of classNodes) {
297+
for (const {
298+
node: reportNode,
299+
unconditional,
300+
parentExpr
301+
} of classNodes) {
258302
// report fixable duplicates and collect reported class names
259303
const reportedClasses = reportDuplicateClasses(reportNode)
260304
if (reportedClasses) {
@@ -272,14 +316,21 @@ module.exports = {
272316
if (reported.has(className)) continue
273317
const existing = collected.get(className)
274318
if (existing) {
275-
// only add duplicate if at least one is unconditional
276-
if (existing.unconditional || unconditional) {
319+
// only add duplicate if at least one is unconditional, or share the same combining parent
320+
const isSameParent =
321+
parentExpr &&
322+
existing.parentExpr === parentExpr &&
323+
(parentExpr.type === 'BinaryExpression' ||
324+
parentExpr.type === 'TemplateLiteral')
325+
326+
if (existing.unconditional || unconditional || isSameParent) {
277327
duplicatesInExpression.add(className)
278328
}
279329
} else {
280330
collected.set(className, {
281331
node: reportNode.parent,
282-
unconditional
332+
unconditional,
333+
parentExpr
283334
})
284335
}
285336
// track unconditional duplicates separately for reporting

tests/lib/rules/no-duplicate-class-names.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ tester.run('no-duplicate-class-names', rule, {
5656
{
5757
filename: 'class-object-duplicate-value.vue',
5858
code: `<div :class="{ 'foo bar': isActive, 'foo': isAnotherActive }"></div>`
59+
},
60+
{
61+
filename: 'class-non-intersecting-conditions.vue',
62+
code: `<template><div :class="[isActive1 && { 'foo': isActive2, 'bar': isActive3 }, isActive4 && 'bar']"></div></template>`
63+
},
64+
{
65+
filename: 'class-multiple-logical-non-intersecting.vue',
66+
code: `<template><div :class="[isActive1 && 'foo', isActive2 && 'foo']"></div></template>`
67+
},
68+
{
69+
filename: 'class-binary-in-logical-non-intersecting.vue',
70+
code: `<template><div :class="[isActive1 && ('foo' + ' bar'), isActive2 && 'foo']"></div></template>`
5971
}
6072
],
6173
invalid: [
@@ -527,6 +539,36 @@ tester.run('no-duplicate-class-names', rule, {
527539
endColumn: 63
528540
}
529541
]
542+
},
543+
{
544+
filename: 'duplicate-class-binary-in-logical-expression.vue',
545+
code: `<template><div :class="isActive && 'bar' + ' bar'"></div></template>`,
546+
output: null,
547+
errors: [
548+
{
549+
message: "Duplicate class name 'bar'.",
550+
type: 'BinaryExpression',
551+
line: 1,
552+
column: 36,
553+
endLine: 1,
554+
endColumn: 50
555+
}
556+
]
557+
},
558+
{
559+
filename: 'duplicate-class-template-literal-in-logical-expression.vue',
560+
code: '<template><div :class="isActive && `foo ${bar} foo`"></div></template>',
561+
output: null,
562+
errors: [
563+
{
564+
message: "Duplicate class name 'foo'.",
565+
type: 'TemplateLiteral',
566+
line: 1,
567+
column: 36,
568+
endLine: 1,
569+
endColumn: 52
570+
}
571+
]
530572
}
531573
]
532574
})

0 commit comments

Comments
 (0)