Repository: vuejs/eslint-plugin-vue
Pull request #1596 (2021-08-07 07:29:26)

Add `vue/valid-v-memo` rule
This PR adds `vue/valid-v-memo` rule.
`vue/valid-v-memo` rule that enforce valid `v-memo` directives.


v-memo is a directive that will be added in Vue 3.2.
See also https://github.com/vuejs/docs/blob/3.2/src/api/directives.md#v-memo-


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index f7a2e01d..4d87f7cc 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -338,6 +338,7 @@ For example:
 | [vue/valid-define-emits](./valid-define-emits.md) | enforce valid `defineEmits` compiler macro |  |
 | [vue/valid-define-props](./valid-define-props.md) | enforce valid `defineProps` compiler macro |  |
 | [vue/valid-next-tick](./valid-next-tick.md) | enforce valid `nextTick` function calls | :wrench: |
+| [vue/valid-v-memo](./valid-v-memo.md) | enforce valid `v-memo` directives |  |
 
 ### Extension Rules
 
diff --git a/docs/rules/valid-v-memo.md b/docs/rules/valid-v-memo.md
new file mode 100644
index 00000000..95d34df7
--- /dev/null
+++ b/docs/rules/valid-v-memo.md
@@ -0,0 +1,66 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/valid-v-memo
+description: enforce valid `v-memo` directives
+---
+# vue/valid-v-memo
+
+> enforce valid `v-memo` directives
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+This rule checks whether every `v-memo` directive is valid.
+
+## :book: Rule Details
+
+This rule reports `v-memo` directives in the following cases:
+
+- The directive has that argument. E.g. `<div v-memo:aaa></div>`
+- The directive has that modifier. E.g. `<div v-memo.bbb></div>`
+- The directive does not have that attribute value. E.g. `<div v-memo></div>`
+- The attribute value of the directive is definitely not array. E.g. `<div v-memo="{x}"></div>`
+- The directive was used inside v-for. E.g. `<div v-for="i in items"><div v-memo="[i]" /></div>`
+
+<eslint-code-block :rules="{'vue/valid-v-memo': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <div v-memo="[x]"/>
+
+  <!-- ✗ BAD -->
+  <div v-memo/>
+  <div v-memo:aaa="[x]"/>
+  <div v-memo.bbb="[x]"/>
+  <div v-memo="{x}"/>
+  <div v-for="i in items">
+    <div v-memo="[i]" />
+  </div>
+</template>
+```
+
+</eslint-code-block>
+
+::: warning Note
+This rule does not check syntax errors in directives because it's checked by [vue/no-parsing-error] rule.
+:::
+
+## :wrench: Options
+
+Nothing.
+
+## :couple: Related Rules
+
+- [vue/no-parsing-error]
+
+[vue/no-parsing-error]: ./no-parsing-error.md
+
+## :books: Further Reading
+
+- [API - v-memo](https://v3.vuejs.org/api/directives.html#v-memo)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/valid-v-memo.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/valid-v-memo.js)
diff --git a/lib/index.js b/lib/index.js
index 1fe6e4eb..d9352faa 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -188,6 +188,7 @@ module.exports = {
     'valid-v-html': require('./rules/valid-v-html'),
     'valid-v-if': require('./rules/valid-v-if'),
     'valid-v-is': require('./rules/valid-v-is'),
+    'valid-v-memo': require('./rules/valid-v-memo'),
     'valid-v-model': require('./rules/valid-v-model'),
     'valid-v-on': require('./rules/valid-v-on'),
     'valid-v-once': require('./rules/valid-v-once'),
diff --git a/lib/rules/no-use-computed-property-like-method.js b/lib/rules/no-use-computed-property-like-method.js
index 1add2c34..c1c8ca76 100644
--- a/lib/rules/no-use-computed-property-like-method.js
+++ b/lib/rules/no-use-computed-property-like-method.js
@@ -228,6 +228,9 @@ module.exports = {
 
         if (node.parent.type !== 'MemberExpression') return
         if (node.parent.property.type !== 'Identifier') return
+        if (node.parent.parent.type !== 'CallExpression') return
+        if (node.parent.parent.callee.type !== 'MemberExpression') return
+        if (!Object.is(node.parent.parent.callee, node.parent)) return
 
         const thisMember = node.parent.property.name
 
diff --git a/lib/rules/valid-v-memo.js b/lib/rules/valid-v-memo.js
new file mode 100644
index 00000000..b20ba49d
--- /dev/null
+++ b/lib/rules/valid-v-memo.js
@@ -0,0 +1,120 @@
+/**
+ * @author Yosuke Ota <https://github.com/ota-meshi>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'enforce valid `v-memo` directives',
+      // TODO Switch to `vue3-essential` in the major version.
+      // categories: ['vue3-essential'],
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/valid-v-memo.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      unexpectedArgument: "'v-memo' directives require no argument.",
+      unexpectedModifier: "'v-memo' directives require no modifier.",
+      expectedValue: "'v-memo' directives require that attribute value.",
+      expectedArray:
+        "'v-memo' directives require the attribute value to be an array.",
+      insideVFor: "'v-memo' directive does not work inside 'v-for'."
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /** @type {VElement | null} */
+    let vForElement = null
+    return utils.defineTemplateBodyVisitor(context, {
+      VElement(node) {
+        if (!vForElement && utils.hasDirective(node, 'for')) {
+          vForElement = node
+        }
+      },
+      'VElement:exit'(node) {
+        if (vForElement === node) {
+          vForElement = null
+        }
+      },
+      /** @param {VDirective} node */
+      "VAttribute[directive=true][key.name.name='memo']"(node) {
+        if (vForElement && vForElement !== node.parent.parent) {
+          context.report({
+            node: node.key,
+            messageId: 'insideVFor'
+          })
+        }
+        if (node.key.argument) {
+          context.report({
+            node: node.key.argument,
+            messageId: 'unexpectedArgument'
+          })
+        }
+        if (node.key.modifiers.length > 0) {
+          context.report({
+            node,
+            loc: {
+              start: node.key.modifiers[0].loc.start,
+              end: node.key.modifiers[node.key.modifiers.length - 1].loc.end
+            },
+            messageId: 'unexpectedModifier'
+          })
+        }
+        if (!node.value || utils.isEmptyValueDirective(node, context)) {
+          context.report({
+            node,
+            messageId: 'expectedValue'
+          })
+          return
+        }
+        if (!node.value.expression) {
+          return
+        }
+        const expressions = [node.value.expression]
+        let expression
+        while ((expression = expressions.pop())) {
+          if (
+            expression.type === 'ObjectExpression' ||
+            expression.type === 'ClassExpression' ||
+            expression.type === 'ArrowFunctionExpression' ||
+            expression.type === 'FunctionExpression' ||
+            expression.type === 'Literal' ||
+            expression.type === 'TemplateLiteral' ||
+            expression.type === 'UnaryExpression' ||
+            expression.type === 'BinaryExpression' ||
+            expression.type === 'UpdateExpression'
+          ) {
+            context.report({
+              node: expression,
+              messageId: 'expectedArray'
+            })
+          } else if (expression.type === 'AssignmentExpression') {
+            expressions.push(expression.right)
+          } else if (expression.type === 'TSAsExpression') {
+            expressions.push(expression.expression)
+          } else if (expression.type === 'SequenceExpression') {
+            expressions.push(
+              expression.expressions[expression.expressions.length - 1]
+            )
+          } else if (expression.type === 'ConditionalExpression') {
+            expressions.push(expression.consequent, expression.alternate)
+          }
+        }
+      }
+    })
+  }
+}
diff --git a/package.json b/package.json
index e5ff5b47..ebee8581 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-plugin-vue",
-  "version": "7.15.1",
+  "version": "7.15.2",
   "description": "Official ESLint plugin for Vue.js",
   "main": "lib/index.js",
   "scripts": {
diff --git a/typings/eslint-plugin-vue/util-types/ast/ast.ts b/typings/eslint-plugin-vue/util-types/ast/ast.ts
index 4356ebc5..68d6b884 100644
--- a/typings/eslint-plugin-vue/util-types/ast/ast.ts
+++ b/typings/eslint-plugin-vue/util-types/ast/ast.ts
@@ -74,6 +74,16 @@ export type VNodeListenerMap = {
       | (V.VExpressionContainer & { expression: ES.Expression | null })
       | null
   }
+  "VAttribute[directive=true][key.name.name='memo']": V.VDirective & {
+    value:
+      | (V.VExpressionContainer & { expression: ES.Expression | null })
+      | null
+  }
+  "VAttribute[directive=true][key.name.name='memo']:exit": V.VDirective & {
+    value:
+      | (V.VExpressionContainer & { expression: ES.Expression | null })
+      | null
+  }
   "VAttribute[directive=true][key.name.name='on']": V.VDirective & {
     value:
       | (V.VExpressionContainer & {


Test patch:
diff --git a/tests/lib/rules/no-use-computed-property-like-method.js b/tests/lib/rules/no-use-computed-property-like-method.js
index b6e5e695..902f29b1 100644
--- a/tests/lib/rules/no-use-computed-property-like-method.js
+++ b/tests/lib/rules/no-use-computed-property-like-method.js
@@ -418,6 +418,26 @@ tester.run('no-use-computed-property-like-method', rule, {
         }
       </script>
       `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+        export default {
+          computed: {
+            computedReturnArray() {
+              return [1,2,3,4,5]
+            },
+            computedReturnArray2() {
+              return [1,2,3,4,5]
+            },
+            computedReturnComputedReturnString() {
+              return this.computedReturnArray.map(() => this.computedReturnArray2)
+            }
+          }
+        }
+      </script>
+      `
     }
   ],
   invalid: [
@@ -698,6 +718,50 @@ tester.run('no-use-computed-property-like-method', rule, {
       errors: [
         'Use this.computedReturnNothing instead of this.computedReturnNothing().'
       ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+        export default {
+          computed: {
+            computedReturnString() {
+              return 'computedReturnString'
+            },
+            computedReturnComputedReturnString() {
+              return this.computedReturnString()
+            }
+          }
+        }
+      </script>
+      `,
+      errors: [
+        'Use this.computedReturnString instead of this.computedReturnString().'
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+        export default {
+          computed: {
+            computedReturnArray() {
+              return [1,2,3,4,5]
+            },
+            computedReturnArray2() {
+              return [1,2,3,4,5]
+            },
+            computedReturnComputedReturnString() {
+              return this.computedReturnArray.map([...this.computedReturnArray(), ...this.computedReturnArray2()])
+            }
+          }
+        }
+      </script>
+      `,
+      errors: [
+        'Use this.computedReturnArray instead of this.computedReturnArray().',
+        'Use this.computedReturnArray2 instead of this.computedReturnArray2().'
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/valid-v-memo.js b/tests/lib/rules/valid-v-memo.js
new file mode 100644
index 00000000..51f08621
--- /dev/null
+++ b/tests/lib/rules/valid-v-memo.js
@@ -0,0 +1,145 @@
+/**
+ * @author Yosuke Ota <https://github.com/ota-meshi>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/valid-v-memo')
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2021 }
+})
+
+tester.run('valid-v-memo', rule, {
+  valid: [
+    {
+      filename: 'test.js',
+      code: 'test'
+    },
+    {
+      filename: 'test.vue',
+      code: ''
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-memo="[x]"></div></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-memo="x"></div></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-memo="x?y:z"></div></template>'
+    },
+    // parsing error
+    {
+      filename: 'parsing-error.vue',
+      code: '<template><div v-memo="." /></template>'
+    },
+    // comment value (parsing error)
+    {
+      filename: 'parsing-error.vue',
+      code: '<template><div v-memo="/**/" /></template>'
+    },
+    // v-for
+    {
+      filename: 'test.vue',
+      code: '<template><div v-for="i in items" v-memo="[x]"></div></template>'
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: '<template><div v-memo:aaa="x"></div></template>',
+      errors: ["'v-memo' directives require no argument."]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-memo.aaa="x"></div></template>',
+      errors: ["'v-memo' directives require no modifier."]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-memo></div></template>',
+      errors: ["'v-memo' directives require that attribute value."]
+    },
+    // empty value
+    {
+      filename: 'empty-value.vue',
+      code: '<template><div v-memo="" /></template>',
+      errors: ["'v-memo' directives require that attribute value."]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div v-memo="{x}" />
+        <div v-memo="a ? {b}: c+d" />
+        <div v-memo="(a,{b},c(),d+1)" />
+        <div v-memo="()=>42" />
+        <div v-memo="a=42" />
+      </template>`,
+      errors: [
+        {
+          message:
+            "'v-memo' directives require the attribute value to be an array.",
+          line: 3,
+          column: 22
+        },
+        {
+          message:
+            "'v-memo' directives require the attribute value to be an array.",
+          line: 4,
+          column: 26
+        },
+        {
+          message:
+            "'v-memo' directives require the attribute value to be an array.",
+          line: 4,
+          column: 31
+        },
+        {
+          message:
+            "'v-memo' directives require the attribute value to be an array.",
+          line: 5,
+          column: 33
+        },
+        {
+          message:
+            "'v-memo' directives require the attribute value to be an array.",
+          line: 6,
+          column: 22
+        },
+        {
+          message:
+            "'v-memo' directives require the attribute value to be an array.",
+          line: 7,
+          column: 24
+        }
+      ]
+    },
+    // v-for
+    {
+      filename: 'test.vue',
+      code: `<template><div v-for="i in items"><div v-memo="[x]" /></div></template>`,
+      errors: [
+        {
+          message: "'v-memo' directive does not work inside 'v-for'.",
+          line: 1,
+          column: 40
+        }
+      ]
+    }
+  ]
+})