From c2f68865a388f8eede160a585e7214b4fdc042d1 Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Mon, 31 Aug 2026 11:12:55 -0300 Subject: [PATCH] state_referenced_locally not reported for declaration tags reading outer-scope state --- .../src/compiler/phases/2-analyze/types.d.ts | 8 ++++ .../2-analyze/visitors/CallExpression.js | 9 +++- .../phases/2-analyze/visitors/ConstTag.js | 3 +- .../2-analyze/visitors/DeclarationTag.js | 1 + .../phases/2-analyze/visitors/Identifier.js | 3 +- .../phases/2-analyze/visitors/SnippetBlock.js | 7 ++- .../2-analyze/visitors/shared/component.js | 11 ++++- .../2-analyze/visitors/shared/function.js | 4 ++ .../input.svelte | 44 +++++++++++++++++++ .../warnings.json | 38 ++++++++++++++++ 10 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally-outer-scope/input.svelte create mode 100644 packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally-outer-scope/warnings.json diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index bd486a12ca..09c4b51210 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -29,6 +29,14 @@ export interface AnalysisState { function_depth: number; + /** + * Set while visiting the declaration of a `{let ...}`/`{const ...}` declaration tag: the + * `function_depth` that applied just outside the tag, i.e. the depth a plain reference at + * that same point in the template would use. Lets `state_referenced_locally` also catch + * non-closure reads of state declared *outside* the tag, not just within it. + */ + outer_function_depth?: number; + // legacy stuff reactive_statement: null | ReactiveStatement; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 312a93e5fd..94b5268c67 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -249,6 +249,9 @@ export function CallExpression(node, context) { ...context.state, function_depth: context.state.function_depth + 1, derived_function_depth: context.state.function_depth + 1, + // `$derived(...)` is itself the reactive boundary declaration tags are missing, so + // an outer-scope reference read here is a legitimate deferred read, not a snapshot + outer_function_depth: undefined, expression }); @@ -259,7 +262,11 @@ export function CallExpression(node, context) { // Tell surrounding declaration tag about metadata for correct calculation of blockers etc if (context.state.in_declaration_tag) context.state.expression?.merge(expression); } else if (rune === '$inspect') { - context.next({ ...context.state, function_depth: context.state.function_depth + 1 }); + context.next({ + ...context.state, + function_depth: context.state.function_depth + 1, + outer_function_depth: undefined + }); } else { context.next(); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js index 64e93d3efe..c2a63f51d2 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js @@ -41,7 +41,8 @@ export function ConstTag(node, context) { expression: node.metadata.expression, // We're treating this like a $derived under the hood function_depth: context.state.function_depth + 1, - derived_function_depth: context.state.function_depth + 1 + derived_function_depth: context.state.function_depth + 1, + outer_function_depth: undefined }); mark_async_declaration(context, node.metadata, [declaration]); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js index 2f72b16c72..5e23fa2d1f 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js @@ -30,6 +30,7 @@ export function DeclarationTag(node, context) { // `function_depth` we're tracking here (`set_scope` doesn't update `function_depth`). // align them so that `state_referenced_locally` warnings are calculated correctly function_depth: context.state.scope.function_depth, + outer_function_depth: context.state.function_depth - 1, expression: node.metadata.expression }); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js index ebb2fc2b67..9745b34bd4 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js @@ -104,7 +104,8 @@ export function Identifier(node, context) { if ( context.state.analysis.runes && node !== binding.node && - context.state.function_depth === binding.scope.function_depth && + (context.state.function_depth === binding.scope.function_depth || + context.state.outer_function_depth === binding.scope.function_depth) && // If we have $state that can be proxied or frozen and isn't re-assigned, then that means // it's likely not using a primitive value and thus this warning isn't that helpful. ((binding.kind === 'state' && diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js index 7618716ae5..eacd49f6c9 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js @@ -23,7 +23,12 @@ export function SnippetBlock(node, context) { } } - context.next({ ...context.state, parent_element: null }); + context.next({ + ...context.state, + parent_element: null, + function_depth: Math.max(context.state.scope.function_depth, context.state.function_depth) + 1, + outer_function_depth: undefined + }); const is_top_level = context.path.length === 1 && context.path[0].type === 'Fragment'; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js index 6d09398fb7..314478e8dc 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/component.js @@ -149,12 +149,19 @@ export function visit_component(node, context) { const component_slots = new Set(); for (const slot_name in nodes) { + const slot_scope = node.metadata.scopes[slot_name]; + /** @type {AnalysisState} */ const state = { ...context.state, - scope: node.metadata.scopes[slot_name], + scope: slot_scope, parent_element: null, - component_slots + component_slots, + // slot content compiles to a snippet (an implicit `children` snippet for the default + // slot) - a real closure boundary, invoked separately and possibly many times - so + // state_referenced_locally shouldn't treat reads inside it as non-closure captures + function_depth: Math.max(slot_scope.function_depth, context.state.function_depth) + 1, + outer_function_depth: undefined }; context.visit({ ...node.fragment, nodes: nodes[slot_name] }, state); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js index 6b3d227eca..79e0d3b4c9 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js @@ -21,6 +21,10 @@ export function visit_function(node, context) { // we generally want to use scope.function_depth unless we specifically increased // that in state.function_depth (e.g. a derived) function_depth: Math.max(context.state.scope.function_depth, context.state.function_depth) + 1, + // a real closure boundary makes any outer-scope reference inside it a legitimate + // deferred read, not a snapshot capture, so the `outer_function_depth` special-case + // from `DeclarationTag` no longer applies once we're inside one + outer_function_depth: undefined, expression: null }); } diff --git a/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally-outer-scope/input.svelte b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally-outer-scope/input.svelte new file mode 100644 index 0000000000..03cab60042 --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally-outer-scope/input.svelte @@ -0,0 +1,44 @@ + + + +{let double1 = count} + + +{#each [1, 2, 3] as item} + {let double2 = count} + {double2} +{/each} + + +{#await promise then value} + {let double3 = count} + {double3} +{/await} + + +{let fn = () => count} + + +{let derived1 = $derived(count * 2)} + + +{#snippet mysnippet()} + {let double4 = count} + {double4} +{/snippet} +{@render mysnippet()} + + + + {let double5 = count} + {double5} + + +{double1}{fn}{derived1} diff --git a/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally-outer-scope/warnings.json b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally-outer-scope/warnings.json new file mode 100644 index 0000000000..d2ab58a773 --- /dev/null +++ b/packages/svelte/tests/validator/samples/declaration-tag-state-referenced-locally-outer-scope/warnings.json @@ -0,0 +1,38 @@ +[ + { + "code": "state_referenced_locally", + "message": "This reference only captures the initial value of `count`. Did you mean to reference it inside a closure instead?", + "start": { + "line": 9, + "column": 15 + }, + "end": { + "line": 9, + "column": 20 + } + }, + { + "code": "state_referenced_locally", + "message": "This reference only captures the initial value of `count`. Did you mean to reference it inside a closure instead?", + "start": { + "line": 13, + "column": 16 + }, + "end": { + "line": 13, + "column": 21 + } + }, + { + "code": "state_referenced_locally", + "message": "This reference only captures the initial value of `count`. Did you mean to reference it inside a closure instead?", + "start": { + "line": 19, + "column": 16 + }, + "end": { + "line": 19, + "column": 21 + } + } +]