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

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
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
33 changes: 32 additions & 1 deletion documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ Cannot assign to %thing%
Cannot bind to %thing%
```

### constructor_state_reassignment

```
A state field declaration in a constructor must be the first assignment, and the only one that uses a rune
```

[State fields]($state#Classes) can be declared as normal class fields or inside the constructor, in which case the declaration must be the _first_ assignment.
Assignments thereafter must not use the rune.

```ts
constructor() {
this.count = $state(0);
this.count = $state(1); // invalid, assigning to the same property with `$state` again
}

constructor() {
this.count = $state(0);
this.count = $state.raw(1); // invalid, assigning to the same property with a different rune
}

constructor() {
this.count = 0;
this.count = $state(1); // invalid, this property was created as a regular property, not state
}

constructor() {
this.count = $state(0);
this.count = 1; // valid, this is setting the state that has already been declared
}
```

### css_empty_declaration

```
Expand Down Expand Up @@ -855,7 +886,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
31 changes: 30 additions & 1 deletion packages/svelte/messages/compile-errors/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,35 @@

> Cannot bind to %thing%

## constructor_state_reassignment

> A state field declaration in a constructor must be the first assignment, and the only one that uses a rune

[State fields]($state#Classes) can be declared as normal class fields or inside the constructor, in which case the declaration must be the _first_ assignment.
Assignments thereafter must not use the rune.

```ts
constructor() {
this.count = $state(0);
this.count = $state(1); // invalid, assigning to the same property with `$state` again
}

constructor() {
this.count = $state(0);
this.count = $state.raw(1); // invalid, assigning to the same property with a different rune
}

constructor() {
this.count = 0;
this.count = $state(1); // invalid, this property was created as a regular property, not state
}

constructor() {
this.count = $state(0);
this.count = 1; // valid, this is setting the state that has already been declared
}
```

## declaration_duplicate

> `%name%` has already been declared
Expand Down Expand Up @@ -218,7 +247,7 @@ It's possible to export a snippet from a `<script module>` block, but only if it

## 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
13 changes: 11 additions & 2 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export function constant_binding(node, thing) {
e(node, 'constant_binding', `Cannot bind to ${thing}\nhttps://svelte.dev/e/constant_binding`);
}

/**
* A state field declaration in a constructor must be the first assignment, and the only one that uses a rune
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function constructor_state_reassignment(node) {
e(node, 'constructor_state_reassignment', `A state field declaration in a constructor must be the first assignment, and the only one that uses a rune\nhttps://svelte.dev/e/constructor_state_reassignment`);
}

/**
* `%name%` has already been declared
* @param {null | number | NodeLike} node
Expand Down Expand Up @@ -471,13 +480,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
10 changes: 6 additions & 4 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,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 +266,7 @@ export function analyze_module(ast, options) {
scope,
scopes,
analysis: /** @type {ComponentAnalysis} */ (analysis),
derived_state: [],
state_fields: null,
// 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 +430,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 +626,7 @@ export function analyze_component(root, source, options) {
has_props_rune: false,
component_slots: new Set(),
expression: null,
derived_state: [],
state_fields: null,
function_depth: scope.function_depth,
reactive_statement: null
};
Expand Down Expand Up @@ -691,7 +693,7 @@ export function analyze_component(root, source, options) {
reactive_statement: null,
component_slots: new Set(),
expression: null,
derived_state: [],
state_fields: null,
function_depth: scope.function_depth
};

Expand Down
8 changes: 6 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,7 @@
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';
import type { ClassBody } from 'estree';

export interface AnalysisState {
scope: Scope;
Expand All @@ -18,7 +19,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: Record<string, StateField> | null;

function_depth: number;

// legacy stuff
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,49 @@ 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
* @returns {node is AssignmentExpression & { left: { type: 'MemberExpression' } & { object: { type: 'ThisExpression' }; property: { type: 'Identifier' | 'PrivateIdentifier' | 'Literal' } } }}
*/
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')
)
) {
return false;
}

// AssignmentExpression (here) -> ExpressionStatement (-1) -> BlockStatement (-2) -> FunctionExpression (-3) -> MethodDefinition (-4)
const maybe_constructor = get_parent(context.path, -5);
return (
maybe_constructor &&
maybe_constructor.type === 'MethodDefinition' &&
maybe_constructor.kind === 'constructor'
);
}
93 changes: 78 additions & 15 deletions packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,93 @@
/** @import { ClassBody } from 'estree' */
/** @import { AssignmentExpression, ClassBody, PropertyDefinition, Expression, PrivateIdentifier, MethodDefinition } from 'estree' */
/** @import { StateField } from '#compiler' */
/** @import { Context } from '../types' */
import { get_rune } from '../../scope.js';
import * as e from '../../../errors.js';
import { is_state_creation_rune } from '../../../../utils.js';

/**
* @param {ClassBody} node
* @param {Context} context
*/
export function ClassBody(node, context) {
/** @type {{name: string, private: boolean}[]} */
const derived_state = [];
if (!context.state.analysis.runes) {
context.next();
return;
}

/** @type {Record<string, StateField>} */
const state_fields = {};

context.state.analysis.classes.set(node, state_fields);

/** @type {string[]} */
const seen = [];

/** @type {MethodDefinition | null} */
let constructor = null;

/**
* @param {PropertyDefinition | AssignmentExpression} node
* @param {Expression | PrivateIdentifier} key
* @param {Expression | null | undefined} value
*/
function handle(node, key, value) {
const name =
(key.type === 'Literal' && String(key.value)) ||
(key.type === 'PrivateIdentifier' && '#' + key.name) ||
(key.type === 'Identifier' && key.name);

if (!name) return;

const rune = get_rune(value, context.state.scope);

if (rune && is_state_creation_rune(rune)) {
if (seen.includes(name)) {
e.constructor_state_reassignment(node); // TODO the same thing applies to duplicate fields, so the code/message needs to change
}

state_fields[name] = {
node,
type: rune
};
}

if (value) {
seen.push(name);
}
}

for (const child of node.body) {
if (child.type === 'PropertyDefinition' && !child.computed) {
handle(child, child.key, child.value);
}

for (const definition of node.body) {
if (
definition.type === 'PropertyDefinition' &&
(definition.key.type === 'PrivateIdentifier' || definition.key.type === 'Identifier') &&
definition.value?.type === 'CallExpression'
child.type === 'MethodDefinition' &&
child.key.type === 'Identifier' &&
child.key.name === 'constructor'
) {
const rune = get_rune(definition.value, context.state.scope);
if (rune === '$derived' || rune === '$derived.by') {
derived_state.push({
name: definition.key.name,
private: definition.key.type === 'PrivateIdentifier'
});
}
constructor = child;
}
}

if (constructor) {
for (const statement of constructor.value.body.body) {
if (statement.type !== 'ExpressionStatement') continue;
if (statement.expression.type !== 'AssignmentExpression') continue;

const { left, right } = statement.expression;

if (left.type !== 'MemberExpression') continue;
if (left.object.type !== 'ThisExpression') continue;
if (left.computed && left.property.type !== 'Literal') continue;

handle(statement.expression, left.property, right);
}
}

context.next({ ...context.state, derived_state });
context.next({
...context.state,
state_fields
});
}
Loading