Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/const-tag-scope-shadow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@svelte-vitals/core': patch
'svelte-vitals': patch
'@svelte-vitals/vite': patch
'@svelte-vitals/mcp': patch
---

Scope resolution now treats template declaration tags — `{@const ...}` and the newer `{let ...}` / `{const ...}` — as shadowing bindings for their enclosing fragment, so a write to such a template-local alias is no longer misattributed to a same-named top-level `$state` (fewer false positives across the component-analysis rules).
14 changes: 12 additions & 2 deletions packages/core/src/component-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,12 @@ export function rootObjectName(node: Node): string | undefined {
* `var` invisible to sibling blocks; accepted imprecision) plus its own `function`/`class`
* declaration names, a
* `for`/`for-of`/`for-in` loop's declared variable, a Svelte `{#each ... as x, i}` block's
* context AND index binding, a `{#snippet}` block's parameters, and an `{#await}` block's
* `then`/`catch` value/error bindings. Used by `walkScoped` so a write/mutation detector
* context AND index binding, a `{#snippet}` block's parameters, an `{#await}` block's
* `then`/`catch` value/error bindings, and a fragment's own `{@const ...}` /
* `{let ...}` / `{const ...}` declaration tags (attributed to the enclosing Fragment,
* shadowing the whole fragment like a block's `let` — a write to one of these
* template-locals, possible for `{let}`, is still not a write to the outer binding).
* Used by `walkScoped` so a write/mutation detector
* doesn't misattribute a write to one of these locals as a write to an outer `$state`/prop
* of the same name (issue #140 — originally a deliberately partial mitigation that left
* `{#snippet}`/`{:then}`/`{:catch}` bindings untracked; now covered too. A block's own
Expand Down Expand Up @@ -350,6 +354,12 @@ export function scopeIntroducedNames(node: Node): Set<string> {
} else if (node.type === 'AwaitBlock') {
if (node.value) addBoundNames(node.value, introduced);
if (node.error) addBoundNames(node.error, introduced);
} else if (node.type === 'Fragment') {
for (const child of node.nodes ?? []) {
if (child?.type === 'ConstTag' || child?.type === 'DeclarationTag') {
for (const d of child.declaration?.declarations ?? []) addBoundNames(d.id, introduced);
}
}
}
return introduced;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/core/test/component-parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,3 +962,17 @@ describe('constableStates — directive escapes', () => {
expect(constable(src)).toEqual([{ name: 'obj', line: 2 }]);
});
});

describe('constableStates — {@const} shadowing', () => {
const constable = (src: string) => parseComponentFacts(src, 'A.svelte').constableStates;

it('does not attribute a write to an {@const} alias to a same-named untouched $state', () => {
const src = `<script>\nlet obj = $state({});\n</script>\n{#each list as g}{@const obj = g.o}<button onclick={() => {\n obj.x = 1;\n}}>x</button>{/each}`;
expect(constable(src)).toEqual([{ name: 'obj', line: 2 }]);
});

it('does not attribute a reassignment of a {let} declaration tag to a same-named untouched $state', () => {
const src = `<script>\nlet obj = $state({});\n</script>\n{#each list as g}{let obj = g.o}<button onclick={() => {\n obj = g.p;\n}}>x</button>{/each}`;
expect(constable(src)).toEqual([{ name: 'obj', line: 2 }]);
});
});
21 changes: 21 additions & 0 deletions packages/core/test/nonreactive-builtin-state-parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ describe('nonreactiveBuiltinStates — exclusions', () => {
expect(nrb(url)).toEqual([{ name: 'u', type: 'URL', line: 2 }]);
});

it('does not count a mutation of an {@const} alias against a same-named outer $state', () => {
const src = script(
`let tags = $state(new Set());`,
`{#each groups as g}{@const tags = g.tags}<button onclick={() => tags.add("x")}>x</button>{/each}`
);
expect(nrb(src)).toEqual([]);
});

it('does not count mutations of {let}/{const} declaration-tag aliases either', () => {
const letTag = script(
`let tags = $state(new Set());`,
`{#each groups as g}{let tags = g.tags}<button onclick={() => tags.add("x")}>x</button>{/each}`
);
expect(nrb(letTag)).toEqual([]);
const constTag = script(
`let tags = $state(new Set());`,
`{#each groups as g}{const tags = g.tags}<button onclick={() => tags.add("x")}>x</button>{/each}`
);
expect(nrb(constTag)).toEqual([]);
});

it('resolves function-scoped var and nested declaration shadows', () => {
const varShadow = script(`let m = $state(new Map());\nfunction f() {\n var m = new Map();\n m.set("k", 1);\n}`);
expect(nrb(varShadow)).toEqual([]);
Expand Down