Repository: vuejs/eslint-plugin-vue (js)

Issue:
vue/no-unused-properties does not detect usages in watch handlers
**Tell us about your environment**
* **ESLint version :6.8.0** 
* **eslint-plugin-vue version: 7.0.0-alpha.9** 
* **Node version:11.5.0** 

**Please show your full configuration:**
<!-- Paste content of your .eslintrc file -->
```json
module.exports = {
  env: {
    node: true
  },
  extends: [
    'plugin:vue/essential',
    '@vue/standard'
  ],
  parser: 'vue-eslint-parser',
  parserOptions: {
    parser: 'babel-eslint'
  },
  root: true,
  rules: {
    /* omitted other rules */
    'vue/no-unused-properties': [ 'warn', {
      'groups': ['props', 'data', 'computed', 'methods', 'setup']
    }]
  }
}

```

**What did you do?**	
<!-- Please include the actual source code causing the issue. -->
Calls to methods within watch handlers do not get picked up as being used by vue/no-unused-properties. GetMoreData will be highlighted as unused
```vue
watch: {
  someData: {
    handler: 'GetMoreData',
    immediate: true
  }
},

......


methods: {
  GetMoreData () {
    /* business logic here */
  }
}
```

**What did you expect to happen?**
GetMoreData is not flagged as unused

**What actually happened?**
<!-- Please include the actual, raw output from ESLint. -->
`'GetMoreData' of method found, but never used. eslint(vue/no-unused-properties)`

Pull request:
Fix false positives for watch handler methods in `vue/no-unused-properties` rule
fixes #1245

Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 33285ae2..7e0b1dd9 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -92,6 +92,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
 | [vue/valid-v-for](./valid-v-for.md) | enforce valid `v-for` directives |  |
 | [vue/valid-v-html](./valid-v-html.md) | enforce valid `v-html` directives |  |
 | [vue/valid-v-if](./valid-v-if.md) | enforce valid `v-if` directives |  |
+| [vue/valid-v-is](./valid-v-is.md) | enforce valid `v-is` directives |  |
 | [vue/valid-v-model](./valid-v-model.md) | enforce valid `v-model` directives |  |
 | [vue/valid-v-on](./valid-v-on.md) | enforce valid `v-on` directives |  |
 | [vue/valid-v-once](./valid-v-once.md) | enforce valid `v-once` directives |  |
@@ -129,6 +130,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
 | [vue/one-component-per-file](./one-component-per-file.md) | enforce that each component should be in its own file |  |
 | [vue/prop-name-casing](./prop-name-casing.md) | enforce specific casing for the Prop name in Vue components |  |
 | [vue/require-default-prop](./require-default-prop.md) | require default value for props |  |
+| [vue/require-explicit-emits](./require-explicit-emits.md) | require `emits` option with name triggered by `$emit()` |  |
 | [vue/require-prop-types](./require-prop-types.md) | require type definitions in props |  |
 | [vue/singleline-html-element-content-newline](./singleline-html-element-content-newline.md) | require a line break before and after the contents of a singleline element | :wrench: |
 | [vue/v-bind-style](./v-bind-style.md) | enforce `v-bind` directive style | :wrench: |
