Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add vue/valid-next-tick rule #1404

Merged
merged 11 commits into from Jan 21, 2021
1 change: 1 addition & 0 deletions docs/rules/README.md
Expand Up @@ -324,6 +324,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

Expand Down
88 changes: 88 additions & 0 deletions 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: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
- :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`).

<eslint-code-block fix :rules="{'vue/valid-next-tick': ['error']}">

```vue
<script>
import { nextTick as nt } from 'vue';

export default {
async mounted() {
/* ✗ BAD: no callback function or awaited Promise */
nt();
Vue.nextTick();
this.$nextTick();

/* ✗ BAD: too many parameters */
nt(callback, anotherCallback);
Vue.nextTick(callback, anotherCallback);
this.$nextTick(callback, anotherCallback);

/* ✗ BAD: no function call */
nt.then(callback);
Vue.nextTick.then(callback);
this.$nextTick.then(callback);
await nt;
await Vue.nextTick;
await this.$nextTick;

/* ✗ BAD: both callback function and awaited Promise */
nt(callback).then(anotherCallback);
Vue.nextTick(callback).then(anotherCallback);
this.$nextTick(callback).then(anotherCallback);
await nt(callback);
await Vue.nextTick(callback);
await this.$nextTick(callback);

/* ✓ GOOD */
await nt();
await Vue.nextTick();
await this.$nextTick();

/* ✓ GOOD */
nt().then(callback);
Vue.nextTick().then(callback);
this.$nextTick().then(callback);

/* ✓ GOOD */
nt(callback);
Vue.nextTick(callback);
this.$nextTick(callback);
}
}
</script>
```

</eslint-code-block>

## :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)
1 change: 1 addition & 0 deletions lib/index.js
Expand Up @@ -162,6 +162,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'),
Expand Down
204 changes: 204 additions & 0 deletions 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({
FloEdelmann marked this conversation as resolved.
Show resolved Hide resolved
node,
message: '`nextTick` is a function.',
fix(fixer) {
return fixer.insertTextAfter(node, '()')
}
})
return
}

if (parentNode.arguments.length === 0) {
FloEdelmann marked this conversation as resolved.
Show resolved Hide resolved
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`.'
})
}
}
})
}
}