Skip to content

feat: State declarations in class constructors #15820

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 65 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
134d435
feat: State declarations in class constructors
elliott-with-the-longest-name-on-github Apr 23, 2025
fb8d6d7
feat: Analysis phase
elliott-with-the-longest-name-on-github Apr 24, 2025
033a466
misc
elliott-with-the-longest-name-on-github Apr 25, 2025
005ba29
feat: client
elliott-with-the-longest-name-on-github Apr 26, 2025
4d6422c
improvements
elliott-with-the-longest-name-on-github Apr 26, 2025
adb6e71
feat: It is now at least backwards compatible. though the new stuff m…
elliott-with-the-longest-name-on-github Apr 29, 2025
92940ff
feat: It works I think?
elliott-with-the-longest-name-on-github Apr 29, 2025
ac42ad5
final cleanup??
elliott-with-the-longest-name-on-github Apr 29, 2025
b44eed9
tests
elliott-with-the-longest-name-on-github Apr 29, 2025
12a02b7
test for better types
elliott-with-the-longest-name-on-github Apr 29, 2025
4a19fd1
Merge branch 'main' into elliott/class-constructor-state
Rich-Harris May 15, 2025
50adbfb
changeset
Rich-Harris May 15, 2025
682b0e6
rename functions (the function doesn't test call-expression-ness)
Rich-Harris May 15, 2025
76b07e5
small readability tweak
Rich-Harris May 15, 2025
6395085
failing test
Rich-Harris May 15, 2025
0024e1e
fix
Rich-Harris May 15, 2025
8c7ad3c
disallow computed state fields
Rich-Harris May 15, 2025
15c7b14
tweak message to better accommodate the case in which state is declar…
Rich-Harris May 15, 2025
0ac22c1
failing test
Rich-Harris May 15, 2025
4edebbc
wildly confusing to have so many things called 'class analysis' - ren…
Rich-Harris May 15, 2025
1e0f423
missed a spot
Rich-Harris May 15, 2025
f81405c
and another
Rich-Harris May 15, 2025
41e8ade
store analysis for use during transformation
Rich-Harris May 15, 2025
1d1f0eb
move code to where it is used
Rich-Harris May 15, 2025
823e66f
do the analysis upfront, it's way simpler
Rich-Harris May 15, 2025
0dcd3cd
skip failing test for now
Rich-Harris May 15, 2025
7341f40
simplify
Rich-Harris May 15, 2025
75680a9
get rid of the class
Rich-Harris May 15, 2025
2ffb863
on second thoughts
Rich-Harris May 15, 2025
b1e095a
reduce indirection
Rich-Harris May 15, 2025
c407dc0
make analysis available at transform time
Rich-Harris May 15, 2025
2efb766
WIP
Rich-Harris May 16, 2025
0e9ca23
WIP
Rich-Harris May 16, 2025
b7bbae8
WIP
Rich-Harris May 16, 2025
737bfb5
fix
Rich-Harris May 16, 2025
c536ec6
remove unused stuff
Rich-Harris May 16, 2025
fbf1b4e
revert snapshot tests
Rich-Harris May 16, 2025
de015b0
unused
Rich-Harris May 16, 2025
4653f54
note to self
Rich-Harris May 16, 2025
23f269b
fix
Rich-Harris May 16, 2025
d1bb2c9
unused
Rich-Harris May 16, 2025
de77e69
unused
Rich-Harris May 16, 2025
dfb4e13
remove some unused stuff
Rich-Harris May 16, 2025
2556031
unused
Rich-Harris May 16, 2025
c7e8422
lint, tidy up
Rich-Harris May 16, 2025
1a78e27
reuse helper
Rich-Harris May 16, 2025
90e7e0d
tweak
Rich-Harris May 16, 2025
a6cc07c
simplify/DRY
Rich-Harris May 16, 2025
45115de
unused
Rich-Harris May 16, 2025
56bd834
tweak
Rich-Harris May 16, 2025
855f209
unused
Rich-Harris May 16, 2025
d31831b
more
Rich-Harris May 16, 2025
0cac3d2
tweak
Rich-Harris May 16, 2025
b1cc424
tweak
Rich-Harris May 16, 2025
bbf0191
fix proxying logic
Rich-Harris May 16, 2025
2b42182
tweak
Rich-Harris May 16, 2025
63e60c7
tweak
Rich-Harris May 16, 2025
c82ede5
adjust message to accommodate more cases
Rich-Harris May 19, 2025
d6c9859
unskip and fix test
Rich-Harris May 19, 2025
ffcabe6
fix
Rich-Harris May 19, 2025
01af3ae
move
Rich-Harris May 19, 2025
1f52cf0
revert unneeded drive-by change
Rich-Harris May 19, 2025
3bfcacc
fix
Rich-Harris May 19, 2025
ae7e5bb
fix
Rich-Harris May 19, 2025
670a7e1
update docs
Rich-Harris May 19, 2025
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
5 changes: 5 additions & 0 deletions .changeset/mean-squids-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: allow state fields to be declared inside class constructors
8 changes: 3 additions & 5 deletions documentation/docs/02-runes/02-$state.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,15 @@ todos[0].done = !todos[0].done;

### Classes

You can also use `$state` in class fields (whether public or private):
You can also use `$state` in class fields (whether public or private), or as the first assignment to a property immediately inside the `constructor`:

```js
// @errors: 7006 2554
class Todo {
done = $state(false);
text = $state();

constructor(text) {
this.text = text;
this.text = $state(text);
}

reset() {
Expand Down Expand Up @@ -110,10 +109,9 @@ You can either use an inline function...
// @errors: 7006 2554
class Todo {
done = $state(false);
text = $state();

constructor(text) {
this.text = text;
this.text = $state(text);
}

+++reset = () => {+++
Expand Down
34 changes: 33 additions & 1 deletion documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,38 @@ Cannot reassign or bind to snippet parameter
This snippet is shadowing the prop `%prop%` with the same name
```

### state_field_duplicate

```
`%name%` has already been declared on this class
```

An assignment to a class field that uses a `$state` or `$derived` rune is considered a _state field declaration_. The declaration can happen in the class body...

```js
class Counter {
count = $state(0);
}
```

...or inside the constructor...

```js
class Counter {
constructor() {
this.count = $state(0);
}
}
```

...but it can only happen once.

### state_field_invalid_assignment

```
Cannot assign to a state field before its declaration
```

### state_invalid_export

```
Expand All @@ -855,7 +887,7 @@ Cannot export state from a module if it is reassigned. Either export a function
### state_invalid_placement

```
`%rune%(...)` can only be used as a variable declaration initializer or a class field
`%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.
```

### store_invalid_scoped_subscription
Expand Down
30 changes: 29 additions & 1 deletion packages/svelte/messages/compile-errors/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,41 @@ It's possible to export a snippet from a `<script module>` block, but only if it

> Cannot reassign or bind to snippet parameter

## state_field_duplicate

> `%name%` has already been declared on this class

An assignment to a class field that uses a `$state` or `$derived` rune is considered a _state field declaration_. The declaration can happen in the class body...

```js
class Counter {
count = $state(0);
}
```

...or inside the constructor...

```js
class Counter {
constructor() {
this.count = $state(0);
}
}
```

...but it can only happen once.

## state_field_invalid_assignment

> Cannot assign to a state field before its declaration

## state_invalid_export

> Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value's properties

## state_invalid_placement

> `%rune%(...)` can only be used as a variable declaration initializer or a class field
> `%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.

## store_invalid_scoped_subscription

Expand Down
23 changes: 21 additions & 2 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,25 @@ export function snippet_parameter_assignment(node) {
e(node, 'snippet_parameter_assignment', `Cannot reassign or bind to snippet parameter\nhttps://svelte.dev/e/snippet_parameter_assignment`);
}

