Repository: vuejs/eslint-plugin-vue
Pull request #2348 (2023-12-07 11:15:31)

Fix siblings and interleaving issues reported in #2342
This PR addresses the bugs reported in #2342 

And added tests

felipemelendez: Hi @FloEdelmann, thank you for reporting #2342, I have hopefully addressed those here, please let me know if I missed something. 
FloEdelmann: @felipemelendez Are you still interested in fixing this?
felipemelendez: > @felipemelendez Are you still interested in fixing this?

Yes! Sorry for the delay, I had a family emergency at the start of the year, so I've been distracted to say the least; I'll get back on this soon!
FloEdelmann: No worries, there's more important stuff in life than coding :slightly_smiling_face: 

Patch:
diff --git a/docs/rules/index.md b/docs/rules/index.md
index e718603f..a75fe157 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -223,6 +223,7 @@ For example:
 | [vue/match-component-file-name](./match-component-file-name.md) | require component name property to match its file name | :bulb: | :hammer: |
 | [vue/match-component-import-name](./match-component-import-name.md) | require the registered component name to match the imported component name |  | :warning: |
 | [vue/max-lines-per-block](./max-lines-per-block.md) | enforce maximum number of lines in Vue SFC blocks |  | :warning: |
+| [vue/max-props](./max-props.md) | enforce maximum number of props in Vue component |  | :warning: |
 | [vue/new-line-between-multi-line-property](./new-line-between-multi-line-property.md) | enforce new lines between multi-line properties in Vue components | :wrench: | :lipstick: |
 | [vue/next-tick-style](./next-tick-style.md) | enforce Promise or callback style in `nextTick` | :wrench: | :hammer: |
 | [vue/no-bare-strings-in-template](./no-bare-strings-in-template.md) | disallow the use of bare strings in `<template>` |  | :hammer: |
