Repository: rrd108/vue-mess-detector
Pull request #150 (2024-08-18 05:14:04)

feat: add new rule rrd - vfor with index key w tests


Patch:
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 1895748..23d2ec4 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -79,6 +79,7 @@ export default defineConfig({
                 { text: 'Script Length', link: '/rules/rrd/script-length' },
                 { text: 'Short Variable Name', link: '/rules/rrd/short-variable-name' },
                 { text: 'Too Many Props', link: '/rules/rrd/too-many-props' },
+                { text: 'VFor with Index Key', link: '/rules/rrd/v-for-with-index-key' }
               ] },
             ],
           },
diff --git a/docs/rules/rrd/index.md b/docs/rules/rrd/index.md
index c3732f6..2bca91a 100644
--- a/docs/rules/rrd/index.md
+++ b/docs/rules/rrd/index.md
@@ -16,6 +16,7 @@ These ruleset is the most opinionated with rules that are not part of the *offic
 - [Script Length](./script-length.md)
 - [Short Variable Name](./short-variable-name.md)
 - [Too Many Props](./too-many-props.md)
+- [VFor with Index Key](./v-for-with-index-key.md)
 
 ## ❓ Why it's good to follow this ruleset?
 
diff --git a/docs/rules/rrd/v-for-with-index-key.md b/docs/rules/rrd/v-for-with-index-key.md
new file mode 100644
index 0000000..9ec6cd6
--- /dev/null
+++ b/docs/rules/rrd/v-for-with-index-key.md
@@ -0,0 +1,38 @@
+# VFor with Index Key
+
+Checks if there are indexes being used as `key` in `v-for`.
+
+## ❓ Why it's good to follow this rule?
+
+- **Avoid Rendering Issues:** Using unique keys prevents issues with item reordering and re-rendering, ensuring stable and predictable updates.
+- **Improve Performance:** Unique keys help Vue.js efficiently update and manage list items, leading to better performance.
+- **Enhance Debugging:** Unique keys make it easier to track and resolve rendering issues, simplifying the debugging process.
+
+
+## 😱 Examples of code for which this rule will throw a warning
+
+::: warning
+The following code contains a `v-for` directive that uses the loop index as the `:key`, which is discouraged because it can cause rendering issues when the array changes.
+:::
+
+```vue
+<template>
+  <div v-for="(item, idx) in items" :key="idx">
+    {{ item.name }}
+  </div>
+</template>
+```
+
+## 🤩 How to fix it?
+
+::: tip
+To ensure stable rendering and improve performance, use a unique identifier from the items themselves as the `:key`. This could be an id or another unique property.
+:::
+
+```vue
+<template>
+  <div v-for="item in items" :key="item.id">
+    {{ item.name }}
+  </div>
+</template>
+```
\ No newline at end of file
diff --git a/src/rules/rrd/vForWithIndexKey.ts b/src/rules/rrd/vForWithIndexKey.ts
new file mode 100644
index 0000000..8314310
--- /dev/null
+++ b/src/rules/rrd/vForWithIndexKey.ts
@@ -0,0 +1,55 @@
+import type { SFCTemplateBlock } from '@vue/compiler-sfc'
+import { BG_RESET, BG_WARN, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+import getLineNumber from '../getLineNumber'
+import type { FileCheckResult, Offense } from '../../types'
+import { createRegExp, whitespace, oneOrMore, wordChar, global } from 'magic-regexp'
+
+const results: FileCheckResult[] = []
+
+const checkVForWithIndexKey = (template: SFCTemplateBlock | null, filePath: string) => {
+  if (!template) {
+    return
+  }
+ 
+  const vForRegex = createRegExp('v-for="(', whitespace.times.any(), oneOrMore(wordChar).grouped(), whitespace.times.any(), ',', whitespace.times.any(), oneOrMore(wordChar).grouped(), whitespace.times.any(), ')', oneOrMore(whitespace), 'in', oneOrMore(whitespace), oneOrMore(wordChar).grouped(), [global])
+  const keyRegex = createRegExp(':key="', whitespace.times.any(), oneOrMore(wordChar).grouped(), whitespace.times.any(), '"', [global])
+
+  const matches = [...template.content.matchAll(vForRegex)]
+  const keyMatches = [...template.content.matchAll(keyRegex)]
+
+  matches.forEach((match) => {
+    const [_, item, index, array] = match;
+
+    keyMatches.forEach((keyMatch) => {
+      const key = keyMatch[1];
+
+      if (key === index) {
+        const lineNumber = getLineNumber(template.content.trim(), key as string);
+        results.push({
+          filePath,
+          message: `line #${lineNumber} ${BG_WARN}index is being used as :key in v-for${BG_RESET}`
+        })
+      }
+    })
+  })
+}
+
+const reportVForWithIndexKey = () => {
+  const offenses: Offense[] = []
+
+  if (results.length > 0) {
+    results.forEach((result) => {
+      offenses.push({
+        file: result.filePath,
+        rule: `${TEXT_INFO}rrd ~ VFor With Index Key${TEXT_RESET}`,
+        description: `👉 ${TEXT_WARN}Avoid using index as key in v-for loops.${TEXT_RESET} See: https://`,
+        message: `${result.message} 🚨`,
+      })
+    })
+  }
+  return offenses
+}
+
+const resetVForWithIndexKey = () => (results.length = 0)
+
+export { checkVForWithIndexKey, reportVForWithIndexKey, resetVForWithIndexKey }
\ No newline at end of file
diff --git a/src/rules/rules.ts b/src/rules/rules.ts
index d33bccf..b303779 100644
--- a/src/rules/rules.ts
+++ b/src/rules/rules.ts
@@ -40,7 +40,8 @@ export const RULES = {
     'propsDrilling',
     'scriptLength',
     'shortVariableName',
-    'tooManyProps'
+    'tooManyProps',
+    'vForWithIndexKey'
   ]
 };
 export type RuleType = typeof RULES
