Merge branch 'from-action' into from-action-remove-interface

pull/15958/head
Rich Harris 4 months ago
commit d166cd6bd9

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: handle more hydration mismatches

@ -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() {
@ -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 = () => {+++

@ -2,7 +2,9 @@
title: {@attach ...}
---
Attachments are functions that run when an element is mounted to the DOM. Optionally, they can return a function that is called when the element is later removed from the DOM.
Attachments are functions that run in an [effect]($effect) when an element is mounted to the DOM or when [state]($state) read inside the function updates.
Optionally, they can return a function that is called before the attachment re-runs, or after the element is later removed from the DOM.
> [!NOTE]
> Attachments are available in Svelte 5.29 and newer.
@ -55,7 +57,7 @@ A useful pattern is for a function, such as `tooltip` in this example, to _retur
</button>
```
Since the `tooltip(content)` expression runs inside an [effect]($effect), the attachment will be destroyed and recreated whenever `content` changes.
Since the `tooltip(content)` expression runs inside an [effect]($effect), the attachment will be destroyed and recreated whenever `content` changes. The same thing would happen for any state read _inside_ the attachment function when it first runs. (If this isn't what you want, see [Controlling when attachments re-run](#Controlling-when-attachments-re-run).)
## Inline attachments
@ -126,9 +128,42 @@ This allows you to create _wrapper components_ that augment elements ([demo](/pl
</Button>
```
### Converting actions to attachments
## Controlling when attachments re-run
Attachments, unlike [actions](use), are fully reactive: `{@attach foo(bar)}` will re-run on changes to `foo` _or_ `bar` (or any state read inside `foo`):
```js
// @errors: 7006 2304 2552
function foo(bar) {
return (node) => {
veryExpensiveSetupWork(node);
update(node, bar);
};
}
```
In the rare case that this is a problem (for example, if `foo` does expensive and unavoidable setup work) consider passing the data inside a function and reading it in a child effect:
```js
// @errors: 7006 2304 2552
function foo(+++getBar+++) {
return (node) => {
veryExpensiveSetupWork(node);
+++ $effect(() => {
update(node, getBar());
});+++
}
}
```
## Creating attachments programmatically
To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).
If you want to use this functionality on Components but you are using a library that only provides actions you can use the `fromAction` utility exported from `svelte/attachments` to convert between the two.
## Converting actions to attachments
If you want to use this functionality on components but you are using a library that only provides actions you can use the [`fromAction`](svelte/attachments#fromAction) utility.
This function accept an action as the first argument and a function returning the arguments of the action as the second argument and returns an attachment.
@ -146,9 +181,4 @@ This function accept an action as the first argument and a function returning th
{@attach fromAction(log, () => count)}
>
{count}
</Button>
```
## Creating attachments programmatically
To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).
</Button>

@ -125,7 +125,7 @@ In many cases this is perfectly fine, but there is a risk: if you mutate the sta
```svelte
<!--- file: App.svelte ---->
<script>
import { myGlobalState } from 'svelte';
import { myGlobalState } from './state.svelte.js';
let { data } = $props();

@ -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
```
@ -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

@ -1,5 +1,29 @@
# svelte
## 5.31.1
### Patch Changes
- fix: avoid auto-parenthesis for special-keywords-only `MediaQuery` ([#15937](https://github.com/sveltejs/svelte/pull/15937))
## 5.31.0
### Minor Changes
- feat: allow state fields to be declared inside class constructors ([#15820](https://github.com/sveltejs/svelte/pull/15820))
### Patch Changes
- fix: Add missing `AttachTag` in `Tag` union type inside the `AST` namespace from `"svelte/compiler"` ([#15946](https://github.com/sveltejs/svelte/pull/15946))
## 5.30.2
### Patch Changes
- fix: falsy attachments types ([#15939](https://github.com/sveltejs/svelte/pull/15939))
- fix: handle more hydration mismatches ([#15851](https://github.com/sveltejs/svelte/pull/15851))
## 5.30.1
### Patch Changes

@ -863,8 +863,8 @@ export interface HTMLAttributes<T extends EventTarget> extends AriaAttributes, D
// allow any data- attribute
[key: `data-${string}`]: any;
// allow any attachment
[key: symbol]: Attachment<T>;
// allow any attachment and falsy values (by using false we prevent the usage of booleans values by themselves)
[key: symbol]: Attachment<T> | false | undefined | null;
}
export type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {});

@ -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

@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
"version": "5.30.1",
"version": "5.31.1",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
@ -136,7 +136,7 @@
],
"scripts": {
"build": "node scripts/process-messages && rollup -c && pnpm generate:types && node scripts/check-treeshakeability.js",
"dev": "node scripts/process-messages && rollup -cw",
"dev": "node scripts/process-messages -w & rollup -cw",
"check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc",
"check:watch": "tsc --watch",
"generate:version": "node ./scripts/generate-version.js",

@ -1,409 +1,441 @@
// @ts-check
import process from 'node:process';
import fs from 'node:fs';
import * as acorn from 'acorn';
import { walk } from 'zimmerframe';
import * as esrap from 'esrap';
/** @type {Record<string, Record<string, { messages: string[], details: string | null }>>} */
const messages = {};
const seen = new Set();
const DIR = '../../documentation/docs/98-reference/.generated';
fs.rmSync(DIR, { force: true, recursive: true });
fs.mkdirSync(DIR);
for (const category of fs.readdirSync('messages')) {
if (category.startsWith('.')) continue;
const watch = process.argv.includes('-w');
messages[category] = {};
function run() {
/** @type {Record<string, Record<string, { messages: string[], details: string | null }>>} */
const messages = {};
const seen = new Set();
for (const file of fs.readdirSync(`messages/${category}`)) {
if (!file.endsWith('.md')) continue;
fs.rmSync(DIR, { force: true, recursive: true });
fs.mkdirSync(DIR);
const markdown = fs
.readFileSync(`messages/${category}/${file}`, 'utf-8')
.replace(/\r\n/g, '\n');
for (const category of fs.readdirSync('messages')) {
if (category.startsWith('.')) continue;
const sorted = [];
messages[category] = {};
for (const match of markdown.matchAll(/## ([\w]+)\n\n([^]+?)(?=$|\n\n## )/g)) {
const [_, code, text] = match;
for (const file of fs.readdirSync(`messages/${category}`)) {
if (!file.endsWith('.md')) continue;
if (seen.has(code)) {
throw new Error(`Duplicate message code ${category}/${code}`);
}
const markdown = fs
.readFileSync(`messages/${category}/${file}`, 'utf-8')
.replace(/\r\n/g, '\n');
sorted.push({ code, _ });
const sorted = [];
const sections = text.trim().split('\n\n');
const details = [];
for (const match of markdown.matchAll(/## ([\w]+)\n\n([^]+?)(?=$|\n\n## )/g)) {
const [_, code, text] = match;
while (!sections[sections.length - 1].startsWith('> ')) {
details.unshift(/** @type {string} */ (sections.pop()));
}
if (seen.has(code)) {
throw new Error(`Duplicate message code ${category}/${code}`);
}
sorted.push({ code, _ });
if (sections.length === 0) {
throw new Error('No message text');
const sections = text.trim().split('\n\n');
const details = [];
while (!sections[sections.length - 1].startsWith('> ')) {
details.unshift(/** @type {string} */ (sections.pop()));
}
if (sections.length === 0) {
throw new Error('No message text');
}
seen.add(code);
messages[category][code] = {
messages: sections.map((section) => section.replace(/^> /gm, '').replace(/^>\n/gm, '\n')),
details: details.join('\n\n')
};
}
seen.add(code);
messages[category][code] = {
messages: sections.map((section) => section.replace(/^> /gm, '').replace(/^>\n/gm, '\n')),
details: details.join('\n\n')
};
sorted.sort((a, b) => (a.code < b.code ? -1 : 1));
fs.writeFileSync(
`messages/${category}/${file}`,
sorted.map((x) => x._.trim()).join('\n\n') + '\n'
);
}
sorted.sort((a, b) => (a.code < b.code ? -1 : 1));
fs.writeFileSync(
`messages/${category}/${file}`,
sorted.map((x) => x._.trim()).join('\n\n') + '\n'
`${DIR}/${category}.md`,
'<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->\n\n' +
Object.entries(messages[category])
.map(([code, { messages, details }]) => {
const chunks = [
`### ${code}`,
...messages.map((message) => '```\n' + message + '\n```')
];
if (details) {
chunks.push(details);
}
return chunks.join('\n\n');
})
.sort()
.join('\n\n') +
'\n'
);
}
fs.writeFileSync(
`${DIR}/${category}.md`,
'<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->\n\n' +
Object.entries(messages[category])
.map(([code, { messages, details }]) => {
const chunks = [`### ${code}`, ...messages.map((message) => '```\n' + message + '\n```')];
if (details) {
chunks.push(details);
}
return chunks.join('\n\n');
})
.sort()
.join('\n\n') +
'\n'
);
}
/**
* @param {string} name
* @param {string} dest
*/
function transform(name, dest) {
const source = fs
.readFileSync(new URL(`./templates/${name}.js`, import.meta.url), 'utf-8')
.replace(/\r\n/g, '\n');
/**
* @type {Array<{
* type: string;
* value: string;
* start: number;
* end: number
* }>}
* @param {string} name
* @param {string} dest
*/
const comments = [];
let ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'module',
onComment: (block, value, start, end) => {
if (block && /\n/.test(value)) {
let a = start;
while (a > 0 && source[a - 1] !== '\n') a -= 1;
let b = a;
while (/[ \t]/.test(source[b])) b += 1;
const indentation = source.slice(a, b);
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
}
comments.push({ type: block ? 'Block' : 'Line', value, start, end });
}
});
function transform(name, dest) {
const source = fs
.readFileSync(new URL(`./templates/${name}.js`, import.meta.url), 'utf-8')
.replace(/\r\n/g, '\n');
ast = walk(ast, null, {
_(node, { next }) {
let comment;
/**
* @type {Array<{
* type: string;
* value: string;
* start: number;
* end: number
* }>}
*/
const comments = [];
let ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'module',
onComment: (block, value, start, end) => {
if (block && /\n/.test(value)) {
let a = start;
while (a > 0 && source[a - 1] !== '\n') a -= 1;
let b = a;
while (/[ \t]/.test(source[b])) b += 1;
const indentation = source.slice(a, b);
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
}
while (comments[0] && comments[0].start < node.start) {
comment = comments.shift();
// @ts-expect-error
(node.leadingComments ||= []).push(comment);
comments.push({ type: block ? 'Block' : 'Line', value, start, end });
}
});
next();
if (comments[0]) {
const slice = source.slice(node.end, comments[0].start);
ast = walk(ast, null, {
_(node, { next }) {
let comment;
if (/^[,) \t]*$/.test(slice)) {
while (comments[0] && comments[0].start < node.start) {
comment = comments.shift();
// @ts-expect-error
node.trailingComments = [comments.shift()];
(node.leadingComments ||= []).push(comment);
}
}
},
// @ts-expect-error
Identifier(node, context) {
if (node.name === 'CODES') {
return {
type: 'ArrayExpression',
elements: Object.keys(messages[name]).map((code) => ({
type: 'Literal',
value: code
}))
};
}
}
});
if (comments.length > 0) {
// @ts-expect-error
(ast.trailingComments ||= []).push(...comments);
}
const category = messages[name];
// find the `export function CODE` node
const index = ast.body.findIndex((node) => {
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'FunctionDeclaration'
) {
return node.declaration.id.name === 'CODE';
}
});
if (index === -1) throw new Error(`missing export function CODE in ${name}.js`);
const template_node = ast.body[index];
ast.body.splice(index, 1);
next();
for (const code in category) {
const { messages } = category[code];
/** @type {string[]} */
const vars = [];
if (comments[0]) {
const slice = source.slice(node.end, comments[0].start);
const group = messages.map((text, i) => {
for (const match of text.matchAll(/%(\w+)%/g)) {
const name = match[1];
if (!vars.includes(name)) {
vars.push(match[1]);
if (/^[,) \t]*$/.test(slice)) {
// @ts-expect-error
node.trailingComments = [comments.shift()];
}
}
},
// @ts-expect-error
Identifier(node, context) {
if (node.name === 'CODES') {
return {
type: 'ArrayExpression',
elements: Object.keys(messages[name]).map((code) => ({
type: 'Literal',
value: code
}))
};
}
}
return {
text,
vars: vars.slice()
};
});
/** @type {import('estree').Expression} */
let message = { type: 'Literal', value: '' };
let prev_vars;
if (comments.length > 0) {
// @ts-expect-error
(ast.trailingComments ||= []).push(...comments);
}
for (let i = 0; i < group.length; i += 1) {
const { text, vars } = group[i];
const category = messages[name];
if (vars.length === 0) {
message = {
type: 'Literal',
value: text
};
prev_vars = vars;
continue;
// find the `export function CODE` node
const index = ast.body.findIndex((node) => {
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'FunctionDeclaration'
) {
return node.declaration.id.name === 'CODE';
}
});
const parts = text.split(/(%\w+%)/);
if (index === -1) throw new Error(`missing export function CODE in ${name}.js`);
/** @type {import('estree').Expression[]} */
const expressions = [];
const template_node = ast.body[index];
ast.body.splice(index, 1);
/** @type {import('estree').TemplateElement[]} */
const quasis = [];
for (const code in category) {
const { messages } = category[code];
/** @type {string[]} */
const vars = [];
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i];
if (i % 2 === 0) {
const str = part.replace(/(`|\${)/g, '\\$1');
quasis.push({
type: 'TemplateElement',
value: { raw: str, cooked: str },
tail: i === parts.length - 1
});
} else {
expressions.push({
type: 'Identifier',
name: part.slice(1, -1)
});
const group = messages.map((text, i) => {
for (const match of text.matchAll(/%(\w+)%/g)) {
const name = match[1];
if (!vars.includes(name)) {
vars.push(match[1]);
}
}
}
return {
text,
vars: vars.slice()
};
});
/** @type {import('estree').Expression} */
const expression = {
type: 'TemplateLiteral',
expressions,
quasis
};
if (prev_vars) {
if (vars.length === prev_vars.length) {
throw new Error('Message overloads must have new parameters');
let message = { type: 'Literal', value: '' };
let prev_vars;
for (let i = 0; i < group.length; i += 1) {
const { text, vars } = group[i];
if (vars.length === 0) {
message = {
type: 'Literal',
value: text
};
prev_vars = vars;
continue;
}
message = {
type: 'ConditionalExpression',
test: {
type: 'Identifier',
name: vars[prev_vars.length]
},
consequent: expression,
alternate: message
};
} else {
message = expression;
}
const parts = text.split(/(%\w+%)/);
prev_vars = vars;
}
/** @type {import('estree').Expression[]} */
const expressions = [];
const clone = walk(/** @type {import('estree').Node} */ (template_node), null, {
// @ts-expect-error Block is a block comment, which is not recognised
Block(node, context) {
if (!node.value.includes('PARAMETER')) return;
const value = /** @type {string} */ (node.value)
.split('\n')
.map((line) => {
if (line === ' * MESSAGE') {
return messages[messages.length - 1]
.split('\n')
.map((line) => ` * ${line}`)
.join('\n');
}
/** @type {import('estree').TemplateElement[]} */
const quasis = [];
if (line.includes('PARAMETER')) {
return vars
.map((name, i) => {
const optional = i >= group[0].vars.length;
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i];
if (i % 2 === 0) {
const str = part.replace(/(`|\${)/g, '\\$1');
quasis.push({
type: 'TemplateElement',
value: { raw: str, cooked: str },
tail: i === parts.length - 1
});
} else {
expressions.push({
type: 'Identifier',
name: part.slice(1, -1)
});
}
}
return optional
? ` * @param {string | undefined | null} [${name}]`
: ` * @param {string} ${name}`;
})
.join('\n');
}
/** @type {import('estree').Expression} */
const expression = {
type: 'TemplateLiteral',
expressions,
quasis
};
return line;
})
.filter((x) => x !== '')
.join('\n');
if (prev_vars) {
if (vars.length === prev_vars.length) {
throw new Error('Message overloads must have new parameters');
}
if (value !== node.value) {
return { ...node, value };
message = {
type: 'ConditionalExpression',
test: {
type: 'Identifier',
name: vars[prev_vars.length]
},
consequent: expression,
alternate: message
};
} else {
message = expression;
}
},
FunctionDeclaration(node, context) {
if (node.id.name !== 'CODE') return;
const params = [];
prev_vars = vars;
}
for (const param of node.params) {
if (param.type === 'Identifier' && param.name === 'PARAMETER') {
params.push(...vars.map((name) => ({ type: 'Identifier', name })));
} else {
params.push(param);
const clone = walk(/** @type {import('estree').Node} */ (template_node), null, {
// @ts-expect-error Block is a block comment, which is not recognised
Block(node, context) {
if (!node.value.includes('PARAMETER')) return;
const value = /** @type {string} */ (node.value)
.split('\n')
.map((line) => {
if (line === ' * MESSAGE') {
return messages[messages.length - 1]
.split('\n')
.map((line) => ` * ${line}`)
.join('\n');
}
if (line.includes('PARAMETER')) {
return vars
.map((name, i) => {
const optional = i >= group[0].vars.length;
return optional
? ` * @param {string | undefined | null} [${name}]`
: ` * @param {string} ${name}`;
})
.join('\n');
}
return line;
})
.filter((x) => x !== '')
.join('\n');
if (value !== node.value) {
return { ...node, value };
}
}
},
FunctionDeclaration(node, context) {
if (node.id.name !== 'CODE') return;
const params = [];
return /** @type {import('estree').FunctionDeclaration} */ ({
.../** @type {import('estree').FunctionDeclaration} */ (context.next()),
params,
id: {
...node.id,
name: code
for (const param of node.params) {
if (param.type === 'Identifier' && param.name === 'PARAMETER') {
params.push(...vars.map((name) => ({ type: 'Identifier', name })));
} else {
params.push(param);
}
}
});
},
TemplateLiteral(node, context) {
/** @type {import('estree').TemplateElement} */
let quasi = {
type: 'TemplateElement',
value: {
...node.quasis[0].value
},
tail: node.quasis[0].tail
};
/** @type {import('estree').TemplateLiteral} */
let out = {
type: 'TemplateLiteral',
quasis: [quasi],
expressions: []
};
return /** @type {import('estree').FunctionDeclaration} */ ({
.../** @type {import('estree').FunctionDeclaration} */ (context.next()),
params,
id: {
...node.id,
name: code
}
});
},
TemplateLiteral(node, context) {
/** @type {import('estree').TemplateElement} */
let quasi = {
type: 'TemplateElement',
value: {
...node.quasis[0].value
},
tail: node.quasis[0].tail
};
for (let i = 0; i < node.expressions.length; i += 1) {
const q = structuredClone(node.quasis[i + 1]);
const e = node.expressions[i];
/** @type {import('estree').TemplateLiteral} */
let out = {
type: 'TemplateLiteral',
quasis: [quasi],
expressions: []
};
if (e.type === 'Literal' && e.value === 'CODE') {
quasi.value.raw += code + q.value.raw;
continue;
}
for (let i = 0; i < node.expressions.length; i += 1) {
const q = structuredClone(node.quasis[i + 1]);
const e = node.expressions[i];
if (e.type === 'Identifier' && e.name === 'MESSAGE') {
if (message.type === 'Literal') {
const str = /** @type {string} */ (message.value).replace(/(`|\${)/g, '\\$1');
quasi.value.raw += str + q.value.raw;
if (e.type === 'Literal' && e.value === 'CODE') {
quasi.value.raw += code + q.value.raw;
continue;
}
if (message.type === 'TemplateLiteral') {
const m = structuredClone(message);
quasi.value.raw += m.quasis[0].value.raw;
out.quasis.push(...m.quasis.slice(1));
out.expressions.push(...m.expressions);
quasi = m.quasis[m.quasis.length - 1];
quasi.value.raw += q.value.raw;
continue;
if (e.type === 'Identifier' && e.name === 'MESSAGE') {
if (message.type === 'Literal') {
const str = /** @type {string} */ (message.value).replace(/(`|\${)/g, '\\$1');
quasi.value.raw += str + q.value.raw;
continue;
}
if (message.type === 'TemplateLiteral') {
const m = structuredClone(message);
quasi.value.raw += m.quasis[0].value.raw;
out.quasis.push(...m.quasis.slice(1));
out.expressions.push(...m.expressions);
quasi = m.quasis[m.quasis.length - 1];
quasi.value.raw += q.value.raw;
continue;
}
}
out.quasis.push((quasi = q));
out.expressions.push(/** @type {import('estree').Expression} */ (context.visit(e)));
}
out.quasis.push((quasi = q));
out.expressions.push(/** @type {import('estree').Expression} */ (context.visit(e)));
return out;
},
Literal(node) {
if (node.value === 'CODE') {
return {
type: 'Literal',
value: code
};
}
},
Identifier(node) {
if (node.name !== 'MESSAGE') return;
return message;
}
});
return out;
},
Literal(node) {
if (node.value === 'CODE') {
return {
type: 'Literal',
value: code
};
}
},
Identifier(node) {
if (node.name !== 'MESSAGE') return;
return message;
}
});
// @ts-expect-error
ast.body.push(clone);
}
const module = esrap.print(ast);
// @ts-expect-error
ast.body.push(clone);
fs.writeFileSync(
dest,
`/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\n` +
module.code,
'utf-8'
);
}
const module = esrap.print(ast);
transform('compile-errors', 'src/compiler/errors.js');
transform('compile-warnings', 'src/compiler/warnings.js');
fs.writeFileSync(
dest,
`/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\n` +
module.code,
'utf-8'
);
transform('client-warnings', 'src/internal/client/warnings.js');
transform('client-errors', 'src/internal/client/errors.js');
transform('server-errors', 'src/internal/server/errors.js');
transform('shared-errors', 'src/internal/shared/errors.js');
transform('shared-warnings', 'src/internal/shared/warnings.js');
}
transform('compile-errors', 'src/compiler/errors.js');
transform('compile-warnings', 'src/compiler/warnings.js');
if (watch) {
let running = false;
let timeout;
fs.watch('messages', { recursive: true }, (type, file) => {
if (running) {
timeout ??= setTimeout(() => {
running = false;
timeout = null;
});
} else {
running = true;
// eslint-disable-next-line no-console
console.log('Regenerating messages...');
run();
}
});
}
transform('client-warnings', 'src/internal/client/warnings.js');
transform('client-errors', 'src/internal/client/errors.js');
transform('server-errors', 'src/internal/server/errors.js');
transform('shared-errors', 'src/internal/shared/errors.js');
transform('shared-warnings', 'src/internal/shared/warnings.js');
run();

