Skip to content

Commit 57e1c08

Browse files
committed
warn on nonstate references
1 parent 9c44fd7 commit 57e1c08

File tree

7 files changed

+76
-31
lines changed

7 files changed

+76
-31
lines changed

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function js(script, root, allow_reactive_declarations, parent) {
3838
body: []
3939
};
4040

41-
const { scope, scopes } = create_scopes(ast, root, allow_reactive_declarations, parent);
41+
const { scope, scopes } = create_scopes(ast, root, allow_reactive_declarations, false, parent);
4242

4343
return { ast, scope, scopes };
4444
}
@@ -191,7 +191,7 @@ function get_delegated_event(node, context) {
191191
* @returns {import('../types.js').Analysis}
192192
*/
193193
export function analyze_module(ast, options) {
194-
const { scope, scopes } = create_scopes(ast, new ScopeRoot(), false, null);
194+
const { scope, scopes } = create_scopes(ast, new ScopeRoot(), false, false, null);
195195

196196
for (const [name, references] of scope.references) {
197197
if (name[0] !== '$' || ReservedKeywords.includes(name)) continue;
@@ -242,7 +242,7 @@ export function analyze_component(root, options) {
242242
const module = js(root.module, scope_root, false, null);
243243
const instance = js(root.instance, scope_root, true, module.scope);
244244

245-
const { scope, scopes } = create_scopes(root.fragment, scope_root, false, instance.scope);
245+
const { scope, scopes } = create_scopes(root.fragment, scope_root, false, true, instance.scope);
246246

247247
/** @type {import('../types.js').Template} */
248248
const template = { ast: root.fragment, scope, scopes };
@@ -414,6 +414,15 @@ export function analyze_component(root, options) {
414414
analysis.reactive_statements = order_reactive_statements(analysis.reactive_statements);
415415
}
416416

417+
// warn on any nonstate declarations that are a) mutated and b) referenced in the template
418+
for (const scope of [module.scope, instance.scope]) {
419+
for (const [name, binding] of scope.declarations) {
420+
if (binding.kind === 'normal' && binding.mutated && binding.referenced_in_template) {
421+
warn(warnings, binding.node, [], 'non-state-reference', name);
422+
}
423+
}
424+
}
425+
417426
analysis.stylesheet.validate(analysis);
418427

419428
for (const element of analysis.elements) {

packages/svelte/src/compiler/phases/scope.js

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export class Scope {
9191
const binding = {
9292
node,
9393
references: [],
94+
referenced_in_template: false,
9495
legacy_dependencies: [],
9596
initial,
9697
mutated: false,
@@ -168,20 +169,22 @@ export class Scope {
168169
/**
169170
* @param {import('estree').Identifier} node
170171
* @param {import('#compiler').SvelteNode[]} path
172+
* @param {boolean} is_template
171173
*/
172-
reference(node, path) {
174+
reference(node, path, is_template) {
173175
let references = this.references.get(node.name);
174176
if (!references) this.references.set(node.name, (references = []));
175177

176178
references.push({ node, path });
177179

178-
const declaration = this.declarations.get(node.name);
179-
if (declaration) {
180-
declaration.references.push({ node, path });
180+
const binding = this.declarations.get(node.name);
181+
if (binding) {
182+
if (is_template) binding.referenced_in_template = true;
183+
binding.references.push({ node, path });
181184
} else if (this.#parent) {
182-
this.#parent.reference(node, path);
185+
this.#parent.reference(node, path, is_template);
183186
} else {
184-
// no declaration was found, and this is the top level scope,
187+
// no binding was found, and this is the top level scope,
185188
// which means this is a global
186189
this.root.conflicts.add(node.name);
187190
}
@@ -214,10 +217,11 @@ export class ScopeRoot {
214217
* @param {import('#compiler').SvelteNode} ast
215218
* @param {ScopeRoot} root
216219
* @param {boolean} allow_reactive_declarations
220+
* @param {boolean} is_template
217221
* @param {Scope | null} parent
218222
*/
219-
export function create_scopes(ast, root, allow_reactive_declarations, parent) {
220-
/** @typedef {{ scope: Scope }} State */
223+
export function create_scopes(ast, root, allow_reactive_declarations, is_template, parent) {
224+
/** @typedef {{ scope: Scope, is_template: boolean }} State */
221225

222226
/**
223227
* A map of node->associated scope. A node appearing in this map does not necessarily mean that it created a scope
@@ -228,9 +232,9 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
228232
scopes.set(ast, scope);
229233

230234
/** @type {State} */
231-
const state = { scope };
235+
const state = { scope, is_template };
232236

233-
/** @type {[Scope, { node: import('estree').Identifier; path: import('#compiler').SvelteNode[] }][]} */
237+
/** @type {[Scope, { node: import('estree').Identifier; path: import('#compiler').SvelteNode[]; is_template: boolean }][]} */
234238
const references = [];
235239

236240
/** @type {[Scope, import('estree').Pattern | import('estree').MemberExpression][]} */
@@ -261,7 +265,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
261265
const scope = state.scope.child(true);
262266
scopes.set(node, scope);
263267

264-
next({ scope });
268+
next({ ...state, scope });
265269
};
266270

267271
/**
@@ -270,7 +274,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
270274
const SvelteFragment = (node, { state, next }) => {
271275
const scope = analyze_let_directives(node, state.scope);
272276
scopes.set(node, scope);
273-
next({ scope });
277+
next({ ...state, scope });
274278
};
275279

276280
/**
@@ -316,7 +320,10 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
316320
Identifier(node, { path, state }) {
317321
const parent = path.at(-1);
318322
if (parent && is_reference(node, /** @type {import('estree').Node} */ (parent))) {
319-
references.push([state.scope, { node, path: path.slice() }]);
323+
references.push([
324+
state.scope,
325+
{ node, path: path.slice(), is_template: state.is_template }
326+
]);
320327
}
321328
},
322329

@@ -339,15 +346,15 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
339346
}
340347
}
341348

342-
next({ scope });
349+
next({ ...state, scope });
343350
},
344351

345352
SvelteFragment,
346353
SvelteElement: SvelteFragment,
347354
RegularElement: SvelteFragment,
348355

349356
Component(node, { state, visit, path }) {
350-
state.scope.reference(b.id(node.name), path);
357+
state.scope.reference(b.id(node.name), path, false);
351358

352359
// let:x from the default slot is a weird one:
353360
// Its scope only applies to children that are not slots themselves.
@@ -371,7 +378,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
371378
} else if (child.type === 'SnippetBlock') {
372379
visit(child);
373380
} else {
374-
visit(child, { scope });
381+
visit(child, { ...state, scope });
375382
}
376383
}
377384
},
@@ -405,7 +412,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
405412
if (node.id) scope.declare(node.id, 'normal', 'function');
406413

407414
add_params(scope, node.params);
408-
next({ scope });
415+
next({ scope, is_template: false });
409416
},
410417

411418
FunctionDeclaration(node, { state, next }) {
@@ -415,15 +422,15 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
415422
scopes.set(node, scope);
416423

417424
add_params(scope, node.params);
418-
next({ scope });
425+
next({ scope, is_template: false });
419426
},
420427

421428
ArrowFunctionExpression(node, { state, next }) {
422429
const scope = state.scope.child();
423430
scopes.set(node, scope);
424431

425432
add_params(scope, node.params);
426-
next({ scope });
433+
next({ scope, is_template: false });
427434
},
428435

429436
ForStatement: create_block_scope,
@@ -468,7 +475,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
468475
state.scope.declare(id, 'normal', 'let');
469476
}
470477

471-
next({ scope });
478+
next({ ...state, scope });
472479
} else {
473480
next();
474481
}
@@ -506,13 +513,13 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
506513
(node.key.type !== 'Identifier' || !node.index || node.key.name !== node.index);
507514
scope.declare(b.id(node.index), is_keyed ? 'derived' : 'normal', 'const');
508515
}
509-
if (node.key) visit(node.key, { scope });
516+
if (node.key) visit(node.key, { ...state, scope });
510517

511518
// children
512519
for (const child of node.body.nodes) {
513-
visit(child, { scope });
520+
visit(child, { ...state, scope });
514521
}
515-
if (node.fallback) visit(node.fallback, { scope });
522+
if (node.fallback) visit(node.fallback, { ...state, scope });
516523

517524
// Check if inner scope shadows something from outer scope.
518525
// This is necessary because we need access to the array expression of the each block
@@ -579,13 +586,13 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
579586
}
580587
}
581588