diff --git a/src/rulesCheck.ts b/src/rulesCheck.ts
index cd9de0c..5e7087f 100644
--- a/src/rulesCheck.ts
+++ b/src/rulesCheck.ts
@@ -33,6 +33,7 @@ import { checkMagicNumbers } from './rules/rrd/magicNumbers'
 import { checkMultiAttributeElements } from './rules/vue-strong/multiAttributeElements'
 import { checkIfWithoutCurlyBraces } from './rules/rrd/ifWithoutCurlyBraces'
 import { checkNestedTernary } from './rules/rrd/nestedTernary'
+import { checkVForWithIndexKey } from './rules/rrd/vForWithIndexKey'
 
 export const checkRules = (descriptor: SFCDescriptor, filePath: string, apply: Array<RuleSetType>) => {
   const script = descriptor.scriptSetup || descriptor.script
@@ -101,8 +102,9 @@ export const checkRules = (descriptor: SFCDescriptor, filePath: string, apply: A
     checkTooManyProps(script, filePath)
 
     if (isVueFile) {
-      checkPlainScript(descriptor.script, filePath)
       checkHtmlLink(descriptor.template, filePath)
+      checkPlainScript(descriptor.script, filePath)
+      checkVForWithIndexKey(descriptor.template, filePath)
     }
   }
 }
diff --git a/src/rulesReport.ts b/src/rulesReport.ts
index af7dc45..c9a9c15 100644
--- a/src/rulesReport.ts
+++ b/src/rulesReport.ts
@@ -32,6 +32,7 @@ import { reportMagicNumbers } from './rules/rrd/magicNumbers'
 import { reportMultiAttributeElements } from './rules/vue-strong/multiAttributeElements'
 import { reportElementSelectorsWithScoped } from './rules/vue-caution/elementSelectorsWithScoped'
 import { reportNestedTernary } from './rules/rrd/nestedTernary'
