diff --git a/.changeset/mean-squids-scream.md b/.changeset/mean-squids-scream.md
new file mode 100644
index 000000000000..2157ea85a64f
--- /dev/null
+++ b/.changeset/mean-squids-scream.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: allow state fields to be declared inside class constructors
diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md
index e8669ead533d..281186168060 100644
--- a/documentation/docs/98-reference/.generated/compile-errors.md
+++ b/documentation/docs/98-reference/.generated/compile-errors.md
@@ -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
```
@@ -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
diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md
index aabcbeae4812..7831be3a42eb 100644
--- a/packages/svelte/messages/compile-errors/script.md
+++ b/packages/svelte/messages/compile-errors/script.md
@@ -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
@@ -218,7 +247,7 @@ It's possible to export a snippet from a `
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/_config.js
new file mode 100644
index 000000000000..dd847ce2f2a6
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/_config.js
@@ -0,0 +1,13 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: ``,
+ ssrHtml: ``,
+
+ async test({ assert, target }) {
+ flushSync();
+
+ assert.htmlEqual(target.innerHTML, ``);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/main.svelte
new file mode 100644
index 000000000000..47b8c901eb95
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/main.svelte
@@ -0,0 +1,12 @@
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js
new file mode 100644
index 000000000000..f47bee71df87
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js
@@ -0,0 +1,3 @@
+import { test } from '../../test';
+
+export default test({});
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte
new file mode 100644
index 000000000000..e2c4f302b397
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js
new file mode 100644
index 000000000000..4cf1aea213dc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js
@@ -0,0 +1,45 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ // The component context class instance gets shared between tests, strangely, causing hydration to fail?
+ mode: ['client', 'server'],
+
+ async test({ assert, target, logs }) {
+ const btn = target.querySelector('button');
+
+ flushSync(() => {
+ btn?.click();
+ });
+
+ assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1]);
+
+ flushSync(() => {
+ btn?.click();
+ });
+
+ assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1, 2]);
+
+ flushSync(() => {
+ btn?.click();
+ });
+
+ assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1, 2, 3]);
+
+ flushSync(() => {
+ btn?.click();
+ });
+
+ assert.deepEqual(logs, [
+ 0,
+ 'class trigger false',
+ 'local trigger false',
+ 1,
+ 2,
+ 3,
+ 4,
+ 'class trigger true',
+ 'local trigger true'
+ ]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte
new file mode 100644
index 000000000000..03687d01bb3d
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js
new file mode 100644
index 000000000000..02cf36d900cc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js
@@ -0,0 +1,20 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: ``,
+
+ test({ assert, target }) {
+ const btn = target.querySelector('button');
+
+ flushSync(() => {
+ btn?.click();
+ });
+ assert.htmlEqual(target.innerHTML, ``);
+
+ flushSync(() => {
+ btn?.click();
+ });
+ assert.htmlEqual(target.innerHTML, ``);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte
new file mode 100644
index 000000000000..5dbbb10afd35
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte
@@ -0,0 +1,12 @@
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js
new file mode 100644
index 000000000000..32cca6c69375
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js
@@ -0,0 +1,20 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: ``,
+
+ test({ assert, target }) {
+ const btn = target.querySelector('button');
+
+ flushSync(() => {
+ btn?.click();
+ });
+ assert.htmlEqual(target.innerHTML, ``);
+
+ flushSync(() => {
+ btn?.click();
+ });
+ assert.htmlEqual(target.innerHTML, ``);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte
new file mode 100644
index 000000000000..d8feb554cd18
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js
new file mode 100644
index 000000000000..f35dc57228a1
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js
@@ -0,0 +1,20 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: ``,
+
+ test({ assert, target }) {
+ const btn = target.querySelector('button');
+
+ flushSync(() => {
+ btn?.click();
+ });
+ assert.htmlEqual(target.innerHTML, ``);
+
+ flushSync(() => {
+ btn?.click();
+ });
+ assert.htmlEqual(target.innerHTML, ``);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte
new file mode 100644
index 000000000000..aa8ba1658b03
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json
new file mode 100644
index 000000000000..3eaacfe85013
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "constructor_state_reassignment",
+ "message": "A state field declaration in a constructor must be the first assignment, and the only one that uses a rune",
+ "start": {
+ "line": 5,
+ "column": 2
+ },
+ "end": {
+ "line": 5,
+ "column": 24
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js
new file mode 100644
index 000000000000..05cd4d9d9d64
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js
@@ -0,0 +1,7 @@
+export class Counter {
+ count = $state(0);
+
+ constructor() {
+ this.count = $state(0);
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json
new file mode 100644
index 000000000000..3eaacfe85013
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "constructor_state_reassignment",
+ "message": "A state field declaration in a constructor must be the first assignment, and the only one that uses a rune",
+ "start": {
+ "line": 5,
+ "column": 2
+ },
+ "end": {
+ "line": 5,
+ "column": 24
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js
new file mode 100644
index 000000000000..e37be4b3e691
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js
@@ -0,0 +1,7 @@
+export class Counter {
+ constructor() {
+ this.count = $state(0);
+ this.count = 1;
+ this.count = $state(0);
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json
new file mode 100644
index 000000000000..a0f12ba4a6e6
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "constructor_state_reassignment",
+ "message": "A state field declaration in a constructor must be the first assignment, and the only one that uses a rune",
+ "start": {
+ "line": 5,
+ "column": 2
+ },
+ "end": {
+ "line": 5,
+ "column": 28
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js
new file mode 100644
index 000000000000..f9196ff3cd51
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js
@@ -0,0 +1,7 @@
+export class Counter {
+ constructor() {
+ this.count = $state(0);
+ this.count = 1;
+ this.count = $state.raw(0);
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json
new file mode 100644
index 000000000000..9f959874c80e
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "state_invalid_placement",
+ "message": "`$state(...)` 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.",
+ "start": {
+ "line": 4,
+ "column": 16
+ },
+ "end": {
+ "line": 4,
+ "column": 25
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js
new file mode 100644
index 000000000000..bf1aada1b5df
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js
@@ -0,0 +1,7 @@
+export class Counter {
+ constructor() {
+ if (true) {
+ this.count = $state(0);
+ }
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json
new file mode 100644
index 000000000000..82209f6a8ccf
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "constructor_state_reassignment",
+ "message": "A state field declaration in a constructor must be the first assignment, and the only one that uses a rune",
+ "start": {
+ "line": 5,
+ "column": 2
+ },
+ "end": {
+ "line": 5,
+ "column": 27
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js
new file mode 100644
index 000000000000..bc3d19a14fae
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js
@@ -0,0 +1,7 @@
+export class Counter {
+ // prettier-ignore
+ 'count' = $state(0);
+ constructor() {
+ this['count'] = $state(0);
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json
new file mode 100644
index 000000000000..7dd90d4837e6
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "constructor_state_reassignment",
+ "message": "A state field declaration in a constructor must be the first assignment, and the only one that uses a rune",
+ "start": {
+ "line": 4,
+ "column": 2
+ },
+ "end": {
+ "line": 4,
+ "column": 27
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js
new file mode 100644
index 000000000000..2ebe52e685ed
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js
@@ -0,0 +1,6 @@
+export class Counter {
+ count = $state(0);
+ constructor() {
+ this['count'] = $state(0);
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-7/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-7/errors.json
new file mode 100644
index 000000000000..64e56f8d5c4e
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-7/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "state_invalid_placement",
+ "message": "`$state(...)` 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.",
+ "start": {
+ "line": 5,
+ "column": 16
+ },
+ "end": {
+ "line": 5,
+ "column": 25
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-7/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-7/input.svelte.js
new file mode 100644
index 000000000000..50c855983700
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-7/input.svelte.js
@@ -0,0 +1,7 @@
+const count = 'count';
+
+export class Counter {
+ constructor() {
+ this[count] = $state(0);
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-8/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-8/errors.json
new file mode 100644
index 000000000000..ae0caee6169d
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-8/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "constructor_state_reassignment",
+ "message": "A state field declaration in a constructor must be the first assignment, and the only one that uses a rune",
+ "start": {
+ "line": 4,
+ "column": 2
+ },
+ "end": {
+ "line": 4,
+ "column": 24
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-8/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-8/input.svelte.js
new file mode 100644
index 000000000000..0a76c6fec90f
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-8/input.svelte.js
@@ -0,0 +1,6 @@
+export class Counter {
+ constructor() {
+ this.count = -1;
+ this.count = $state(0);
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-9/_config.js b/packages/svelte/tests/validator/samples/class-state-constructor-9/_config.js
new file mode 100644
index 000000000000..18cc8bd6d146
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-9/_config.js
@@ -0,0 +1,5 @@
+import { test } from '../../test';
+
+export default test({
+ skip: true // TODO delete this file so the test runs
+});
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-9/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-9/errors.json
new file mode 100644
index 000000000000..6cdeb4a14e59
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-9/errors.json
@@ -0,0 +1,14 @@
+[
+ {
+ "code": "constructor_state_reassignment",
+ "message": "A state field declaration in a constructor must be the first assignment, and the only one that uses a rune",
+ "start": {
+ "line": 7,
+ "column": 2
+ },
+ "end": {
+ "line": 7,
+ "column": 24
+ }
+ }
+]
diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-9/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-9/input.svelte.js
new file mode 100644
index 000000000000..e5ad562727c7
--- /dev/null
+++ b/packages/svelte/tests/validator/samples/class-state-constructor-9/input.svelte.js
@@ -0,0 +1,9 @@
+export class Counter {
+ constructor() {
+ if (true) {
+ this.count = -1;
+ }
+
+ this.count = $state(0);
+ }
+}
diff --git a/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json b/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json
index 32594e426846..e1906b181a4d 100644
--- a/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json
+++ b/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json
@@ -1,7 +1,7 @@
[
{
"code": "state_invalid_placement",
- "message": "`$derived(...)` can only be used as a variable declaration initializer or a class field",
+ "message": "`$derived(...)` 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.",
"start": {
"line": 2,
"column": 15