Skip to content

Commit f70cc81

Browse files
committed
feat: add no-unused-props rule
1 parent af43c87 commit f70cc81

25 files changed

+192
-232
lines changed

.changeset/twelve-beers-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-svelte': minor
3+
---
4+
5+
feat: add `no-unused-props` rule

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ These rules relate to better ways of doing things to help you avoid problems:
362362
| [svelte/no-reactive-literals](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-reactive-literals/) | don't assign literal values in reactive statements | :star::bulb: |
363363
| [svelte/no-svelte-internal](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-svelte-internal/) | svelte/internal will be removed in Svelte 6. | :star: |
364364
| [svelte/no-unused-class-name](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-class-name/) | disallow the use of a class in the template without a corresponding style | |
365+
| [svelte/no-unused-props](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-props/) | Warns about defined Props properties that are unused | |
365366
| [svelte/no-unused-svelte-ignore](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-svelte-ignore/) | disallow unused svelte-ignore comments | :star: |
366367
| [svelte/no-useless-children-snippet](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-useless-children-snippet/) | disallow explicit children snippet where it's not needed | :star: |
367368
| [svelte/no-useless-mustaches](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-useless-mustaches/) | disallow unnecessary mustache interpolations | :star::wrench: |

docs/rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ These rules relate to better ways of doing things to help you avoid problems:
5959
| [svelte/no-reactive-literals](./rules/no-reactive-literals.md) | don't assign literal values in reactive statements | :star::bulb: |
6060
| [svelte/no-svelte-internal](./rules/no-svelte-internal.md) | svelte/internal will be removed in Svelte 6. | :star: |
6161
| [svelte/no-unused-class-name](./rules/no-unused-class-name.md) | disallow the use of a class in the template without a corresponding style | |
62+
| [svelte/no-unused-props](./rules/no-unused-props.md) | Warns about defined Props properties that are unused | |
6263
| [svelte/no-unused-svelte-ignore](./rules/no-unused-svelte-ignore.md) | disallow unused svelte-ignore comments | :star: |
6364
| [svelte/no-useless-children-snippet](./rules/no-useless-children-snippet.md) | disallow explicit children snippet where it's not needed | :star: |
6465
| [svelte/no-useless-mustaches](./rules/no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :star::wrench: |

docs/rules/no-unused-props.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
pageClass: 'rule-details'
3+
sidebarDepth: 0
4+
title: 'svelte/no-unused-props'
5+
description: 'Warns about defined Props properties that are unused'
6+
---
7+
8+
# svelte/no-unused-props
9+
10+
> Warns about defined Props properties that are unused
11+
12+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
13+
14+
## :book: Rule Details
15+
16+
This rule reports properties that are defined in Props but never used in the component code.
17+
It helps to detect dead code and improve component clarity by ensuring that every declared prop is utilized.
18+
19+
This rule checks various usage patterns of props:
20+
21+
- Direct property access
22+
- Destructuring assignment
23+
- Method calls
24+
- Computed property access
25+
- Object spread
26+
- Constructor calls (new expressions)
27+
- Assignment to other variables
28+
29+
<!--eslint-skip-->
30+
31+
```svelte
32+
<!-- ✓ Good Examples -->
33+
<script lang="ts">
34+
// Direct property access
35+
const props = $props<{ value: string }>();
36+
console.log(props.value);
37+
38+
// Destructuring assignment
39+
const { width, height } = $props<{ width: number; height: number }>();
40+
console.log(width, height);
41+
42+
// Method calls
43+
const props2 = $props<{ callback: () => void }>();
44+
props2.callback();
45+
46+
// Computed property access
47+
const props3 = $props<{ 'data-value': string }>();
48+
const value = props3['data-value'];
49+
50+
// Constructor calls
51+
const props4 = $props<{ config: { new(): any } }>();
52+
new props4.config();
53+
</script>
54+
55+
<!-- ✗ Bad Examples -->
56+
<script lang="ts">
57+
// Unused property 'b'
58+
const props = $props<{ a: string; b: number }>();
59+
console.log(props.a);
60+
61+
// Unused property in destructuring
62+
const { x } = $props<{ x: number; y: number }>();
63+
console.log(x);
64+
</script>
65+
```
66+
67+
## :wrench: Options
68+
69+
Nothing.
70+
71+
## :mag: Implementation
72+
73+
- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/no-unused-props.ts)
74+
- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/no-unused-props.ts)

