diff --git a/docs/rules/README.md b/docs/rules/README.md index 92674e1f5..0fd8e2fa1 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -325,6 +325,7 @@ For example: | [vue/v-for-delimiter-style](./v-for-delimiter-style.md) | enforce `v-for` directive's delimiter style | :wrench: | | [vue/v-on-event-hyphenation](./v-on-event-hyphenation.md) | enforce v-on event naming style on custom components in template | :wrench: | | [vue/v-on-function-call](./v-on-function-call.md) | enforce or forbid parentheses after method calls without arguments in `v-on` directives | :wrench: | +| [vue/valid-next-tick](./valid-next-tick.md) | enforce valid `nextTick` function calls | :wrench: | ### Extension Rules diff --git a/docs/rules/valid-next-tick.md b/docs/rules/valid-next-tick.md new file mode 100644 index 000000000..076fcc792 --- /dev/null +++ b/docs/rules/valid-next-tick.md @@ -0,0 +1,88 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/valid-next-tick +description: enforce valid `nextTick` function calls +--- +# vue/valid-next-tick + +> enforce valid `nextTick` function calls + +- :exclamation: ***This rule has not been released yet.*** +- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule. + +## :book: Rule Details + +Calling `Vue.nextTick` or `vm.$nextTick` without passing a callback and without awaiting the returned Promise is likely a mistake (probably a missing `await`). + + + +```vue + +``` + + + +## :wrench: Options + +Nothing. + +## :books: Further Reading + +- [`Vue.nextTick` API in Vue 2](https://vuejs.org/v2/api/#Vue-nextTick) +- [`vm.$nextTick` API in Vue 2](https://vuejs.org/v2/api/#vm-nextTick) +- [Global API Treeshaking](https://v3.vuejs.org/guide/migration/global-api-treeshaking.html) +- [Global `nextTick` API in Vue 3](https://v3.vuejs.org/api/global-api.html#nexttick) +- [Instance `$nextTick` API in Vue 3](https://v3.vuejs.org/api/instance-methods.html#nexttick) + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/valid-next-tick.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/valid-next-tick.js) diff --git a/lib/index.js b/lib/index.js index ec311919d..2d600117d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -164,6 +164,7 @@ module.exports = { 'v-on-function-call': require('./rules/v-on-function-call'), 'v-on-style': require('./rules/v-on-style'), 'v-slot-style': require('./rules/v-slot-style'), + 'valid-next-tick': require('./rules/valid-next-tick'), 'valid-template-root': require('./rules/valid-template-root'), 'valid-v-bind-sync': require('./rules/valid-v-bind-sync'), 'valid-v-bind': require('./rules/valid-v-bind'), diff --git a/lib/rules/valid-next-tick.js b/lib/rules/valid-next-tick.js new file mode 100644 index 000000000..4e41abbf2 --- /dev/null +++ b/lib/rules/valid-next-tick.js @@ -0,0 +1,204 @@ +/** + * @fileoverview enforce valid `nextTick` function calls + * @author Flo Edelmann + * @copyright 2021 Flo Edelmann. All rights reserved. + * See LICENSE file in root directory for full license. + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const utils = require('../utils') +const { findVariable } = require('eslint-utils') + +// ------------------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------------------ + +/** + * @param {Identifier} identifier + * @param {RuleContext} context + * @returns {ASTNode|undefined} + */ +function getVueNextTickNode(identifier, context) { + // Instance API: this.$nextTick() + if ( + identifier.name === '$nextTick' && + identifier.parent.type === 'MemberExpression' && + utils.isThis(identifier.parent.object, context) + ) { + return identifier.parent + } + + // Vue 2 Global API: Vue.nextTick() + if ( + identifier.name === 'nextTick' && + identifier.parent.type === 'MemberExpression' && + identifier.parent.object.type === 'Identifier' && + identifier.parent.object.name === 'Vue' + ) { + return identifier.parent + } + + // Vue 3 Global API: import { nextTick as nt } from 'vue'; nt() + const variable = findVariable(context.getScope(), identifier) + + if (variable != null && variable.defs.length === 1) { + const def = variable.defs[0] + if ( + def.type === 'ImportBinding' && + def.node.type === 'ImportSpecifier' && + def.node.imported.type === 'Identifier' && + def.node.imported.name === 'nextTick' && + def.node.parent.type === 'ImportDeclaration' && + def.node.parent.source.value === 'vue' + ) { + return identifier + } + } + + return undefined +} + +/** + * @param {CallExpression} callExpression + * @returns {boolean} + */ +function isAwaitedPromise(callExpression) { + if (callExpression.parent.type === 'AwaitExpression') { + // cases like `await nextTick()` + return true + } + + if (callExpression.parent.type === 'ReturnStatement') { + // cases like `return nextTick()` + return true + } + + if ( + callExpression.parent.type === 'MemberExpression' && + callExpression.parent.property.type === 'Identifier' && + callExpression.parent.property.name === 'then' + ) { + // cases like `nextTick().then()` + return true + } + + if ( + callExpression.parent.type === 'VariableDeclarator' || + callExpression.parent.type === 'AssignmentExpression' + ) { + // cases like `let foo = nextTick()` or `foo = nextTick()` + return true + } + + if ( + callExpression.parent.type === 'ArrayExpression' && + callExpression.parent.parent.type === 'CallExpression' && + callExpression.parent.parent.callee.type === 'MemberExpression' && + callExpression.parent.parent.callee.object.type === 'Identifier' && + callExpression.parent.parent.callee.object.name === 'Promise' && + callExpression.parent.parent.callee.property.type === 'Identifier' + ) { + // cases like `Promise.all([nextTick()])` + return true + } + + return false +} + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'enforce valid `nextTick` function calls', + // categories: ['vue3-essential', 'essential'], + categories: undefined, + url: 'https://eslint.vuejs.org/rules/valid-next-tick.html' + }, + fixable: 'code', + schema: [] + }, + /** @param {RuleContext} context */ + create(context) { + return utils.defineVueVisitor(context, { + /** @param {Identifier} node */ + Identifier(node) { + const nextTickNode = getVueNextTickNode(node, context) + if (!nextTickNode || !nextTickNode.parent) { + return + } + + const parentNode = nextTickNode.parent + + if ( + parentNode.type === 'CallExpression' && + parentNode.callee !== nextTickNode + ) { + // cases like `foo.then(nextTick)` are allowed + return + } + + if ( + parentNode.type === 'VariableDeclarator' || + parentNode.type === 'AssignmentExpression' + ) { + // cases like `let foo = nextTick` or `foo = nextTick` are allowed + return + } + + if (parentNode.type !== 'CallExpression') { + context.report({ + node, + message: '`nextTick` is a function.', + fix(fixer) { + return fixer.insertTextAfter(node, '()') + } + }) + return + } + + if (parentNode.arguments.length === 0) { + if (!isAwaitedPromise(parentNode)) { + context.report({ + node, + message: + 'Await the Promise returned by `nextTick` or pass a callback function.', + suggest: [ + { + desc: 'Add missing `await` statement.', + fix(fixer) { + return fixer.insertTextBefore(parentNode, 'await ') + } + } + ] + }) + } + return + } + + if (parentNode.arguments.length > 1) { + context.report({ + node, + message: '`nextTick` expects zero or one parameters.' + }) + return + } + + if (isAwaitedPromise(parentNode)) { + context.report({ + node, + message: + 'Either await the Promise or pass a callback function to `nextTick`.' + }) + } + } + }) + } +} diff --git a/tests/lib/rules/valid-next-tick.js b/tests/lib/rules/valid-next-tick.js new file mode 100644 index 000000000..220e43417 --- /dev/null +++ b/tests/lib/rules/valid-next-tick.js @@ -0,0 +1,405 @@ +/** + * @fileoverview enforce valid `nextTick` function calls + * @author Flo Edelmann + * @copyright 2021 Flo Edelmann. All rights reserved. + * See LICENSE file in root directory for full license. + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/valid-next-tick') + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const tester = new RuleTester({ + parser: require.resolve('vue-eslint-parser'), + parserOptions: { + ecmaVersion: 2017, + sourceType: 'module' + } +}) + +tester.run('valid-next-tick', rule, { + valid: [ + { + filename: 'test.vue', + code: '' + }, + { + filename: 'test.vue', + code: `` + }, + + // https://github.com/vuejs/eslint-plugin-vue/pull/1404#discussion_r550937500 + { + filename: 'test.vue', + code: `` + }, + + // https://github.com/vuejs/eslint-plugin-vue/pull/1404#discussion_r550936410 + { + filename: 'test.vue', + code: `` + }, + + // https://github.com/vuejs/eslint-plugin-vue/pull/1404#discussion_r550936933 + { + filename: 'test.vue', + code: `` + }, + + // https://github.com/vuejs/eslint-plugin-vue/pull/1404#discussion_r551769969 + { + filename: 'test.vue', + code: `` + } + ], + invalid: [ + { + filename: 'test.vue', + code: ``, + output: null, + errors: [ + { + message: + 'Await the Promise returned by `nextTick` or pass a callback function.', + line: 4, + column: 11, + suggestions: [ + { + output: `` + } + ] + }, + { + message: + 'Await the Promise returned by `nextTick` or pass a callback function.', + line: 5, + column: 15, + suggestions: [ + { + output: `` + } + ] + }, + { + message: + 'Await the Promise returned by `nextTick` or pass a callback function.', + line: 6, + column: 16, + suggestions: [ + { + output: `` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: '`nextTick` is a function.', + line: 4, + column: 11 + }, + { + message: '`nextTick` is a function.', + line: 5, + column: 15 + }, + { + message: '`nextTick` is a function.', + line: 6, + column: 16 + }, + { + message: '`nextTick` is a function.', + line: 8, + column: 11 + }, + { + message: '`nextTick` is a function.', + line: 9, + column: 15 + }, + { + message: '`nextTick` is a function.', + line: 10, + column: 16 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: '`nextTick` is a function.', + line: 4, + column: 17 + }, + { + message: '`nextTick` is a function.', + line: 5, + column: 21 + }, + { + message: '`nextTick` is a function.', + line: 6, + column: 23 + } + ] + }, + + // https://github.com/vuejs/eslint-plugin-vue/pull/1404#discussion_r550936933 + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: '`nextTick` is a function.', + line: 4, + column: 24 + }, + { + message: '`nextTick` is a function.', + line: 5, + column: 28 + }, + { + message: '`nextTick` is a function.', + line: 6, + column: 29 + } + ] + }, + + { + filename: 'test.vue', + code: ``, + output: null, + errors: [ + { + message: '`nextTick` expects zero or one parameters.', + line: 4, + column: 11 + }, + { + message: '`nextTick` expects zero or one parameters.', + line: 5, + column: 15 + }, + { + message: '`nextTick` expects zero or one parameters.', + line: 6, + column: 16 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: null, + errors: [ + { + message: + 'Either await the Promise or pass a callback function to `nextTick`.', + line: 4, + column: 11 + }, + { + message: + 'Either await the Promise or pass a callback function to `nextTick`.', + line: 5, + column: 15 + }, + { + message: + 'Either await the Promise or pass a callback function to `nextTick`.', + line: 6, + column: 16 + }, + { + message: + 'Either await the Promise or pass a callback function to `nextTick`.', + line: 8, + column: 17 + }, + { + message: + 'Either await the Promise or pass a callback function to `nextTick`.', + line: 9, + column: 21 + }, + { + message: + 'Either await the Promise or pass a callback function to `nextTick`.', + line: 10, + column: 22 + } + ] + } + ] +})