Repository: vuejs/eslint-plugin-vue
Pull request #1474 (2021-04-12 07:26:08)

Add `vue/no-unused-refs` rule
Ths PR adds `vue/no-unused-refs` rule.

`vue/no-unused-refs`rule that reports refs that are defined using the `ref` attribute in `<template>` but are not used via `$refs`.


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index d00e5478..cb65b19a 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -300,6 +300,7 @@ For example:
 | [vue/no-boolean-default](./no-boolean-default.md) | disallow boolean defaults | :wrench: |
 | [vue/no-duplicate-attr-inheritance](./no-duplicate-attr-inheritance.md) | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` |  |
 | [vue/no-empty-component-block](./no-empty-component-block.md) | disallow the `<template>` `<script>` `<style>` block to be empty |  |
+| [vue/no-invalid-model-keys](./no-invalid-model-keys.md) | require valid keys in model option |  |
 | [vue/no-multiple-objects-in-class](./no-multiple-objects-in-class.md) | disallow to pass multiple objects into array to class |  |
 | [vue/no-potential-component-option-typo](./no-potential-component-option-typo.md) | disallow a potential typo in your component property |  |
 | [vue/no-reserved-component-names](./no-reserved-component-names.md) | disallow the use of reserved names in component definitions |  |
@@ -315,6 +316,7 @@ For example:
 | [vue/no-unregistered-components](./no-unregistered-components.md) | disallow using components that are not registered inside templates |  |
 | [vue/no-unsupported-features](./no-unsupported-features.md) | disallow unsupported Vue.js syntax on the specified version | :wrench: |
 | [vue/no-unused-properties](./no-unused-properties.md) | disallow unused properties |  |
+| [vue/no-unused-refs](./no-unused-refs.md) | disallow unused refs |  |
 | [vue/no-useless-mustaches](./no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :wrench: |
 | [vue/no-useless-v-bind](./no-useless-v-bind.md) | disallow unnecessary `v-bind` directives | :wrench: |
 | [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
diff --git a/docs/rules/no-unused-refs.md b/docs/rules/no-unused-refs.md
new file mode 100644
index 00000000..8c59f47c
--- /dev/null
+++ b/docs/rules/no-unused-refs.md
@@ -0,0 +1,50 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-unused-refs
+description: disallow unused refs
+---
+# vue/no-unused-refs
+
+> disallow unused refs
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule is aimed at eliminating unused refs.
+This rule reports refs that are defined using the `ref` attribute in `<template>` but are not used via `$refs`.
+
+::: warning Note
+This rule cannot be checked for use in other components (e.g. `mixins`, Access via `$refs.x.$refs`).
+:::
+
+<eslint-code-block :rules="{'vue/no-unused-refs': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <input ref="foo" />
+
+  <!-- ✗ BAD (`bar` is not used) -->
+  <input ref="bar" />
+</template>
+<script>
+export default {
+  mounted() {
+    this.$refs.foo.value = 'foo'
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-unused-refs.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-unused-refs.js)
diff --git a/lib/index.js b/lib/index.js
index a5f63231..d38cff32 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -117,6 +117,7 @@ module.exports = {
     'no-unsupported-features': require('./rules/no-unsupported-features'),
     'no-unused-components': require('./rules/no-unused-components'),
     'no-unused-properties': require('./rules/no-unused-properties'),
+    'no-unused-refs': require('./rules/no-unused-refs'),
     'no-unused-vars': require('./rules/no-unused-vars'),
     'no-use-v-if-with-v-for': require('./rules/no-use-v-if-with-v-for'),
     'no-useless-concat': require('./rules/no-useless-concat'),
diff --git a/lib/rules/no-unused-refs.js b/lib/rules/no-unused-refs.js
new file mode 100644
index 00000000..0e9455f6
--- /dev/null
+++ b/lib/rules/no-unused-refs.js
@@ -0,0 +1,204 @@
+/**
+ * @fileoverview Disallow unused refs.
+ * @author Yosuke Ota
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * Extract names from references objects.
+ * @param {VReference[]} references
+ */
+function getReferences(references) {
+  return references.filter((ref) => ref.variable == null).map((ref) => ref.id)
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'disallow unused refs',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-unused-refs.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      unused: "'{{name}}' is defined as ref, but never used."
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /** @type {Set<string>} */
+    const usedRefs = new Set()
+
+    /** @type {VLiteral[]} */
+    const defineRefs = []
+    let hasUnknown = false
+
+    /**
+     * Report all unused refs.
+     */
+    function reportUnusedRefs() {
+      for (const defineRef of defineRefs) {
+        if (usedRefs.has(defineRef.value)) {
+          continue
+        }
+
+        context.report({
+          node: defineRef,
+          messageId: 'unused',
+          data: {
+            name: defineRef.value
+          }
+        })
+      }
+    }
+
+    /**
+     * Extract the use ref names for ObjectPattern.
+     * @param {ObjectPattern} node
+     * @returns {void}
+     */
+    function extractUsedForObjectPattern(node) {
+      for (const prop of node.properties) {
+        if (prop.type === 'Property') {
+          const name = utils.getStaticPropertyName(prop)
+          if (name) {
+            usedRefs.add(name)
+          } else {
+            hasUnknown = true
+            return
+          }
+        } else {
+          hasUnknown = true
+          return
+        }
+      }
+    }
+    /**
+     * Extract the use ref names.
+     * @param {Identifier | MemberExpression} refsNode
+     * @returns {void}
+     */
+    function extractUsedForPattern(refsNode) {
+      /** @type {Identifier | MemberExpression | ChainExpression} */
+      let node = refsNode
+      while (node.parent.type === 'ChainExpression') {
+        node = node.parent
+      }
+      const parent = node.parent
+      if (parent.type === 'AssignmentExpression') {
+        if (parent.right === node) {
+          if (parent.left.type === 'ObjectPattern') {
+            // `({foo} = $refs)`
+            extractUsedForObjectPattern(parent.left)
+          } else if (parent.left.type === 'Identifier') {
+            // `foo = $refs`
+            hasUnknown = true
+          }
+        }
+      } else if (parent.type === 'VariableDeclarator') {
+        if (parent.init === node) {
+          if (parent.id.type === 'ObjectPattern') {
+            // `const {foo} = $refs`
+            extractUsedForObjectPattern(parent.id)
+          } else if (parent.id.type === 'Identifier') {
+            // `const foo = $refs`
+            hasUnknown = true
+          }
+        }
+      } else if (parent.type === 'MemberExpression') {
+        if (parent.object === node) {
+          // `$refs.foo`
+          const name = utils.getStaticPropertyName(parent)
+          if (name) {
+            usedRefs.add(name)
+          } else {
+            hasUnknown = true
+          }
+        }
+      } else if (parent.type === 'CallExpression') {
+        const argIndex = parent.arguments.indexOf(node)
+        if (argIndex > -1) {
+          // `foo($refs)`
+          hasUnknown = true
+        }
+      } else if (
+        parent.type === 'ForInStatement' ||
+        parent.type === 'ReturnStatement'
+      ) {
+        hasUnknown = true
+      }
+    }
+
+    return utils.defineTemplateBodyVisitor(
+      context,
+      {
+        /**
+         * @param {VExpressionContainer} node
+         */
+        VExpressionContainer(node) {
+          if (hasUnknown) {
+            return
+          }
+          for (const id of getReferences(node.references)) {
+            if (id.name !== '$refs') {
+              continue
+            }
+            extractUsedForPattern(id)
+          }
+        },
+        /**
+         * @param {VAttribute} node
+         */
+        'VAttribute[directive=false]'(node) {
+          if (hasUnknown) {
+            return
+          }
+          if (node.key.name === 'ref' && node.value != null) {
+            defineRefs.push(node.value)
+          }
+        },
+        "VElement[parent.type!='VElement']:exit"() {
+          if (hasUnknown) {
+            return
+          }
+          reportUnusedRefs()
+        }
+      },
+      {
+        Identifier(id) {
+          if (hasUnknown) {
+            return
+          }
+          if (id.name !== '$refs') {
+            return
+          }
+          /** @type {Identifier | MemberExpression} */
+          let refsNode = id
+          if (id.parent.type === 'MemberExpression') {
+            if (id.parent.property === id) {
+              // `this.$refs.foo`
+              refsNode = id.parent
+            }
+          }
+          extractUsedForPattern(refsNode)
+        }
+      }
+    )
+  }
+}


Test patch:
diff --git a/tests/lib/rules/no-unused-refs.js b/tests/lib/rules/no-unused-refs.js
new file mode 100644
index 00000000..3deea468
--- /dev/null
+++ b/tests/lib/rules/no-unused-refs.js
@@ -0,0 +1,384 @@
+/**
+ * @fileoverview Disallow unused refs.
+ * @author Yosuke Ota
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-unused-refs')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('no-unused-refs', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <!-- ✓ GOOD -->
+        <input ref="foo" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          this.$refs.foo.value = 'foo'
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="foo" />
+        <button @click="alert($refs.foo.value)">Click Me</button>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input :ref="e=>foo=e" />
+        <button @click="alert($refs.foo.value)">Click Me</button>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input id="foo" />
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="a" />
+        <input ref="b" />
+        <input ref="c" />
+        <input ref="d" />
+        <input ref="e" />
+        <input ref="f" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          this.$refs.a;
+          const {b} = this.$refs;
+          ({c} = this.$refs);
+          $refs.d;
+          const {e} = $refs;
+          ({f} = $refs);
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="a" />
+        <input ref="b" />
+        <input ref="c" />
+        <input ref="d" />
+        <input ref="e" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          this.$refs?.a;
+          this?.$refs.b;
+          $refs?.c;
+          this?.$refs?.d;
+          (this?.$refs)?.e;
+        }
+      }
+      </script>
+      `
+    },
+
+    // has unknown
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="foo" />
+        <input ref="bar" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          this.$refs[foo].value = 'foo'
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+        <button @click="alert($refs[foo].value)">Click Me</button>
+        <input ref="y" />
+        <input ref="z" />
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+        <button @click="alert($refs[foo].value)">Click Me</button>
+        <input ref="y" />
+        <button @click="alert($refs.z.value)">Click Me</button>
+        <input ref="z" />
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          this.$refs[a];
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          const {[a]: a} = this.$refs;
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          ({[a]: a} = this.$refs);
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          const {...a} = this.$refs;
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          const a = this.$refs;
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          a = this.$refs;
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          fn(this.$refs)
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          for(const k in this.$refs) {}
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        methods: {
+          getRefs() {
+            return this.$refs
+          }
+        }
+      }
+      </script>
+      `
+    }
+  ],
+
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="foo" />
+      </template>
+      `,
+      errors: [
+        {
+          message: "'foo' is defined as ref, but never used.",
+          line: 3
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <!-- ✓ GOOD -->
+        <input ref="foo" />
+
+        <!-- ✗ BAD (bar is not used) -->
+        <input ref="bar" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          this.$refs.foo.value = 'foo'
+        }
+      }
+      </script>
+      `,
+      errors: [
+        {
+          message: "'bar' is defined as ref, but never used.",
+          line: 7
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+        <button @click="alert($refs.foo.value)">Click Me</button>
+      </template>
+      `,
+      errors: [
+        {
+          message: "'x' is defined as ref, but never used.",
+          line: 3
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="alert($refs.foo.value)">Click Me</button>
+        <input ref="x" />
+      </template>
+      `,
+      errors: [
+        {
+          message: "'x' is defined as ref, but never used.",
+          line: 4
+        }
+      ]
+    },
+
+    // illegal use
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input ref="x" />
+      </template>
+      <script>
+      export default {
+        mounted() {
+          this.$refs();
+          x[this.$refs];
+          this.$refs = foo;
+          const {[this.$refs]: a} = foo;
+          const [b] = this.$refs;
+          ([b] = this.$refs);
+        }
+      }
+      </script>
+      `,
+      errors: ["'x' is defined as ref, but never used."]
+    }
+  ]
+})