Skip to content

Commit 3cbb1b3

Browse files
FlareZhota-meshi
andauthored
Add shallowOnly option to vue/no-mutating-props (#2135)
Co-authored-by: Yosuke Ota <otameshiyo23@gmail.com>
1 parent d58fb19 commit 3cbb1b3

File tree

3 files changed

+416
-47
lines changed

3 files changed

+416
-47
lines changed

docs/rules/no-mutating-props.md

+71-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ This rule reports mutation of component props.
2222
<template>
2323
<div>
2424
<input v-model="value" @click="openModal">
25+
<button @click="pushItem">Push Item</button>
26+
<button @click="changeId">Change ID</button>
2527
</div>
2628
</template>
2729
<script>
@@ -30,11 +32,25 @@ This rule reports mutation of component props.
3032
value: {
3133
type: String,
3234
required: true
35+
},
36+
list: {
37+
type: Array,
38+
required: true
39+
},
40+
user: {
41+
type: Object,
42+
required: true
3343
}
3444
},
3545
methods: {
3646
openModal() {
3747
this.value = 'test'
48+
},
49+
pushItem() {
50+
this.list.push(0)
51+
},
52+
changeId() {
53+
this.user.id = 1
3854
}
3955
}
4056
}
@@ -50,6 +66,8 @@ This rule reports mutation of component props.
5066
<template>
5167
<div>
5268
<input :value="value" @input="$emit('input', $event.target.value)" @click="openModal">
69+
<button @click="pushItem">Push Item</button>
70+
<button @click="changeId">Change ID</button>
5371
</div>
5472
</template>
5573
<script>
@@ -58,11 +76,25 @@ This rule reports mutation of component props.
5876
value: {
5977
type: String,
6078
required: true
79+
},
80+
list: {
81+
type: Array,
82+
required: true
83+
},
84+
user: {
85+
type: Object,
86+
required: true
6187
}
6288
},
6389
methods: {
6490
openModal() {
6591
this.$emit('input', 'test')
92+
},
93+
pushItem() {
94+
this.$emit('push', 0)
95+
},
96+
changeId() {
97+
this.$emit('change-id', 1)
6698
}
6799
}
68100
}
@@ -88,7 +120,45 @@ This rule reports mutation of component props.
88120

89121
## :wrench: Options
90122

91-
Nothing.
123+
```json
124+
{
125+
"vue/no-mutating-props": ["error", {
126+
"shallowOnly": false
127+
}]
128+
}
129+
```
130+
131+
- "shallowOnly" (`boolean`) Enables mutating the value of a prop but leaving the reference the same. Default is `false`.
132+
133+
### "shallowOnly": true
134+
135+
<eslint-code-block :rules="{'vue/no-mutating-props': ['error', {shallowOnly: true}]}">
136+
137+
```vue
138+
<!-- ✓ GOOD -->
139+
<template>
140+
<div>
141+
<input v-model="value.id" @click="openModal">
142+
</div>
143+
</template>
144+
<script>
145+
export default {
146+
props: {
147+
value: {
148+
type: Object,
149+
required: true
150+
}
151+
},
152+
methods: {
153+
openModal() {
154+
this.value.visible = true
155+
}
156+
}
157+
}
158+
</script>
159+
```
160+
161+
</eslint-code-block>
92162

93163
## :books: Further Reading
94164

lib/rules/no-mutating-props.js

+115-44
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
*/
55
'use strict'
66

7+
/**
8+
* @typedef {{name?: string, set: Set<string>}} PropsInfo
9+
*/
10+
711
const utils = require('../utils')
812
const { findVariable } = require('@eslint-community/eslint-utils')
913

@@ -84,6 +88,19 @@ function isVmReference(node) {
8488
return false
8589
}
8690

