Repository: sveltejs/eslint-plugin-svelte (ts)

Issue:
`consistent-selector-style`: False positives with dynamic names in each blocks
### Before You File a Bug Report Please Confirm You Have Done The Following...

- [x] I have tried restarting my IDE and the issue persists.
- [x] I have updated to the latest version of the packages.

### What version of ESLint are you using?

9.26.0

### What version of `eslint-plugin-svelte` are you using?

04ca0d14cbec79a84987c0cee48c62cf4ff98a8f

### What did you do?

```svelte
{#each ["one", "two"] as count}
  <b class={"bold-" + count}>Bold in each</b>
{/each}

<style>
  .bold-one {
    color: red;
  }

  .bold-two {
    color: blue;
  }
</style>
```


### What did you expect to happen?

No error or suggesting to use ID

### What actually happened?

Sufggesting using element type

### Link to **GitHub Repo** with Minimal Reproducible Example

N/A

### Additional comments

Will make a PR

Pull request:
fix(consistent-selector-style): Fixed detections of repeated elements such as in {#each}
Closes #1229

Patch:
diff --git a/.changeset/fast-birds-crash.md b/.changeset/fast-birds-crash.md
new file mode 100644
index 0000000..0e61e51
--- /dev/null
+++ b/.changeset/fast-birds-crash.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-svelte': patch
+---
+
+fix(consistent-selector-style): Fixed detections of repeated elements such as in {#each}
diff --git a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts
index 1f64ee0..0864dc6 100644
--- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts
+++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts
@@ -12,6 +12,7 @@ import {
 	extractExpressionSuffixLiteral
 } from '../utils/expression-affixes.js';
 import { createRule } from '../utils/index.js';
+import { ElementOccurenceCount, elementOccurrenceCount } from '../utils/element-occurences.js';
 
 interface Selections {
 	exact: Map<string, AST.SvelteHTMLElement[]>;
@@ -143,7 +144,7 @@ export default createRule('consistent-selector-style', {
 				if (styleValue === 'class') {
 					return;
 				}
-				if (styleValue === 'id' && canUseIdSelector(selection)) {
+				if (styleValue === 'id' && canUseIdSelector(selection.map(([elem]) => elem))) {
 					context.report({
 						messageId: 'classShouldBeId',
 						loc: styleSelectorNodeLoc(node) as AST.SourceLocation
@@ -285,11 +286,13 @@ function addToArrayMap<T>(
 /**
  * Finds all nodes in selections that could be matched by key
  */
-function matchSelection(selections: Selections, key: string): AST.SvelteHTMLElement[] {
-	const selection = selections.exact.get(key) ?? [];
+function matchSelection(selections: Selections, key: string): [AST.SvelteHTMLElement, boolean][] {
+	const selection = (selections.exact.get(key) ?? []).map<[AST.SvelteHTMLElement, boolean]>(
+		(elem) => [elem, true]
+	);
 	selections.affixes.forEach((nodes, [prefix, suffix]) => {
 		if ((prefix === null || key.startsWith(prefix)) && (suffix === null || key.endsWith(suffix))) {
-			selection.push(...nodes);
+			selection.push(...nodes.map<[AST.SvelteHTMLElement, boolean]>((elem) => [elem, false]));
 		}
 	});
 	return selection;
@@ -299,26 +302,43 @@ function matchSelection(selections: Selections, key: string): AST.SvelteHTMLElem
  * Checks whether a given selection could be obtained using an ID selector
  */
 function canUseIdSelector(selection: AST.SvelteHTMLElement[]): boolean {
-	return selection.length <= 1;
+	return (
+		selection.length === 0 ||
+		(selection.length === 1 &&
+			elementOccurrenceCount(selection[0]) !== ElementOccurenceCount.ZeroToInf)
+	);
 }
 
 /**
  * Checks whether a given selection could be obtained using a type selector
  */
 function canUseTypeSelector(
-	selection: AST.SvelteHTMLElement[],
+	selection: [AST.SvelteHTMLElement, boolean][],
 	typeSelections: Map<string, AST.SvelteHTMLElement[]>
 ): boolean {
-	const types = new Set(selection.map((node) => node.name.name));
+	const types = new Set(selection.map(([node]) => node.name.name));
 	if (types.size > 1) {
 		return false;
 	}
 	if (types.size < 1) {
 		return true;
 	}
+	if (
+		selection.some(
+			([elem, exact]) => !exact && elementOccurrenceCount(elem) === ElementOccurenceCount.ZeroToInf
+		)
+	) {
+		return false;
+	}
 	const type = [...types][0];
 	const typeSelection = typeSelections.get(type);
-	return typeSelection !== undefined && arrayEquals(typeSelection, selection);
+	return (
+		typeSelection !== undefined &&
+		arrayEquals(
+			typeSelection,
+			selection.map(([elem]) => elem)
+		)
+	);
 }
 
 /**
diff --git a/packages/eslint-plugin-svelte/src/utils/element-occurences.ts b/packages/eslint-plugin-svelte/src/utils/element-occurences.ts
new file mode 100644
index 0000000..6bc30fa
--- /dev/null
+++ b/packages/eslint-plugin-svelte/src/utils/element-occurences.ts
@@ -0,0 +1,55 @@
+import type { AST } from 'svelte-eslint-parser';
+
+export enum ElementOccurenceCount {
+	ZeroOrOne,
+	One,
+	ZeroToInf
+}
+
+function multiplyCounts(
+	left: ElementOccurenceCount,
+	right: ElementOccurenceCount
+): ElementOccurenceCount {
+	if (left === ElementOccurenceCount.One) {
+		return right;
+	}
+	if (right === ElementOccurenceCount.One) {
+		return left;
+	}
+	if (left === right) {
+		return left;
+	}
+	return ElementOccurenceCount.ZeroToInf;
+}
+
+function childElementOccurenceCount(parent: AST.SvelteHTMLNode | null): ElementOccurenceCount {
+	if (parent === null) {
+		return ElementOccurenceCount.One;
+	}
+	if (
+		[
+			'SvelteIfBlock',
+			'SvelteElseBlock',
+			'SvelteAwaitBlock',
+			'SvelteAwaitPendingBlock',
+			'SvelteAwaitThenBlock',
+			'SvelteAwaitCatchBlock'
+		].includes(parent.type)
+	) {
+		return ElementOccurenceCount.ZeroOrOne;
+	}
+	if (
+		['SvelteEachBlock', 'SvelteSnippetBlock'].includes(parent.type) ||
+		(parent.type === 'SvelteElement' && parent.kind === 'component')
+	) {
+		return ElementOccurenceCount.ZeroToInf;
+	}
+	return ElementOccurenceCount.One;
+}
+
+export function elementOccurrenceCount(element: AST.SvelteHTMLNode): ElementOccurenceCount {
+	const parentCount =
+		element.parent !== null ? elementOccurrenceCount(element.parent) : ElementOccurenceCount.One;
+	const parentChildCount = childElementOccurenceCount(element.parent);
+	return multiplyCounts(parentCount, parentChildCount);
+}


Test patch:
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/class01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/class01-input.svelte
index b849abc..8109e77 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/class01-input.svelte
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/class01-input.svelte
@@ -10,6 +10,14 @@
 
 <b class:conditional={true}>Text 3</b>
 
+{#each ["one", "two"] as iter}
+  <span class="iterated-each">{iter}</span>
+{/each}
+
+<CustomComponent>
+  <span class="iterated-component">Text 5</span>
+</CustomComponent>
+
 <style>
   .link {
     color: red;
@@ -38,4 +46,12 @@
   .conditional {
     color: red;
   }
+
+  .iterated-each {
+    color: red;
+  }
+
+  .iterated-component {
+    color: red;
+  }
 </style>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/svelte-5/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/svelte-5/_requirements.json
new file mode 100644
index 0000000..4986613
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/svelte-5/_requirements.json
@@ -0,0 +1,3 @@
+{
+	"svelte": ">=5.0.0"
+}
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/svelte-5/class01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/svelte-5/class01-input.svelte
new file mode 100644
index 0000000..a44da90
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/id-class-type/svelte-5/class01-input.svelte
@@ -0,0 +1,11 @@
+<span>Outside</span>
+
+{#snippet iterated()}
+  <span class="iterated-snippet">Text 4</span>
+{/snippet}
+
+<style>
+  .iterated-snippet {
+    color: red;
+  }
+</style>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte
index 5d6b127..2dd97dc 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte
@@ -19,6 +19,10 @@
 
 <a class={derived}>Click me four!</a>
 
+{#each ["one", "two"] as count}
+  <b class={"bold-" + count}>Bold in each</b>
+{/each}
+
 <style>
   .foo-link-one {
     color: red;
@@ -31,4 +35,12 @@
   .foo-link-three {
     color: red;
   }
+
+  .bold-one {
+    color: red;
+  }
+
+  .bold-two {
+    color: blue;
+  }
 </style>