diff --git a/.changeset/const-tag-scope-shadow.md b/.changeset/const-tag-scope-shadow.md new file mode 100644 index 000000000..df7094e04 --- /dev/null +++ b/.changeset/const-tag-scope-shadow.md @@ -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). diff --git a/packages/core/src/component-parse.ts b/packages/core/src/component-parse.ts index 92bc30aa0..1766ede70 100644 --- a/packages/core/src/component-parse.ts +++ b/packages/core/src/component-parse.ts @@ -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 @@ -350,6 +354,12 @@ export function scopeIntroducedNames(node: Node): Set { } 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; } diff --git a/packages/core/test/component-parse.test.ts b/packages/core/test/component-parse.test.ts index a1fd3115e..65542f639 100644 --- a/packages/core/test/component-parse.test.ts +++ b/packages/core/test/component-parse.test.ts @@ -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 = `\n{#each list as g}{@const obj = g.o}{/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 = `\n{#each list as g}{let obj = g.o}{/each}`; + expect(constable(src)).toEqual([{ name: 'obj', line: 2 }]); + }); +}); diff --git a/packages/core/test/nonreactive-builtin-state-parse.test.ts b/packages/core/test/nonreactive-builtin-state-parse.test.ts index f150bb916..65a523359 100644 --- a/packages/core/test/nonreactive-builtin-state-parse.test.ts +++ b/packages/core/test/nonreactive-builtin-state-parse.test.ts @@ -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}{/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}{/each}` + ); + expect(nrb(letTag)).toEqual([]); + const constTag = script( + `let tags = $state(new Set());`, + `{#each groups as g}{const tags = g.tags}{/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([]);