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