@ -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
@ -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`);
}
/**

@ -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';
@ -164,6 +165,7 @@ const visitors = {
MemberExpression,
NewExpression,
OnDirective,
PropertyDefinition,
RegularElement,
RenderTag,
SlotElement,
@ -256,7 +258,8 @@ export function analyze_module(ast, options) {
accessors: false,
runes: true,
immutable: true,
tracing: false
tracing: false,
classes: new Map()
};
walk(
@ -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),
@ -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,
@ -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
};
@ -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
};

@ -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;
@ -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

@ -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;

@ -211,7 +211,7 @@ function get_delegated_event(event_name, handler, context) {
if (
binding !== null &&
// Bail out if the the binding is a rest param
// Bail out if the binding is a rest param
(binding.declaration_kind === 'rest_param' ||
// Bail out if we reference anything from the EachBlock (for now) that mutates in non-runes mode,
(((!context.state.analysis.runes && binding.kind === 'each') ||

@ -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);

@ -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);
}
@ -130,6 +131,7 @@ export function CallExpression(node, context) {
}
break;
}
case '$effect':
case '$effect.pre':
@ -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;
}

@ -1,30 +1,107 @@
/** @import { ClassBody } from 'estree' */
/** @import { AssignmentExpression, CallExpression, ClassBody, PropertyDefinition, Expression, PrivateIdentifier, MethodDefinition } from 'estree' */
/** @import { StateField } from '#compiler' */
/** @import { Context } from '../types' */
import * as b from '#compiler/builders';
import { get_rune } from '../../scope.js';
import * as e from '../../../errors.js';
import { is_state_creation_rune } from '../../../../utils.js';
import { get_name } from '../../nodes.js';
import { regex_invalid_identifier_chars } from '../../patterns.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 {string[]} */
const private_ids = [];
for (const definition of node.body) {
for (const prop of node.body) {
if (
definition.type === 'PropertyDefinition' &&
(definition.key.type === 'PrivateIdentifier' || definition.key.type === 'Identifier') &&
definition.value?.type === 'CallExpression'
(prop.type === 'MethodDefinition' || prop.type === 'PropertyDefinition') &&
prop.key.type === 'PrivateIdentifier'
) {
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'
});
private_ids.push(prop.key.name);
}
}
/** @type {Map<string, StateField>} */
const state_fields = new Map();
context.state.analysis.classes.set(node, state_fields);
/** @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 = get_name(key);
if (name === null) return;
const rune = get_rune(value, context.state.scope);
if (rune && is_state_creation_rune(rune)) {
if (state_fields.has(name)) {
e.state_field_duplicate(node, name);
}
state_fields.set(name, {
node,
type: rune,
// @ts-expect-error for public state this is filled out in a moment
key: key.type === 'PrivateIdentifier' ? key : null,
value: /** @type {CallExpression} */ (value)
});
}
}
for (const child of node.body) {
if (child.type === 'PropertyDefinition' && !child.computed && !child.static) {
handle(child, child.key, child.value);
}
if (child.type === 'MethodDefinition' && child.kind === 'constructor') {
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);
}
}
for (const [name, field] of state_fields) {
if (name[0] === '#') {
continue;
}
let deconflicted = name.replace(regex_invalid_identifier_chars, '_');
while (private_ids.includes(deconflicted)) {
deconflicted = '_' + deconflicted;
}
private_ids.push(deconflicted);
field.key = b.private_id(deconflicted);
}
context.next({ ...context.state, derived_state });
context.next({ ...context.state, state_fields });
}

@ -0,0 +1,21 @@
/** @import { PropertyDefinition } from 'estree' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { get_name } from '../../nodes.js';
/**
* @param {PropertyDefinition} node
* @param {Context} context
*/
export function PropertyDefinition(node, context) {
const name = get_name(node.key);
const field = name && context.state.state_fields.get(name);
if (field && node !== field.node && node.value) {
if (/** @type {number} */ (node.start) < /** @type {number} */ (field.node.start)) {
e.state_field_invalid_assignment(node);
}
}
context.next();
}

@ -8,7 +8,7 @@ import { validate_assignment } from './shared/utils.js';
* @param {Context} context
*/
export function UpdateExpression(node, context) {
validate_assignment(node, node.argument, context.state);
validate_assignment(node, node.argument, context);
if (context.state.reactive_statement) {
const id = node.argument.type === 'MemberExpression' ? object(node.argument) : node.argument;

@ -4,24 +4,25 @@
/** @import { Scope } from '../../../scope' */
/** @import { NodeLike } from '../../../../errors.js' */
import * as e from '../../../../errors.js';
import { extract_identifiers } from '../../../../utils/ast.js';
import { extract_identifiers, get_parent } from '../../../../utils/ast.js';
import * as w from '../../../../warnings.js';
import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js';
import { get_name } from '../../../nodes.js';
/**
* @param {AssignmentExpression | UpdateExpression | AST.BindDirective} node
* @param {Pattern | Expression} argument
* @param {AnalysisState} state
* @param {Context} context
*/
export function validate_assignment(node, argument, state) {
validate_no_const_assignment(node, argument, state.scope, node.type === 'BindDirective');
export function validate_assignment(node, argument, context) {
validate_no_const_assignment(node, argument, context.state.scope, node.type === 'BindDirective');
if (argument.type === 'Identifier') {
const binding = state.scope.get(argument.name);
const binding = context.state.scope.get(argument.name);
if (state.analysis.runes) {
if (binding?.node === state.analysis.props_id) {
if (context.state.analysis.runes) {
if (binding?.node === context.state.analysis.props_id) {
e.constant_assignment(node, '$props.id()');
}
@ -34,6 +35,41 @@ export function validate_assignment(node, argument, state) {
e.snippet_parameter_assignment(node);
}
}
if (argument.type === 'MemberExpression' && argument.object.type === 'ThisExpression') {
const name =
argument.computed && argument.property.type !== 'Literal'
? null
: get_name(argument.property);
const field = name !== null && context.state.state_fields?.get(name);
// check we're not assigning to a state field before its declaration in the constructor
if (field && field.node.type === 'AssignmentExpression' && node !== field.node) {
let i = context.path.length;
while (i--) {
const parent = context.path[i];
if (
parent.type === 'FunctionDeclaration' ||
parent.type === 'FunctionExpression' ||
parent.type === 'ArrowFunctionExpression'
) {
const grandparent = get_parent(context.path, i - 1);
if (
grandparent.type === 'MethodDefinition' &&
grandparent.kind === 'constructor' &&
/** @type {number} */ (node.start) < /** @type {number} */ (field.node.start)
) {
e.state_field_invalid_assignment(node);
}
break;
}
}
}
}
}
/**

@ -163,8 +163,7 @@ export function client_component(analysis, options) {
},
events: new Set(),
preserve_whitespace: options.preserveWhitespace,
public_state: new Map(),
private_state: new Map(),
state_fields: new Map(),
transform: {},
in_constructor: false,
instance_level_snippets: [],
@ -671,8 +670,7 @@ export function client_module(analysis, options) {
options,
scope: analysis.module.scope,
scopes: analysis.module.scopes,
public_state: new Map(),
private_state: new Map(),
state_fields: new Map(),
transform: {},
in_constructor: false
};

@ -9,15 +9,12 @@ import type {
UpdateExpression,
VariableDeclaration
} from 'estree';
import type { AST, Namespace, ValidatedCompileOptions } from '#compiler';
import type { AST, Namespace, StateField, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js';
import type { ComponentAnalysis } from '../../types.js';
import type { SourceLocation } from '#shared';
export interface ClientTransformState extends TransformState {
readonly private_state: Map<string, StateField>;
readonly public_state: Map<string, StateField>;
/**
* `true` if the current lexical scope belongs to a class constructor. this allows
* us to rewrite `this.foo` as `this.#foo.value`
@ -94,11 +91,6 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly module_level_snippets: VariableDeclaration[];
}
export interface StateField {
kind: 'state' | 'raw_state' | 'derived' | 'derived_by';
id: PrivateIdentifier;
}
export type Context = import('zimmerframe').Context<AST.SvelteNode, ClientTransformState>;
export type Visitors = import('zimmerframe').Visitors<AST.SvelteNode, any>;

