Repository: sveltejs/eslint-plugin-svelte
Pull request #1143 (2025-03-18 10:17:51)

fix: stop reporting child properties in `no-unused-props` when the parent object itself is used
close: https://github.com/sveltejs/eslint-plugin-svelte/issues/1142

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 159e4ed7020cc91f0b5614f7a0f1049f412749d7

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name                 | Type  |
| -------------------- | ----- |
| eslint-plugin-svelte | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/sveltejs/eslint-plugin-svelte/new/fix/1142?filename=.changeset/large-mice-battle.md&value=---%0A%22eslint-plugin-svelte%22%3A%20patch%0A---%0A%0Afix%3A%20stop%20reporting%20child%20properties%20in%20%60no-unused-props%60%20when%20the%20parent%20object%20is%20used%0A)


github-actions[bot]: <!-- posted by pkg.pr.new-comment.mjs -->

## Try the Instant Preview in Online Playground

[ESLint Online Playground](https://eslint-online-playground.netlify.app/?overrideDeps=%7B%22eslint-plugin-svelte%22%3A%22https%3A%2F%2Fpkg.pr.new%2Feslint-plugin-svelte%401143%22%7D#eslint-plugin-svelte%20with%20typescript)

## Install the Instant Preview to Your Local

```
npm i https://pkg.pr.new/eslint-plugin-svelte@1143
```

## Published Instant Preview Packages:

- eslint-plugin-svelte: https://pkg.pr.new/eslint-plugin-svelte@1143

[View Commit](https://github.com/sveltejs/eslint-plugin-svelte/commit/159e4ed7020cc91f0b5614f7a0f1049f412749d7)

Patch:
diff --git a/.changeset/floppy-symbols-sing.md b/.changeset/floppy-symbols-sing.md
new file mode 100644
index 0000000..11aa7c6
--- /dev/null
+++ b/.changeset/floppy-symbols-sing.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-svelte': patch
+---
+
+fix: stop reporting child properties in `no-unused-props` when the parent object itself is used
diff --git a/packages/eslint-plugin-svelte/src/rules/no-unused-props.ts b/packages/eslint-plugin-svelte/src/rules/no-unused-props.ts
index 84faa36..5de3133 100644
--- a/packages/eslint-plugin-svelte/src/rules/no-unused-props.ts
+++ b/packages/eslint-plugin-svelte/src/rules/no-unused-props.ts
@@ -156,7 +156,12 @@ export default createRule('no-unused-props', {
 
 			const paths: PropertyPath[] = [];
 			for (const reference of variable.references) {
-				if ('identifier' in reference && reference.identifier.type === 'Identifier') {
+				if (
+					'identifier' in reference &&
+					reference.identifier.type === 'Identifier' &&
+					(reference.identifier.range[0] !== node.range[0] ||
+						reference.identifier.range[1] !== node.range[1])
+				) {
 					const referencePath = getPropertyPath(reference.identifier);
 					paths.push(referencePath);
 				}
@@ -265,11 +270,17 @@ export default createRule('no-unused-props', {
 				if (reportedProps.has(currentPathStr)) continue;
 
 				const propType = typeChecker.getTypeOfSymbol(prop);
-				const isUsedInPath = usedPaths.some((path) => {
-					const usedPath = path.join('.');
-					return usedPath === currentPathStr || usedPath.startsWith(`${currentPathStr}.`);
+
+				const joinedUsedPaths = usedPaths.map((path) => path.join('.'));
+				const isUsedThisInPath = joinedUsedPaths.includes(currentPathStr);
+				const isUsedInPath = joinedUsedPaths.some((path) => {
+					return path.startsWith(`${currentPathStr}.`);
 				});
 
+				if (isUsedThisInPath && !isUsedInPath) {
+					continue;
+				}
+
 				const isUsedInProps = usedProps.has(propName);
 
 				if (!isUsedInPath && !isUsedInProps) {
@@ -282,10 +293,11 @@ export default createRule('no-unused-props', {
 							parent: parentPath.join('.')
 						}
 					});
+					continue;
 				}
 
-				const isUsedNested = usedPaths.some((path) => {
-					return path.join('.').startsWith(`${currentPathStr}.`);
+				const isUsedNested = joinedUsedPaths.some((path) => {
+					return path.startsWith(`${currentPathStr}.`);
 				});
 
 				if (isUsedNested || isUsedInProps) {
@@ -324,6 +336,18 @@ export default createRule('no-unused-props', {
 			return usedProps.size === 0;
 		}
 
+		function normalizeUsedPaths(paths: PropertyPath[]): PropertyPath[] {
+			const normalized: PropertyPath[] = [];
+			for (const path of paths.sort((a, b) => a.length - b.length)) {
+				if (path.length === 0) continue;
+				if (normalized.some((p) => p.every((part, idx) => part === path[idx]))) {
+					continue;
+				}
+				normalized.push(path);
+			}
+			return normalized;
+		}
+
 		return {
 			'VariableDeclaration > VariableDeclarator': (node: TSESTree.VariableDeclarator) => {
 				// Only check $props declarations
@@ -359,7 +383,7 @@ export default createRule('no-unused-props', {
 
 				checkUnusedProperties(
 					propType,
-					usedPaths,
+					normalizeUsedPaths(usedPaths),
 					usedProps,
 					node.id,
 					[],


Test patch:
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props2-input.svelte
new file mode 100644
index 0000000..3fb8790
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props2-input.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+	import GenericPopout from './GenericPopout.svelte';
+
+	let {
+		position
+	}: {
+		position: {
+			x: number;
+			y: number;
+		};
+	} = $props();
+</script>
+
+<GenericPopout {position}>Test</GenericPopout>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props3-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props3-input.svelte
new file mode 100644
index 0000000..7090054
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props3-input.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+	import GenericPopout from './GenericPopout.svelte';
+
+	let {
+		wrapper
+	}: {
+		wrapper: {
+			position: {
+				x: number;
+				y: number;
+			};
+		};
+	} = $props();
+</script>
+
+<GenericPopout position={wrapper.position}>Test</GenericPopout>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props4-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props4-input.svelte
new file mode 100644
index 0000000..15bdb12
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-unused-props/valid/nested-props4-input.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+	import GenericPopout from './GenericPopout.svelte';
+
+	let {
+		wrapper
+	}: {
+		wrapper: {
+			position: {
+				x: number;
+				y: number;
+			};
+		};
+	} = $props();
+</script>
+
+<GenericPopout x={wrapper.position.x}>Test</GenericPopout>
+<GenericPopout position={wrapper.position}>Test</GenericPopout>