Skip to content

Commit de4c1aa

Browse files
Moktoclaude
andcommitted
fix(svelte): share script-block scope so cross-referenced bindings aren't flagged as unused
A Svelte component's `<script module>` and `<script>` compile to one module and share a top-level scope, but each block was analyzed as an isolated embedded snippet. A binding (import, function, or variable) declared in one block and used only in the other was therefore reported as unused by noUnusedImports / noUnusedVariables / useImportType. The workspace now also collects value/type references from Svelte source `<script>` snippets (not just template snippets), so a name referenced in the sibling block counts as used. Gated to Svelte, where the two blocks genuinely share a module scope; Vue/Astro are unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e1851db commit de4c1aa

8 files changed

Lines changed: 180 additions & 4 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed false positives in `noUnusedImports`, `noUnusedVariables`, and `useImportType` for Svelte components that use both a `<script module>` and a `<script>` block. The two blocks compile to a single module and share a top-level scope, so a binding (import, function, or variable) declared in one block and used only in the other is no longer reported as unused.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!-- should not generate diagnostics -->
2+
<script module lang="ts">
3+
import { tick } from "svelte";
4+
5+
// `Snippet` is imported in the instance <script> below, but referenced here.
6+
export interface Props {
7+
row?: Snippet;
8+
}
9+
</script>
10+
11+
<script lang="ts">
12+
import type { Snippet } from "svelte";
13+
14+
const { row }: Props = $props();
15+
16+
// `tick` is imported in the module <script> above, but used only here.
17+
async function refresh() {
18+
await tick();
19+
}
20+
21+
refresh();
22+
</script>
23+
24+
<div>{row}</div>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: valid-svelte-cross-script-imports.svelte
4+
---
5+
# Input
6+
```svelte
7+
<!-- should not generate diagnostics -->
8+
<script module lang="ts">
9+
import { tick } from "svelte";
10+
11+
// `Snippet` is imported in the instance <script> below, but referenced here.
12+
export interface Props {
13+
row?: Snippet;
14+
}
15+
</script>
16+
17+
<script lang="ts">
18+
import type { Snippet } from "svelte";
19+
20+
const { row }: Props = $props();
21+
22+
// `tick` is imported in the module <script> above, but used only here.
23+
async function refresh() {
24+
await tick();
25+
}
26+
27+
refresh();
28+
</script>
29+
30+
<div>{row}</div>
31+
32+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script module lang="ts">
2+
// Declared in the module script and referenced in neither block nor the
3+
// template: still reported as unused.
4+
function unusedHelper(): string {
5+
return "unused";
6+
}
7+
</script>
8+
9+
<script lang="ts">
10+
const value = 1;
11+
</script>
12+
13+
<p>{value}</p>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: invalid-svelte-unused-module-binding.svelte
4+
---
5+
# Input
6+
```svelte
7+
<script module lang="ts">
8+
// Declared in the module script and referenced in neither block nor the
9+
// template: still reported as unused.
10+
function unusedHelper(): string {
11+
return "unused";
12+
}
13+
</script>
14+
15+
<script lang="ts">
16+
const value = 1;
17+
</script>
18+
19+
<p>{value}</p>
20+
21+
```
22+
23+
# Diagnostics
24+
```
25+
invalid-svelte-unused-module-binding.svelte:4:10 lint/correctness/noUnusedVariables FIXABLE ━━━━━━━━━━
26+
27+
! This function unusedHelper is unused.
28+
29+
2 │ // Declared in the module script and referenced in neither block nor the
30+
3 │ // template: still reported as unused.
31+
> 4 │ function unusedHelper(): string {
32+
^^^^^^^^^^^^
33+
5return "unused";
34+
6}
35+
36+
i Unused variables are often the result of typos, incomplete refactors, or other sources of bugs.
37+
38+
i Unsafe fix: If this is intentional, prepend unusedHelper with an underscore.
39+
40+
2 2 │ // Declared in the module script and referenced in neither block nor the
41+
3 3 │ // template: still reported as unused.
42+
4 │ - function·unusedHelper():·string·{
43+
4+ function·_unusedHelper():·string·{
44+
5 5return "unused";
45+
6 6 │ }
46+
47+
48+
```
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!-- should not generate diagnostics -->
2+
<script module lang="ts">
3+
// `<script module>` and `<script>` compile to one module and share a
4+
// top-level scope. These bindings are declared here but used only in the
5+
// instance script below, so they must not be reported as unused.
6+
function formatId(id: number): string {
7+
return `row-${id}`;
8+
}
9+
10+
const PREFIX = "row";
11+
</script>
12+
13+
<script lang="ts">
14+
const id = formatId(1);
15+
const label = `${PREFIX}-${id}`;
16+
</script>
17+
18+
<div data-id={id}>{label}</div>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: valid-svelte-cross-script-references.svelte
4+
---
5+
# Input
6+
```svelte
7+
<!-- should not generate diagnostics -->
8+
<script module lang="ts">
9+
// `<script module>` and `<script>` compile to one module and share a
10+
// top-level scope. These bindings are declared here but used only in the
11+
// instance script below, so they must not be reported as unused.
12+
function formatId(id: number): string {
13+
return `row-${id}`;
14+
}
15+
16+
const PREFIX = "row";
17+
</script>
18+
19+
<script lang="ts">
20+
const id = formatId(1);
21+
const label = `${PREFIX}-${id}`;
22+
</script>
23+
24+
<div data-id={id}>{label}</div>
25+
26+
```

crates/biome_service/src/workspace/server.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ impl WorkspaceServer {
666666

667667
// Track value references from non-source snippets (templates)
668668
let mut value_references = EmbeddedValueReferences::default();
669+
let is_svelte = source.to_html_file_source().is_some_and(|s| s.is_svelte());
669670
for snippet in &embedded_snippets {
670671
if let Some(js_snippet) = snippet.as_js_embedded_snippet() {
671672
let Some(file_source) = self.get_source(snippet.file_source_index()) else {
@@ -674,8 +675,11 @@ impl WorkspaceServer {
674675
let Some(js_file_source) = file_source.to_js_file_source() else {
675676
continue;
676677
};
677-
// Only process non-source snippets (templates)
678-
if !js_file_source.is_embedded_source() {
678+
// Templates always; for Svelte also the sibling `<script>` blocks.
679+
// A Svelte component's `<script module>` and `<script>` compile to
680+
// one module and share a top-level scope, so a binding used only in
681+
// the other block must still count as used.
682+
if !js_file_source.is_embedded_source() || is_svelte {
679683
let mut builder = value_references.builder();
680684
builder.visit_non_source_snippet(&js_snippet.parse.tree());
681685
value_references.finish(builder);
@@ -2158,6 +2162,9 @@ impl Workspace for WorkspaceServer {
21582162

21592163
// Track value references from non-source snippets (templates)
21602164
let mut value_references = EmbeddedValueReferences::default();
2165+
let is_svelte = document_source
2166+
.to_html_file_source()
2167+
.is_some_and(|s| s.is_svelte());
21612168
for snippet in &embedded_snippets {
21622169
if let Some(js_snippet) = snippet.as_js_embedded_snippet() {
21632170
let Some(file_source) = self.get_source(snippet.file_source_index()) else {
@@ -2166,8 +2173,11 @@ impl Workspace for WorkspaceServer {
21662173
let Some(js_file_source) = file_source.to_js_file_source() else {
21672174
continue;
21682175
};
2169-
// Only process non-source snippets (templates)
2170-
if !js_file_source.is_embedded_source() {
2176+
// Templates always; for Svelte also the sibling `<script>` blocks.
2177+
// A Svelte component's `<script module>` and `<script>` compile to
2178+
// one module and share a top-level scope, so a binding used only in
2179+
// the other block must still count as used.
2180+
if !js_file_source.is_embedded_source() || is_svelte {
21712181
let mut builder = value_references.builder();
21722182
builder.visit_non_source_snippet(&js_snippet.parse.tree());
21732183
value_references.finish(builder);

0 commit comments

Comments
 (0)