/**
* `%name%` has already been declared on this class
* @param {null | number | NodeLike} node
* @param {string} name
* @returns {never}
*/
export function state_field_duplicate(node, name) {
e(node, 'state_field_duplicate', `\`${name}\` has already been declared on this class\nhttps://svelte.dev/e/state_field_duplicate`);
}

/**
* Cannot assign to a state field before its declaration
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function state_field_invalid_assignment(node) {
e(node, 'state_field_invalid_assignment', `Cannot assign to a state field before its declaration\nhttps://svelte.dev/e/state_field_invalid_assignment`);
}

/**
* Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value's properties
* @param {null | number | NodeLike} node
Expand All @@ -471,13 +490,13 @@ export function state_invalid_export(node) {
}

/**
* `%rune%(...)` can only be used as a variable declaration initializer or a class field
* `%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.
* @param {null | number | NodeLike} node
* @param {string} rune
* @returns {never}
*/
export function state_invalid_placement(node, rune) {
e(node, 'state_invalid_placement', `\`${rune}(...)\` can only be used as a variable declaration initializer or a class field\nhttps://svelte.dev/e/state_invalid_placement`);
e(node, 'state_invalid_placement', `\`${rune}(...)\` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.\nhttps://svelte.dev/e/state_invalid_placement`);
}

