Skip to content

Commit 8cb00fb

Browse files
FloEdelmannclaude
andauthored
feat: add vue/prefer-v-model rule (#3062)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5ae3456 commit 8cb00fb

File tree

6 files changed

+493
-0
lines changed

6 files changed

+493
-0
lines changed

.changeset/red-maps-relax.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-vue": minor
3+
---
4+
5+
Added new `vue/prefer-v-model` rule (#2237)

docs/rules/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ For example:
278278
| [vue/prefer-single-event-payload] | enforce passing a single argument to custom event emissions | | :hammer: |
279279
| [vue/prefer-true-attribute-shorthand] | require shorthand form attribute when `v-bind` value is `true` | :bulb: | :hammer: |
280280
| [vue/prefer-use-template-ref] | require using `useTemplateRef` instead of `ref`/`shallowRef` for template refs | | :hammer: |
281+
| [vue/prefer-v-model] | enforce using `v-model` instead of `:prop`/`@update:prop` pair | :bulb: | :hammer: |
281282
| [vue/require-default-export] | require components to be the default export | | :warning: |
282283
| [vue/require-direct-export] | require the component to be directly exported | | :hammer: |
283284
| [vue/require-emit-validator] | require type definitions in emits | :bulb: | :hammer: |
@@ -565,6 +566,7 @@ The following rules extend the rules provided by ESLint itself and apply them to
565566
[vue/prefer-template]: ./prefer-template.md
566567
[vue/prefer-true-attribute-shorthand]: ./prefer-true-attribute-shorthand.md
567568
[vue/prefer-use-template-ref]: ./prefer-use-template-ref.md
569+
[vue/prefer-v-model]: ./prefer-v-model.md
568570
[vue/prop-name-casing]: ./prop-name-casing.md
569571
[vue/quote-props]: ./quote-props.md
570572
[vue/require-component-is]: ./require-component-is.md

docs/rules/prefer-v-model.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
pageClass: rule-details
3+
sidebarDepth: 0
4+
title: vue/prefer-v-model
5+
description: enforce using `v-model` instead of `:prop`/`@update:prop` pair
6+
---
7+
8+
# vue/prefer-v-model
9+
10+
> enforce using `v-model` instead of `:prop`/`@update:prop` pair
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+
- :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
14+
15+
## :book: Rule Details
16+
17+
In Vue 3, `:foo="bar" @update:foo="bar = $event"` can be simplified to `v-model:foo="bar"`, and `:modelValue="foo" @update:modelValue="foo = $event"` can be simplified to `v-model="foo"`. This rule suggests those simplifications.
18+
19+
<eslint-code-block :rules="{'vue/prefer-v-model': ['error']}">
20+
21+
```vue
22+
<template>
23+
<!-- ✓ GOOD -->
24+
<my-component v-model="foo" />
25+
<my-component v-model:foo="bar" />
26+
<my-component :foo="bar" @update:foo="baz = $event" />
27+
<my-component :foo="bar" @update:foo="updateFoo($event)" />
28+
<my-component :foo="bar" @update:foo="(val) => updateFoo(val)" />
29+
30+
<!-- ✗ BAD -->
31+
<my-component :modelValue="foo" @update:modelValue="foo = $event" />
32+
<my-component :model-value="foo" @update:model-value="foo = $event" />
33+
<my-component :foo="bar" @update:foo="bar = $event" />
34+
<my-component v-bind:foo="bar" v-on:update:foo="bar = $event" />
35+
<my-component :foo="bar" @update:foo="(val) => bar = val" />
36+
</template>
37+
```
38+
39+
</eslint-code-block>
40+
41+
## :wrench: Options
42+
43+
Nothing.
44+
45+
## :books: Further Reading
46+
47+
- [Vue.js - Component v-model](https://vuejs.org/guide/components/v-model.html)
48+
- [Vue.js - v-model arguments](https://vuejs.org/guide/components/v-model.html#v-model-arguments)
49+
50+
## :mag: Implementation
51+
52+
- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/prefer-v-model.ts)
53+
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/prefer-v-model.test.ts)

lib/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ import preferSingleEventPayload from './rules/prefer-single-event-payload.ts'
189189
import preferTemplate from './rules/prefer-template.js'
190190
import preferTrueAttributeShorthand from './rules/prefer-true-attribute-shorthand.ts'
191191
import preferUseTemplateRef from './rules/prefer-use-template-ref.js'
192+
import preferVModel from './rules/prefer-v-model.ts'
192193
import propNameCasing from './rules/prop-name-casing.ts'
193194
import quoteProps from './rules/quote-props.js'
194195
import requireComponentIs from './rules/require-component-is.js'
@@ -446,6 +447,7 @@ export default {
446447
'prefer-template': preferTemplate,
447448
'prefer-true-attribute-shorthand': preferTrueAttributeShorthand,
448449
'prefer-use-template-ref': preferUseTemplateRef,
450+
'prefer-v-model': preferVModel,
449451
'prop-name-casing': propNameCasing,
450452
'quote-props': quoteProps,
451453
'require-component-is': requireComponentIs,

lib/rules/prefer-v-model.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @author Flo Edelmann
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
import { camelCase } from '../utils/casing.ts'
6+
import utils from '../utils/index.js'
7+
8+
/**
9+
* Get the static argument name of a directive, or `null` for dynamic arguments.
10+
*/
11+
const getStaticArgName = (directive: VDirective): string | null =>
12+
directive.key.argument?.type === 'VIdentifier'
13+
? directive.key.argument.rawName
14+
: null
15+
16+
/**
17+
* Extract the prop name from an `update:propName` event directive argument.
18+
*/
19+
function getUpdateEventPropName(onDirective: VDirective): string | null {
20+
const argName = getStaticArgName(onDirective)
21+
if (!argName?.startsWith('update:')) {
22+
return null
23+
}
24+
return argName.slice('update:'.length)
25+
}
26+
27+
/**
28+
* Check if the event handler is a simple mirror assignment of the bind expression.
29+
* Matches: `bar = $event` or `(param) => bar = param`
30+
*/
31+
function isMirrorAssignment(
32+
bindExpr: Expression,
33+
onExpr: VOnExpression | Expression,
34+
sourceCode: SourceCode
35+
): boolean {
36+
const bindText = sourceCode.getText(bindExpr as ASTNode)
37+
38+
// Form A: VOnExpression with "bar = $event"
39+
if (onExpr.type === 'VOnExpression') {
40+
const statements = onExpr.body
41+
if (statements.length !== 1) {
42+
return false
43+
}
44+
const stmt = statements[0]
45+
if (stmt.type !== 'ExpressionStatement') {
46+
return false
47+
}
48+
const expr = stmt.expression
49+
if (expr.type !== 'AssignmentExpression' || expr.operator !== '=') {
50+
return false
51+
}
52+
const lhsText = sourceCode.getText(expr.left as ASTNode)
53+
return (
54+
lhsText === bindText &&
55+
expr.right.type === 'Identifier' &&
56+
expr.right.name === '$event'
57+
)
58+
}
59+
60+
// Form B: ArrowFunctionExpression "(param) => bar = param"
61+
if (onExpr.type === 'ArrowFunctionExpression') {
62+
if (onExpr.params.length !== 1) {
63+
return false
64+
}
65+
const param = onExpr.params[0]
66+
if (param.type !== 'Identifier') {
67+
return false
68+
}
69+
const body = onExpr.body
70+
if (body.type !== 'AssignmentExpression' || body.operator !== '=') {
71+
return false
72+
}
73+
const lhsText = sourceCode.getText(body.left as ASTNode)
74+
return (
75+
lhsText === bindText &&
76+
body.right.type === 'Identifier' &&
77+
body.right.name === param.name
78+
)
79+
}
80+
81+
return false
82+
}
83+
84+
export default {
85+
meta: {
86+
type: 'suggestion',
87+
docs: {
88+
description:
89+
'enforce using `v-model` instead of `:prop`/`@update:prop` pair',
90+
categories: undefined,
91+
url: 'https://eslint.vuejs.org/rules/prefer-v-model.html'
92+
},
93+
fixable: null,
94+
hasSuggestions: true,
95+
schema: [],
96+
messages: {
97+
preferVModel:
98+
'Prefer `{{ vModelName }}` over the `:{{ propName }}`/`@update:{{ eventName }}` pair.',
99+
replaceWithVModel: 'Replace with `{{ vModelName }}`.'
100+
}
101+
},
102+
create(context: RuleContext) {
103+
const sourceCode = context.sourceCode
104+
105+
return utils.defineTemplateBodyVisitor(context, {
106+
VStartTag(node) {
107+
const element = node.parent
108+
if (!utils.isCustomComponent(element)) {
109+
return
110+
}
111+
112+
const bindDirectives: VDirective[] = []
113+
const onDirectives: VDirective[] = []
114+
115+
for (const attr of node.attributes) {
116+
if (!attr.directive) continue
117+
if (
118+
attr.key.name.name === 'bind' &&
119+
getStaticArgName(attr) != null &&
120+
attr.key.modifiers.length === 0
121+
) {
122+
bindDirectives.push(attr)
123+
}
124+
if (
125+
attr.key.name.name === 'on' &&
126+
getUpdateEventPropName(attr) != null &&
127+
attr.key.modifiers.length === 0
128+
) {
129+
onDirectives.push(attr)
130+
}
131+
}
132+
133+
for (const bindDir of bindDirectives) {
134+
const propName = getStaticArgName(bindDir)
135+
if (!propName) {
136+
continue
137+
}
138+
139+
const normalizedBindName = camelCase(propName)
140+
141+
const matchingOnDir = onDirectives.find(
142+
(onDir) =>
143+
camelCase(getUpdateEventPropName(onDir)!) === normalizedBindName
144+
)
145+
146+
if (!matchingOnDir) {
147+
continue
148+
}
149+
150+
const bindExpr = bindDir.value?.expression
151+
const onExpr = matchingOnDir.value?.expression
152+
if (
153+
!bindExpr ||
154+
bindExpr.type === 'VFilterSequenceExpression' ||
155+
bindExpr.type === 'VForExpression' ||
156+
bindExpr.type === 'VOnExpression' ||
157+
bindExpr.type === 'VSlotScopeExpression' ||
158+
!onExpr ||
159+
!isMirrorAssignment(
160+
bindExpr,
161+
onExpr as VOnExpression | Expression,
162+
sourceCode
163+
)
164+
) {
165+
continue
166+
}
167+
168+
const isModelValue = normalizedBindName === 'modelValue'
169+
const vModelName = isModelValue ? 'v-model' : `v-model:${propName}`
170+
const eventName = getUpdateEventPropName(matchingOnDir) ?? propName
171+
172+
const bindValueText = sourceCode.getText(bindDir.value as ASTNode)
173+
const vModelText = `${vModelName}=${bindValueText}`
174+
175+
context.report({
176+
node: bindDir,
177+
messageId: 'preferVModel',
178+
data: {
179+
vModelName,
180+
propName,
181+
eventName
182+
},
183+
suggest: [
184+
{
185+
messageId: 'replaceWithVModel',
186+
data: { vModelName },
187+
*fix(fixer) {
188+
yield fixer.replaceText(bindDir, vModelText)
189+
190+
// Remove the `v-on` directive including preceding whitespace
191+
const textBefore = sourceCode
192+
.getText()
193+
.slice(0, matchingOnDir.range[0])
194+
const removeStart =
195+
matchingOnDir.range[0] -
196+
(textBefore.length - textBefore.trimEnd().length)
197+
yield fixer.removeRange([removeStart, matchingOnDir.range[1]])
198+
}
199+
}
200+
]
201+
})
202+
}
203+
}
204+
})
205+
}
206+
}

0 commit comments

Comments
 (0)