91+
/**
92+
* @param { object } options
93+
* @param { boolean } options.shallowOnly Enables mutating the value of a prop but leaving the reference the same
94+
*/
95+
function parseOptions(options) {
96+
return Object.assign(
97+
{
98+
shallowOnly: false
99+
},
100+
options
101+
)
102+
}
103+
87104
module.exports = {
88105
meta: {
89106
type: 'suggestion',
@@ -94,12 +111,21 @@ module.exports = {
94111
},
95112
fixable: null, // or "code" or "whitespace"
96113
schema: [
97-
// fill in your schema
114+
{
115+
type: 'object',
116+
properties: {
117+
shallowOnly: {
118+
type: 'boolean'
119+
}
120+
},
121+
additionalProperties: false
122+
}
98123
]
99124
},
100125
/** @param {RuleContext} context */
101126
create(context) {
102-
/** @type {Map<ObjectExpression|CallExpression, Set<string>>} */
127+
const { shallowOnly } = parseOptions(context.options[0])
128+
/** @type {Map<ObjectExpression|CallExpression, PropsInfo>} */
103129
const propsMap = new Map()
104130
/** @type { { type: 'export' | 'mark' | 'definition', object: ObjectExpression } | { type: 'setup', object: CallExpression } | null } */
105131
let vueObjectData = null
@@ -138,10 +164,11 @@ module.exports = {
138164
/**
139165
* @param {MemberExpression|Identifier} props
140166
* @param {string} name
167+
* @param {boolean} isRootProps
141168
*/
142-
function verifyMutating(props, name) {
169+
function verifyMutating(props, name, isRootProps = false) {
143170
const invalid = utils.findMutating(props)
144-
if (invalid) {
171+
if (invalid && isShallowOnlyInvalid(invalid, isRootProps)) {
145172
report(invalid.node, name)
146173
}
147174
}
@@ -210,6 +237,9 @@ module.exports = {
210237
continue
211238
}
212239
let name
240+
if (!isShallowOnlyInvalid(invalid, path.length === 0)) {
241+
continue
242+
}
213243
if (path.length === 0) {
214244
if (invalid.pathNodes.length === 0) {
215245
continue
@@ -246,26 +276,43 @@ module.exports = {
246276
}
247277
}
248278

279+
/**
280+
* Is shallowOnly false or the prop reassigned
281+
* @param {Exclude<ReturnType<typeof utils.findMutating>, null>} invalid
282+
* @param {boolean} isRootProps
283+
* @return {boolean}
284+
*/
285+
function isShallowOnlyInvalid(invalid, isRootProps) {
286+
return (
287+
!shallowOnly ||
288+
(invalid.pathNodes.length === (isRootProps ? 1 : 0) &&
289+
['assignment', 'update'].includes(invalid.kind))
290+
)
291+
}
292+
249293
return utils.compositingVisitors(
250294
{},
251295
utils.defineScriptSetupVisitor(context, {
252296
onDefinePropsEnter(node, props) {
253297
const defineVariableNames = new Set(extractDefineVariableNames())
254298

255-
const propsSet = new Set(
256-
props
257-
.map((p) => p.propName)
258-
.filter(
259-
/**
260-
* @returns {propName is string}
261-
*/
262-
(propName) =>
263-
utils.isDef(propName) &&
264-
!GLOBALS_WHITE_LISTED.has(propName) &&
265-
!defineVariableNames.has(propName)
266-
)
267-
)
268-
propsMap.set(node, propsSet)
299+
const propsInfo = {
300+
name: '',
301+
set: new Set(
302+
props
303+
.map((p) => p.propName)
304+
.filter(
305+
/**
306+
* @returns {propName is string}
307+
*/
308+
(propName) =>
309+
utils.isDef(propName) &&
310+
!GLOBALS_WHITE_LISTED.has(propName) &&
311+
!defineVariableNames.has(propName)
312+
)
313+
)
314+
}
315+
propsMap.set(node, propsInfo)
269316
vueObjectData = {
270317
type: 'setup',
271318
object: node
@@ -294,22 +341,25 @@ module.exports = {
294341
target.parent.id,
295342
[]
296343
)) {
344+
if (path.length === 0) {
345+
propsInfo.name = prop.name
346+
} else {
347+
propsInfo.set.add(prop.name)
348+
}
297349
verifyPropVariable(prop, path)
298-
propsSet.add(prop.name)
299350
}
300351
}
301352
}),
302353
utils.defineVueVisitor(context, {
303354
onVueObjectEnter(node) {
304-
propsMap.set(
305-
node,
306-
new Set(
355+
propsMap.set(node, {
356+
set: new Set(
307357
utils
308358
.getComponentPropsFromOptions(node)
309359
.map((p) => p.propName)
310360
.filter(utils.isDef)
311361
)
312-
)
362+
})
313363
},
314364
onVueObjectExit(node, { type }) {
315365
if (
@@ -359,7 +409,7 @@ module.exports = {
359409
const name = utils.getStaticPropertyName(mem)
360410
if (
361411
name &&
362-
/** @type {Set<string>} */ (propsMap.get(vueNode)).has(name)
412+
/** @type {PropsInfo} */ (propsMap.get(vueNode)).set.has(name)
363413
) {
364414
verifyMutating(mem, name)
365415
}
@@ -378,9 +428,9 @@ module.exports = {
378428
const name = utils.getStaticPropertyName(mem)
379429
if (
380430
name &&
381-
/** @type {Set<string>} */ (propsMap.get(vueObjectData.object)).has(
382-
name
383-
)
431+
/** @type {PropsInfo} */ (
432+
propsMap.get(vueObjectData.object)
433+
).set.has(name)
384434
) {
385435
verifyMutating(mem, name)
386436
}
@@ -393,14 +443,18 @@ module.exports = {
393443
if (!isVmReference(node)) {
394444
return
395445
}
396-
const name = node.name
397-
if (
398-
name &&
399-
/** @type {Set<string>} */ (propsMap.get(vueObjectData.object)).has(
400-
name
401-
)
402-
) {
403-
verifyMutating(node, name)
446+
const propsInfo = /** @type {PropsInfo} */ (
447+
propsMap.get(vueObjectData.object)
448+
)
449+
const isRootProps = !!node.name && propsInfo.name === node.name
450+
const parent = node.parent
451+
const name =
452+
(isRootProps &&
453+
parent.type === 'MemberExpression' &&
454+
utils.getStaticPropertyName(parent)) ||
455+
node.name
456+
if (name && (propsInfo.set.has(name) || isRootProps)) {
457+
verifyMutating(node, name, isRootProps)
404458
}
405459
},
406460
/** @param {ESNode} node */
@@ -423,28 +477,45 @@ module.exports = {
423477
return
424478
}
425479

480+
const propsInfo = /** @type {PropsInfo} */ (
481+
propsMap.get(vueObjectData.object)
482+
)
483+
426484
const nodes = utils.getMemberChaining(node)
427485
const first = nodes[0]
428486
let name
429487
if (isVmReference(first)) {
430-
name = first.name
488+
if (first.name === propsInfo.name) {
489+
// props variable
490+
if (shallowOnly && nodes.length > 2) {
491+
return
492+
}
493+
name = (nodes[1] && getPropertyNameText(nodes[1])) || first.name
494+
} else {
495+
if (shallowOnly && nodes.length > 1) {
496+
return
497+
}
498+
name = first.name
499+
if (!name || !propsInfo.set.has(name)) {
500+
return
501+
}
502+
}
431503
} else if (first.type === 'ThisExpression') {
504+
if (shallowOnly && nodes.length > 2) {
505+
return
506+
}
432507
const mem = nodes[1]
433508
if (!mem) {
434509
return
435510
}
436511
name = utils.getStaticPropertyName(mem)
512+
if (!name || !propsInfo.set.has(name)) {
513+
return
514+
}
437515
} else {
438516
return
439517
}
440-
if (
441-
name &&
442-
/** @type {Set<string>} */ (propsMap.get(vueObjectData.object)).has(
443-
name
444-
)
445-
) {
446-
report(node, name)
447-
}
518+
report(node, name)
448519
}
449520
})
450521
)

0 commit comments

Comments
 (0)