Skip to content

Commit 79d6a31

Browse files
waynzhFloEdelmann
andauthored
Added ignoredObjectNames option to vue/no-async-in-computed-properties (#2927)
Co-authored-by: Flo Edelmann <[email protected]>
1 parent 3e122e5 commit 79d6a31

File tree

4 files changed

+294
-13
lines changed

4 files changed

+294
-13
lines changed

.changeset/cute-bears-sneeze.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 `ignoredObjectNames` option to `vue/no-async-in-computed-properties`

docs/rules/no-async-in-computed-properties.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,42 @@ export default {
108108

109109
## :wrench: Options
110110

111-
Nothing.
111+
```js
112+
{
113+
"vue/no-async-in-computed-properties": ["error", {
114+
"ignoredObjectNames": []
115+
}]
116+
}
117+
```
118+
119+
- `ignoredObjectNames`: An array of object names that should be ignored when used with promise-like methods (`.then()`, `.catch()`, `.finally()`). This is useful for validation libraries like Zod that use these method names for non-promise purposes (e.g. [`z.catch()`](https://zod.dev/api#catch)).
120+
121+
### `"ignoredObjectNames": ["z"]`
122+
123+
<eslint-code-block :rules="{'vue/no-async-in-computed-properties': ['error', {ignoredObjectNames: ['z']}]}">
124+
125+
```vue
126+
<script setup>
127+
import { computed } from 'vue'
128+
import { z } from 'zod'
129+
130+
/* ✓ GOOD */
131+
const schema1 = computed(() => {
132+
return z.string().catch('default')
133+
})
134+
135+
const schema2 = computed(() => {
136+
return z.catch(z.string().min(2), 'fallback')
137+
})
138+
139+
/* ✗ BAD */
140+
const fetchData = computed(() => {
141+
return myFunc().then(res => res.json())
142+
})
143+
</script>
144+
```
145+
146+
</eslint-code-block>
112147

113148
## :books: Further Reading
114149

lib/rules/no-async-in-computed-properties.js

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,88 @@ function isTimedFunction(node) {
3838
)
3939
}
4040

41+
/**
42+
* @param {*} node
43+
* @returns {*}
44+
*/
45+
function skipWrapper(node) {
46+
while (node && node.expression) {
47+
node = node.expression
48+
}
49+
return node
50+
}
51+
52+
/**
53+
* Get the root object name from a member expression chain
54+
* @param {MemberExpression} memberExpr
55+
* @returns {string|null}
56+
*/
57+
function getRootObjectName(memberExpr) {
58+
let current = skipWrapper(memberExpr.object)
59+
60+
while (current) {
61+
switch (current.type) {
62+
case 'MemberExpression': {
63+
current = skipWrapper(current.object)
64+
break
65+
}
66+
case 'CallExpression': {
67+
const calleeExpr = skipWrapper(current.callee)
68+
if (calleeExpr.type === 'MemberExpression') {
69+
current = skipWrapper(calleeExpr.object)
70+
} else if (calleeExpr.type === 'Identifier') {
71+
return calleeExpr.name
72+
} else {
73+
return null
74+
}
75+
break
76+
}
77+
case 'Identifier': {
78+
return current.name
79+
}
80+
default: {
81+
return null
82+
}
83+
}
84+
}
85+
86+
return null
87+
}
88+
89+
/**
90+
* @param {string} name
91+
* @param {*} callee
92+
* @returns {boolean}
93+
*/
94+
function isPromiseMethod(name, callee) {
95+
return (
96+
// hello.PROMISE_FUNCTION()
97+
PROMISE_FUNCTIONS.has(name) ||
98+
// Promise.PROMISE_METHOD()
99+
(callee.object.type === 'Identifier' &&
100+
callee.object.name === 'Promise' &&
101+
PROMISE_METHODS.has(name))
102+
)
103+
}
104+
41105
/**
42106
* @param {CallExpression} node
107+
* @param {Set<string>} ignoredObjectNames
43108
*/
44-
function isPromise(node) {
109+
function isPromise(node, ignoredObjectNames) {
45110
const callee = utils.skipChainExpression(node.callee)
46111
if (callee.type === 'MemberExpression') {
47112
const name = utils.getStaticPropertyName(callee)
48-
return (
49-
name &&
50-
// hello.PROMISE_FUNCTION()
51-
(PROMISE_FUNCTIONS.has(name) ||
52-
// Promise.PROMISE_METHOD()
53-
(callee.object.type === 'Identifier' &&
54-
callee.object.name === 'Promise' &&
55-
PROMISE_METHODS.has(name)))
56-
)
113+
if (!name || !isPromiseMethod(name, callee)) {
114+
return false
115+
}
116+
117+
const rootObjectName = getRootObjectName(callee)
118+
if (rootObjectName && ignoredObjectNames.has(rootObjectName)) {
119+
return false
120+
}
121+
122+
return true
57123
}
58124
return false
59125
}
@@ -85,7 +151,20 @@ module.exports = {
85151
url: 'https://eslint.vuejs.org/rules/no-async-in-computed-properties.html'
86152
},
87153
fixable: null,
88-
schema: [],
154+
schema: [
155+
{
156+
type: 'object',
157+
properties: {
158+
ignoredObjectNames: {
159+
type: 'array',
160+
items: { type: 'string' },
161+
uniqueItems: true,
162+
additionalItems: false
163+
}
164+
},
165+
additionalProperties: false
166+
}
167+
],
89168
messages: {
90169
unexpectedInFunction:
91170
'Unexpected {{expressionName}} in computed function.',
@@ -95,6 +174,9 @@ module.exports = {
95174
},
96175
/** @param {RuleContext} context */
97176
create(context) {
177+
const options = context.options[0] || {}
178+
const ignoredObjectNames = new Set(options.ignoredObjectNames || [])
179+
98180
/** @type {Map<ObjectExpression, ComponentComputedProperty[]>} */
99181
const computedPropertiesMap = new Map()
100182
/** @type {(FunctionExpression | ArrowFunctionExpression)[]} */
@@ -217,7 +299,7 @@ module.exports = {
217299
if (!scopeStack) {
218300
return
219301
}
220-
if (isPromise(node)) {
302+
if (isPromise(node, ignoredObjectNames)) {
221303
verify(
222304
node,
223305
scopeStack.body,

tests/lib/rules/no-async-in-computed-properties.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,73 @@ ruleTester.run('no-async-in-computed-properties', rule, {
324324
sourceType: 'module',
325325
ecmaVersion: 2020
326326
}
327+
},
328+
{
329+
filename: 'test.vue',
330+
code: `
331+
export default {
332+
computed: {
333+
foo: function () {
334+
return z.catch(
335+
z.string().check(z.minLength(2)),
336+
'default'
337+
).then(val => val).finally(() => {})
338+
}
339+
}
340+
}
341+
`,
342+
options: [{ ignoredObjectNames: ['z'] }],
343+
languageOptions
344+
},
345+
{
346+
filename: 'test.vue',
347+
code: `
348+
<script setup>
349+
import { computed } from 'vue'
350+
351+
const numberWithCatch = computed(() => z.number().catch(42))
352+
</script>`,
353+
options: [{ ignoredObjectNames: ['z'] }],
354+
languageOptions: {
355+
parser,
356+
sourceType: 'module',
357+
ecmaVersion: 2020
358+
}
359+
},
360+
{
361+
filename: 'test.vue',
362+
code: `
363+
export default {
364+
computed: {
365+
foo: function () {
366+
return z.a?.['b'].[c].d.method().catch(err => err).finally(() => {})
367+
}
368+
}
369+
}
370+
`,
371+
options: [{ ignoredObjectNames: ['z'] }],
372+
languageOptions: {
373+
parser,
374+
sourceType: 'module',
375+
ecmaVersion: 2020
376+
}
377+
},
378+
{
379+
filename: 'test.vue',
380+
code: `
381+
<script setup lang="ts">
382+
import { computed } from 'vue'
383+
import { z } from 'zod'
384+
385+
const foo = computed(() => z.a?.['b'].c!.d.method().catch(err => err).finally(() => {}))
386+
</script>`,
387+
options: [{ ignoredObjectNames: ['z'] }],
388+
languageOptions: {
389+
parser: require('vue-eslint-parser'),
390+
parserOptions: {
391+
parser: require.resolve('@typescript-eslint/parser')
392+
}
393+
}
327394
}
328395
],
329396

@@ -1542,6 +1609,98 @@ ruleTester.run('no-async-in-computed-properties', rule, {
15421609
endColumn: 8
15431610
}
15441611
]
1612+
},
1613+
{
1614+
filename: 'test.vue',
1615+
code: `
1616+
export default {
1617+
computed: {
1618+
foo: function () {
1619+
return myFunc().catch('default')
1620+
}
1621+
}
1622+
}
1623+
`,
1624+
languageOptions,
1625+
errors: [
1626+
{
1627+
message: 'Unexpected asynchronous action in "foo" computed property.',
1628+
line: 5,
1629+
column: 22,
1630+
endLine: 5,
1631+
endColumn: 47
1632+
}
1633+
]
1634+
},
1635+
{
1636+
filename: 'test.vue',
1637+
code: `
1638+
export default {
1639+
computed: {
1640+
foo: function () {
1641+
return z.number().catch(42)
1642+
}
1643+
}
1644+
}
1645+
`,
1646+
languageOptions,
1647+
errors: [
1648+
{
1649+
message: 'Unexpected asynchronous action in "foo" computed property.',
1650+
line: 5,
1651+
column: 22,
1652+
endLine: 5,
1653+
endColumn: 42
1654+
}
1655+
]
1656+
},
1657+
{
1658+
filename: 'test.vue',
1659+
code: `
1660+
export default {
1661+
computed: {
1662+
foo: function () {
1663+
return someLib.string().catch(42)
1664+
}
1665+
}
1666+
}
1667+
`,
1668+
options: [{ ignoredObjectNames: ['z'] }],
1669+
languageOptions,
1670+
errors: [
1671+
{
1672+
message: 'Unexpected asynchronous action in "foo" computed property.',
1673+
line: 5,
1674+
column: 22,
1675+
endLine: 5,
1676+
endColumn: 48
1677+
}
1678+
]
1679+
},
1680+
{
1681+
filename: 'test.vue',
1682+
code: `
1683+
<script setup>
1684+
import {computed} from 'vue'
1685+
1686+
const deepCall = computed(() => z.a.b.c.d().e().f().catch())
1687+
</script>
1688+
`,
1689+
options: [{ ignoredObjectNames: ['a'] }],
1690+
languageOptions: {
1691+
parser,
1692+
sourceType: 'module',
1693+
ecmaVersion: 2020
1694+
},
1695+
errors: [
1696+
{
1697+
message: 'Unexpected asynchronous action in computed function.',
1698+
line: 5,
1699+
column: 41,
1700+
endLine: 5,
1701+
endColumn: 68
1702+
}
1703+
]
15451704
}
15461705
]
15471706
})

0 commit comments

Comments
 (0)