diff --git a/.changeset/cute-bears-sneeze.md b/.changeset/cute-bears-sneeze.md new file mode 100644 index 000000000..7bc9cb0bc --- /dev/null +++ b/.changeset/cute-bears-sneeze.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-vue': minor +--- + +Added `ignoredObjectNames` option to `vue/no-async-in-computed-properties` diff --git a/docs/rules/no-async-in-computed-properties.md b/docs/rules/no-async-in-computed-properties.md index 3cea89465..ff4f18d78 100644 --- a/docs/rules/no-async-in-computed-properties.md +++ b/docs/rules/no-async-in-computed-properties.md @@ -108,7 +108,42 @@ export default { ## :wrench: Options -Nothing. +```js +{ + "vue/no-async-in-computed-properties": ["error", { + "ignoredObjectNames": [] + }] +} +``` + +- `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)). + +### `"ignoredObjectNames": ["z"]` + + + +```vue + +``` + + ## :books: Further Reading diff --git a/lib/rules/no-async-in-computed-properties.js b/lib/rules/no-async-in-computed-properties.js index e8ed02f0e..0cf8c639c 100644 --- a/lib/rules/no-async-in-computed-properties.js +++ b/lib/rules/no-async-in-computed-properties.js @@ -38,22 +38,88 @@ function isTimedFunction(node) { ) } +/** + * @param {*} node + * @returns {*} + */ +function skipWrapper(node) { + while (node && node.expression) { + node = node.expression + } + return node +} + +/** + * Get the root object name from a member expression chain + * @param {MemberExpression} memberExpr + * @returns {string|null} + */ +function getRootObjectName(memberExpr) { + let current = skipWrapper(memberExpr.object) + + while (current) { + switch (current.type) { + case 'MemberExpression': { + current = skipWrapper(current.object) + break + } + case 'CallExpression': { + const calleeExpr = skipWrapper(current.callee) + if (calleeExpr.type === 'MemberExpression') { + current = skipWrapper(calleeExpr.object) + } else if (calleeExpr.type === 'Identifier') { + return calleeExpr.name + } else { + return null + } + break + } + case 'Identifier': { + return current.name + } + default: { + return null + } + } + } + + return null +} + +/** + * @param {string} name + * @param {*} callee + * @returns {boolean} + */ +function isPromiseMethod(name, callee) { + return ( + // hello.PROMISE_FUNCTION() + PROMISE_FUNCTIONS.has(name) || + // Promise.PROMISE_METHOD() + (callee.object.type === 'Identifier' && + callee.object.name === 'Promise' && + PROMISE_METHODS.has(name)) + ) +} + /** * @param {CallExpression} node + * @param {Set} ignoredObjectNames */ -function isPromise(node) { +function isPromise(node, ignoredObjectNames) { const callee = utils.skipChainExpression(node.callee) if (callee.type === 'MemberExpression') { const name = utils.getStaticPropertyName(callee) - return ( - name && - // hello.PROMISE_FUNCTION() - (PROMISE_FUNCTIONS.has(name) || - // Promise.PROMISE_METHOD() - (callee.object.type === 'Identifier' && - callee.object.name === 'Promise' && - PROMISE_METHODS.has(name))) - ) + if (!name || !isPromiseMethod(name, callee)) { + return false + } + + const rootObjectName = getRootObjectName(callee) + if (rootObjectName && ignoredObjectNames.has(rootObjectName)) { + return false + } + + return true } return false } @@ -85,7 +151,20 @@ module.exports = { url: 'https://eslint.vuejs.org/rules/no-async-in-computed-properties.html' }, fixable: null, - schema: [], + schema: [ + { + type: 'object', + properties: { + ignoredObjectNames: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + additionalItems: false + } + }, + additionalProperties: false + } + ], messages: { unexpectedInFunction: 'Unexpected {{expressionName}} in computed function.', @@ -95,6 +174,9 @@ module.exports = { }, /** @param {RuleContext} context */ create(context) { + const options = context.options[0] || {} + const ignoredObjectNames = new Set(options.ignoredObjectNames || []) + /** @type {Map} */ const computedPropertiesMap = new Map() /** @type {(FunctionExpression | ArrowFunctionExpression)[]} */ @@ -217,7 +299,7 @@ module.exports = { if (!scopeStack) { return } - if (isPromise(node)) { + if (isPromise(node, ignoredObjectNames)) { verify( node, scopeStack.body, diff --git a/tests/lib/rules/no-async-in-computed-properties.js b/tests/lib/rules/no-async-in-computed-properties.js index 3f08c81e5..2c2e29c72 100644 --- a/tests/lib/rules/no-async-in-computed-properties.js +++ b/tests/lib/rules/no-async-in-computed-properties.js @@ -324,6 +324,73 @@ ruleTester.run('no-async-in-computed-properties', rule, { sourceType: 'module', ecmaVersion: 2020 } + }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + return z.catch( + z.string().check(z.minLength(2)), + 'default' + ).then(val => val).finally(() => {}) + } + } + } + `, + options: [{ ignoredObjectNames: ['z'] }], + languageOptions + }, + { + filename: 'test.vue', + code: ` + `, + options: [{ ignoredObjectNames: ['z'] }], + languageOptions: { + parser, + sourceType: 'module', + ecmaVersion: 2020 + } + }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + return z.a?.['b'].[c].d.method().catch(err => err).finally(() => {}) + } + } + } + `, + options: [{ ignoredObjectNames: ['z'] }], + languageOptions: { + parser, + sourceType: 'module', + ecmaVersion: 2020 + } + }, + { + filename: 'test.vue', + code: ` + `, + options: [{ ignoredObjectNames: ['z'] }], + languageOptions: { + parser: require('vue-eslint-parser'), + parserOptions: { + parser: require.resolve('@typescript-eslint/parser') + } + } } ], @@ -1542,6 +1609,98 @@ ruleTester.run('no-async-in-computed-properties', rule, { endColumn: 8 } ] + }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + return myFunc().catch('default') + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Unexpected asynchronous action in "foo" computed property.', + line: 5, + column: 22, + endLine: 5, + endColumn: 47 + } + ] + }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + return z.number().catch(42) + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Unexpected asynchronous action in "foo" computed property.', + line: 5, + column: 22, + endLine: 5, + endColumn: 42 + } + ] + }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + return someLib.string().catch(42) + } + } + } + `, + options: [{ ignoredObjectNames: ['z'] }], + languageOptions, + errors: [ + { + message: 'Unexpected asynchronous action in "foo" computed property.', + line: 5, + column: 22, + endLine: 5, + endColumn: 48 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ ignoredObjectNames: ['a'] }], + languageOptions: { + parser, + sourceType: 'module', + ecmaVersion: 2020 + }, + errors: [ + { + message: 'Unexpected asynchronous action in computed function.', + line: 5, + column: 41, + endLine: 5, + endColumn: 68 + } + ] } ] })

AltStyle によって変換されたページ (->オリジナル) /