diff --git a/docs/rules/max-props.md b/docs/rules/max-props.md
new file mode 100644
index 00000000..b6979660
--- /dev/null
+++ b/docs/rules/max-props.md
@@ -0,0 +1,62 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/max-props
+description: enforce maximum number of props in Vue component
+---
+
+# vue/max-props
+
+> enforce maximum number of props in Vue component
+
+- :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 enforces a maximum number of props in a Vue SFC, in order to aid in maintainability and reduce complexity.
+
+## :wrench: Options
+
+This rule takes an object, where you can specify the maximum number of props allowed in a Vue SFC.
+There is one property that can be specified for the object.
+
+- `maxProps` ... Specify the maximum number of props in the `script` block.
+
+### `{ maxProps: 1 }`
+
+<eslint-code-block :rules="{'vue/max-props': ['error', { maxProps: 1 }]}">
+
+```vue
+<!-- ✗ BAD -->
+<template>
+</template>
+
+<script setup>
+defineProps({ 
+  prop1: String,
+  prop2: String,
+})
+</script>
+```
+
+</eslint-code-block>
+
+### `{ maxProps: 5 }`
+
+<eslint-code-block :rules="{'vue/max-props': ['error', { maxProps: 5 }]}">
+
+```vue
+<!-- ✓ GOOD -->
+<script>
+defineProps({
+  prop1: String
+})
+</script>
+```
+
+</eslint-code-block>
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/max-props.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/max-props.js)
diff --git a/lib/configs/flat/base.js b/lib/configs/flat/base.js
index 6f745c33..eb2393ed 100644
--- a/lib/configs/flat/base.js
+++ b/lib/configs/flat/base.js
@@ -6,7 +6,7 @@
 const globals = require('globals')
 module.exports = [
   {
-    name: 'vue:base:setup',
+    name: 'vue/base/setup',
     plugins: {
       get vue() {
         return require('../../index')
@@ -18,7 +18,7 @@ module.exports = [
     }
   },
   {
-    name: 'vue:base:setup-for-vue',
+    name: 'vue/base/setup-for-vue',
     files: ['*.vue', '**/*.vue'],
     plugins: {
       get vue() {
diff --git a/lib/configs/flat/vue2-essential.js b/lib/configs/flat/vue2-essential.js
index 1de89dbc..0d092041 100644
--- a/lib/configs/flat/vue2-essential.js
+++ b/lib/configs/flat/vue2-essential.js
@@ -9,7 +9,7 @@ const config = require('./base.js')
 module.exports = [
   ...config,
   {
-    name: 'vue:vue2-essential:rules',
+    name: 'vue/vue2-essential/rules',
     rules: {
       'vue/multi-word-component-names': 'error',
       'vue/no-arrow-functions-in-watch': 'error',
diff --git a/lib/configs/flat/vue2-recommended.js b/lib/configs/flat/vue2-recommended.js
index 6ccde545..0d98a4dd 100644
--- a/lib/configs/flat/vue2-recommended.js
+++ b/lib/configs/flat/vue2-recommended.js
@@ -9,7 +9,7 @@ const config = require('./vue2-strongly-recommended.js')
 module.exports = [
   ...config,
   {
-    name: 'vue:vue2-recommended:rules',
+    name: 'vue/vue2-recommended/rules',
     rules: {
       'vue/attributes-order': 'warn',
       'vue/component-tags-order': 'warn',
diff --git a/lib/configs/flat/vue2-strongly-recommended.js b/lib/configs/flat/vue2-strongly-recommended.js
index dc45fea3..e4913f40 100644
--- a/lib/configs/flat/vue2-strongly-recommended.js
+++ b/lib/configs/flat/vue2-strongly-recommended.js
@@ -9,7 +9,7 @@ const config = require('./vue2-essential.js')
 module.exports = [
   ...config,
   {
-    name: 'vue:vue2-strongly-recommended:rules',
+    name: 'vue/vue2-strongly-recommended/rules',
     rules: {
       'vue/attribute-hyphenation': 'warn',
       'vue/component-definition-name-casing': 'warn',
diff --git a/lib/configs/flat/vue3-essential.js b/lib/configs/flat/vue3-essential.js
index 595af5f1..18d8c857 100644
--- a/lib/configs/flat/vue3-essential.js
+++ b/lib/configs/flat/vue3-essential.js
@@ -9,7 +9,7 @@ const config = require('./base.js')
 module.exports = [
   ...config,
   {
-    name: 'vue:essential:rules',
+    name: 'vue/essential/rules',
     rules: {
       'vue/multi-word-component-names': 'error',
       'vue/no-arrow-functions-in-watch': 'error',
diff --git a/lib/configs/flat/vue3-recommended.js b/lib/configs/flat/vue3-recommended.js
index 8ab8aa46..523718f3 100644
--- a/lib/configs/flat/vue3-recommended.js
+++ b/lib/configs/flat/vue3-recommended.js
@@ -9,7 +9,7 @@ const config = require('./vue3-strongly-recommended.js')
 module.exports = [
   ...config,
   {
-    name: 'vue:recommended:rules',
+    name: 'vue/recommended/rules',
     rules: {
       'vue/attributes-order': 'warn',
       'vue/component-tags-order': 'warn',
diff --git a/lib/configs/flat/vue3-strongly-recommended.js b/lib/configs/flat/vue3-strongly-recommended.js
index 51134ebc..fee1d3bb 100644
--- a/lib/configs/flat/vue3-strongly-recommended.js
+++ b/lib/configs/flat/vue3-strongly-recommended.js
@@ -9,7 +9,7 @@ const config = require('./vue3-essential.js')
 module.exports = [
   ...config,
   {
-    name: 'vue:strongly-recommended:rules',
+    name: 'vue/strongly-recommended/rules',
     rules: {
       'vue/attribute-hyphenation': 'warn',
       'vue/component-definition-name-casing': 'warn',
diff --git a/lib/index.js b/lib/index.js
index 956eeaa4..1bfacebe 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -79,6 +79,7 @@ const plugin = {
     'max-attributes-per-line': require('./rules/max-attributes-per-line'),
     'max-len': require('./rules/max-len'),
     'max-lines-per-block': require('./rules/max-lines-per-block'),
+    'max-props': require('./rules/max-props'),
     'multi-word-component-names': require('./rules/multi-word-component-names'),
     'multiline-html-element-content-newline': require('./rules/multiline-html-element-content-newline'),
     'multiline-ternary': require('./rules/multiline-ternary'),
diff --git a/lib/rules/max-props.js b/lib/rules/max-props.js
new file mode 100644
index 00000000..a7f44b8e
--- /dev/null
+++ b/lib/rules/max-props.js
@@ -0,0 +1,67 @@
+/**
+ * @author kevsommer Kevin Sommer
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+const utils = require('../utils')
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'enforce maximum number of props in Vue component',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/max-props.html'
+    },
+    fixable: null,
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          maxProps: {
+            type: 'integer',
+            minimum: 1
+          }
+        },
+        additionalProperties: false,
+        minProperties: 1
+      }
+    ],
+    messages: {
+      tooManyProps:
+        'Component has too many props ({{propCount}}). Maximum allowed is {{limit}}.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /** @type {Record<string, number>} */
+    const option = context.options[0] || {}
+
+    /**
+     * @param {import('../utils').ComponentProp[]} props
+     */
+    function checkMaxNumberOfProps(props) {
+      if (props.length > option.maxProps && props[0].node) {
+        context.report({
+          node: props[0].node,
+          messageId: 'tooManyProps',
+          data: {
+            propCount: props.length,
+            limit: option.maxProps
+          }
+        })
+      }
+    }
+
+    return utils.compositingVisitors(
+      utils.executeOnVue(context, (obj) => {
+        checkMaxNumberOfProps(utils.getComponentPropsFromOptions(obj))
+      }),
+      utils.defineScriptSetupVisitor(context, {
+        onDefinePropsEnter(_, props) {
+          checkMaxNumberOfProps(props)
+        }
+      })
+    )
+  }
+}
diff --git a/lib/rules/v-if-else-key.js b/lib/rules/v-if-else-key.js
index 75add9ca..a83d436b 100644
--- a/lib/rules/v-if-else-key.js
+++ b/lib/rules/v-if-else-key.js
@@ -25,6 +25,28 @@ const casing = require('../utils/casing')
  * @property {VElement | null} else - The node associated with the 'v-else' directive, or null if there isn't one.
  */
 
+/**
+ * Checks if a given node has sibling nodes of the same type that are also conditionally rendered.
+ * This is used to determine if multiple instances of the same component are being conditionally
+ * rendered within the same parent scope.
+ *
+ * @param {VElement} node - The Vue component node to check for conditional rendering siblings.
+ * @param {string} componentName - The name of the component to check for sibling instances.
+ * @returns {boolean} True if there are sibling nodes of the same type and conditionally rendered, false otherwise.
+ */
+const hasConditionalRenderedSiblings = (node, componentName) => {
+  if (!node.parent || node.parent.type !== 'VElement') {
+    return false
+  }
+  return node.parent.children.some(
+    (sibling) =>
+      sibling !== node &&
+      sibling.type === 'VElement' &&
+      sibling.rawName === componentName &&
+      hasConditionalDirective(sibling)
+  )
+}
+
 /**
  * Checks for the presence of a 'key' attribute in the given node. If the 'key' attribute is missing
  * and the node is part of a conditional family a report is generated.
@@ -44,26 +66,31 @@ const checkForKey = (
   uniqueKey,
   conditionalFamilies
 ) => {
-  if (node.parent && node.parent.type === 'VElement') {
-    const conditionalFamily = conditionalFamilies.get(node.parent)
+  if (
+    !node.parent ||
+    node.parent.type !== 'VElement' ||
+    !hasConditionalRenderedSiblings(node, componentName)
+  ) {
+    return
+  }
 
-    if (
-      conditionalFamily &&
-      (utils.hasDirective(node, 'bind', 'key') ||
-        utils.hasAttribute(node, 'key') ||
-        !hasConditionalDirective(node) ||
-        !(conditionalFamily.else || conditionalFamily.elseIf.length > 0))
-    ) {
-      return
-    }
+  const conditionalFamily = conditionalFamilies.get(node.parent)
+
+  if (!conditionalFamily || utils.hasAttribute(node, 'key')) {
+    return
+  }
+
+  const needsKey =
+    conditionalFamily.if === node ||
+    conditionalFamily.else === node ||
+    conditionalFamily.elseIf.includes(node)
 
+  if (needsKey) {
     context.report({
       node: node.startTag,
       loc: node.startTag.loc,
       messageId: 'requireKey',
-      data: {
-        componentName
-      },
+      data: { componentName },
       fix(fixer) {
         const afterComponentNamePosition =
           node.startTag.range[0] + componentName.length + 1
@@ -190,13 +217,18 @@ module.exports = {
           if (node.parent && node.parent.type === 'VElement') {
             let conditionalFamily = conditionalFamilies.get(node.parent)
 
-            if (conditionType === 'if' && !conditionalFamily) {
+            if (!conditionalFamily) {
               conditionalFamily = createConditionalFamily(node)
               conditionalFamilies.set(node.parent, conditionalFamily)
             }
 
             if (conditionalFamily) {
               switch (conditionType) {
+                case 'if': {
+                  conditionalFamily = createConditionalFamily(node)
+                  conditionalFamilies.set(node.parent, conditionalFamily)
+                  break
+                }
                 case 'else-if': {
                   conditionalFamily.elseIf.push(node)
                   break
diff --git a/tools/update-lib-flat-configs.js b/tools/update-lib-flat-configs.js
index 9d1671b9..d25ba281 100644
--- a/tools/update-lib-flat-configs.js
+++ b/tools/update-lib-flat-configs.js
@@ -58,7 +58,7 @@ function formatCategory(category) {
 const globals = require('globals')
 module.exports = [
   {
-    name: 'vue:base:setup',
+    name: 'vue/base/setup',
     plugins: {
       get vue() {
         return require('../../index')
@@ -70,7 +70,7 @@ module.exports = [
     }
   },
   {
-    name: 'vue:base:setup-for-vue',
+    name: 'vue/base/setup-for-vue',
     files: ['*.vue', '**/*.vue'],
     plugins: {
       get vue() {
@@ -99,7 +99,7 @@ const config = require('./${extendsCategoryId}.js')
 module.exports = [
   ...config,
   {
-    name: 'vue:${category.categoryId.replace(/^vue3-/u, '')}:rules',
+    name: 'vue/${category.categoryId.replace(/^vue3-/u, '')}/rules',
     rules: ${formatRules(category.rules, category.categoryId)},
   }
 ]


Test patch:
diff --git a/tests/lib/rules/max-props.js b/tests/lib/rules/max-props.js
new file mode 100644
index 00000000..1dd42a03
--- /dev/null
+++ b/tests/lib/rules/max-props.js
@@ -0,0 +1,162 @@
+/**
+ * @author kevsommer Kevin Sommer
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('../../eslint-compat').RuleTester
+const rule = require('../../../lib/rules/max-props')
+
+const tester = new RuleTester({
+  languageOptions: {
+    parser: require('vue-eslint-parser'),
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('max-props', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      defineProps({ prop1: '', prop2: '' })
+      </script>
+      `,
+      options: [{ maxProps: 5 }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        props: {
+          prop1: String,
+          prop2: String
+        }
+      }
+      </script>
+      `,
+      options: [{ maxProps: 5 }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        props: {
+          prop1: String,
+          prop2: String,
+          prop3: String,
+          prop4: String,
+          prop5: String
+        }
+      }
+      </script>
+      `,
+      options: [{ maxProps: 5 }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        props: {}
+      }
+      </script>
+      `,
+      options: [{ maxProps: 5 }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      defineProps({})
+      </script>
+      `,
+      options: [{ maxProps: 5 }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      </script>
+      `,
+      options: [{ maxProps: 5 }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+      defineProps<{ prop1: string, prop2: string }>();
+      </script>
+      `,
+      options: [{ maxProps: 5 }],
+      languageOptions: {
+        parser: require('vue-eslint-parser'),
+        parserOptions: {
+          parser: require.resolve('@typescript-eslint/parser')
+        }
+      }
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      defineProps({ prop1: '', prop2: '' })
+      </script>
+      `,
+      options: [{ maxProps: 1 }],
+      errors: [
+        {
+          message: 'Component has too many props (2). Maximum allowed is 1.',
+          line: 3
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        props: {
+          prop1: String,
+          prop2: String
+        }
+      }
+      </script>
+      `,
+      options: [{ maxProps: 1 }],
+      errors: [
+        {
+          message: 'Component has too many props (2). Maximum allowed is 1.',
+          line: 5
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+      defineProps<{ prop1: string, prop2: string, prop3: string }>();
+      </script>
+      `,
+      options: [{ maxProps: 2 }],
+      languageOptions: {
+        parser: require('vue-eslint-parser'),
+        parserOptions: {
+          parser: require.resolve('@typescript-eslint/parser')
+        }
+      },
+      errors: [
+        {
+          message: 'Component has too many props (3). Maximum allowed is 2.',
+          line: 3
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/v-if-else-key.js b/tests/lib/rules/v-if-else-key.js
index 16d975a0..46c42f52 100644
--- a/tests/lib/rules/v-if-else-key.js
+++ b/tests/lib/rules/v-if-else-key.js
@@ -127,6 +127,90 @@ tester.run('v-if-else-key', rule, {
         }
         </script>
         `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div>
+            <div>
+              <ComponentA v-if="foo" /> 
+              <ComponentB v-else-if="bar" /> 
+            </div>
+            <div>
+              <div>
+                <ComponentA v-if="foo" />
+                <ComponentB v-else-if="baz" /> 
+              </div>
+              <div>
+                <ComponentA v-if="foo" />
+                <ComponentB v-else-if="baz" />
+              </div>
+            </div>
+            <div>
+              <ComponentA v-if="foo" />
+              <ComponentB v-else /> 
+            </div>
+            <div>
+              <div v-if="foo" />
+              <ComponentB v-else />
+            </div>
+          </div>
+        </template>
+        <script>
+        export default {
+          components: {
+            ComponentA,
+            ComponentB
+          }
+        }
+        </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div>
+            <div>
+              <CustomComponent v-if="foo" />
+              <div v-else />
+            </div>
+      
+            <CustomComponent v-if="bar" />
+          </div>
+        </template>
+        <script>
+        export default {
+            components: {
+                CustomComponent
+            }
+        }
+        </script>
+        `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <template>
+          <tile>
+            <template v-if="state === 'foo'">
+                <ComponentA>…</ComponentA>
+                <ComponentB>…</ComponentB>
+            </template>
+            <ComponentA v-else-if="state === 'bar'" key="a">…</ComponentA>
+            <ComponentA v-else-if="state === 'bar'" key="b">…</ComponentA>
+          </tile>
+        </template>
+        <script>
+        export default {
+            components: {
+                ComponentA,
+                ComponentB
+            }
+        }
+        </script>
+        `
     }
   ],
   invalid: [
@@ -424,6 +508,74 @@ tester.run('v-if-else-key', rule, {
           line: 6
         }
       ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div>
+            <ComponentA v-if="foo" />
+            <ComponentA v-else />
+
+            <ComponentA v-if="bar" />
+            <ComponentA v-else-if="baz" />
+            <ComponentA v-else />
+          </div>
+        </template>
+        <script>
+        export default {
+          components: {
+            ComponentA
+          }
+        }
+        </script>
+        `,
+      output: `
+        <template>
+          <div>
+            <ComponentA key="component-a-1" v-if="foo" />
+            <ComponentA key="component-a-2" v-else />
+
+            <ComponentA key="component-a-3" v-if="bar" />
+            <ComponentA key="component-a-4" v-else-if="baz" />
+            <ComponentA key="component-a-5" v-else />
+          </div>
+        </template>
+        <script>
+        export default {
+          components: {
+            ComponentA
+          }
+        }
+        </script>
+        `,
+      errors: [
+        {
+          message:
+            "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+          line: 4
+        },
+        {
+          message:
+            "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+          line: 5
+        },
+        {
+          message:
+            "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+          line: 7
+        },
+        {
+          message:
+            "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+          line: 8
+        },
+        {
+          message:
+            "Conditionally rendered repeated component 'ComponentA' expected to have a 'key' attribute.",
+          line: 9
+        }
+      ]
     }
   ]
 })