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

Issue:
Rule Proposal: no unnecessary template
Could we add a rule that emits an error when `template` tag is not necessary?

If a `template` tag has only one child element, or it has no `v-if` / `v-else-if` / `v-else` / `v-for` directive, this tag can be omitted.

For example, there are some examples of incorrect code for this rule:
```vue
<template>
  <div class="root">
    <template v-if="isXxx">
      <span>{{ xxx }}</span>
    </template>
  </div>
</template>
```
```vue
<template>
  <div class="root">
    <template>
      <span>{{ xxx }}</span>
      <span>{{ yyy }}</span>
    </template>
  </div>
</template>
```
Here are some correct ones:
```vue
<template>
  <div class="root">
    <span v-if="isXxx">{{ xxx }}</span>
  </div>
</template>
```
```vue
<template>
  <div class="root">
    <template v-for="item in items">
      <span>{{ item.xxx }}</span>
      <span>{{ item.yyy }}</span>
    </template>
  </div>
</template>
```

What do you think of this?

Pull request description:
Add `vue/no-lone-template` rule
This PR adds `vue/no-lone-template` rule.

`vue/no-lone-template` rule that disallow unnecessary `<template>` element.
This rule reports `<template>` elements that have no specific directives (`v-if`, `v-else`, `v-else-if`, `v-for`, `v-slot`, `slot`, `slot-scope` and `scope`).

Note: `slot` and `slot-scope` are deprecated in Vue.js 2.6, `scope` is deprecated in Vue.js 2.5.