packages/eslint-plugin-svelte/src/rule-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@ export interface RuleOptions {
261261
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-class-name/
262262
*/
263263
'svelte/no-unused-class-name'?: Linter.RuleEntry<SvelteNoUnusedClassName>
264+
/**
265+
* Warns about defined Props properties that are unused
266+
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-props/
267+
*/
268+
'svelte/no-unused-props'?: Linter.RuleEntry<[]>
264269
/**
265270
* disallow unused svelte-ignore comments
266271
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-svelte-ignore/

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

Lines changed: 66 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { createRule } from '../utils/index.js';
2-
import { getSourceCode } from '../utils/compat.js';
32
import { getTypeScriptTools } from '../utils/ts-utils/index.js';
43
import type { TSESTree } from '@typescript-eslint/types';
4+
import type ts from 'typescript';
5+
import { findVariable } from '../utils/ast-utils.js';
56

6-
type TSInterfaceDeclarationWithId = TSESTree.TSInterfaceDeclaration & {
7-
id: TSESTree.Identifier;
8-
body: TSESTree.TSInterfaceBody;
9-
};
7+
const unknown = Symbol('unknown');
108

119
export default createRule('no-unused-props', {
1210
meta: {
@@ -28,202 +26,93 @@ export default createRule('no-unused-props', {
2826
]
2927
},
3028
create(context) {
31-
const sourceCode = getSourceCode(context);
32-
const scopeManager = sourceCode.scopeManager;
33-
34-
// Get TypeScript tools using the provided utility.
35-
const _tsTools = getTypeScriptTools(context);
36-
37-
// Property names obtained from the Props interface.
38-
const declaredProps = new Map<string, TSESTree.Node>();
39-
let hasIndexSignature = false;
40-
41-
// Track used properties and rest usage.
42-
const usedProps = new Set<string>();
43-
const restUsage = new Map<string, boolean>(); // variableName -> hasRest
44-
45-
// Track renamed variables.
46-
const renamedVars = new Map<string, string>();
29+
const tools = getTypeScriptTools(context);
30+
if (!tools) {
31+
return {};
32+
}
4733

48-
// Track used variables.
49-
const usedVars = new Set<string>();
34+
const typeChecker = tools.service.program.getTypeChecker();
35+
if (!typeChecker) {
36+
return {};
37+
}
5038

51-
// Track $props variables by name and its identifier node.
52-
const propsVars = new Set<string>();
53-
const propsNodes = new Map<string, TSESTree.Node>();
39+
function getUsedPropertyNames(node: TSESTree.Identifier): (string | typeof unknown)[] {
40+
const variable = findVariable(context, node);
41+
if (!variable) {
42+
return [unknown];
43+
}
5444

55-
// Track processed interfaces to avoid infinite recursion.
56-
const processedInterfaces = new Set<string>();
45+
const usedProps = new Set<string | typeof unknown>();
5746

58-
// Collect declared properties from the Props interface.
59-
function collectPropsFromInterface(node: TSInterfaceDeclarationWithId) {
60-
if (!node.id || node.id.name !== 'Props') return;
61-
processedInterfaces.add(node.id.name);
62-
for (const m of node.body.body) {
63-
if (m.type === 'TSPropertySignature' && m.key.type === 'Identifier') {
64-
declaredProps.set(m.key.name, m);
65-
// For nested properties, collect property paths (e.g., "nested.a").
66-
if (m.typeAnnotation?.typeAnnotation.type === 'TSTypeLiteral') {
67-
const nestedProps = m.typeAnnotation.typeAnnotation.members;
68-
for (const nestedProp of nestedProps) {
69-
if (
70-
nestedProp.type === 'TSPropertySignature' &&
71-
nestedProp.key.type === 'Identifier'
72-
) {
73-
const fullPath = `${m.key.name}.${nestedProp.key.name}`;
74-
declaredProps.set(fullPath, nestedProp);
75-
}
76-
}
77-
}
78-
}
79-
if (m.type === 'TSIndexSignature') {
80-
hasIndexSignature = true;
81-
}
82-
}
83-
// Handle extends clause.
84-
if (node.extends) {
85-
for (const heritage of node.extends) {
86-
if (heritage.expression.type === 'Identifier') {
87-
const baseName = heritage.expression.name;
88-
if (baseName === 'Props' || processedInterfaces.has(baseName)) continue;
89-
const program = sourceCode.ast;
90-
for (const scriptElem of program.body) {
91-
if (scriptElem.type === 'SvelteScriptElement') {
92-
const scriptProgram = scriptElem.body;
93-
if (scriptProgram && 'body' in scriptProgram) {
94-
const statements = scriptProgram.body as TSESTree.Statement[];
95-
for (const stmt of statements) {
96-
if (
97-
stmt.type === 'TSInterfaceDeclaration' &&
98-
stmt.id.type === 'Identifier' &&
99-
stmt.id.name === baseName
100-
) {
101-
collectPropsFromInterface(stmt as TSInterfaceDeclarationWithId);
102-
break;
103-
}
104-
}
105-
}
106-
}
107-
}
108-
}
109-
}
110-
}
111-
}
47+
for (const reference of variable.references) {
48+
const parent = reference.identifier.parent;
49+
if (!parent) continue;
11250

113-
// Collect used property names from an object destructuring pattern.
114-
function markDestructuredProps(pattern: TSESTree.ObjectPattern, variableName: string) {
115-
for (const prop of pattern.properties) {
116-
if (prop.type === 'Property' && prop.key.type === 'Identifier') {
117-
const originalName = prop.key.name;
118-
if (prop.value.type === 'Identifier') {
119-
const renamedVar = prop.value.name;
120-
renamedVars.set(renamedVar, originalName);
51+
if (parent.type === 'MemberExpression' && parent.object === reference.identifier) {
52+
if (parent.property.type === 'Identifier' && !parent.computed) {
53+
usedProps.add(parent.property.name);
12154
} else {
122-
usedProps.add(originalName);
123-
}
124-
}
125-
if (prop.type === 'RestElement') {
126-
restUsage.set(variableName, true);
127-
if (hasIndexSignature) {
128-
for (const [propName] of declaredProps) {
129-
usedProps.add(propName);
130-
}
55+
usedProps.add(unknown);
13156
}
132-
}
133-
}
134-
}
135-
136-
function isPropsCall(initNode: TSESTree.Expression): boolean {
137-
return (
138-
initNode.type === 'CallExpression' &&
139-
initNode.callee.type === 'Identifier' &&
140-
initNode.callee.name === '$props'
141-
);
142-
}
143-
144-
// Traverse scopes and record variables assigned from $props().
145-
function analyzePropsUsageInScopes() {
146-
for (const scope of scopeManager.scopes) {
147-
for (const variable of scope.variables) {
148-
const def = variable.defs.find((d) => d.type === 'Variable');
149-
if (!def || !def.node || !def.node.init) continue;
150-
const typeAnn = def.node.id.typeAnnotation?.typeAnnotation;
57+
} else if (parent.type === 'CallExpression' || parent.type === 'NewExpression') {
15158
if (
152-
isPropsCall(def.node.init) &&
153-
typeAnn?.type === 'TSTypeReference' &&
154-
typeAnn.typeName.type === 'Identifier' &&
155-
typeAnn.typeName.name === 'Props'
59+
'arguments' in parent &&
60+
Array.isArray(parent.arguments) &&
61+
parent.arguments.some((arg): arg is TSESTree.Identifier => arg === reference.identifier)
15662
) {
157-
if (def.node.id.type === 'Identifier') {
158-
propsVars.add(def.node.id.name);
159-
propsNodes.set(def.node.id.name, def.node.id);
160-
}
161-
if (def.node.id.type === 'ObjectPattern') {
162-
markDestructuredProps(def.node.id, variable.name);
163-
}
63+
usedProps.add(unknown);
16464
}
65+
} else if (parent.type === 'AssignmentExpression' || parent.type === 'AssignmentPattern') {
66+
usedProps.add(unknown);
67+
} else if (parent.type === 'SpreadElement') {
68+
usedProps.add(unknown);
16569
}
16670
}
71+
72+
return Array.from(usedProps);
16773
}
16874

16975
return {
170-
TSInterfaceDeclaration(node: TSESTree.Node) {
76+
'VariableDeclaration > VariableDeclarator': (node: TSESTree.VariableDeclarator) => {
17177
if (
172-
node.type === 'TSInterfaceDeclaration' &&
173-
node.id &&
174-
node.id.type === 'Identifier' &&
175-
node.id.name === 'Props'
78+
node.init?.type !== 'CallExpression' ||
79+
node.init.callee.type !== 'Identifier' ||
80+
node.init.callee.name !== '$props'
17681
) {
177-
collectPropsFromInterface(node as TSInterfaceDeclarationWithId);
178-
}
179-
},
180-
Program() {
181-
analyzePropsUsageInScopes();
182-
},
183-
MemberExpression(node: TSESTree.MemberExpression) {
184-
let current: TSESTree.Expression | TSESTree.Super = node.object;
185-
const parts: string[] = [];
186-
if (node.property.type === 'Identifier') {
187-
parts.push(node.property.name);
188-
}
189-
while (current.type === 'MemberExpression') {
190-
if (current.property.type === 'Identifier') {
191-
parts.unshift(current.property.name);
192-
}
193-
current = current.object;
194-
}
195-
if (current.type === 'Identifier' && propsVars.has(current.name)) {
196-
let path = '';
197-
for (const part of parts) {
198-
path = path ? `${path}.${part}` : part;
199-
usedProps.add(path);
200-
}
201-
}
202-
},
203-
Identifier(node: TSESTree.Identifier) {
204-
usedVars.add(node.name);
205-
const originalName = renamedVars.get(node.name);
206-
if (originalName && usedVars.has(node.name)) {
207-
usedProps.add(originalName);
82+
return;
20883
}
209-
},
210-
'Program:exit'() {
211-
let hasRestWithIndexSignature = false;
212-
for (const [_, hasRest] of restUsage) {
213-
if (hasRest && hasIndexSignature) {
214-
hasRestWithIndexSignature = true;
215-
break;
84+
85+
const tsNode = tools.service.esTreeNodeToTSNodeMap.get(node) as ts.VariableDeclaration;
86+
if (!tsNode || !tsNode.type) return;
87+
const checker = tools.service.program.getTypeChecker();
88+
const propType = checker.getTypeFromTypeNode(tsNode.type);
89+
const properties = checker.getPropertiesOfType(propType);
90+
const propNames = properties.map((p) => p.getName());
91+
92+
const usedNames: (string | typeof unknown)[] = [];
93+
if (node.id.type === 'ObjectPattern') {
94+
for (const prop of node.id.properties) {
95+
if (prop.type === 'Property' && prop.key.type === 'Identifier') {
96+
usedNames.push(prop.key.name);
97+
} else if (prop.type === 'RestElement' && prop.argument.type === 'Identifier') {
98+
usedNames.push(...getUsedPropertyNames(prop.argument));
99+
}
216100
}
101+
} else if (node.id.type === 'Identifier' && node.id.typeAnnotation) {
102+
usedNames.push(...getUsedPropertyNames(node.id));
217103
}
218-
if (hasRestWithIndexSignature) {
104+
105+
if (usedNames.includes(unknown)) {
219106
return;
220107
}
221-
for (const [propName, propNode] of declaredProps) {
222-
if (!usedProps.has(propName)) {
108+
for (const propName of propNames) {
109+
if (!usedNames.includes(propName)) {
223110
context.report({
224-
node: propNode,
111+
node: node.id,
225112
messageId: 'unusedProp',
226-
data: { name: propName }
113+
data: {
114+
name: propName
115+
}
227116
});
228117
}
229118
}

0 commit comments

Comments
 (0)