@ -11,6 +11,8 @@ import { dev, locate_node } from '../../../../state.js';
import { should_proxy } from '../utils.js';
import { visit_assignment_expression } from '../../shared/assignments.js';
import { validate_mutation } from './shared/utils.js';
import { get_rune } from '../../../scope.js';
import { get_name } from '../../../nodes.js';
/**
* @param {AssignmentExpression} node
@ -50,25 +52,42 @@ const callees = {
* @returns {Expression | null}
*/
function build_assignment(operator, left, right, context) {
// Handle class private/public state assignment cases
if (
context.state.analysis.runes &&
left.type === 'MemberExpression' &&
left.property.type === 'PrivateIdentifier'
) {
const private_state = context.state.private_state.get(left.property.name);
if (context.state.analysis.runes && left.type === 'MemberExpression') {
const name = get_name(left.property);
const field = name && context.state.state_fields.get(name);
if (field) {
// special case — state declaration in class constructor
if (field.node.type === 'AssignmentExpression' && left === field.node.left) {
const rune = get_rune(right, context.state.scope);
if (rune) {
const child_state = {
...context.state,
in_constructor: rune !== '$derived' && rune !== '$derived.by'
};
return b.assignment(
operator,
b.member(b.this, field.key),
/** @type {Expression} */ (context.visit(right, child_state))
);
}
}
if (private_state !== undefined) {
let value = /** @type {Expression} */ (
context.visit(build_assignment_value(operator, left, right))
);
// special case — assignment to private state field
if (left.property.type === 'PrivateIdentifier') {
let value = /** @type {Expression} */ (
context.visit(build_assignment_value(operator, left, right))
);
const needs_proxy =
private_state.kind === 'state' &&
is_non_coercive_operator(operator) &&
should_proxy(value, context.state.scope);
const needs_proxy =
field.type === '$state' &&
is_non_coercive_operator(operator) &&
should_proxy(value, context.state.scope);
return b.call('$.set', left, value, needs_proxy && b.true);
return b.call('$.set', left, value, needs_proxy && b.true);
}
}
}

@ -4,19 +4,52 @@ import { dev, is_ignored } from '../../../../state.js';
import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js';
import { transform_inspect_rune } from '../../utils.js';
import { should_proxy } from '../utils.js';
/**
* @param {CallExpression} node
* @param {Context} context
*/
export function CallExpression(node, context) {
switch (get_rune(node, context.state.scope)) {
const rune = get_rune(node, context.state.scope);
switch (rune) {
case '$host':
return b.id('$$props.$$host');
case '$effect.tracking':
return b.call('$.effect_tracking');
// transform state field assignments in constructors
case '$state':
case '$state.raw': {
let arg = node.arguments[0];
/** @type {Expression | undefined} */
let value = undefined;
if (arg) {
value = /** @type {Expression} */ (context.visit(node.arguments[0]));
if (
rune === '$state' &&
should_proxy(/** @type {Expression} */ (arg), context.state.scope)
) {
value = b.call('$.proxy', value);
}
}
return b.call('$.state', value);
}
case '$derived':
case '$derived.by': {
let fn = /** @type {Expression} */ (context.visit(node.arguments[0]));
if (rune === '$derived') fn = b.thunk(fn);
return b.call('$.derived', fn);
}
case '$state.snapshot':
return b.call(
'$.snapshot',

@ -1,184 +1,96 @@
/** @import { ClassBody, Expression, Identifier, Literal, MethodDefinition, PrivateIdentifier, PropertyDefinition } from 'estree' */
/** @import { Context, StateField } from '../types' */
/** @import { CallExpression, ClassBody, MethodDefinition, PropertyDefinition, StaticBlock } from 'estree' */
/** @import { StateField } from '#compiler' */
/** @import { Context } from '../types' */
import * as b from '#compiler/builders';
import { regex_invalid_identifier_chars } from '../../../patterns.js';
import { get_rune } from '../../../scope.js';
import { should_proxy } from '../utils.js';
import { get_name } from '../../../nodes.js';
/**
* @param {ClassBody} node
* @param {Context} context
*/
export function ClassBody(node, context) {
if (!context.state.analysis.runes) {
const state_fields = context.state.analysis.classes.get(node);
if (!state_fields) {
// in legacy mode, do nothing
context.next();
return;
}
/** @type {Map<string, StateField>} */
const public_state = new Map();
/** @type {Array<MethodDefinition | PropertyDefinition | StaticBlock>} */
const body = [];
/** @type {Map<string, StateField>} */
const private_state = new Map();
const child_state = { ...context.state, state_fields };
/** @type {Map<(MethodDefinition|PropertyDefinition)["key"], string>} */
const definition_names = new Map();
for (const [name, field] of state_fields) {
if (name[0] === '#') {
continue;
}
/** @type {string[]} */
const private_ids = [];
// insert backing fields for stuff declared in the constructor
if (field.node.type === 'AssignmentExpression') {
const member = b.member(b.this, field.key);
for (const definition of node.body) {
if (
(definition.type === 'PropertyDefinition' || definition.type === 'MethodDefinition') &&
(definition.key.type === 'Identifier' ||
definition.key.type === 'PrivateIdentifier' ||
definition.key.type === 'Literal')
) {
const type = definition.key.type;
const name = get_name(definition.key, public_state);
if (!name) continue;
// we store the deconflicted name in the map so that we can access it later
definition_names.set(definition.key, name);
const is_private = type === 'PrivateIdentifier';
if (is_private) private_ids.push(name);
if (definition.value?.type === 'CallExpression') {
const rune = get_rune(definition.value, context.state.scope);
if (
rune === '$state' ||
rune === '$state.raw' ||
rune === '$derived' ||
rune === '$derived.by'
) {
/** @type {StateField} */
const field = {
kind:
rune === '$state'
? 'state'
: rune === '$state.raw'
? 'raw_state'
: rune === '$derived.by'
? 'derived_by'
: 'derived',
// @ts-expect-error this is set in the next pass
id: is_private ? definition.key : null
};
if (is_private) {
private_state.set(name, field);
} else {
public_state.set(name, field);
}
}
}
}
}
const should_proxy = field.type === '$state' && true; // TODO
// each `foo = $state()` needs a backing `#foo` field
for (const [name, field] of public_state) {
let deconflicted = name;
while (private_ids.includes(deconflicted)) {
deconflicted = '_' + deconflicted;
}
const key = b.key(name);
private_ids.push(deconflicted);
field.id = b.private_id(deconflicted);
}
body.push(
b.prop_def(field.key, null),
/** @type {Array<MethodDefinition | PropertyDefinition>} */
const body = [];
b.method('get', key, [], [b.return(b.call('$.get', member))]),
const child_state = { ...context.state, public_state, private_state };
b.method(
'set',
key,
[b.id('value')],
[b.stmt(b.call('$.set', member, b.id('value'), should_proxy && b.true))]
)
);
}
}
// Replace parts of the class body
for (const definition of node.body) {
if (
definition.type === 'PropertyDefinition' &&
(definition.key.type === 'Identifier' ||
definition.key.type === 'PrivateIdentifier' ||
definition.key.type === 'Literal')
) {
const name = definition_names.get(definition.key);
if (!name) continue;
const is_private = definition.key.type === 'PrivateIdentifier';
const field = (is_private ? private_state : public_state).get(name);
if (definition.value?.type === 'CallExpression' && field !== undefined) {
let value = null;
if (definition.value.arguments.length > 0) {
const init = /** @type {Expression} **/ (
context.visit(definition.value.arguments[0], child_state)
);
value =
field.kind === 'state'
? b.call(
'$.state',
should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init
)
: field.kind === 'raw_state'
? b.call('$.state', init)
: field.kind === 'derived_by'
? b.call('$.derived', init)
: b.call('$.derived', b.thunk(init));
} else {
// if no arguments, we know it's state as `$derived()` is a compile error
value = b.call('$.state');
}
if (is_private) {
body.push(b.prop_def(field.id, value));
} else {
// #foo;
const member = b.member(b.this, field.id);
body.push(b.prop_def(field.id, value));
// get foo() { return this.#foo; }
body.push(b.method('get', definition.key, [], [b.return(b.call('$.get', member))]));
// set foo(value) { this.#foo = value; }
const val = b.id('value');
body.push(
b.method(
'set',
definition.key,
[val],
[b.stmt(b.call('$.set', member, val, field.kind === 'state' && b.true))]
)
);
}
continue;
}
if (definition.type !== 'PropertyDefinition') {
body.push(
/** @type {MethodDefinition | StaticBlock} */ (context.visit(definition, child_state))
);
continue;
}
body.push(/** @type {MethodDefinition} **/ (context.visit(definition, child_state)));
}
const name = get_name(definition.key);
const field = name && /** @type {StateField} */ (state_fields.get(name));
return { ...node, body };
}
if (!field) {
body.push(/** @type {PropertyDefinition} */ (context.visit(definition, child_state)));
continue;
}
/**
* @param {Identifier | PrivateIdentifier | Literal} node
* @param {Map<string, StateField>} public_state
*/
function get_name(node, public_state) {
if (node.type === 'Literal') {
let name = node.value?.toString().replace(regex_invalid_identifier_chars, '_');
// the above could generate conflicts because it has to generate a valid identifier
// so stuff like `0` and `1` or `state%` and `state^` will result in the same string
// so we have to de-conflict. We can only check `public_state` because private state
// can't have literal keys
while (name && public_state.has(name)) {
name = '_' + name;
if (name[0] === '#') {
body.push(/** @type {PropertyDefinition} */ (context.visit(definition, child_state)));
} else if (field.node === definition) {
const member = b.member(b.this, field.key);
const should_proxy = field.type === '$state' && true; // TODO
body.push(
b.prop_def(
field.key,
/** @type {CallExpression} */ (context.visit(field.value, child_state))
),
b.method('get', definition.key, [], [b.return(b.call('$.get', member))]),
b.method(
'set',
definition.key,
[b.id('value')],
[b.stmt(b.call('$.set', member, b.id('value'), should_proxy && b.true))]
)
);
}
return name;
} else {
return node.name;
}
return { ...node, body };
}

@ -9,9 +9,11 @@ import * as b from '#compiler/builders';
export function MemberExpression(node, context) {
// rewrite `this.#foo` as `this.#foo.v` inside a constructor
if (node.property.type === 'PrivateIdentifier') {
const field = context.state.private_state.get(node.property.name);
const field = context.state.state_fields.get('#' + node.property.name);
if (field) {
return context.state.in_constructor && (field.kind === 'raw_state' || field.kind === 'state')
return context.state.in_constructor &&
(field.type === '$state.raw' || field.type === '$state')
? b.member(node, 'v')
: b.call('$.get', node);
}

@ -564,7 +564,7 @@ export function build_style_directives_object(style_directives, context) {
/**
* Serializes an assignment to an element property by adding relevant statements to either only
* the init or the the init and update arrays, depending on whether or not the value is dynamic.
* the init or the init and update arrays, depending on whether or not the value is dynamic.
* Resulting code for static looks something like this:
* ```js
* element.property = value;

@ -15,7 +15,7 @@ export function UpdateExpression(node, context) {
argument.type === 'MemberExpression' &&
argument.object.type === 'ThisExpression' &&
argument.property.type === 'PrivateIdentifier' &&
context.state.private_state.has(argument.property.name)
context.state.state_fields.has('#' + argument.property.name)
) {
let fn = '$.update';
if (node.prefix) fn += '_pre';

@ -23,7 +23,6 @@ import { Identifier } from './visitors/Identifier.js';
import { IfBlock } from './visitors/IfBlock.js';
import { KeyBlock } from './visitors/KeyBlock.js';
import { LabeledStatement } from './visitors/LabeledStatement.js';
import { MemberExpression } from './visitors/MemberExpression.js';
import { PropertyDefinition } from './visitors/PropertyDefinition.js';
import { RegularElement } from './visitors/RegularElement.js';
import { RenderTag } from './visitors/RenderTag.js';
@ -49,7 +48,6 @@ const global_visitors = {
ExpressionStatement,
Identifier,
LabeledStatement,
MemberExpression,
PropertyDefinition,
UpdateExpression,
VariableDeclaration
@ -99,7 +97,7 @@ export function server_component(analysis, options) {
template: /** @type {any} */ (null),
namespace: options.namespace,
preserve_whitespace: options.preserveWhitespace,
private_derived: new Map(),
state_fields: new Map(),
skip_hydration_boundaries: false
};
@ -395,7 +393,7 @@ export function server_module(analysis, options) {
// to be present for `javascript_visitors_legacy` and so is included in module
// transform state as well as component transform state
legacy_reactive_statements: new Map(),
private_derived: new Map()
state_fields: new Map()
};
const module = /** @type {Program} */ (

@ -2,12 +2,10 @@ import type { Expression, Statement, ModuleDeclaration, LabeledStatement } from
import type { AST, Namespace, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js';
import type { ComponentAnalysis } from '../../types.js';
import type { StateField } from '../client/types.js';
export interface ServerTransformState extends TransformState {
/** The $: calls, which will be ordered in the end */
readonly legacy_reactive_statements: Map<LabeledStatement, Statement>;
readonly private_derived: Map<string, StateField>;
}
export interface ComponentServerTransformState extends ServerTransformState {

@ -3,6 +3,8 @@
/** @import { Context, ServerTransformState } from '../types.js' */
import * as b from '#compiler/builders';
import { build_assignment_value } from '../../../../utils/ast.js';
import { get_name } from '../../../nodes.js';
import { get_rune } from '../../../scope.js';
import { visit_assignment_expression } from '../../shared/assignments.js';
/**
@ -22,6 +24,29 @@ export function AssignmentExpression(node, context) {
* @returns {Expression | null}
*/
function build_assignment(operator, left, right, context) {
if (context.state.analysis.runes && left.type === 'MemberExpression') {
const name = get_name(left.property);
const field = name && context.state.state_fields.get(name);
// special case — state declaration in class constructor
if (field && field.node.type === 'AssignmentExpression' && left === field.node.left) {
const rune = get_rune(right, context.state.scope);
if (rune) {
const key =
left.property.type === 'PrivateIdentifier' || rune === '$state' || rune === '$state.raw'
? left.property
: field.key;
return b.assignment(
operator,
b.member(b.this, key, key.type === 'Literal'),
/** @type {Expression} */ (context.visit(right))
);
}
}
}
let object = left;
while (object.type === 'MemberExpression') {

@ -25,6 +25,15 @@ export function CallExpression(node, context) {
return b.arrow([], b.block([]));
}
if (rune === '$state' || rune === '$state.raw') {
return node.arguments[0] ? context.visit(node.arguments[0]) : b.void0;
}
if (rune === '$derived' || rune === '$derived.by') {
const fn = /** @type {Expression} */ (context.visit(node.arguments[0]));
return b.call('$.once', rune === '$derived' ? b.thunk(fn) : fn);
}
if (rune === '$state.snapshot') {
return b.call(
'$.snapshot',

@ -1,120 +1,77 @@
/** @import { ClassBody, Expression, MethodDefinition, PropertyDefinition } from 'estree' */
/** @import { CallExpression, ClassBody, MethodDefinition, PropertyDefinition, StaticBlock } from 'estree' */
/** @import { Context } from '../types.js' */
/** @import { StateField } from '../../client/types.js' */
import { dev } from '../../../../state.js';
import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js';
import { get_name } from '../../../nodes.js';
/**
* @param {ClassBody} node
* @param {Context} context
*/
export function ClassBody(node, context) {
if (!context.state.analysis.runes) {
const state_fields = context.state.analysis.classes.get(node);
if (!state_fields) {
// in legacy mode, do nothing
context.next();
return;
}
/** @type {Map<string, StateField>} */
const public_derived = new Map();
/** @type {Array<MethodDefinition | PropertyDefinition | StaticBlock>} */
const body = [];
/** @type {Map<string, StateField>} */
const private_derived = new Map();
const child_state = { ...context.state, state_fields };
/** @type {string[]} */
const private_ids = [];
for (const [name, field] of state_fields) {
if (name[0] === '#') {
continue;
}
for (const definition of node.body) {
// insert backing fields for stuff declared in the constructor
if (
definition.type === 'PropertyDefinition' &&
(definition.key.type === 'Identifier' || definition.key.type === 'PrivateIdentifier')
field &&
field.node.type === 'AssignmentExpression' &&
(field.type === '$derived' || field.type === '$derived.by')
) {
const { type, name } = definition.key;
const is_private = type === 'PrivateIdentifier';
if (is_private) private_ids.push(name);
const member = b.member(b.this, field.key);
if (definition.value?.type === 'CallExpression') {
const rune = get_rune(definition.value, context.state.scope);
if (rune === '$derived' || rune === '$derived.by') {
/** @type {StateField} */
const field = {
kind: rune === '$derived.by' ? 'derived_by' : 'derived',
// @ts-expect-error this is set in the next pass
id: is_private ? definition.key : null
};
if (is_private) {
private_derived.set(name, field);
} else {
public_derived.set(name, field);
}
}
}
body.push(
b.prop_def(field.key, null),
b.method('get', b.key(name), [], [b.return(b.call(member))])
);
}
}
// each `foo = $derived()` needs a backing `#foo` field
for (const [name, field] of public_derived) {
let deconflicted = name;
while (private_ids.includes(deconflicted)) {
deconflicted = '_' + deconflicted;
}
private_ids.push(deconflicted);
field.id = b.private_id(deconflicted);
}
/** @type {Array<MethodDefinition | PropertyDefinition>} */
const body = [];
const child_state = { ...context.state, private_derived };
// Replace parts of the class body
for (const definition of node.body) {
if (
definition.type === 'PropertyDefinition' &&
(definition.key.type === 'Identifier' || definition.key.type === 'PrivateIdentifier')
) {
const name = definition.key.name;
const is_private = definition.key.type === 'PrivateIdentifier';
const field = (is_private ? private_derived : public_derived).get(name);
if (definition.type !== 'PropertyDefinition') {
body.push(
/** @type {MethodDefinition | StaticBlock} */ (context.visit(definition, child_state))
);
continue;
}
if (definition.value?.type === 'CallExpression' && field !== undefined) {
const init = /** @type {Expression} **/ (
context.visit(definition.value.arguments[0], child_state)
);
const value =
field.kind === 'derived_by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init));
const name = get_name(definition.key);
const field = name && state_fields.get(name);
if (is_private) {
body.push(b.prop_def(field.id, value));
} else {
// #foo;
const member = b.member(b.this, field.id);
body.push(b.prop_def(field.id, value));
if (!field) {
body.push(/** @type {PropertyDefinition} */ (context.visit(definition, child_state)));
continue;
}
// get foo() { return this.#foo; }
body.push(b.method('get', definition.key, [], [b.return(b.call(member))]));
if (name[0] === '#' || field.type === '$state' || field.type === '$state.raw') {
body.push(/** @type {PropertyDefinition} */ (context.visit(definition, child_state)));
} else if (field.node === definition) {
const member = b.member(b.this, field.key);
if (dev && (field.kind === 'derived' || field.kind === 'derived_by')) {
body.push(
b.method(
'set',
definition.key,
[b.id('_')],
[b.throw_error(`Cannot update a derived property ('${name}')`)]
)
);
}
}
body.push(
b.prop_def(
field.key,
/** @type {CallExpression} */ (context.visit(field.value, child_state))
),
continue;
}
b.method('get', definition.key, [], [b.return(b.call(member))])
);
}
body.push(/** @type {MethodDefinition} **/ (context.visit(definition, child_state)));
}
return { ...node, body };

@ -1,23 +0,0 @@
/** @import { MemberExpression } from 'estree' */
/** @import { Context } from '../types.js' */
import * as b from '#compiler/builders';
/**
* @param {MemberExpression} node
* @param {Context} context
*/
export function MemberExpression(node, context) {
if (
context.state.analysis.runes &&
node.object.type === 'ThisExpression' &&
node.property.type === 'PrivateIdentifier'
) {
const field = context.state.private_derived.get(node.property.name);
if (field) {
return b.call(node);
}
}
context.next();
}

@ -1,5 +1,5 @@
import type { Scope } from '../scope.js';
import type { AST, ValidatedModuleCompileOptions } from '#compiler';
import type { AST, StateField, ValidatedModuleCompileOptions } from '#compiler';
import type { Analysis } from '../types.js';
export interface TransformState {
@ -7,4 +7,6 @@ export interface TransformState {
readonly options: ValidatedModuleCompileOptions;
readonly scope: Scope;
readonly scopes: Map<AST.SvelteNode, Scope>;
readonly state_fields: Map<string, StateField>;
}

@ -1,4 +1,6 @@
/** @import { Expression, PrivateIdentifier } from 'estree' */
/** @import { AST, ExpressionMetadata } from '#compiler' */
/**
* All nodes that can appear elsewhere than the top level, have attributes and can contain children
*/
@ -64,3 +66,14 @@ export function create_expression_metadata() {
has_call: false
};
}
/**
* @param {Expression | PrivateIdentifier} node
*/
export function get_name(node) {
if (node.type === 'Literal') return String(node.value);
if (node.type === 'PrivateIdentifier') return '#' + node.name;
if (node.type === 'Identifier') return node.name;
return null;
}

@ -1,6 +1,15 @@
import type { AST, Binding } from '#compiler';
import type { Identifier, LabeledStatement, Node, Program } from 'estree';
import type { AST, Binding, StateField } from '#compiler';
import type {
AssignmentExpression,
ClassBody,
Identifier,
LabeledStatement,
Node,
Program
} from 'estree';
import type { Scope, ScopeRoot } from './scope.js';
import type { StateCreationRuneName } from '../../utils.js';
import type { AnalysisState } from './2-analyze/types.js';
export interface Js {
ast: Program;
@ -29,6 +38,8 @@ export interface Analysis {
immutable: boolean;
tracing: boolean;
classes: Map<ClassBody, Map<string, StateField>>;
// TODO figure out if we can move this to ComponentAnalysis
accessors: boolean;
}

@ -2,6 +2,13 @@ import type { SourceMap } from 'magic-string';
import type { Binding } from '../phases/scope.js';
import type { AST, Namespace } from './template.js';
import type { ICompileDiagnostic } from '../utils/compile_diagnostic.js';
import type { StateCreationRuneName } from '../../utils.js';
import type {
AssignmentExpression,
CallExpression,
PrivateIdentifier,
PropertyDefinition
} from 'estree';
/** The return value of `compile` from `svelte/compiler` */
export interface CompileResult {
@ -269,6 +276,13 @@ export interface ExpressionMetadata {
has_call: boolean;
}
export interface StateField {
type: StateCreationRuneName;
node: PropertyDefinition | AssignmentExpression;
key: PrivateIdentifier;
value: CallExpression;
}
export * from './template.js';
export { Binding, Scope } from '../phases/scope.js';

@ -547,7 +547,13 @@ export namespace AST {
| AST.SvelteWindow
| AST.SvelteBoundary;
export type Tag = AST.ExpressionTag | AST.HtmlTag | AST.ConstTag | AST.DebugTag | AST.RenderTag;
export type Tag =
| AST.AttachTag
| AST.ConstTag
| AST.DebugTag
| AST.ExpressionTag
| AST.HtmlTag
| AST.RenderTag;
export type TemplateNode =
| AST.Root

@ -24,7 +24,7 @@ export function set_class(dom, is_html, value, hash, prev_classes, next_classes)
if (!hydrating || next_class_name !== dom.getAttribute('class')) {
// Removing the attribute when the value is only an empty string causes
// performance issues vs simply making the className an empty string. So
// we should only remove the class if the the value is nullish
// we should only remove the class if the value is nullish
// and there no hash/directives :
if (next_class_name == null) {
dom.removeAttribute('class');

@ -3,6 +3,19 @@ import { ReactiveValue } from './reactive-value.js';
const parenthesis_regex = /\(.+\)/;
// these keywords are valid media queries but they need to be without parenthesis
//
// eg: new MediaQuery('screen')
//
// however because of the auto-parenthesis logic in the constructor since there's no parentehesis
// in the media query they'll be surrounded by parenthesis
//
// however we can check if the media query is only composed of these keywords
// and skip the auto-parenthesis
//
// https://github.com/sveltejs/svelte/issues/15930
const non_parenthesized_keywords = new Set(['all', 'print', 'screen', 'and', 'or', 'not', 'only']);
/**
* Creates a media query and provides a `current` property that reflects whether or not it matches.
*
@ -27,7 +40,12 @@ export class MediaQuery extends ReactiveValue {
* @param {boolean} [fallback] Fallback value for the server
*/
constructor(query, fallback) {
let final_query = parenthesis_regex.test(query) ? query : `(${query})`;
let final_query =
parenthesis_regex.test(query) ||
// we need to use `some` here because technically this `window.matchMedia('random,screen')` still returns true
query.split(/[\s,]+/).some((keyword) => non_parenthesized_keywords.has(keyword.trim()))
? query
: `(${query})`;
const q = window.matchMedia(final_query);
super(
() => q.matches,

@ -428,15 +428,19 @@ export function is_mathml(name) {
return MATHML_ELEMENTS.includes(name);
}
const RUNES = /** @type {const} */ ([
export const STATE_CREATION_RUNES = /** @type {const} */ ([
'$state',
'$state.raw',
'$derived',
'$derived.by'
]);
const RUNES = /** @type {const} */ ([
...STATE_CREATION_RUNES,
'$state.snapshot',
'$props',
'$props.id',
'$bindable',
'$derived',
'$derived.by',
'$effect',
'$effect.pre',
'$effect.tracking',
@ -447,12 +451,24 @@ const RUNES = /** @type {const} */ ([
'$host'
]);
/** @typedef {RUNES[number]} RuneName */
/**
* @param {string} name
* @returns {name is RUNES[number]}
* @returns {name is RuneName}
*/
export function is_rune(name) {
return RUNES.includes(/** @type {RUNES[number]} */ (name));
return RUNES.includes(/** @type {RuneName} */ (name));
}
/** @typedef {STATE_CREATION_RUNES[number]} StateCreationRuneName */
/**
* @param {string} name
* @returns {name is StateCreationRuneName}
*/
export function is_state_creation_rune(name) {
return STATE_CREATION_RUNES.includes(/** @type {StateCreationRuneName} */ (name));
}
/** List of elements that require raw contents and should not have SSR comments put in them */

@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
export const VERSION = '5.30.1';
export const VERSION = '5.31.1';
export const PUBLIC_VERSION = '5';

@ -4,7 +4,7 @@ export default test({
error: {
code: 'state_invalid_placement',
message:
'`$state(...)` can only be used as a variable declaration initializer or a class field',
'`$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.',
position: [33, 41]
}
});

@ -3,6 +3,7 @@ import { test } from '../../test';
export default test({
error: {
code: 'state_invalid_placement',
message: '`$state(...)` can only be used as a variable declaration initializer or a class field'
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.'
}
});

@ -4,6 +4,6 @@ export default test({
error: {
code: 'state_invalid_placement',
message:
'`$derived(...)` can only be used as a variable declaration initializer or a class field'
'`$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.'
}
});

@ -3,6 +3,7 @@ import { test } from '../../test';
export default test({
error: {
code: 'state_invalid_placement',
message: '`$state(...)` can only be used as a variable declaration initializer or a class field'
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.'
}
});

@ -0,0 +1,13 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>10</button>`,
ssrHtml: `<button>0</button>`,
async test({ assert, target }) {
flushSync();
assert.htmlEqual(target.innerHTML, `<button>10</button>`);
}
});

@ -0,0 +1,13 @@
<script>
class Counter {
constructor() {
this.count = $state(0);
$effect(() => {
this.count = 10;
});
}
}
const counter = new Counter();
</script>
<button on:click={() => counter.count++}>{counter.count}</button>

@ -0,0 +1,13 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>10</button>`,
ssrHtml: `<button>0</button>`,
async test({ assert, target }) {
flushSync();
assert.htmlEqual(target.innerHTML, `<button>10</button>`);
}
});

@ -0,0 +1,12 @@
<script>
const counter = new class Counter {
constructor() {
this.count = $state(0);
$effect(() => {
this.count = 10;
});
}
}
</script>
<button on:click={() => counter.count++}>{counter.count}</button>

@ -0,0 +1,9 @@
<script>
class Test {
0 = $state();
constructor() {
this[1] = $state();
}
}
</script>

@ -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'
]);
}
});

@ -0,0 +1,37 @@
<script module>
class SomeLogic {
trigger() {
this.someValue++;
}
constructor() {
this.someValue = $state(0);
this.isAboveThree = $derived(this.someValue > 3);
}
}
const someLogic = new SomeLogic();
</script>
<script>
function increment() {
someLogic.trigger();
}
let localDerived = $derived(someLogic.someValue > 3);
$effect(() => {
console.log(someLogic.someValue);
});
$effect(() => {
console.log('class trigger ' + someLogic.isAboveThree)
});
$effect(() => {
console.log('local trigger ' + localDerived)
});
</script>
<button on:click={increment}>
clicks: {someLogic.someValue}
</button>

@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>0</button>`,
test({ assert, target }) {
const btn = target.querySelector('button');
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>1</button>`);
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
}
});

@ -0,0 +1,12 @@
<script>
class Counter {
count;
constructor(count) {
this.count = $state(count);
}
}
const counter = new Counter(0);
</script>
<button onclick={() => counter.count++}>{counter.count}</button>

@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>10: 20</button>`,
test({ assert, target }) {
const btn = target.querySelector('button');
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>11: 22</button>`);
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>12: 24</button>`);
}
});

@ -0,0 +1,22 @@
<script>
class Counter {
constructor(initial) {
this.count = $state(initial);
}
increment = () => {
this.count++;
}
}
class PluggableCounter extends Counter {
constructor(initial, plugin) {
super(initial)
this.custom = $derived(plugin(this.count));
}
}
const counter = new PluggableCounter(10, (count) => count * 2);
</script>
<button onclick={counter.increment}>{counter.count}: {counter.custom}</button>

@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>20</button>`,
test({ assert, target }) {
const btn = target.querySelector('button');
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>22</button>`);
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>24</button>`);
}
});

@ -0,0 +1,18 @@
<script>
class Counter {
/** @type {number} */
#count;
constructor(initial) {
this.#count = $state(initial);
this.doubled = $derived(this.#count * 2);
}
increment = () => {
this.#count++;
}
}
const counter = new Counter(10);
</script>
<button onclick={counter.increment}>{counter.doubled}</button>

@ -5,5 +5,10 @@ export default test({
async test({ window }) {
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 599px), (min-width: 900px)');
expect(window.matchMedia).toHaveBeenCalledWith('(min-width: 900px)');
expect(window.matchMedia).toHaveBeenCalledWith('screen');
expect(window.matchMedia).toHaveBeenCalledWith('not print');
expect(window.matchMedia).toHaveBeenCalledWith('screen,print');
expect(window.matchMedia).toHaveBeenCalledWith('screen, print');
expect(window.matchMedia).toHaveBeenCalledWith('screen, random');
}
});

@ -3,4 +3,9 @@
const mq = new MediaQuery("(max-width: 599px), (min-width: 900px)");
const mq2 = new MediaQuery("min-width: 900px");
const mq3 = new MediaQuery("screen");
const mq4 = new MediaQuery("not print");
const mq5 = new MediaQuery("screen,print");
const mq6 = new MediaQuery("screen, print");
const mq7 = new MediaQuery("screen, random");
</script>

@ -0,0 +1,14 @@
[
{
"code": "state_field_duplicate",
"message": "`count` has already been declared on this class",
"start": {
"line": 5,
"column": 2
},
"end": {
"line": 5,
"column": 24
}
}
]

@ -0,0 +1,7 @@
export class Counter {
count = $state(0);
constructor() {
this.count = $state(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "state_field_invalid_assignment",
"message": "Cannot assign to a state field before its declaration",
"start": {
"line": 4,
"column": 3
},
"end": {
"line": 4,
"column": 18
}
}
]

@ -0,0 +1,9 @@
export class Counter {
constructor() {
if (true) {
this.count = -1;
}
this.count = $state(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "state_field_duplicate",
"message": "`count` has already been declared on this class",
"start": {
"line": 5,
"column": 2
},
"end": {
"line": 5,
"column": 24
}
}
]

@ -0,0 +1,7 @@
export class Counter {
constructor() {
this.count = $state(0);
this.count = 1;
this.count = $state(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "state_field_duplicate",
"message": "`count` has already been declared on this class",
"start": {
"line": 5,
"column": 2
},
"end": {
"line": 5,
"column": 28
}
}
]

@ -0,0 +1,7 @@
export class Counter {
constructor() {
this.count = $state(0);
this.count = 1;
this.count = $state.raw(0);
}
}

@ -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
}
}
]

@ -0,0 +1,7 @@
export class Counter {
constructor() {
if (true) {
this.count = $state(0);
}
}
}

@ -0,0 +1,14 @@
[
{
"code": "state_field_duplicate",
"message": "`count` has already been declared on this class",
"start": {
"line": 5,
"column": 2
},
"end": {
"line": 5,
"column": 27
}
}
]

@ -0,0 +1,7 @@
export class Counter {
// prettier-ignore
'count' = $state(0);
constructor() {
this['count'] = $state(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "state_field_duplicate",
"message": "`count` has already been declared on this class",
"start": {
"line": 4,
"column": 2
},
"end": {
"line": 4,
"column": 27
}
}
]

@ -0,0 +1,6 @@
export class Counter {
count = $state(0);
constructor() {
this['count'] = $state(0);
}
}

@ -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
}
}
]

@ -0,0 +1,7 @@
const count = 'count';
export class Counter {
constructor() {
this[count] = $state(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "state_field_invalid_assignment",
"message": "Cannot assign to a state field before its declaration",
"start": {
"line": 3,
"column": 2
},
"end": {
"line": 3,
"column": 17
}
}
]

@ -0,0 +1,6 @@
export class Counter {
constructor() {
this.count = -1;
this.count = $state(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "state_field_invalid_assignment",
"message": "Cannot assign to a state field before its declaration",
"start": {
"line": 2,
"column": 1
},
"end": {
"line": 2,
"column": 12
}
}
]

@ -0,0 +1,7 @@
export class Counter {
count = -1;
constructor() {
this.count = $state(0);
}
}

@ -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

@ -1375,7 +1375,13 @@ declare module 'svelte/compiler' {
| AST.SvelteWindow
| AST.SvelteBoundary;
export type Tag = AST.ExpressionTag | AST.HtmlTag | AST.ConstTag | AST.DebugTag | AST.RenderTag;
export type Tag =
| AST.AttachTag
| AST.ConstTag
| AST.DebugTag
| AST.ExpressionTag
| AST.HtmlTag
| AST.RenderTag;
export type TemplateNode =
| AST.Root

Loading…
Cancel
Save