The name of the this rule was inspired by [no-lone-blocks](https://eslint.org/docs/rules/no-lone-blocks).

close #567

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 1dd68927..2a02e022 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -10,10 +10,10 @@ about: Create a report to help us improve
   To make sure it's not, run: yarn eslint src/your-file.vue
 -->
 
-**Tell us about your environment**	
-* **ESLint version:**	
-* **eslint-plugin-vue version:**	
-* **Node version:**	
+**Tell us about your environment**
+* **ESLint version:** 
+* **eslint-plugin-vue version:** 
+* **Node version:** 
 
 **Please show your full configuration:**
 <!-- Paste content of your .eslintrc file -->
diff --git a/docs/rules/README.md b/docs/rules/README.md
index e2353b63..6b74ad28 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -148,6 +148,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
 |:--------|:------------|:---|
 | [vue/attributes-order](./attributes-order.md) | enforce order of attributes | :wrench: |
 | [vue/component-tags-order](./component-tags-order.md) | enforce order of component top-level elements |  |
+| [vue/no-lone-template](./no-lone-template.md) | disallow unnecessary `<template>` |  |
 | [vue/no-multiple-slot-args](./no-multiple-slot-args.md) | disallow to pass multiple arguments to scoped slots |  |
 | [vue/no-v-html](./no-v-html.md) | disallow use of v-html to prevent XSS attack |  |
 | [vue/order-in-components](./order-in-components.md) | enforce order of properties in components | :wrench: |
@@ -256,6 +257,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
 |:--------|:------------|:---|
 | [vue/attributes-order](./attributes-order.md) | enforce order of attributes | :wrench: |
 | [vue/component-tags-order](./component-tags-order.md) | enforce order of component top-level elements |  |
+| [vue/no-lone-template](./no-lone-template.md) | disallow unnecessary `<template>` |  |
 | [vue/no-multiple-slot-args](./no-multiple-slot-args.md) | disallow to pass multiple arguments to scoped slots |  |
 | [vue/no-v-html](./no-v-html.md) | disallow use of v-html to prevent XSS attack |  |
 | [vue/order-in-components](./order-in-components.md) | enforce order of properties in components | :wrench: |
diff --git a/docs/rules/no-empty-component-block.md b/docs/rules/no-empty-component-block.md
index 25cc0ef1..5b164b6d 100644
--- a/docs/rules/no-empty-component-block.md
+++ b/docs/rules/no-empty-component-block.md
@@ -17,7 +17,7 @@ See: https://vue-loader.vuejs.org/spec.html#src-imports
 <eslint-code-block :rules="{'vue/no-empty-component-block': ['error']}">
 
 ```vue
-// ✓ GOOD
+<!-- ✓ GOOD -->
 <template>
   <p>foo</p>
 </template>
@@ -42,7 +42,7 @@ See: https://vue-loader.vuejs.org/spec.html#src-imports
 <style src="./style.css" />
 
 
-// ✗ BAD
+<!-- ✗ BAD -->
 <template></template>
 <template />
 <template src="" />
diff --git a/docs/rules/no-lone-template.md b/docs/rules/no-lone-template.md
new file mode 100644
index 00000000..c3ea7b71
--- /dev/null
+++ b/docs/rules/no-lone-template.md
@@ -0,0 +1,82 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-lone-template
+description: disallow unnecessary `<template>`
+---
+# vue/no-lone-template
+> disallow unnecessary `<template>`
+
+- :gear: This rule is included in `"plugin:vue/vue3-recommended"` and `"plugin:vue/recommended"`.
+
+## :book: Rule Details
+
+This rule aims to eliminate unnecessary and potentially confusing `<template>`.  
+In Vue.js 2.x, the `<template>` elements that have no specific directives have no effect.  
+In Vue.js 3.x, the `<template>` elements that have no specific directives render the `<template>` elements as is, but in most cases this may not be what you intended.
+
+<eslint-code-block :rules="{'vue/no-lone-template': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <template v-if="foo">...</template>
+  <template v-else-if="bar">...</template>
+  <template v-else>...</template>
+  <template v-for="e in list">...</template>
+  <template v-slot>...</template>
+
+  <!-- ✗ BAD -->
+  <template>...</template>
+  <template/>
+</template>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```json
+{
+  "vue/no-lone-template": ["error", {
+    "ignoreAccessible": false
+  }]
+}
+```
+
+- `ignoreAccessible` ... If `true`, ignore accessible `<template>` elements. default `false`.  
+  Note: this option is useless if you are using Vue.js 2.x.
+
+### `"ignoreAccessible": true`
+
+<eslint-code-block :rules="{'vue/no-lone-template': ['error', { 'ignoreAccessible': true }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <template ref="foo">...</template>
+  <template id="bar">...</template>
+
+  <!-- ✗ BAD -->
+  <template class="baz">...</template>
+</template>
+```
+
+</eslint-code-block>
+
+## :mute: When Not To Use It
+
+If you are using Vue.js 3.x and want to define the `<template>` element intentionally, you will have to turn this rule off or use `"ignoreAccessible"` option.
+
+## :couple: Related rules
+
+- [vue/no-template-key]
+- [no-lone-blocks]
+
+[no-lone-blocks]: https://eslint.org/docs/rules/no-lone-blocks
+[vue/no-template-key]: ./no-template-key.md
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-lone-template.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-lone-template.js)
diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md
index 469cdb94..4579d6c7 100644
--- a/docs/user-guide/README.md
+++ b/docs/user-guide/README.md
@@ -65,7 +65,7 @@ eslint "src/**/*.{js,vue}"
 If you installed [@vue/cli-plugin-eslint](https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint), you should have the `lint` script added to your `package.json`. That means you can just run `yarn lint` or `npm run lint`.
 :::
 
