Skip to content

Commit 5ba5c3e

Browse files
authored
fix(no-unused-props): validate spread operator properly (#1378)
1 parent 27cf677 commit 5ba5c3e

12 files changed

+192
-25
lines changed

.changeset/real-books-stare.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-svelte': patch
3+
---
4+
5+
fix(no-unused-props): validate spread operator properly

packages/eslint-plugin-svelte/src/rules/no-unused-props.ts

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type ts from 'typescript';
55
import { findVariable } from '../utils/ast-utils.js';
66
import { toRegExp } from '../utils/regexp.js';
77
import { normalize } from 'path';
8+
import type { AST as SvAST } from 'svelte-eslint-parser';
89

910
type PropertyPathArray = string[];
1011
type DeclaredPropertyNames = Set<{ originalName: string; aliasName: string }>;
@@ -130,49 +131,66 @@ export default createRule('no-unused-props', {
130131
/**
131132
* Extracts property paths from member expressions.
132133
*/
133-
function getPropertyPath(node: TSESTree.Identifier): PropertyPathArray {
134+
function getPropertyPath(node: TSESTree.Identifier): {
135+
paths: PropertyPathArray;
136+
isSpread: boolean;
137+
} {
134138
const paths: PropertyPathArray = [];
135-
let currentNode: TSESTree.Node = node;
136-
let parentNode: TSESTree.Node | null = currentNode.parent ?? null;
137-
139+
let isSpread = false;
140+
let currentNode: TSESTree.Node | SvAST.SvelteSpreadAttribute = node;
141+
let parentNode: TSESTree.Node | SvAST.SvelteSpreadAttribute | null =
142+
currentNode.parent ?? null;
138143
while (parentNode) {
139144
if (parentNode.type === 'MemberExpression' && parentNode.object === currentNode) {
140145
const property = parentNode.property;
141146
if (property.type === 'Identifier') {
142147
paths.push(property.name);
143148
} else if (property.type === 'Literal' && typeof property.value === 'string') {
144149
paths.push(property.value);
145-
} else {
146-
break;
147150
}
151+
} else {
152+
if (parentNode.type === 'SpreadElement' || parentNode.type === 'SvelteSpreadAttribute') {
153+
isSpread = true;
154+
}
155+
break;
148156
}
157+
149158
currentNode = parentNode;
150-
parentNode = currentNode.parent ?? null;
159+
parentNode = (currentNode.parent as TSESTree.Node | SvAST.SvelteSpreadAttribute) ?? null;
151160
}
152161

153-
return paths;
162+
return { paths, isSpread };
154163
}
155164

156165
/**
157166
* Finds all property access paths for a given variable.
158167
*/
159-
function getUsedNestedPropertyPathsArray(node: TSESTree.Identifier): PropertyPathArray[] {
168+
function getUsedNestedPropertyPathsArray(node: TSESTree.Identifier): {
169+
paths: PropertyPathArray[];
170+
spreadPaths: PropertyPathArray[];
171+
} {
160172
const variable = findVariable(context, node);
161-
if (!variable) return [];
173+
if (!variable) return { paths: [], spreadPaths: [] };
162174

163175
const pathsArray: PropertyPathArray[] = [];
176+
const spreadPathsArray: PropertyPathArray[] = [];
164177
for (const reference of variable.references) {
165178
if (
166179
'identifier' in reference &&
167180
reference.identifier.type === 'Identifier' &&
168181
(reference.identifier.range[0] !== node.range[0] ||
169182
reference.identifier.range[1] !== node.range[1])
170183
) {
171-
const referencePath = getPropertyPath(reference.identifier);
172-
pathsArray.push(referencePath);
184+
const { paths, isSpread } = getPropertyPath(reference.identifier);
185+
if (isSpread) {
186+
spreadPathsArray.push(paths);
187+
} else {
188+
pathsArray.push(paths);
189+
}
173190
}
174191
}
175-
return pathsArray;
192+
193+
return { paths: pathsArray, spreadPaths: spreadPathsArray };
176194
}
177195

178196
/**
@@ -239,6 +257,7 @@ export default createRule('no-unused-props', {
239257
function checkUnusedProperties({
240258
propsType,
241259
usedPropertyPaths,
260+
usedSpreadPropertyPaths,
242261
declaredPropertyNames,
243262
reportNode,
244263
parentPath,
@@ -247,6 +266,7 @@ export default createRule('no-unused-props', {
247266
}: {
248267
propsType: ts.Type;
249268
usedPropertyPaths: string[];
269+
usedSpreadPropertyPaths: string[];
250270
declaredPropertyNames: DeclaredPropertyNames;
251271
reportNode: TSESTree.Node;
252272
parentPath: string[];
@@ -273,6 +293,7 @@ export default createRule('no-unused-props', {
273293
checkUnusedProperties({
274294
propsType: propsBaseType,
275295
usedPropertyPaths,
296+
usedSpreadPropertyPaths,
276297
declaredPropertyNames,
277298
reportNode,
278299
parentPath,
@@ -290,13 +311,17 @@ export default createRule('no-unused-props', {
290311
if (shouldIgnoreProperty(propName)) continue;
291312

292313
const currentPath = [...parentPath, propName];
293-
const currentPathStr = [...parentPath, propName].join('.');
314+
const currentPathStr = currentPath.join('.');
294315

295316
if (reportedPropertyPaths.has(currentPathStr)) continue;
296317

297318
const propType = typeChecker.getTypeOfSymbol(prop);
298319

299-
const isUsedThisInPath = usedPropertyPaths.includes(currentPathStr);
320+
const isUsedThisInPath =
321+
usedPropertyPaths.includes(currentPathStr) ||
322+
usedSpreadPropertyPaths.some((path) => {
323+
return path === '' || path === currentPathStr || path.startsWith(`${currentPathStr}.`);
324+
});
300325
const isUsedInPath = usedPropertyPaths.some((path) => {
301326
return path.startsWith(`${currentPathStr}.`);
302327
});
@@ -330,6 +355,7 @@ export default createRule('no-unused-props', {
330355
checkUnusedProperties({
331356
propsType: propType,
332357
usedPropertyPaths,
358+
usedSpreadPropertyPaths,
333359
declaredPropertyNames,
334360
reportNode,
335361
parentPath: currentPath,
@@ -370,7 +396,6 @@ export default createRule('no-unused-props', {
370396
): PropertyPathArray[] {
371397
const normalized: PropertyPathArray[] = [];
372398
for (const path of paths.sort((a, b) => a.length - b.length)) {
373-
if (path.length === 0) continue;
374399
if (normalized.some((p) => p.every((part, idx) => part === path[idx]))) {
375400
continue;
376401
}
@@ -398,7 +423,8 @@ export default createRule('no-unused-props', {
398423
if (!tsNode || !tsNode.type) return;
399424

400425
const propsType = typeChecker.getTypeFromTypeNode(tsNode.type);
401-
let usedPropertyPathsArray: PropertyPathArray[] = [];
426+
const usedPropertyPathsArray: PropertyPathArray[] = [];
427+
const usedSpreadPropertyPathsArray: PropertyPathArray[] = [];
402428
let declaredPropertyNames: DeclaredPropertyNames = new Set();
403429

404430
if (node.id.type === 'ObjectPattern') {
@@ -416,21 +442,28 @@ export default createRule('no-unused-props', {
416442
}
417443
}
418444
for (const identifier of identifiers) {
419-
const paths = getUsedNestedPropertyPathsArray(identifier);
445+
const { paths, spreadPaths } = getUsedNestedPropertyPathsArray(identifier);
420446
usedPropertyPathsArray.push(...paths.map((path) => [identifier.name, ...path]));
447+
usedSpreadPropertyPathsArray.push(
448+
...spreadPaths.map((path) => [identifier.name, ...path])
449+
);
421450
}
422451
} else if (node.id.type === 'Identifier') {
423-
usedPropertyPathsArray = getUsedNestedPropertyPathsArray(node.id);
452+
const { paths, spreadPaths } = getUsedNestedPropertyPathsArray(node.id);
453+
usedPropertyPathsArray.push(...paths);
454+
usedSpreadPropertyPathsArray.push(...spreadPaths);
455+
}
456+
457+
function runNormalizeUsedPaths(paths: PropertyPathArray[]) {
458+
return normalizeUsedPaths(paths, options.allowUnusedNestedProperties).map((pathArray) => {
459+
return pathArray.join('.');
460+
});
424461
}
425462

426463
checkUnusedProperties({
427464
propsType,
428-
usedPropertyPaths: normalizeUsedPaths(
429-
usedPropertyPathsArray,
430-
options.allowUnusedNestedProperties
431-
).map((pathArray) => {
432-
return pathArray.join('.');
433-
}),
465+
usedPropertyPaths: runNormalizeUsedPaths(usedPropertyPathsArray),
466+
usedSpreadPropertyPaths: runNormalizeUsedPaths(usedSpreadPropertyPathsArray),
434467
declaredPropertyNames,
435468
reportNode: node.id,
436469
parentPath: [],
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!--Test1.svelte-->
2+
<script lang="ts">
3+
import Test from '$lib/Test.svelte';
4+
5+
interface Props {
6+
a: string;
7+
b: {
8+
c: string;
9+
d: number;
10+
};
11+
}
12+
13+
let props: Props = $props();
14+
</script>
15+
16+
<Test a={props.a} {...props.b} />
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!--Test1.svelte-->
2+
<script lang="ts">
3+
import Test from '$lib/Test.svelte';
4+
5+
interface Props {
6+
a: string;
7+
b: {
8+
c: string;
9+
d: number;
10+
};
11+
}
12+
13+
let { a, b }: Props = $props();
14+
</script>
15+
16+
<Test {a} {...b} />
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!--Test1.svelte-->
2+
<script lang="ts">
3+
import Test from '$lib/Test.svelte';
4+
5+
interface Props {
6+
a: {
7+
c: string;
8+
d: number;
9+
};
10+
}
11+
12+
let props: Props = $props();
13+
</script>
14+
15+
<Test {...props.a} />
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"options": [
3+
{
4+
"allowUnusedNestedProperties": true
5+
}
6+
]
7+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!--Test1.svelte-->
2+
<script lang="ts">
3+
import Test from '$lib/Test.svelte';
4+
5+
interface Props {
6+
a: {
7+
c: string;
8+
d: number;
9+
};
10+
}
11+
12+
let props: Props = $props();
13+
</script>
14+
15+
<Test {...props.a} />
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!--Test1.svelte-->
2+
<script lang="ts">
3+
import Test from '$lib/Test.svelte';
4+
5+
interface Props {
6+
a: string;
7+
b: {
8+
c: string;
9+
d: number;
10+
};
11+
}
12+
13+
let props: Props = $props();
14+
15+
console.log(...props);
16+
</script>
17+
18+
<Test />
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<script lang="ts">
2+
import Test from '$lib/Test.svelte';
3+
4+
interface Props {
5+
a: string;
6+
}
7+
8+
let props: Props = $props();
9+
</script>
10+
11+
<Test {...props} />
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"options": [
3+
{
4+
"allowUnusedNestedProperties": true
5+
}
6+
]
7+
}

0 commit comments

Comments
 (0)