582-
context.next({ scope: child_scope });
589+
context.next({ ...state, scope: child_scope });
583590
},
584591

585592
Fragment: (node, context) => {
586593
const scope = context.state.scope.child(node.transparent);
587594
scopes.set(node, scope);
588-
context.next({ scope });
595+
context.next({ ...state, scope });
589596
},
590597

591598
BindDirective(node, context) {
@@ -622,8 +629,8 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
622629

623630
// we do this after the fact, so that we don't need to worry
624631
// about encountering references before their declarations
625-
for (const [scope, { node, path }] of references) {
626-
scope.reference(node, path);
632+
for (const [scope, { node, path, is_template }] of references) {
633+
scope.reference(node, path, is_template);
627634
}
628635

629636
for (const [scope, node] of updates) {

packages/svelte/src/compiler/types/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export interface Binding {
268268
initial: null | Expression | FunctionDeclaration | ClassDeclaration | ImportDeclaration;
269269
is_called: boolean;
270270
references: { node: Identifier; path: SvelteNode[] }[];
271+
referenced_in_template: boolean;
271272
mutated: boolean;
272273
scope: Scope;
273274
/** For `legacy_reactive`: its reactive dependencies */

packages/svelte/src/compiler/warnings.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ const runes = {
2323
`Referencing a local variable with a $ prefix will create a store subscription. Please rename ${name} to avoid the ambiguity.`,
2424
/** @param {string} name */
2525
'state-rune-not-mutated': (name) =>
26-
`${name} is declared with $state(...) but is never updated. Did you mean to create a function that changes its value?`
26+
`${name} is declared with $state(...) but is never updated. Did you mean to create a function that changes its value?`,
27+
/** @param {string} name */
28+
'non-state-reference': (name) =>
29+
`${name} is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates.`
2730
};
2831

2932
/** @satisfies {Warnings} */
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { test } from '../../test';
2+
3+
export default test({});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
let a = $state(1);
3+
let b = 2;
4+
</script>
5+
6+
<button onclick={() => a += 1}>a += 1</button>
7+
<button onclick={() => b += 1}>b += 1</button>
8+
<p>{a} + {b} = {a + b}</p>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"code": "non-state-reference",
4+
"message": "b is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates.",
5+
"start": {
6+
"column": 5,
7+
"line": 3
8+
},
9+
"end": {
10+
"column": 6,
11+
"line": 3
12+
}
13+
}
14+
]

0 commit comments

Comments
 (0)