-#### How to use a custom parser?
+### How to use a custom parser?
 
 If you want to use custom parsers such as [babel-eslint](https://www.npmjs.com/package/babel-eslint) or [@typescript-eslint/parser](https://www.npmjs.com/package/@typescript-eslint/parser), you have to use the `parserOptions.parser` option instead of the `parser` option. Because this plugin requires [vue-eslint-parser](https://www.npmjs.com/package/vue-eslint-parser) to parse `.vue` files, this plugin doesn't work if you overwrite the `parser` option.
 
@@ -197,7 +197,7 @@ If you already use another parser (e.g. `"parser": "babel-eslint"`), please move
 + "parser": "vue-eslint-parser",
   "parserOptions": {
 +     "parser": "babel-eslint",
-      "ecmaVersion": 2017,
+      "ecmaVersion": 2020,
       "sourceType": "module"
   }
 ```
@@ -264,3 +264,22 @@ module.exports = {
 
 [prettier]: https://prettier.io/
 [eslint-config-prettier]: https://github.com/prettier/eslint-config-prettier
+
+### Using JSX.
+
+If you are using JSX, you need to enable JSX in your ESLint configuration.
+
+```diff
+  "parserOptions": {
+      "ecmaVersion": 2020,
+      "ecmaFeatures": {
++         "jsx": true
+      }
+  }
+```
+
+See also [ESLint - Specifying Parser Options](https://eslint.org/docs/user-guide/configuring#specifying-parser-options).
+
+The same configuration is required when using JSX with TypeScript (TSX) in the `.vue` file.  
+See also [here](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/parser/README.md#parseroptionsecmafeaturesjsx).  
+Note that you cannot use angle-bracket type assertion style (`var x = <foo>bar;`) when using `jsx: true`.
diff --git a/lib/configs/base.js b/lib/configs/base.js
index 747f2e65..71279b9a 100644
--- a/lib/configs/base.js
+++ b/lib/configs/base.js
@@ -7,10 +7,7 @@ module.exports = {
   parser: require.resolve('vue-eslint-parser'),
   parserOptions: {
     ecmaVersion: 2018,
-    sourceType: 'module',
-    ecmaFeatures: {
-      jsx: true
-    }
+    sourceType: 'module'
   },
   env: {
     browser: true,
diff --git a/lib/configs/recommended.js b/lib/configs/recommended.js
index e6388e15..61781f75 100644
--- a/lib/configs/recommended.js
+++ b/lib/configs/recommended.js
@@ -8,6 +8,7 @@ module.exports = {
   rules: {
     'vue/attributes-order': 'warn',
     'vue/component-tags-order': 'warn',
+    'vue/no-lone-template': 'warn',
     'vue/no-multiple-slot-args': 'warn',
     'vue/no-v-html': 'warn',
     'vue/order-in-components': 'warn',
diff --git a/lib/configs/vue3-recommended.js b/lib/configs/vue3-recommended.js
index 318690c3..452703dc 100644
--- a/lib/configs/vue3-recommended.js
+++ b/lib/configs/vue3-recommended.js
@@ -8,6 +8,7 @@ module.exports = {
   rules: {
     'vue/attributes-order': 'warn',
     'vue/component-tags-order': 'warn',
+    'vue/no-lone-template': 'warn',
     'vue/no-multiple-slot-args': 'warn',
     'vue/no-v-html': 'warn',
     'vue/order-in-components': 'warn',
diff --git a/lib/index.js b/lib/index.js
index ae86686a..8e54379a 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -74,6 +74,7 @@ module.exports = {
     'no-extra-parens': require('./rules/no-extra-parens'),
     'no-irregular-whitespace': require('./rules/no-irregular-whitespace'),
     'no-lifecycle-after-await': require('./rules/no-lifecycle-after-await'),
+    'no-lone-template': require('./rules/no-lone-template'),
     'no-multi-spaces': require('./rules/no-multi-spaces'),
     'no-multiple-objects-in-class': require('./rules/no-multiple-objects-in-class'),
     'no-multiple-slot-args': require('./rules/no-multiple-slot-args'),
diff --git a/lib/rules/no-lone-template.js b/lib/rules/no-lone-template.js
new file mode 100644
index 00000000..b56c6081
--- /dev/null
+++ b/lib/rules/no-lone-template.js
@@ -0,0 +1,129 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+// https://github.com/vuejs/vue-next/blob/64e2f4643602c5980361e66674141e61ba60ef70/packages/compiler-core/src/parse.ts#L405
+const SPECIAL_TEMPLATE_DIRECTIVES = new Set([
+  'if',
+  'else',
+  'else-if',
+  'for',
+  'slot'
+])
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'disallow unnecessary `<template>`',
+      categories: ['vue3-recommended', 'recommended'],
+      url: 'https://eslint.vuejs.org/rules/no-lone-template.html'
+    },
+    fixable: null,
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          ignoreAccessible: {
+            type: 'boolean'
+          }
+        },
+        additionalProperties: false
+      }
+    ],
+    messages: {
+      requireDirective: '`<template>` require directive.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const options = context.options[0] || {}
+    const ignoreAccessible = options.ignoreAccessible === true
+
+    /**
+     * @param {VAttribute | VDirective} attr
+     */
+    function getKeyName(attr) {
+      if (attr.directive) {
+        if (attr.key.name.name !== 'bind') {
+          // no v-bind
+          return null
+        }
+        if (
+          !attr.key.argument ||
+          attr.key.argument.type === 'VExpressionContainer'
+        ) {
+          // unknown
+          return null
+        }
+        return attr.key.argument.name
+      }
+      return attr.key.name
+    }
+
+    return utils.defineTemplateBodyVisitor(context, {
+      /** @param {VStartTag} node */
+      "VElement[name='template'][parent.type='VElement'] > VStartTag"(node) {
+        if (
+          node.attributes.some((attr) => {
+            if (attr.directive) {
+              const directiveName = attr.key.name.name
+              if (SPECIAL_TEMPLATE_DIRECTIVES.has(directiveName)) {
+                return true
+              }
+              if (directiveName === 'slot-scope') {
+                // `slot-scope` is deprecated in Vue.js 2.6
+                return true
+              }
+              if (directiveName === 'scope') {
+                // `scope` is deprecated in Vue.js 2.5
+                return true
+              }
+            }
+
+            const keyName = getKeyName(attr)
+            if (keyName === 'slot') {
+              // `slot` is deprecated in Vue.js 2.6
+              return true
+            }
+
+            return false
+          })
+        ) {
+          return
+        }
+
+        if (
+          ignoreAccessible &&
+          node.attributes.some((attr) => {
+            const keyName = getKeyName(attr)
+            return keyName === 'id' || keyName === 'ref'
+          })
+        ) {
+          return
+        }
+
+        context.report({
+          node,
+          messageId: 'requireDirective'
+        })
+      }
+    })
+  }
+}
diff --git a/tools/update-lib-configs.js b/tools/update-lib-configs.js
index ff026eca..5379decb 100644
--- a/tools/update-lib-configs.js
+++ b/tools/update-lib-configs.js
@@ -50,10 +50,7 @@ module.exports = {
   parser: require.resolve('vue-eslint-parser'),
   parserOptions: {
     ecmaVersion: 2018,
-    sourceType: 'module',
-    ecmaFeatures: {
-      jsx: true,
-    }
+    sourceType: 'module'
   },
   env: {
     browser: true,


Test patch:
diff --git a/tests/lib/rules/no-lone-template.js b/tests/lib/rules/no-lone-template.js
new file mode 100644
index 00000000..947934fc
--- /dev/null
+++ b/tests/lib/rules/no-lone-template.js
@@ -0,0 +1,181 @@
+/**
+ * @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-lone-template')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2019,
+    sourceType: 'module'
+  }
+})
+
+tester.run('no-lone-template', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-if="foo">...</template>
+        <template v-else-if="bar">...</template>
+        <template v-else>...</template>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-for="e in list">...</template>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-slot>...</template>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <CoolButton>
+         <template slot="foo">...</template>
+        </CoolButton>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <CoolButton>
+         <template slot-scope="foo">...</template>
+        </CoolButton>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <CoolButton>
+         <template scope="foo">...</template>
+        </CoolButton>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template id="a">...</template>
+      </template>
+      `,
+      options: [{ ignoreAccessible: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template :id="a">...</template>
+      </template>
+      `,
+      options: [{ ignoreAccessible: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template ref="b">...</template>
+      </template>
+      `,
+      options: [{ ignoreAccessible: true }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template :ref="b">...</template>
+      </template>
+      `,
+      options: [{ ignoreAccessible: true }]
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template>...</template>
+      </template>
+      `,
+      errors: [
+        {
+          message: '`<template>` require directive.',
+          line: 3,
+          column: 9,
+          endLine: 3,
+          endColumn: 19
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template/>
+      </template>
+      `,
+      errors: ['`<template>` require directive.']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-on:id="a">...</template>
+      </template>
+      `,
+      options: [{ ignoreAccessible: true }],
+      errors: ['`<template>` require directive.']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-bind="id">...</template>
+      </template>
+      `,
+      options: [{ ignoreAccessible: true }],
+      errors: ['`<template>` require directive.']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-bind:[foo]="id">...</template>
+      </template>
+      `,
+      options: [{ ignoreAccessible: true }],
+      errors: ['`<template>` require directive.']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template class="b">...</template>
+      </template>
+      `,
+      options: [{ ignoreAccessible: true }],
+      errors: ['`<template>` require directive.']
+    }
+  ]
+})