@@ -306,7 +308,6 @@ For example:
 | [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: |
 | [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported |  |
-| [vue/require-explicit-emits](./require-explicit-emits.md) | require `emits` option with name triggered by `$emit()` |  |
 | [vue/require-name-property](./require-name-property.md) | require a name property in Vue components |  |
 | [vue/script-indent](./script-indent.md) | enforce consistent indentation in `<script>` | :wrench: |
 | [vue/sort-keys](./sort-keys.md) | enforce sort-keys in a manner that is compatible with order-in-components |  |
diff --git a/docs/rules/attributes-order.md b/docs/rules/attributes-order.md
index 2da566bf..c6391ffa 100644
--- a/docs/rules/attributes-order.md
+++ b/docs/rules/attributes-order.md
@@ -15,7 +15,7 @@ description: enforce order of attributes
 This rule aims to enforce ordering of component attributes. The default order is specified in the [Vue styleguide](https://v3.vuejs.org/style-guide/#element-attribute-order-recommended) and is:
 
 - `DEFINITION`
-  e.g. 'is'
+  e.g. 'is', 'v-is'
 - `LIST_RENDERING`
   e.g. 'v-for item in items'
 - `CONDITIONALS`
diff --git a/docs/rules/no-unsupported-features.md b/docs/rules/no-unsupported-features.md
index 4487a4f7..b706ef88 100644
--- a/docs/rules/no-unsupported-features.md
+++ b/docs/rules/no-unsupported-features.md
@@ -30,6 +30,7 @@ The `"ignores"` option accepts an array of the following strings.
   - Vue.js 3.0.0+
     - `"v-model-argument"` ... [argument on `v-model`][Vue RFCs - 0005-replace-v-bind-sync-with-v-model-argument]
     - `"v-model-custom-modifiers"` ... [custom modifiers on `v-model`][Vue RFCs - 0011-v-model-api-change]
+    - `"v-id"` ... [v-is](https://v3.vuejs.org/api/directives.html#v-is) directive.
   - Vue.js 2.6.0+
     - `"dynamic-directive-arguments"` ... [dynamic directive arguments](https://v3.vuejs.org/guide/template-syntax.html#dynamic-arguments).
     - `"v-slot"` ... [v-slot](https://v3.vuejs.org/api/directives.html#v-slot) directive.
@@ -90,6 +91,7 @@ The `"ignores"` option accepts an array of the following strings.
 
 ## :books: Further Reading
 
+- [API - v-is](https://v3.vuejs.org/api/directives.html#v-is)
 - [Guide - Dynamic Arguments](https://v3.vuejs.org/guide/template-syntax.html#dynamic-arguments)
 - [API - v-slot](https://v3.vuejs.org/api/directives.html#v-slot)
 - [API (for v2) - slot-scope](https://vuejs.org/v2/api/#slot-scope-deprecated)
diff --git a/docs/rules/require-explicit-emits.md b/docs/rules/require-explicit-emits.md
index 60a914ca..2a165ec1 100644
--- a/docs/rules/require-explicit-emits.md
+++ b/docs/rules/require-explicit-emits.md
@@ -7,6 +7,8 @@ description: require `emits` option with name triggered by `$emit()`
 # vue/require-explicit-emits
 > require `emits` option with name triggered by `$emit()`
 
+- :gear: This rule is included in `"plugin:vue/vue3-strongly-recommended"` and `"plugin:vue/vue3-recommended"`.
+
 ## :book: Rule Details
 
 This rule reports event triggers not declared with the `emits` option. (The `emits` option is a new in Vue.js 3.0.0+)
diff --git a/docs/rules/valid-v-is.md b/docs/rules/valid-v-is.md
new file mode 100644
index 00000000..c8b44d21
--- /dev/null
+++ b/docs/rules/valid-v-is.md
@@ -0,0 +1,63 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/valid-v-is
+description: enforce valid `v-is` directives
+---
+# vue/valid-v-is
+> enforce valid `v-is` directives
+
+- :gear: This rule is included in all of `"plugin:vue/vue3-essential"`, `"plugin:vue/vue3-strongly-recommended"` and `"plugin:vue/vue3-recommended"`.
+
+This rule checks whether every `v-is` directive is valid.
+
+## :book: Rule Details
+
+This rule reports `v-is` directives in the following cases:
+
+- The directive has that argument. E.g. `<div v-is:aaa="foo"></div>`
+- The directive has that modifier. E.g. `<div v-is.bbb="foo"></div>`
+- The directive does not have that attribute value. E.g. `<div v-is></div>`
+- The directive is on Vue-components. E.g. `<MyComponent v-is="foo"></MyComponent>`
+
+<eslint-code-block :rules="{'vue/valid-v-is': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <tr v-is="'blog-post-row'"></tr>
+  <tr v-is="foo"></tr>
+
+  <!-- ✗ BAD -->
+  <tr v-is:a="foo"></tr>
+  <tr v-is.m="foo"></tr>
+  <tr v-is></tr>
+  <tr v-is=""></tr>
+  <MyComponent v-is="foo" />
+</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-is](https://v3.vuejs.org/api/directives.html#v-is)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/valid-v-is.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/valid-v-is.js)
diff --git a/lib/configs/vue3-essential.js b/lib/configs/vue3-essential.js
index 5adbbcd2..8b2d6a13 100644
--- a/lib/configs/vue3-essential.js
+++ b/lib/configs/vue3-essential.js
@@ -60,6 +60,7 @@ module.exports = {
     'vue/valid-v-for': 'error',
     'vue/valid-v-html': 'error',
     'vue/valid-v-if': 'error',
+    'vue/valid-v-is': 'error',
     'vue/valid-v-model': 'error',
     'vue/valid-v-on': 'error',
     'vue/valid-v-once': 'error',
diff --git a/lib/configs/vue3-strongly-recommended.js b/lib/configs/vue3-strongly-recommended.js
index f62d3450..9adfcd7c 100644
--- a/lib/configs/vue3-strongly-recommended.js
+++ b/lib/configs/vue3-strongly-recommended.js
@@ -23,6 +23,7 @@ module.exports = {
     'vue/one-component-per-file': 'warn',
     'vue/prop-name-casing': 'warn',
     'vue/require-default-prop': 'warn',
+    'vue/require-explicit-emits': 'warn',
     'vue/require-prop-types': 'warn',
     'vue/singleline-html-element-content-newline': 'warn',
     'vue/v-bind-style': 'warn',
diff --git a/lib/index.js b/lib/index.js
index 530c05c5..5f285be6 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -158,6 +158,7 @@ module.exports = {
     'valid-v-for': require('./rules/valid-v-for'),
     '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-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/attributes-order.js b/lib/rules/attributes-order.js
index 794628a5..34a9d7d3 100644
--- a/lib/rules/attributes-order.js
+++ b/lib/rules/attributes-order.js
@@ -84,6 +84,8 @@ function getAttributeType(attribute, sourceCode) {
         return ATTRS.CONTENT
       } else if (name === 'slot') {
         return ATTRS.UNIQUE
+      } else if (name === 'is') {
+        return ATTRS.DEFINITION
       } else {
         return ATTRS.OTHER_DIRECTIVES
       }
diff --git a/lib/rules/no-unregistered-components.js b/lib/rules/no-unregistered-components.js
index 20558022..56f03e40 100644
--- a/lib/rules/no-unregistered-components.js
+++ b/lib/rules/no-unregistered-components.js
@@ -95,7 +95,7 @@ module.exports = {
           usedComponentNodes.push({ node, name: node.rawName })
         },
         /** @param {VDirective} node */
-        "VAttribute[directive=true][key.name.name='bind'][key.argument.name='is']"(
+        "VAttribute[directive=true][key.name.name='bind'][key.argument.name='is'], VAttribute[directive=true][key.name.name='is']"(
           node
         ) {
           if (
diff --git a/lib/rules/no-unsupported-features.js b/lib/rules/no-unsupported-features.js
index 468e718e..6f7a87ab 100644
--- a/lib/rules/no-unsupported-features.js
+++ b/lib/rules/no-unsupported-features.js
@@ -24,7 +24,8 @@ const FEATURES = {
   'v-bind-prop-modifier-shorthand': require('./syntaxes/v-bind-prop-modifier-shorthand'),
   // Vue.js 3.0.0+
   'v-model-argument': require('./syntaxes/v-model-argument'),
-  'v-model-custom-modifiers': require('./syntaxes/v-model-custom-modifiers')
+  'v-model-custom-modifiers': require('./syntaxes/v-model-custom-modifiers'),
+  'v-is': require('./syntaxes/v-is')
 }
 
 const cache = new Map()
@@ -93,7 +94,8 @@ module.exports = {
       forbiddenVModelArgument:
         'Argument on `v-model` is not supported until Vue.js "3.0.0".',
       forbiddenVModelCustomModifiers:
-        'Custom modifiers on `v-model` are not supported until Vue.js "3.0.0".'
+        'Custom modifiers on `v-model` are not supported until Vue.js "3.0.0".',
+      forbiddenVIs: '`v-is` are not supported until Vue.js "3.0.0".'
     }
   },
   /** @param {RuleContext} context */
diff --git a/lib/rules/no-unused-components.js b/lib/rules/no-unused-components.js
index b76af719..e067e3eb 100644
--- a/lib/rules/no-unused-components.js
+++ b/lib/rules/no-unused-components.js
@@ -67,7 +67,7 @@ module.exports = {
           usedComponents.add(node.rawName)
         },
         /** @param {VDirective} node */
-        "VAttribute[directive=true][key.name.name='bind'][key.argument.name='is']"(
+        "VAttribute[directive=true][key.name.name='bind'][key.argument.name='is'], VAttribute[directive=true][key.name.name='is']"(
           node
         ) {
           if (
diff --git a/lib/rules/no-unused-properties.js b/lib/rules/no-unused-properties.js
index f7f30dc8..23fab442 100644
--- a/lib/rules/no-unused-properties.js
+++ b/lib/rules/no-unused-properties.js
@@ -583,19 +583,47 @@ module.exports = {
       utils.defineVueVisitor(context, {
         onVueObjectEnter(node) {
           const container = getVueComponentPropertiesContainer(node)
-          const watcherNames = new Set()
+          const watcherUsedProperties = new Set()
           for (const watcher of utils.iterateProperties(
             node,
             new Set([GROUP_WATCHER])
           )) {
+            // Process `watch: { foo /* <- this */ () {} }`
             let path
             for (const seg of watcher.name.split('.')) {
               path = path ? `${path}.${seg}` : seg
-              watcherNames.add(path)
+              watcherUsedProperties.add(path)
+            }
+
+            // Process `watch: { x: 'foo' /* <- this */  }`
+            if (watcher.type === 'object') {
+              const property = watcher.property
+              if (property.kind === 'init') {
+                /** @type {Expression | null} */
+                let handlerValueNode = null
+                if (property.value.type === 'ObjectExpression') {
+                  const handler = utils.findProperty(property.value, 'handler')
+                  if (handler) {
+                    handlerValueNode = handler.value
+                  }
+                } else {
+                  handlerValueNode = property.value
+                }
+                if (
+                  handlerValueNode &&
+                  (handlerValueNode.type === 'Literal' ||
+                    handlerValueNode.type === 'TemplateLiteral')
+                ) {
+                  const name = utils.getStringLiteralValue(handlerValueNode)
+                  if (name != null) {
+                    watcherUsedProperties.add(name)
+                  }
+                }
+              }
             }
           }
           for (const prop of utils.iterateProperties(node, groups)) {
-            if (watcherNames.has(prop.name)) {
+            if (watcherUsedProperties.has(prop.name)) {
               continue
             }
             container.properties.push(prop)
diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js
index 27ae600f..fdadc233 100644
--- a/lib/rules/require-explicit-emits.js
+++ b/lib/rules/require-explicit-emits.js
@@ -85,7 +85,7 @@ module.exports = {
     type: 'suggestion',
     docs: {
       description: 'require `emits` option with name triggered by `$emit()`',
-      categories: undefined,
+      categories: ['vue3-strongly-recommended'],
       url: 'https://eslint.vuejs.org/rules/require-explicit-emits.html'
     },
     fixable: null,
diff --git a/lib/rules/syntaxes/v-is.js b/lib/rules/syntaxes/v-is.js
new file mode 100644
index 00000000..a974b820
--- /dev/null
+++ b/lib/rules/syntaxes/v-is.js
@@ -0,0 +1,26 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+module.exports = {
+  supported: '3.0.0',
+  /** @param {RuleContext} context @returns {TemplateListener} */
+  createTemplateBodyVisitor(context) {
+    /**
+     * Reports `v-is` node
+     * @param {VDirective} vSlotAttr node of `v-is`
+     * @returns {void}
+     */
+    function reportVSlot(vSlotAttr) {
+      context.report({
+        node: vSlotAttr.key,
+        messageId: 'forbiddenVIs'
+      })
+    }
+
+    return {
+      "VAttribute[directive=true][key.name.name='is']": reportVSlot
+    }
+  }
+}
diff --git a/lib/rules/valid-v-is.js b/lib/rules/valid-v-is.js
new file mode 100644
index 00000000..50fa1fb5
--- /dev/null
+++ b/lib/rules/valid-v-is.js
@@ -0,0 +1,95 @@
+/**
+ * @fileoverview enforce valid `v-is` directives
+ * @author Yosuke Ota
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * Check whether the given node is valid or not.
+ * @param {VElement} node The element node to check.
+ * @returns {boolean} `true` if the node is valid.
+ */
+function isValidElement(node) {
+  if (
+    utils.isHtmlElementNode(node) &&
+    !utils.isHtmlWellKnownElementName(node.rawName)
+  ) {
+    // Vue-component
+    return false
+  }
+  return true
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'enforce valid `v-is` directives',
+      categories: ['vue3-essential'],
+      url: 'https://eslint.vuejs.org/rules/valid-v-is.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      unexpectedArgument: "'v-is' directives require no argument.",
+      unexpectedModifier: "'v-is' directives require no modifier.",
+      expectedValue: "'v-is' directives require that attribute value.",
+      ownerMustBeHTMLElement:
+        "'v-is' directive must be owned by a native HTML element, but '{{name}}' is not."
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    return utils.defineTemplateBodyVisitor(context, {
+      "VAttribute[directive=true][key.name.name='is']"(node) {
+        if (node.key.argument) {
+          context.report({
+            node,
+            loc: node.loc,
+            messageId: 'unexpectedArgument'
+          })
+        }
+        if (node.key.modifiers.length > 0) {
+          context.report({
+            node,
+            loc: node.loc,
+            messageId: 'unexpectedModifier'
+          })
+        }
+        if (!node.value || utils.isEmptyValueDirective(node, context)) {
+          context.report({
+            node,
+            loc: node.loc,
+            messageId: 'expectedValue'
+          })
+        }
+
+        const element = node.parent.parent
+
+        if (!isValidElement(element)) {
+          const name = element.name
+          context.report({
+            node,
+            loc: node.loc,
+            messageId: 'ownerMustBeHTMLElement',
+            data: { name }
+          })
+        }
+      }
+    })
+  }
+}
diff --git a/lib/utils/index.js b/lib/utils/index.js
index 13053052..5abb635e 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -83,7 +83,7 @@
 /**
  * @typedef { 'props' | 'data' | 'computed' | 'setup' | 'watch' | 'methods' } GroupName
  * @typedef { { type: 'array',  name: string, groupName: GroupName, node: Literal | TemplateLiteral } } ComponentArrayPropertyData
- * @typedef { { type: 'object', name: string, groupName: GroupName, node: Identifier | Literal | TemplateLiteral } } ComponentObjectPropertyData
+ * @typedef { { type: 'object', name: string, groupName: GroupName, node: Identifier | Literal | TemplateLiteral, property: Property } } ComponentObjectPropertyData
  * @typedef { ComponentArrayPropertyData | ComponentObjectPropertyData } ComponentPropertyData
  */
 /**
@@ -578,7 +578,8 @@ module.exports = {
       (this.isHtmlElementNode(node) &&
         !this.isHtmlWellKnownElementName(node.rawName)) ||
       this.hasAttribute(node, 'is') ||
-      this.hasDirective(node, 'bind', 'is')
+      this.hasDirective(node, 'bind', 'is') ||
+      this.hasDirective(node, 'is')
     )
   },
 
@@ -1117,7 +1118,13 @@ module.exports = {
                 continue
               }
             }
-            yield { type: 'object', name, groupName, node: key }
+            yield {
+              type: 'object',
+              name,
+              groupName,
+              node: key,
+              property: item
+            }
           }
         }
       }
diff --git a/typings/eslint-plugin-vue/util-types/ast/ast.ts b/typings/eslint-plugin-vue/util-types/ast/ast.ts
index 357b5640..d8f21315 100644
--- a/typings/eslint-plugin-vue/util-types/ast/ast.ts
+++ b/typings/eslint-plugin-vue/util-types/ast/ast.ts
@@ -62,6 +62,8 @@ export type VNodeListenerMap = {
       | (V.VExpressionContainer & { expression: ES.Expression | null })
       | null
   }
+  "VAttribute[directive=true][key.name.name='is']": V.VDirective
+  "VAttribute[directive=true][key.name.name='is']:exit": V.VDirective
   "VAttribute[directive=true][key.name.name='model']": V.VDirective & {
     value:
       | (V.VExpressionContainer & { expression: ES.Expression | null })


Test patch:
diff --git a/tests/lib/rules/attributes-order.js b/tests/lib/rules/attributes-order.js
index a0b93024..87e72b56 100644
--- a/tests/lib/rules/attributes-order.js
+++ b/tests/lib/rules/attributes-order.js
@@ -932,6 +932,17 @@ tester.run('attributes-order', rule, {
           message: 'Attribute "v-foo.a" should go before "v-foo.b".'
         }
       ]
+    },
+
+    {
+      filename: 'test.vue',
+      code: '<template><div v-cloak v-is="foo"></div></template>',
+      output: '<template><div v-is="foo" v-cloak></div></template>',
+      errors: [
+        {
+          message: 'Attribute "v-is" should go before "v-cloak".'
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/no-unregistered-components.js b/tests/lib/rules/no-unregistered-components.js
index e7ae5198..da9e37ee 100644
--- a/tests/lib/rules/no-unregistered-components.js
+++ b/tests/lib/rules/no-unregistered-components.js
@@ -378,6 +378,21 @@ tester.run('no-unregistered-components', rule, {
           <Component is />
         </template>
       `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div v-is="'CustomComponent'" />
+        </template>
+        <script>
+        export default {
+          components: {
+            CustomComponent
+          }
+        }
+        </script>
+      `
     }
   ],
   invalid: [
diff --git a/tests/lib/rules/no-unsupported-features/v-is.js b/tests/lib/rules/no-unsupported-features/v-is.js
new file mode 100644
index 00000000..3212a757
--- /dev/null
+++ b/tests/lib/rules/no-unsupported-features/v-is.js
@@ -0,0 +1,58 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../../lib/rules/no-unsupported-features')
+const utils = require('./utils')
+
+const buildOptions = utils.optionsBuilder('v-is', '^2.6.0')
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2019
+  }
+})
+
+tester.run('no-unsupported-features/v-is', rule, {
+  valid: [
+    {
+      code: `
+      <template>
+        <div v-is="foo" />
+      </template>`,
+      options: buildOptions({ version: '^3.0.0' })
+    },
+    {
+      code: `
+      <template>
+        <div :is="foo" />
+      </template>`,
+      options: buildOptions()
+    },
+    {
+      code: `
+      <template>
+        <div v-is="foo" />
+      </template>`,
+      options: buildOptions({ version: '^2.5.0', ignores: ['v-is'] })
+    }
+  ],
+  invalid: [
+    {
+      code: `
+      <template>
+        <div v-is="foo" />
+      </template>`,
+      options: buildOptions(),
+      errors: [
+        {
+          message: '`v-is` are not supported until Vue.js "3.0.0".',
+          line: 3
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/no-unused-components.js b/tests/lib/rules/no-unused-components.js
index 181fbd6a..02cb1a3c 100644
--- a/tests/lib/rules/no-unused-components.js
+++ b/tests/lib/rules/no-unused-components.js
@@ -471,6 +471,21 @@ tester.run('no-unused-components', rule, {
           }
         }
       </script>`
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div v-is="'CustomComponent'" />
+        </template>
+        <script>
+        export default {
+          components: {
+            CustomComponent
+          }
+        }
+        </script>
+      `
     }
   ],
   invalid: [
diff --git a/tests/lib/rules/no-unused-properties.js b/tests/lib/rules/no-unused-properties.js
index 4319d14f..dffa036f 100644
--- a/tests/lib/rules/no-unused-properties.js
+++ b/tests/lib/rules/no-unused-properties.js
@@ -1031,6 +1031,53 @@ tester.run('no-unused-properties', rule, {
         }
       })
       `
+    },
+    // handlers
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+        export default {
+          props: ['foo', 'bar'],
+          watch: {
+            foo: 'updateFoo',
+            bar: {
+              handler: 'updateBar',
+              immediate: true
+            }
+          },
+          methods: {
+            updateFoo() {},
+            updateBar() {}
+          }
+        };
+      </script>
+      `,
+      options: [{ groups: ['props', 'methods'] }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+        export default {
+          props: ['foo', 'bar'],
+          data () {
+            return {
+              updateFoo() {},
+              updateBar() {}
+            }
+          },
+          watch: {
+            foo: 'updateFoo',
+            bar: {
+              handler: 'updateBar',
+              immediate: true
+            }
+          }
+        };
+      </script>
+      `,
+      options: [{ groups: ['props', 'data'] }]
     }
   ],
 
diff --git a/tests/lib/rules/valid-v-is.js b/tests/lib/rules/valid-v-is.js
new file mode 100644
index 00000000..1a3a45e5
--- /dev/null
+++ b/tests/lib/rules/valid-v-is.js
@@ -0,0 +1,109 @@
+/**
+ * @author Yosuke Ota
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/valid-v-is')
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2020 }
+})
+
+tester.run('valid-v-is', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: ''
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-is="foo" /></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-bind:foo="foo" /></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div v-is="'foo'" /></template>`
+    },
+    // parsing error
+    {
+      filename: 'parsing-error.vue',
+      code: '<template><div v-is="." /></template>'
+    },
+    // comment value (parsing error)
+    {
+      filename: 'comment-value.vue',
+      code: '<template><div v-is="/**/" /></template>'
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: '<template><div v-is:a="foo" /></template>',
+      errors: [
+        {
+          message: "'v-is' directives require no argument.",
+          column: 16,
+          endColumn: 28
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-is.a="foo" /></template>',
+      errors: [
+        {
+          message: "'v-is' directives require no modifier.",
+          column: 16,
+          endColumn: 28
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-is /></template>',
+      errors: [
+        {
+          message: "'v-is' directives require that attribute value.",
+          column: 16,
+          endColumn: 20
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-is="" /></template>',
+      errors: [
+        {
+          message: "'v-is' directives require that attribute value.",
+          column: 16,
+          endColumn: 23
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><MyComponent v-is="foo" /></template>',
+      errors: [
+        {
+          message:
+            "'v-is' directive must be owned by a native HTML element, but 'mycomponent' is not.",
+          column: 24,
+          endColumn: 34
+        }
+      ]
+    }
+  ]
+})