Skip to content

Commit 2dc606c

Browse files
authored
Add support for props destructure to vue/no-setup-props-reactivity-loss rule (#2550)
1 parent e8a09e9 commit 2dc606c

File tree

3 files changed

+155
-81
lines changed

3 files changed

+155
-81
lines changed

Diff for: lib/rules/no-setup-props-reactivity-loss.js

+136-64
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,60 @@
66
const { findVariable } = require('@eslint-community/eslint-utils')
77
const utils = require('../utils')
88

9+
/**
10+
* @typedef {'props'|'prop'} PropIdKind
11+
* - `'props'`: A node is a container object that has props.
12+
* - `'prop'`: A node is a variable with one prop.
13+
*/
14+
/**
15+
* @typedef {object} PropId
16+
* @property {Pattern} node
17+
* @property {PropIdKind} kind
18+
*/
19+
/**
20+
* Iterates over Prop identifiers by parsing the given pattern
21+
* in the left operand of defineProps().
22+
* @param {Pattern} node
23+
* @returns {IterableIterator<PropId>}
24+
*/
25+
function* iteratePropIds(node) {
26+
switch (node.type) {
27+
case 'ObjectPattern': {
28+
for (const prop of node.properties) {
29+
yield prop.type === 'Property'
30+
? {
31+
// e.g. `const { prop } = defineProps()`
32+
node: unwrapAssignment(prop.value),
33+
kind: 'prop'
34+
}
35+
: {
36+
// RestElement
37+
// e.g. `const { x, ...prop } = defineProps()`
38+
node: unwrapAssignment(prop.argument),
39+
kind: 'props'
40+
}
41+
}
42+
break
43+
}
44+
default: {
45+
// e.g. `const props = defineProps()`
46+
yield { node: unwrapAssignment(node), kind: 'props' }
47+
}
48+
}
49+
}
50+
51+
/**
52+
* @template {Pattern} T
53+
* @param {T} node
54+
* @returns {Pattern}
55+
*/
56+
function unwrapAssignment(node) {
57+
if (node.type === 'AssignmentPattern') {
58+
return node.left
59+
}
60+
return node
61+
}
62+
963
module.exports = {
1064
meta: {
1165
type: 'suggestion',
@@ -31,7 +85,9 @@ module.exports = {
3185
create(context) {
3286
/**
3387
* @typedef {object} ScopePropsReferences
34-
* @property {Set<Identifier>} refs
88+
* @property {object} refs
89+
* @property {Set<Identifier>} refs.props A set of references to container objects with multiple props.
90+
* @property {Set<Identifier>} refs.prop A set of references a variable with one property.
3591
* @property {string} scopeName
3692
*/
3793
/** @type {Map<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | Program, ScopePropsReferences>} */
@@ -72,70 +128,72 @@ module.exports = {
72128
wrapperExpressionTypes.has(rightNode.type) &&
73129
isPropsMemberAccessed(rightNode, propsReferences)
74130
) {
75-
return report(rightNode, 'getProperty', propsReferences.scopeName)
76-
}
77-
78-
if (
79-
left.type !== 'ArrayPattern' &&
80-
left.type !== 'ObjectPattern' &&
81-
rightNode.type !== 'MemberExpression' &&
82-
rightNode.type !== 'ConditionalExpression' &&
83-
rightNode.type !== 'TemplateLiteral'
84-
) {
131+
// e.g. `const foo = { x: props.x }`
132+
report(rightNode, 'getProperty', propsReferences.scopeName)
85133
return
86134
}
87135

88-
if (rightNode.type === 'TemplateLiteral') {
89-
rightNode.expressions.some((expression) =>
90-
checkMemberAccess(expression, propsReferences, left, right)
91-
)
92-
} else {
93-
checkMemberAccess(rightNode, propsReferences, left, right)
136+
// Get the expression that provides the value.
137+
/** @type {Expression | Super} */
138+
let expression = rightNode
139+
while (expression.type === 'MemberExpression') {
140+
expression = utils.skipChainExpression(expression.object)
94141
}
95-
}
142+
/** A list of expression nodes to verify */
143+
const expressions =
144+
expression.type === 'TemplateLiteral'
145+
? expression.expressions
146+
: expression.type === 'ConditionalExpression'
147+
? [expression.test, expression.consequent, expression.alternate]
148+
: expression.type === 'Identifier'
149+
? [expression]
150+
: []
96151

97-
/**
98-
* @param {Expression | Super} rightId
99-
* @param {ScopePropsReferences} propsReferences
100-
* @param {Pattern} left
101-
* @param {Expression} right
102-
* @return {boolean}
103-
*/
104-
function checkMemberAccess(rightId, propsReferences, left, right) {
105-
while (rightId.type === 'MemberExpression') {
106-
rightId = utils.skipChainExpression(rightId.object)
107-
}
108-
if (rightId.type === 'Identifier' && propsReferences.refs.has(rightId)) {
109-
report(left, 'getProperty', propsReferences.scopeName)
110-
return true
111-
}
112152
if (
113-
rightId.type === 'ConditionalExpression' &&
114-
(isPropsMemberAccessed(rightId.test, propsReferences) ||
115-
isPropsMemberAccessed(rightId.consequent, propsReferences) ||
116-
isPropsMemberAccessed(rightId.alternate, propsReferences))
153+
(left.type === 'ArrayPattern' || left.type === 'ObjectPattern') &&
154+
expressions.some(
155+
(expr) =>
156+
expr.type === 'Identifier' && propsReferences.refs.props.has(expr)
157+
)
117158
) {
118-
report(right, 'getProperty', propsReferences.scopeName)
119-
return true
159+
// e.g. `const {foo} = props`
160+
report(left, 'getProperty', propsReferences.scopeName)
161+
return
162+
}
163+
164+
const reportNode = expressions.find((expr) =>
165+
isPropsMemberAccessed(expr, propsReferences)
166+
)
167+
if (reportNode) {
168+
report(reportNode, 'getProperty', propsReferences.scopeName)
120169
}
121-
return false
122170
}
123171

124172
/**
125-
* @param {Expression} node
173+
* @param {Expression | Super} node
126174
* @param {ScopePropsReferences} propsReferences
127175
*/
128176
function isPropsMemberAccessed(node, propsReferences) {
129-
const propRefs = [...propsReferences.refs.values()]
130-
131-
return propRefs.some((props) => {
177+
for (const props of propsReferences.refs.props) {
132178
const isPropsInExpressionRange = utils.inRange(node.range, props)
133179
const isPropsMemberExpression =
134180
props.parent.type === 'MemberExpression' &&
135181
props.parent.object === props
136182

137-
return isPropsInExpressionRange && isPropsMemberExpression
138-
})
183+
if (isPropsInExpressionRange && isPropsMemberExpression) {
184+
return true
185+
}
186+
}
187+
188+
// Checks for actual member access using prop destructuring.
189+
for (const prop of propsReferences.refs.prop) {
190+
const isPropsInExpressionRange = utils.inRange(node.range, prop)
191+
if (isPropsInExpressionRange) {
192+
return true
193+
}
194+
}
195+
196+
return false
139197
}
140198

141199
/**
@@ -149,16 +207,12 @@ module.exports = {
149207
let scopeStack = null
150208

151209
/**
152-
* @param {Pattern | null} node
210+
* @param {PropId} propId
153211
* @param {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | Program} scopeNode
154212
* @param {import('eslint').Scope.Scope} currentScope
155213
* @param {string} scopeName
156214
*/
157-
function processPattern(node, scopeNode, currentScope, scopeName) {
158-
if (!node) {
159-
// no arguments
160-
return
161-
}
215+
function processPropId({ node, kind }, scopeNode, currentScope, scopeName) {
162216
if (
163217
node.type === 'RestElement' ||
164218
node.type === 'AssignmentPattern' ||
@@ -176,7 +230,19 @@ module.exports = {
176230
if (!variable) {
177231
return
178232
}
179-
const propsReferenceIds = new Set()
233+
234+
let scopePropsReferences = setupScopePropsReferenceIds.get(scopeNode)
235+
if (!scopePropsReferences) {
236+
scopePropsReferences = {
237+
refs: {
238+
props: new Set(),
239+
prop: new Set()
240+
},
241+
scopeName
242+
}
243+
setupScopePropsReferenceIds.set(scopeNode, scopePropsReferences)
244+
}
245+
const propsReferenceIds = scopePropsReferences.refs[kind]
180246
for (const reference of variable.references) {
181247
// If reference is in another scope, we can't check it.
182248
if (reference.from !== currentScope) {
@@ -189,11 +255,8 @@ module.exports = {
189255

190256
propsReferenceIds.add(reference.identifier)
191257
}
192-
setupScopePropsReferenceIds.set(scopeNode, {
193-
refs: propsReferenceIds,
194-
scopeName
195-
})
196258
}
259+
197260
return utils.compositingVisitors(
198261
{
199262
/**
@@ -287,20 +350,29 @@ module.exports = {
287350
} else if (target.parent.type === 'AssignmentExpression') {
288351
id = target.parent.right === target ? target.parent.left : null
289352
}
353+
if (!id) return
290354
const currentScope = utils.getScope(context, node)
291-
processPattern(
292-
id,
293-
context.getSourceCode().ast,
294-
currentScope,
295-
'<script setup>'
296-
)
355+
for (const propId of iteratePropIds(id)) {
356+
processPropId(
357+
propId,
358+
context.getSourceCode().ast,
359+
currentScope,
360+
'<script setup>'
361+
)
362+
}
297363
}
298364
}),
299365
utils.defineVueVisitor(context, {
300366
onSetupFunctionEnter(node) {
301367
const currentScope = utils.getScope(context, node)
302368
const propsParam = utils.skipDefaultParamValue(node.params[0])
303-
processPattern(propsParam, node, currentScope, 'setup()')
369+
if (!propsParam) return
370+
processPropId(
371+
{ node: propsParam, kind: 'props' },
372+
node,
373+
currentScope,
374+
'setup()'
375+
)
304376
}
305377
})
306378
)

Diff for: tests/lib/rules/no-setup-props-destructure.js

-15
Original file line numberDiff line numberDiff line change
@@ -504,21 +504,6 @@ tester.run('no-setup-props-destructure', rule, {
504504
}
505505
]
506506
},
507-
{
508-
filename: 'test.vue',
509-
code: `
510-
<script setup>
511-
const {count} = defineProps({count:Number})
512-
</script>
513-
`,
514-
errors: [
515-
{
516-
message:
517-
'Destructuring the `props` will cause the value to lose reactivity.',
518-
line: 3
519-
}
520-
]
521-
},
522507
{
523508
filename: 'test.vue',
524509
code: `

Diff for: tests/lib/rules/no-setup-props-reactivity-loss.js

+19-2
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,22 @@ tester.run('no-setup-props-reactivity-loss', rule, {
251251
const count = computed(() => props.count)
252252
</script>
253253
`
254+
},
255+
{
256+
filename: 'test.vue',
257+
code: `
258+
<script setup>
259+
const {count} = defineProps({count:Number})
260+
</script>
261+
`
262+
},
263+
{
264+
filename: 'test.vue',
265+
code: `
266+
<script setup>
267+
const { foo = 1, bar = 'ok' } = defineProps({ foo: Number, bar: String })
268+
</script>
269+
`
254270
}
255271
],
256272
invalid: [
@@ -532,13 +548,14 @@ tester.run('no-setup-props-reactivity-loss', rule, {
532548
code: `
533549
<script setup>
534550
const {count} = defineProps({count:Number})
551+
const foo = count
535552
</script>
536553
`,
537554
errors: [
538555
{
539556
message:
540-
'Destructuring the `props` will cause the value to lose reactivity.',
541-
line: 3
557+
'Getting a value from the `props` in root scope of `<script setup>` will cause the value to lose reactivity.',
558+
line: 4
542559
}
543560
]
544561
},

0 commit comments

Comments
 (0)