+import { reportVForWithIndexKey } from './rules/rrd/vForWithIndexKey'
 import { BG_ERR } from './rules/asceeCodes'
 
 export const reportRules = (groupBy: GroupBy) => {
@@ -97,6 +98,7 @@ export const reportRules = (groupBy: GroupBy) => {
   processOffenses(reportScriptLength)
   processOffenses(reportShortVariableName)
   processOffenses(reportTooManyProps)
+  processOffenses(reportVForWithIndexKey)
 
   const health: { file: string, errors: number, warnings: number }[] = []
 


Test patch:
diff --git a/src/rules/rrd/vForWithIndexKey.test.ts b/src/rules/rrd/vForWithIndexKey.test.ts
new file mode 100644
index 0000000..d73e640
--- /dev/null
+++ b/src/rules/rrd/vForWithIndexKey.test.ts
@@ -0,0 +1,101 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { SFCTemplateBlock } from '@vue/compiler-sfc'
+import { BG_RESET, BG_WARN, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+import { checkVForWithIndexKey, reportVForWithIndexKey, resetVForWithIndexKey } from './vForWithIndexKey'
+
+describe('checkVForWithIndexKey', () => {
+  beforeEach(() => {
+    resetVForWithIndexKey()
+  })
+
+  it('should not report files with a unique identifier as key', () => {
+    let template = {
+      content: `
+        <template>
+          <div v-for="item in products" :key="item.id">{{ item.name }}</div>
+        </template>
+      `
+    } as SFCTemplateBlock
+    const fileName = 'vForWithIndexKey.vue'
+    checkVForWithIndexKey(template, fileName)
+    expect(reportVForWithIndexKey().length).toBe(0)
+    expect(reportVForWithIndexKey()).toStrictEqual([])
+  })
+
+  it('should not report files with index where using a unique identifier as key', () => {
+    let template = {
+      content: `
+        <template>
+          <div v-for="(item, index) in products" :key="item.id">{{ item.name }}</div>
+        </template>
+      `
+    } as SFCTemplateBlock
+    const fileName = 'vForWithIndexKey.vue'
+    checkVForWithIndexKey(template, fileName)
+    expect(reportVForWithIndexKey().length).toBe(0)
+    expect(reportVForWithIndexKey()).toStrictEqual([])
+  })
+
+  it('should report files with index as key', () => {
+    const template = {
+        content: `
+          <template>
+            <div v-for="(item, index) in products" :key="index">
+              <p>{{ item.name }}</p>
+            </div>
+          </template>
+        `
+    } as SFCTemplateBlock
+    const fileName = 'vForWithIndexKey-problem.vue'
+    checkVForWithIndexKey(template, fileName)
+    expect(reportVForWithIndexKey().length).toBe(1)
+    expect(reportVForWithIndexKey()).toStrictEqual([{
+      file: fileName,
+      rule: `${TEXT_INFO}rrd ~ VFor With Index Key${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Avoid using index as key in v-for loops.${TEXT_RESET} See: https://`,
+      message: `line #2 ${BG_WARN}index is being used as :key in v-for${BG_RESET} 🚨`,
+    }])
+  })
+
+  it('should report files with index but using other names as key', () => {
+    const template = {
+        content: `
+          <template>
+            <div v-for="(item, idx) in products" :key="idx">
+              <p>{{ item.name }}</p>
+            </div>
+          </template>
+        `
+    } as SFCTemplateBlock
+    const fileName = 'vForWithIndexKey-problem.vue'
+    checkVForWithIndexKey(template, fileName)
+    expect(reportVForWithIndexKey().length).toBe(1)
+    expect(reportVForWithIndexKey()).toStrictEqual([{
+      file: fileName,
+      rule: `${TEXT_INFO}rrd ~ VFor With Index Key${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Avoid using index as key in v-for loops.${TEXT_RESET} See: https://`,
+      message: `line #2 ${BG_WARN}index is being used as :key in v-for${BG_RESET} 🚨`,
+    }])
+  })
+
+  it('should report files with index but using other names as key v2', () => {
+    const template = {
+        content: `
+          <template>
+            <div v-for="(item, i) in products" :key="i">
+              <p>{{ item.name }}</p>
+            </div>
+          </template>
+        `
+    } as SFCTemplateBlock
+    const fileName = 'vForWithIndexKey-problem.vue'
+    checkVForWithIndexKey(template, fileName)
+    expect(reportVForWithIndexKey().length).toBe(1)
+    expect(reportVForWithIndexKey()).toStrictEqual([{
+      file: fileName,
+      rule: `${TEXT_INFO}rrd ~ VFor With Index Key${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Avoid using index as key in v-for loops.${TEXT_RESET} See: https://`,
+      message: `line #2 ${BG_WARN}index is being used as :key in v-for${BG_RESET} 🚨`,
+    }])
+  })
+})