/**
Expand Down
12 changes: 8 additions & 4 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { Literal } from './visitors/Literal.js';
import { MemberExpression } from './visitors/MemberExpression.js';
import { NewExpression } from './visitors/NewExpression.js';
import { OnDirective } from './visitors/OnDirective.js';
import { PropertyDefinition } from './visitors/PropertyDefinition.js';
import { RegularElement } from './visitors/RegularElement.js';
import { RenderTag } from './visitors/RenderTag.js';
import { SlotElement } from './visitors/SlotElement.js';
Expand Down Expand Up @@ -164,6 +165,7 @@ const visitors = {
MemberExpression,
NewExpression,
OnDirective,
PropertyDefinition,
RegularElement,
RenderTag,
SlotElement,
Expand Down Expand Up @@ -256,7 +258,8 @@ export function analyze_module(ast, options) {
accessors: false,
runes: true,
immutable: true,
tracing: false
tracing: false,
classes: new Map()
};

walk(
Expand All @@ -265,7 +268,7 @@ export function analyze_module(ast, options) {
scope,
scopes,
analysis: /** @type {ComponentAnalysis} */ (analysis),
derived_state: [],
state_fields: new Map(),
// TODO the following are not needed for modules, but we have to pass them in order to avoid type error,
// and reducing the type would result in a lot of tedious type casts elsewhere - find a good solution one day
ast_type: /** @type {any} */ (null),
Expand Down Expand Up @@ -429,6 +432,7 @@ export function analyze_component(root, source, options) {
elements: [],
runes,
tracing: false,
classes: new Map(),
immutable: runes || options.immutable,
exports: [],
uses_props: false,
Expand Down Expand Up @@ -624,7 +628,7 @@ export function analyze_component(root, source, options) {
has_props_rune: false,
component_slots: new Set(),
expression: null,
derived_state: [],
state_fields: new Map(),
function_depth: scope.function_depth,
reactive_statement: null
};
Expand Down Expand Up @@ -691,7 +695,7 @@ export function analyze_component(root, source, options) {
reactive_statement: null,
component_slots: new Set(),
expression: null,
derived_state: [],
state_fields: new Map(),
function_depth: scope.function_depth
};

Expand Down
7 changes: 5 additions & 2 deletions packages/svelte/src/compiler/phases/2-analyze/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Scope } from '../scope.js';
import type { ComponentAnalysis, ReactiveStatement } from '../types.js';
import type { AST, ExpressionMetadata, ValidatedCompileOptions } from '#compiler';
import type { AST, ExpressionMetadata, StateField, ValidatedCompileOptions } from '#compiler';

export interface AnalysisState {
scope: Scope;
Expand All @@ -18,7 +18,10 @@ export interface AnalysisState {
component_slots: Set<string>;
/** Information about the current expression/directive/block value */
expression: ExpressionMetadata | null;
derived_state: { name: string; private: boolean }[];

/** Used to analyze class state */
state_fields: Map<string, StateField>;

function_depth: number;

// legacy stuff
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { validate_assignment } from './shared/utils.js';
* @param {Context} context
*/
export function AssignmentExpression(node, context) {
validate_assignment(node, node.left, context.state);
validate_assignment(node, node.left, context);

if (context.state.reactive_statement) {
const id = node.left.type === 'MemberExpression' ? object(node.left) : node.left;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export function BindDirective(node, context) {
return;
}

validate_assignment(node, node.expression, context.state);
validate_assignment(node, node.expression, context);

const assignee = node.expression;
const left = object(assignee);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,13 @@ export function CallExpression(node, context) {
case '$state':
case '$state.raw':
case '$derived':
case '$derived.by':
if (
(parent.type !== 'VariableDeclarator' ||
get_parent(context.path, -3).type === 'ConstTag') &&
!(parent.type === 'PropertyDefinition' && !parent.static && !parent.computed)
) {
case '$derived.by': {
const valid =
is_variable_declaration(parent, context) ||
is_class_property_definition(parent) ||
is_class_property_assignment_at_constructor_root(parent, context);

if (!valid) {
e.state_invalid_placement(node, rune);
}

Expand All @@ -130,6 +131,7 @@ export function CallExpression(node, context) {
}

break;
}

case '$effect':
case '$effect.pre':
Expand Down Expand Up @@ -270,3 +272,40 @@ function get_function_label(nodes) {
return parent.id.name;
}
}

/**
* @param {AST.SvelteNode} parent
* @param {Context} context
*/
function is_variable_declaration(parent, context) {
return parent.type === 'VariableDeclarator' && get_parent(context.path, -3).type !== 'ConstTag';
}

/**
* @param {AST.SvelteNode} parent
*/
function is_class_property_definition(parent) {
return parent.type === 'PropertyDefinition' && !parent.static && !parent.computed;
}

/**
* @param {AST.SvelteNode} node
* @param {Context} context
*/
function is_class_property_assignment_at_constructor_root(node, context) {
if (
node.type === 'AssignmentExpression' &&
node.operator === '=' &&
node.left.type === 'MemberExpression' &&
node.left.object.type === 'ThisExpression' &&
((node.left.property.type === 'Identifier' && !node.left.computed) ||
node.left.property.type === 'PrivateIdentifier' ||
node.left.property.type === 'Literal')
) {
// MethodDefinition (-5) -> FunctionExpression (-4) -> BlockStatement (-3) -> ExpressionStatement (-2) -> AssignmentExpression (-1)
const parent = get_parent(context.path, -5);
return parent?.type === 'MethodDefinition' && parent.kind === 'constructor';
}

return false;
}
Loading