pull/16197/head
Rich Harris 4 months ago
commit 53b9b8f335

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: allow characters in the supplementary special-purpose plane

@ -67,19 +67,29 @@ for (let i = 0; i < results[0].length; i += 1) {
for (const metric of ['time', 'gc_time']) { for (const metric of ['time', 'gc_time']) {
const times = results.map((result) => +result[i][metric]); const times = results.map((result) => +result[i][metric]);
let min = Infinity; let min = Infinity;
let max = -Infinity;
let min_index = -1; let min_index = -1;
for (let b = 0; b < times.length; b += 1) { for (let b = 0; b < times.length; b += 1) {
if (times[b] < min) { const time = times[b];
min = times[b];
if (time < min) {
min = time;
min_index = b; min_index = b;
} }
if (time > max) {
max = time;
}
} }
if (min !== 0) { if (min !== 0) {
console.group(`${metric}: fastest is ${branches[min_index]}`); console.group(`${metric}: fastest is ${char(min_index)} (${branches[min_index]})`);
times.forEach((time, b) => { times.forEach((time, b) => {
console.log(`${branches[b]}: ${time.toFixed(2)}ms (${((time / min) * 100).toFixed(2)}%)`); const SIZE = 20;
const n = Math.round(SIZE * (time / max));
console.log(`${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`);
}); });
console.groupEnd(); console.groupEnd();
} }
@ -87,3 +97,7 @@ for (let i = 0; i < results[0].length; i += 1) {
console.groupEnd(); console.groupEnd();
} }
function char(i) {
return String.fromCharCode(97 + i);
}

@ -20,7 +20,7 @@ Unlike other frameworks you may have encountered, there is no API for interactin
If `$state` is used with an array or a simple object, the result is a deeply reactive _state proxy_. [Proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) allow Svelte to run code when you read or write properties, including via methods like `array.push(...)`, triggering granular updates. If `$state` is used with an array or a simple object, the result is a deeply reactive _state proxy_. [Proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) allow Svelte to run code when you read or write properties, including via methods like `array.push(...)`, triggering granular updates.
> [!NOTE] Classes like `Set` and `Map` will not be proxied, but Svelte provides reactive implementations for various built-ins like these that can be imported from [`svelte/reactivity`](./svelte-reactivity). > [!NOTE] Class instances are not proxied. You can create [reactive state fields](#Classes) on classes that you define. Svelte provides reactive implementations of built-ins like `Set` and `Map` that can be imported from [`svelte/reactivity`](svelte-reactivity).
State is proxified recursively until Svelte finds something other than an array or simple object. In a case like this... State is proxified recursively until Svelte finds something other than an array or simple object. In a case like this...
@ -67,16 +67,15 @@ todos[0].done = !todos[0].done;
### Classes ### 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 ```js
// @errors: 7006 2554 // @errors: 7006 2554
class Todo { class Todo {
done = $state(false); done = $state(false);
text = $state();
constructor(text) { constructor(text) {
this.text = text; this.text = $state(text);
} }
reset() { reset() {
@ -110,10 +109,9 @@ You can either use an inline function...
// @errors: 7006 2554 // @errors: 7006 2554
class Todo { class Todo {
done = $state(false); done = $state(false);
text = $state();
constructor(text) { constructor(text) {
this.text = text; this.text = $state(text);
} }
+++reset = () => {+++ +++reset = () => {+++

@ -52,6 +52,7 @@ This rune, added in 5.14, causes the surrounding function to be _traced_ in deve
import { doSomeWork } from './elsewhere'; import { doSomeWork } from './elsewhere';
$effect(() => { $effect(() => {
+++// $inspect.trace must be the first statement of a function body+++
+++$inspect.trace();+++ +++$inspect.trace();+++
doSomeWork(); doSomeWork();
}); });

@ -82,12 +82,14 @@ As with elements, `name={name}` can be replaced with the `{name}` shorthand.
<Widget foo={bar} answer={42} text="hello" /> <Widget foo={bar} answer={42} text="hello" />
``` ```
## Spread attributes
_Spread attributes_ allow many attributes or properties to be passed to an element or component at once. _Spread attributes_ allow many attributes or properties to be passed to an element or component at once.
An element or component can have multiple spread attributes, interspersed with regular ones. An element or component can have multiple spread attributes, interspersed with regular ones. Order matters — if `things.a` exists it will take precedence over `a="b"`, while `c="d"` would take precedence over `things.c`:
```svelte ```svelte
<Widget {...things} /> <Widget a="b" {...things} c="d" />
``` ```
## Events ## Events

@ -43,7 +43,9 @@ An each block can also specify an _index_, equivalent to the second argument in
{#each expression as name, index (key)}...{/each} {#each expression as name, index (key)}...{/each}
``` ```
If a _key_ expression is provided — which must uniquely identify each list item — Svelte will use it to diff the list when data changes, rather than adding or removing items at the end. The key can be any object, but strings and numbers are recommended since they allow identity to persist when the objects themselves change. If a _key_ expression is provided — which must uniquely identify each list item — Svelte will use it to intelligently update the list when data changes by inserting, moving and deleting items, rather than adding or removing items at the end and updating the state in the middle.
The key can be any object, but strings and numbers are recommended since they allow identity to persist when the objects themselves change.
```svelte ```svelte
{#each items as item (item.id)} {#each items as item (item.id)}

@ -0,0 +1,166 @@
---
title: {@attach ...}
---
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.
```svelte
<!--- file: App.svelte --->
<script>
/** @type {import('svelte/attachments').Attachment} */
function myAttachment(element) {
console.log(element.nodeName); // 'DIV'
return () => {
console.log('cleaning up');
};
}
</script>
<div {@attach myAttachment}>...</div>
```
An element can have any number of attachments.
## Attachment factories
A useful pattern is for a function, such as `tooltip` in this example, to _return_ an attachment ([demo](/playground/untitled#H4sIAAAAAAAAE3VT0XLaMBD8lavbDiaNCUlbHhTItG_5h5AH2T5ArdBppDOEMv73SkbGJGnH47F9t3un3TsfMyO3mInsh2SW1Sa7zlZKo8_E0zHjg42pGAjxBPxp7cTvUHOMldLjv-IVGUbDoUw295VTlh-WZslqa8kxsLL2ACtHWxh175NffnQfAAGikSGxYQGfPEvGfPSIWtOH0TiBVo2pWJEBJtKhQp4YYzjG9JIdcuMM5IZqHMPioY8vOSA997zQoevf4a7heO7cdp34olRiTGr07OhwH1IdoO2A7dLMbwahZq6MbRhKZWqxk7rBxTGVbuHmhCgb5qDgmIx_J6XtHHukHTrYYqx_YpzYng8aO4RYayql7hU-1ZJl0akqHBE_D9KLolwL-Dibzc7iSln9XjtqTF1UpMkJ2EmXR-BgQErsN4pxIJKr0RVO1qrxAqaTO4fbc9bKulZm3cfDY3aZDgvFGErWjmzhN7KmfX5rXyDeX8Pt1mU-hXjdBOrtuB97vK4GPUtmJ41XcRMEGDLD8do0nJ73zhUhSlyRw0t3vPqD8cjfLs-axiFgNBrkUd9Ulp50c-GLxlXAVlJX-ffpZyiSn7H0eLCUySZQcQdXlxj4El0Yv_FZvIKElqqGTruVLhzu7VRKCh22_5toOyxsWqLwwzK-cCbYNdg-hy-p9D7sbiZWUnts_wLUOF3CJgQAAA==)):
```svelte
<!--- file: App.svelte --->
<script>
import tippy from 'tippy.js';
let content = $state('Hello!');
/**
* @param {string} content
* @returns {import('svelte/attachments').Attachment}
*/
function tooltip(content) {
return (element) => {
const tooltip = tippy(element, { content });
return tooltip.destroy;
};
}
</script>
<input bind:value={content} />
<button {@attach tooltip(content)}>
Hover me
</button>
```
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
Attachments can also be created inline ([demo](/playground/untitled#H4sIAAAAAAAAE71Wf3OaWBT9KoyTTnW3MS-I3dYmnWXVtnRAazRJzbozRSQEApiRhwKO333vuY8m225m_9yZGOT9OPfcc84D943UTfxGr_G7K6Xr3TVeNW7D2M8avT_3DVk-YAoDNF4vNB8e2tnWjyXGlm7mPzfurVPpp5JgGmeZtwkf5PtFupCxLzVvHa832rl2lElX-s2Xm2DZFNqp_hs-rZetd4v07ORpT3qmQHu7MF2td0BZp8k6z_xkvfXP902_pZ2_1_aYWEiqm0kN8I4r79qbdZ6umnq3q_2iNf22F4dE6qt2oimwdpim_uY6XMm7Fuo-IQT_iTD_CeGTHwZ38ieIJUFQRxirR1Xf39Dw0X5z0I72Af4tD61vvPNwWKQnqmfPTbduhsEd2J3vO_oBd3dc6fF2X7umNdWGf0vBRhSS6qoV7cCXfTXWfKmvWG61_si_vfU92Wz-E4RhsLhNIYinsox9QKGVd8-tuACCeKXRX12P-T_eKf7fhTq0Hvt-f3ailtSeoxJHRo1-58NoPe1UiBc1hkL8Yeh45y_vQ3mcuNl9T8s3cXPRWLnS7YWJG_gn2Tb4tUjid8jua-PVl08j_ab8I14mH8Llx0s5Tz5Err4ql52r_GYg0mVy1bEGZuD0ze64b5TWYFiM-16wSuJ4JT5vfVpDcztrcG_YkRU4s6HxufzDWF4XuVeJ1P10IbzBemt3Vp1V2e04ZXfrJd7Wicyd039brRIv_RIVu_nXi7X1cfL2sy66ztToUp1TO7qJ7NlwZ0f30pld5qNSVE5o6PbMojFHjgZB7oSicPpGteyLclQap7SvY0dXtM_LR1NT2JFHey3aaxa0VxCeYJ7RMHemoiCcgPZV9pR7o7kgcOjeGliYk9hjDZx8FAq6enwlTPSZj_vYPw9Il64dXdIY8ZmapzwfEd8-1ZyaxWhqkIZOibXUd-6Upqi1pD4uMicCV1GA_7zi73UN8BaF4sC8peJtMjfmjbHZBFwq5ov50qRaE0l96NZggnW4KqypYRAW-uhSz9ADvklwJF2J-5W0Z5fQPBhDX92R6I_0IFxRgDftge4l4dP-gH1hjD7uqU6fsOEZ9UNrCdPB-nys6uXgY6O3ZMd9sy5T9PghqrWHdjo4jB51CgLiKJaDYYA-7WgYONf1FbjkI-mE3EAfUY_rijfuJ_CVPaR50oe9JF7Q0pI8Dw3osxxYHdYPGbp2CnwHF8KvwJv2wEv0Z3ilQI6U9uwbZxbYJXvEmjjQjjCHkvNLvNg3yhzXQd1olamsT4IRrZmX0MUDpwL7R8zzHj7pSh9hPHFSHjLezKqAST51uC5zmtQ87skDUaneLokT5RbXkPWSYz53Abgjc8_o4KFGUZ-Hgv2Z1l5OTYM9D-HfUD0L-EwxH5wRnIG61gS-khfgY1bq7IAP_DA4l5xRuh9xlm8yGjutc8t-wHtkhWv3hc7aqGwiK5KzgvM5xRkZYn193uEln-su55j1GaIv7oM4iPrsVHiG0Dx7TR9-1lBfqFdwfvSd5LNL5xyZVp5NoHFZ57FkfiF6vKs4k5zvIfrX5xX6MXmt0gM5MTu8DjnhukrHHzTRd3jm0dma0_f_x5cxP9f4jBdqHvmbq2fUjzqcKh2Cp-yWj9ntcHanXmBXxhu7Q--eyjhfNFpaV7zgz4nWEUb7zUOhpevjjf_gu_KZ99pxFlZ-T3sttkmYqrco_26q35v0Ewzv5EZPbnL_8BfduWGMnyyN3q0bZ_7hb_7KG_L4CQAA)):
```svelte
<!--- file: App.svelte --->
<canvas
width={32}
height={32}
{@attach (canvas) => {
const context = canvas.getContext('2d');
$effect(() => {
context.fillStyle = color;
context.fillRect(0, 0, canvas.width, canvas.height);
});
}}
></canvas>
```
> [!NOTE]
> The nested effect runs whenever `color` changes, while the outer effect (where `canvas.getContext(...)` is called) only runs once, since it doesn't read any reactive state.
## Passing attachments to components
When used on a component, `{@attach ...}` will create a prop whose key is a [`Symbol`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). If the component then [spreads](/tutorial/svelte/spread-props) props onto an element, the element will receive those attachments.
This allows you to create _wrapper components_ that augment elements ([demo](/playground/untitled#H4sIAAAAAAAAE3VUS3ObMBD-KxvajnFqsJM2PhA7TXrKob31FjITAbKtRkiMtDhJPfz3LiAMdpxhGJvdb1_fPnaeYjn3Iu-WIbJ04028lZDcetHDzsO3olbVApI74F1RhHbLJdayhFl-Sp5qhVwhufEWNjWiwJtYxSjyQhsEFEXxBiujcxg1_8O_dnQ9APwsEbVyiHDafjrvDZCgkiO4MLCEzxYZcn90z6XUZ6OxA61KlaIgV6i1pFC-sxjDrlbHaDiWRoGvdMbHsLzp5DES0mJnRxGaRBvcBHb7yFUTCQeunEWYcYtGv12TqgFUDbCK1WLaM6IWQhUlQiJUFm2ZLPly51xXMG0Rjoyd69C7UqqG2nu95QZyXvtvLVpri2-SN4hoLXXCZFfhQ8aQBU1VgdEaH_vSgyBZR_BpPp_vi0tY-rw2ulRZkGqpTQRbZvwa2BPgFC8bgbw31CbjJjAsE6WNYBZeGp7vtQXLMqHWnZx-5kM1TR5ycpkZXQR2wzL94l8Ur1C_3-g168SfQf1MyfRi3LW9fs77emJEw5QV9SREoLTq06tcczq7d6xEUcJX2vAhO1b843XK34e5unZEMBr15ekuKEusluWAF8lXhE2ZTP2r2RcIHJ-163FPKerCgYJLOB9i4GvNwviI5-gAQiFFBk3tBTOU3HFXEk0R8o86WvUD64aINhv5K3oRmpJXkw8uxMG6Hh6JY9X7OwGSqfUy9tDG3sHNoEi0d_d_fv9qndxRU0VClFqo3KVo3U655Hnt1PXB3Qra2Y2QGdEwgTAMCxopsoxOe6SD0gD8movDhT0LAnhqlE8gVCpLWnRoV7OJCkFAwEXitrYL1W7p7pbiE_P7XH6E_rihODm5s52XtiH9Ekaw0VgI9exadWL1uoEYjPtg2672k5szsxbKyWB2fdT0w5Y_0hcT8oXOlRetmLS8-g-6TLXXQgYAAA==)):
```svelte
<!--- file: Button.svelte --->
<script>
/** @type {import('svelte/elements').HTMLButtonAttributes} */
let { children, ...props } = $props();
</script>
<!-- `props` includes attachments -->
<button {...props}>
{@render children?.()}
</button>
```
```svelte
<!--- file: App.svelte --->
<script>
import tippy from 'tippy.js';
import Button from './Button.svelte';
let content = $state('Hello!');
/**
* @param {string} content
* @returns {import('svelte/attachments').Attachment}
*/
function tooltip(content) {
return (element) => {
const tooltip = tippy(element, { content });
return tooltip.destroy;
};
}
</script>
<input bind:value={content} />
<Button {@attach tooltip(content)}>
Hover me
</Button>
```
## 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).
## Converting actions to attachments
If you're using a library that only provides actions, you can convert them to attachments with [`fromAction`](svelte-attachments#fromAction), allowing you to (for example) use them with components.

@ -117,6 +117,29 @@ Since 5.6.0, if an `<input>` has a `defaultChecked` attribute and is part of a f
</form> </form>
``` ```
## `<input bind:indeterminate>`
Checkboxes can be in an [indeterminate](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/indeterminate) state, independently of whether they are checked or unchecked:
```svelte
<script>
let checked = $state(false);
let indeterminate = $state(true);
</script>
<form>
<input type="checkbox" bind:checked bind:indeterminate>
{#if indeterminate}
waiting...
{:else if checked}
checked
{:else}
unchecked
{/if}
</form>
```
## `<input bind:group>` ## `<input bind:group>`
Inputs that work together can use `bind:group`. Inputs that work together can use `bind:group`.
@ -227,6 +250,7 @@ You can give the `<select>` a default value by adding a `selected` attribute to
- [`seeking`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/seeking_event) - [`seeking`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/seeking_event)
- [`ended`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended) - [`ended`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended)
- [`readyState`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState) - [`readyState`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState)
- [`played`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/played)
```svelte ```svelte
<audio src={clip} bind:duration bind:currentTime bind:paused></audio> <audio src={clip} bind:duration bind:currentTime bind:paused></audio>
@ -254,6 +278,10 @@ You can give the `<select>` a default value by adding a `selected` attribute to
</details> </details>
``` ```
## `window` and `document`
To bind to properties of `window` and `document`, see [`<svelte:window>`](svelte-window) and [`<svelte:document>`](svelte-document).
## Contenteditable bindings ## Contenteditable bindings
Elements with the `contenteditable` attribute support the following bindings: Elements with the `contenteditable` attribute support the following bindings:
@ -278,6 +306,10 @@ All visible elements have the following readonly bindings, measured with a `Resi
- [`clientHeight`](https://developer.mozilla.org/en-US/docs/Web/API/Element/clientHeight) - [`clientHeight`](https://developer.mozilla.org/en-US/docs/Web/API/Element/clientHeight)
- [`offsetWidth`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetWidth) - [`offsetWidth`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetWidth)
- [`offsetHeight`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetHeight) - [`offsetHeight`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetHeight)
- [`contentRect`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/contentRect)
- [`contentBoxSize`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/contentBoxSize)
- [`borderBoxSize`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/borderBoxSize)
- [`devicePixelContentBoxSize`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/devicePixelContentBoxSize)
```svelte ```svelte
<div bind:offsetWidth={width} bind:offsetHeight={height}> <div bind:offsetWidth={width} bind:offsetHeight={height}>
@ -285,7 +317,7 @@ All visible elements have the following readonly bindings, measured with a `Resi
</div> </div>
``` ```
> [!NOTE] `display: inline` elements do not have a width or height (except for elements with 'intrinsic' dimensions, like `<img>` and `<canvas>`), and cannot be observed with a `ResizeObserver`. You will need to change the `display` style of these elements to something else, such as `inline-block`. > [!NOTE] `display: inline` elements do not have a width or height (except for elements with 'intrinsic' dimensions, like `<img>` and `<canvas>`), and cannot be observed with a `ResizeObserver`. You will need to change the `display` style of these elements to something else, such as `inline-block`. Note that CSS transformations do not trigger `ResizeObserver` callbacks.
## bind:this ## bind:this

@ -2,6 +2,9 @@
title: use: title: use:
--- ---
> [!NOTE]
> In Svelte 5.29 and newer, consider using [attachments](@attach) instead, as they are more flexible and composable.
Actions are functions that are called when an element is mounted. They are added with the `use:` directive, and will typically use an `$effect` so that they can reset any state when the element is unmounted: Actions are functions that are called when an element is mounted. They are added with the `use:` directive, and will typically use an `$effect` so that they can reset any state when the element is unmounted:
```svelte ```svelte

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

@ -6,9 +6,9 @@ Testing helps you write and maintain your code and guard against regressions. Te
## Unit and integration testing using Vitest ## Unit and integration testing using Vitest
Unit tests allow you to test small isolated parts of your code. Integration tests allow you to test parts of your application to see if they work together. If you're using Vite (including via SvelteKit), we recommend using [Vitest](https://vitest.dev/). Unit tests allow you to test small isolated parts of your code. Integration tests allow you to test parts of your application to see if they work together. If you're using Vite (including via SvelteKit), we recommend using [Vitest](https://vitest.dev/). You can use the Svelte CLI to [setup Vitest](/docs/cli/vitest) either during project creation or later on.
To get started, install Vitest: To setup Vitest manually, first install it:
```bash ```bash
npm install -D vitest npm install -D vitest
@ -254,9 +254,9 @@ When writing component tests that involve two-way bindings, context or snippet p
E2E (short for 'end to end') tests allow you to test your full application through the eyes of the user. This section uses [Playwright](https://playwright.dev/) as an example, but you can also use other solutions like [Cypress](https://www.cypress.io/) or [NightwatchJS](https://nightwatchjs.org/). E2E (short for 'end to end') tests allow you to test your full application through the eyes of the user. This section uses [Playwright](https://playwright.dev/) as an example, but you can also use other solutions like [Cypress](https://www.cypress.io/) or [NightwatchJS](https://nightwatchjs.org/).
To get started with Playwright, either install it via [the VS Code extension](https://playwright.dev/docs/getting-started-vscode), or install it from the command line using `npm init playwright`. It is also part of the setup CLI when you run `npx sv create`. You can use the Svelte CLI to [setup Playwright](/docs/cli/playwright) either during project creation or later on. You can also [set it up with `npm init playwright`](https://playwright.dev/docs/intro). Additionally, you may also want to install an IDE plugin such as [the VS Code extension](https://playwright.dev/docs/getting-started-vscode) to be able to execute tests from inside your IDE.
After you've done that, you should have a `tests` folder and a Playwright config. You may need to adjust that config to tell Playwright what to do before running the tests - mainly starting your application at a certain port: If you've run `npm init playwright` or are not using Vite, you may need to adjust the Playwright config to tell Playwright what to do before running the tests - mainly starting your application at a certain port. For example:
```js ```js
/// file: playwright.config.js /// file: playwright.config.js

@ -81,9 +81,10 @@ _End-to-End Tests_: To ensure your users are able to interact with your applicat
Some resources for getting started with testing: Some resources for getting started with testing:
- [Svelte docs on testing](/docs/svelte/testing)
- [Setup Vitest using the Svelte CLI](/docs/cli/vitest)
- [Svelte Testing Library](https://testing-library.com/docs/svelte-testing-library/example/) - [Svelte Testing Library](https://testing-library.com/docs/svelte-testing-library/example/)
- [Svelte Component Testing in Cypress](https://docs.cypress.io/guides/component-testing/svelte/overview) - [Svelte Component Testing in Cypress](https://docs.cypress.io/guides/component-testing/svelte/overview)
- [Example using vitest](https://github.com/vitest-dev/vitest/tree/main/examples/sveltekit)
- [Example using uvu test runner with JSDOM](https://github.com/lukeed/uvu/tree/master/examples/svelte) - [Example using uvu test runner with JSDOM](https://github.com/lukeed/uvu/tree/master/examples/svelte)
- [Test Svelte components using Vitest & Playwright](https://davipon.hashnode.dev/test-svelte-component-using-vitest-playwright) - [Test Svelte components using Vitest & Playwright](https://davipon.hashnode.dev/test-svelte-component-using-vitest-playwright)
- [Component testing with WebdriverIO](https://webdriver.io/docs/component-testing/svelte) - [Component testing with WebdriverIO](https://webdriver.io/docs/component-testing/svelte)

@ -858,6 +858,38 @@ Cannot reassign or bind to snippet parameter
This snippet is shadowing the prop `%prop%` with the same name 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 ### state_invalid_export
``` ```
@ -867,7 +899,7 @@ Cannot export state from a module if it is reassigned. Either export a function
### state_invalid_placement ### 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 ### store_invalid_scoped_subscription

@ -586,6 +586,14 @@ Attributes should not contain ':' characters to prevent ambiguity with Svelte di
Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes Quoted attributes on components and custom elements will be stringified in a future version of Svelte. If this isn't what you want, remove the quotes
``` ```
### bidirectional_control_characters
```
A bidirectional control character was detected in your code. These characters can be used to alter the visual direction of your code and could have unintended consequences
```
Bidirectional control characters can alter the direction in which text appears to be in. For example, via control characters, you can make `defabc` look like `abcdef`. As a result, if you were to unknowingly copy and paste some code that has these control characters, they may alter the behavior of your code in ways you did not intend. See [trojansource.codes](https://trojansource.codes/) for more information.
### bind_invalid_each_rest ### bind_invalid_each_rest
``` ```
@ -624,6 +632,25 @@ In some situations a selector may target an element that is not 'visible' to the
</style> </style>
``` ```
### element_implicitly_closed
```
This element is implicitly closed by the following `%tag%`, which can cause an unexpected DOM structure. Add an explicit `%closing%` to avoid surprises.
```
In HTML, some elements are implicitly closed by another element. For example, you cannot nest a `<p>` inside another `<p>`:
```html
<!-- this HTML... -->
<p><p>hello</p>
<!-- results in this DOM structure -->
<p></p>
<p>hello</p>
```
Similarly, a parent element's closing tag will implicitly close all child elements, even if the `</` was a typo and you meant to create a _new_ element. To avoid ambiguity, it's always a good idea to have an explicit closing tag.
### element_invalid_self_closing_tag ### element_invalid_self_closing_tag
``` ```

@ -0,0 +1,5 @@
---
title: svelte/attachments
---
> MODULE: svelte/attachments

@ -1,5 +1,105 @@
# svelte # svelte
## 5.32.0
### Minor Changes
- feat: warn on implicitly closed tags ([#15932](https://github.com/sveltejs/svelte/pull/15932))
- feat: attachments `fromAction` utility ([#15933](https://github.com/sveltejs/svelte/pull/15933))
### Patch Changes
- fix: only re-run directly applied attachment if it changed ([#15962](https://github.com/sveltejs/svelte/pull/15962))
## 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
- fix: add `typeParams` to `SnippetBlock` for legacy parser ([#15921](https://github.com/sveltejs/svelte/pull/15921))
## 5.30.0
### Minor Changes
- feat: allow generics on snippets ([#15915](https://github.com/sveltejs/svelte/pull/15915))
## 5.29.0
### Minor Changes
- feat: attachments ([#15000](https://github.com/sveltejs/svelte/pull/15000))
## 5.28.7
### Patch Changes
- fix: remove unncessary guards that require CSP privilege when removing event attributes ([#15846](https://github.com/sveltejs/svelte/pull/15846))
- fix: rewrite destructuring logic to handle iterators ([#15813](https://github.com/sveltejs/svelte/pull/15813))
## 5.28.6
### Patch Changes
- fix: use `transform.read` for `ownership_validator.mutation` array ([#15848](https://github.com/sveltejs/svelte/pull/15848))
- fix: don't redeclare `$slots` ([#15849](https://github.com/sveltejs/svelte/pull/15849))
## 5.28.5
### Patch Changes
- fix: proxify the value in assignment shorthands to the private field ([#15862](https://github.com/sveltejs/svelte/pull/15862))
- fix: more frequently update `bind:buffered` to actual value ([#15874](https://github.com/sveltejs/svelte/pull/15874))
## 5.28.4
### Patch Changes
- fix: treat nullish expression as empty string ([#15901](https://github.com/sveltejs/svelte/pull/15901))
- fix: prevent invalid BigInt calls from blowing up at compile time ([#15900](https://github.com/sveltejs/svelte/pull/15900))
- fix: warn on bidirectional control characters ([#15893](https://github.com/sveltejs/svelte/pull/15893))
- fix: emit right error for a shadowed invalid rune ([#15892](https://github.com/sveltejs/svelte/pull/15892))
## 5.28.3
### Patch Changes
- chore: avoid microtasks when flushing sync ([#15895](https://github.com/sveltejs/svelte/pull/15895))
- fix: improve error message for migration errors when slot would be renamed ([#15841](https://github.com/sveltejs/svelte/pull/15841))
- fix: allow characters in the supplementary special-purpose plane ([#15823](https://github.com/sveltejs/svelte/pull/15823))
## 5.28.2 ## 5.28.2
### Patch Changes ### Patch Changes

@ -31,6 +31,8 @@
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// TypeScript Version: 2.8 // TypeScript Version: 2.8
import type { Attachment } from 'svelte/attachments';
// Note: We also allow `null` as a valid value because Svelte treats this the same as `undefined` // Note: We also allow `null` as a valid value because Svelte treats this the same as `undefined`
type Booleanish = boolean | 'true' | 'false'; type Booleanish = boolean | 'true' | 'false';
@ -860,6 +862,9 @@ export interface HTMLAttributes<T extends EventTarget> extends AriaAttributes, D
// allow any data- attribute // allow any data- attribute
[key: `data-${string}`]: any; [key: `data-${string}`]: any;
// 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 & {}); export type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {});

@ -220,13 +220,41 @@ It's possible to export a snippet from a `<script module>` block, but only if it
> Cannot reassign or bind to snippet parameter > 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 ## 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 > 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 ## 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 ## store_invalid_scoped_subscription

@ -1,3 +1,9 @@
## bidirectional_control_characters
> A bidirectional control character was detected in your code. These characters can be used to alter the visual direction of your code and could have unintended consequences
Bidirectional control characters can alter the direction in which text appears to be in. For example, via control characters, you can make `defabc` look like `abcdef`. As a result, if you were to unknowingly copy and paste some code that has these control characters, they may alter the behavior of your code in ways you did not intend. See [trojansource.codes](https://trojansource.codes/) for more information.
## legacy_code ## legacy_code
> `%code%` is no longer valid — please use `%suggestion%` instead > `%code%` is no longer valid — please use `%suggestion%` instead

@ -30,6 +30,23 @@
> `<%name%>` will be treated as an HTML element unless it begins with a capital letter > `<%name%>` will be treated as an HTML element unless it begins with a capital letter
## element_implicitly_closed
> This element is implicitly closed by the following `%tag%`, which can cause an unexpected DOM structure. Add an explicit `%closing%` to avoid surprises.
In HTML, some elements are implicitly closed by another element. For example, you cannot nest a `<p>` inside another `<p>`:
```html
<!-- this HTML... -->
<p><p>hello</p>
<!-- results in this DOM structure -->
<p></p>
<p>hello</p>
```
Similarly, a parent element's closing tag will implicitly close all child elements, even if the `</` was a typo and you meant to create a _new_ element. To avoid ambiguity, it's always a good idea to have an explicit closing tag.
## element_invalid_self_closing_tag ## element_invalid_self_closing_tag
> Self-closing HTML tags for non-void elements are ambiguous — use `<%name% ...></%name%>` rather than `<%name% ... />` > Self-closing HTML tags for non-void elements are ambiguous — use `<%name% ...></%name%>` rather than `<%name% ... />`

@ -2,7 +2,7 @@
"name": "svelte", "name": "svelte",
"description": "Cybernetically enhanced web apps", "description": "Cybernetically enhanced web apps",
"license": "MIT", "license": "MIT",
"version": "5.28.2", "version": "5.32.0",
"type": "module", "type": "module",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"engines": { "engines": {
@ -34,6 +34,10 @@
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"default": "./src/animate/index.js" "default": "./src/animate/index.js"
}, },
"./attachments": {
"types": "./types/index.d.ts",
"default": "./src/attachments/index.js"
},
"./compiler": { "./compiler": {
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"require": "./compiler/index.js", "require": "./compiler/index.js",
@ -132,7 +136,7 @@
], ],
"scripts": { "scripts": {
"build": "node scripts/process-messages && rollup -c && pnpm generate:types && node scripts/check-treeshakeability.js", "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": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc",
"check:watch": "tsc --watch", "check:watch": "tsc --watch",
"generate:version": "node ./scripts/generate-version.js", "generate:version": "node ./scripts/generate-version.js",

@ -35,6 +35,7 @@ await createBundle({
[pkg.name]: `${dir}/src/index.d.ts`, [pkg.name]: `${dir}/src/index.d.ts`,
[`${pkg.name}/action`]: `${dir}/src/action/public.d.ts`, [`${pkg.name}/action`]: `${dir}/src/action/public.d.ts`,
[`${pkg.name}/animate`]: `${dir}/src/animate/public.d.ts`, [`${pkg.name}/animate`]: `${dir}/src/animate/public.d.ts`,
[`${pkg.name}/attachments`]: `${dir}/src/attachments/public.d.ts`,
[`${pkg.name}/compiler`]: `${dir}/src/compiler/public.d.ts`, [`${pkg.name}/compiler`]: `${dir}/src/compiler/public.d.ts`,
[`${pkg.name}/easing`]: `${dir}/src/easing/index.js`, [`${pkg.name}/easing`]: `${dir}/src/easing/index.js`,
[`${pkg.name}/legacy`]: `${dir}/src/legacy/legacy-client.js`, [`${pkg.name}/legacy`]: `${dir}/src/legacy/legacy-client.js`,

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

@ -0,0 +1,113 @@
/** @import { Action, ActionReturn } from '../action/public' */
/** @import { Attachment } from './public' */
import { noop, render_effect } from 'svelte/internal/client';
import { ATTACHMENT_KEY } from '../constants.js';
import { untrack } from 'svelte';
import { teardown } from '../internal/client/reactivity/effects.js';
/**
* Creates an object key that will be recognised as an attachment when the object is spread onto an element,
* as a programmatic alternative to using `{@attach ...}`. This can be useful for library authors, though
* is generally not needed when building an app.
*
* ```svelte
* <script>
* import { createAttachmentKey } from 'svelte/attachments';
*
* const props = {
* class: 'cool',
* onclick: () => alert('clicked'),
* [createAttachmentKey()]: (node) => {
* node.textContent = 'attached!';
* }
* };
* </script>
*
* <button {...props}>click me</button>
* ```
* @since 5.29
*/
export function createAttachmentKey() {
return Symbol(ATTACHMENT_KEY);
}
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
* @template {EventTarget} E
* @template {unknown} T
* @overload
* @param {Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>)} action The action function
* @param {() => T} fn A function that returns the argument for the action
* @returns {Attachment<E>}
*/
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
* @template {EventTarget} E
* @overload
* @param {Action<E, void> | ((element: E) => void | ActionReturn<void>)} action The action function
* @returns {Attachment<E>}
*/
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
*
* @template {EventTarget} E
* @template {unknown} T
* @param {Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>)} action The action function
* @param {() => T} fn A function that returns the argument for the action
* @returns {Attachment<E>}
* @since 5.32
*/
export function fromAction(action, fn = /** @type {() => T} */ (noop)) {
return (element) => {
const { update, destroy } = untrack(() => action(element, fn()) ?? {});
if (update) {
var ran = false;
render_effect(() => {
const arg = fn();
if (ran) update(arg);
});
ran = true;
}
if (destroy) {
teardown(destroy);
}
};
}

@ -0,0 +1,12 @@
/**
* An [attachment](https://svelte.dev/docs/svelte/@attach) is a function that runs when an element is mounted
* to the DOM, and optionally returns a function that is called when the element is later removed.
*
* It can be attached to an element with an `{@attach ...}` tag, or by spreading an object containing
* a property created with [`createAttachmentKey`](https://svelte.dev/docs/svelte/svelte-attachments#createAttachmentKey).
*/
export interface Attachment<T extends EventTarget = Element> {
(element: T): void | (() => void);
}
export * from './index.js';

@ -479,6 +479,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`); 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 * 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 * @param {null | number | NodeLike} node
@ -489,13 +508,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 {null | number | NodeLike} node
* @param {string} rune * @param {string} rune
* @returns {never} * @returns {never}
*/ */
export function state_invalid_placement(node, rune) { 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`);
} }
/** /**

@ -378,7 +378,8 @@ export function convert(source, ast) {
end: node.end, end: node.end,
expression: node.expression, expression: node.expression,
parameters: node.parameters, parameters: node.parameters,
children: node.body.nodes.map((child) => visit(child)) children: node.body.nodes.map((child) => visit(child)),
typeParams: node.typeParams
}; };
}, },
// @ts-expect-error // @ts-expect-error

@ -1310,7 +1310,7 @@ const template = {
name = state.scope.generate(slot_name); name = state.scope.generate(slot_name);
if (name !== slot_name) { if (name !== slot_name) {
throw new MigrationError( throw new MigrationError(
'This migration would change the name of a slot making the component unusable' `This migration would change the name of a slot (${slot_name} to ${name}) making the component unusable`
); );
} }
} }
@ -1883,7 +1883,7 @@ function handle_identifier(node, state, path) {
let new_name = state.scope.generate(name); let new_name = state.scope.generate(name);
if (new_name !== name) { if (new_name !== name) {
throw new MigrationError( throw new MigrationError(
'This migration would change the name of a slot making the component unusable' `This migration would change the name of a slot (${name} to ${new_name}) making the component unusable`
); );
} }
} }

@ -1,7 +1,7 @@
/** @import { Location } from 'locate-character' */ /** @import { Location } from 'locate-character' */
/** @import { Pattern } from 'estree' */ /** @import { Pattern } from 'estree' */
/** @import { Parser } from '../index.js' */ /** @import { Parser } from '../index.js' */
import { is_bracket_open, is_bracket_close, get_bracket_close } from '../utils/bracket.js'; import { match_bracket } from '../utils/bracket.js';
import { parse_expression_at } from '../acorn.js'; import { parse_expression_at } from '../acorn.js';
import { regex_not_newline_characters } from '../../patterns.js'; import { regex_not_newline_characters } from '../../patterns.js';
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
@ -33,7 +33,9 @@ export default function read_pattern(parser) {
}; };
} }
if (!is_bracket_open(parser.template[i])) { const char = parser.template[i];
if (char !== '{' && char !== '[') {
e.expected_pattern(i); e.expected_pattern(i);
} }
@ -71,75 +73,6 @@ export default function read_pattern(parser) {
} }
} }
/**
* @param {Parser} parser
* @param {number} start
*/
function match_bracket(parser, start) {
const bracket_stack = [];
let i = start;
while (i < parser.template.length) {
let char = parser.template[i++];
if (char === "'" || char === '"' || char === '`') {
i = match_quote(parser, i, char);
continue;
}
if (is_bracket_open(char)) {
bracket_stack.push(char);
} else if (is_bracket_close(char)) {
const popped = /** @type {string} */ (bracket_stack.pop());
const expected = /** @type {string} */ (get_bracket_close(popped));
if (char !== expected) {
e.expected_token(i - 1, expected);
}
if (bracket_stack.length === 0) {
return i;
}
}
}
e.unexpected_eof(parser.template.length);
}
/**
* @param {Parser} parser
* @param {number} start
* @param {string} quote
*/
function match_quote(parser, start, quote) {
let is_escaped = false;
let i = start;
while (i < parser.template.length) {
const char = parser.template[i++];
if (is_escaped) {
is_escaped = false;
continue;
}
if (char === quote) {
return i;
}
if (char === '\\') {
is_escaped = true;
}
if (quote === '`' && char === '$' && parser.template[i] === '{') {
i = match_bracket(parser, i);
}
}
e.unterminated_string_constant(start);
}
/** /**
* @param {Parser} parser * @param {Parser} parser
* @returns {any} * @returns {any}

@ -16,7 +16,7 @@ const ALLOWED_ATTRIBUTES = ['context', 'generics', 'lang', 'module'];
/** /**
* @param {Parser} parser * @param {Parser} parser
* @param {number} start * @param {number} start
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive>} attributes * @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag>} attributes
* @returns {AST.Script} * @returns {AST.Script}
*/ */
export function read_script(parser, start, attributes) { export function read_script(parser, start, attributes) {

@ -18,7 +18,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/;
/** /**
* @param {Parser} parser * @param {Parser} parser
* @param {number} start * @param {number} start
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive>} attributes * @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag>} attributes
* @returns {AST.CSS.StyleSheet} * @returns {AST.CSS.StyleSheet}
*/ */
export default function read_style(parser, start, attributes) { export default function read_style(parser, start, attributes) {

@ -93,7 +93,16 @@ export default function element(parser) {
} }
} }
if (parent.type !== 'RegularElement' && !parser.loose) { if (parent.type === 'RegularElement') {
if (!parser.last_auto_closed_tag || parser.last_auto_closed_tag.tag !== name) {
const end = parent.fragment.nodes[0]?.start ?? start;
w.element_implicitly_closed(
{ start: parent.start, end },
`</${name}>`,
`</${parent.name}>`
);
}
} else if (!parser.loose) {
if (parser.last_auto_closed_tag && parser.last_auto_closed_tag.tag === name) { if (parser.last_auto_closed_tag && parser.last_auto_closed_tag.tag === name) {
e.element_invalid_closing_tag_autoclosed(start, name, parser.last_auto_closed_tag.reason); e.element_invalid_closing_tag_autoclosed(start, name, parser.last_auto_closed_tag.reason);
} else { } else {
@ -186,6 +195,8 @@ export default function element(parser) {
parser.allow_whitespace(); parser.allow_whitespace();
if (parent.type === 'RegularElement' && closing_tag_omitted(parent.name, name)) { if (parent.type === 'RegularElement' && closing_tag_omitted(parent.name, name)) {
const end = parent.fragment.nodes[0]?.start ?? start;
w.element_implicitly_closed({ start: parent.start, end }, `<${name}>`, `</${parent.name}>`);
parent.end = start; parent.end = start;
parser.pop(); parser.pop();
parser.last_auto_closed_tag = { parser.last_auto_closed_tag = {
@ -482,7 +493,7 @@ function read_static_attribute(parser) {
/** /**
* @param {Parser} parser * @param {Parser} parser
* @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | null} * @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag | null}
*/ */
function read_attribute(parser) { function read_attribute(parser) {
const start = parser.index; const start = parser.index;
@ -490,6 +501,27 @@ function read_attribute(parser) {
if (parser.eat('{')) { if (parser.eat('{')) {
parser.allow_whitespace(); parser.allow_whitespace();
if (parser.eat('@attach')) {
parser.require_whitespace();
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
/** @type {AST.AttachTag} */
const attachment = {
type: 'AttachTag',
start,
end: parser.index,
expression,
metadata: {
expression: create_expression_metadata()
}
};
return attachment;
}
if (parser.eat('...')) { if (parser.eat('...')) {
const expression = read_expression(parser); const expression = read_expression(parser);

@ -8,9 +8,12 @@ import { parse_expression_at } from '../acorn.js';
import read_pattern from '../read/context.js'; import read_pattern from '../read/context.js';
import read_expression, { get_loose_identifier } from '../read/expression.js'; import read_expression, { get_loose_identifier } from '../read/expression.js';
import { create_fragment } from '../utils/create.js'; import { create_fragment } from '../utils/create.js';
import { match_bracket } from '../utils/bracket.js';
const regex_whitespace_with_closing_curly_brace = /^\s*}/; const regex_whitespace_with_closing_curly_brace = /^\s*}/;
const pointy_bois = { '<': '>' };
/** @param {Parser} parser */ /** @param {Parser} parser */
export default function tag(parser) { export default function tag(parser) {
const start = parser.index; const start = parser.index;
@ -357,6 +360,22 @@ function open(parser) {
const params_start = parser.index; const params_start = parser.index;
// snippets could have a generic signature, e.g. `#snippet foo<T>(...)`
/** @type {string | undefined} */
let type_params;
// if we match a generic opening
if (parser.ts && parser.match('<')) {
const start = parser.index;
const end = match_bracket(parser, start, pointy_bois);
type_params = parser.template.slice(start + 1, end - 1);
parser.index = end;
}
parser.allow_whitespace();
const matched = parser.eat('(', true, false); const matched = parser.eat('(', true, false);
if (matched) { if (matched) {
@ -394,6 +413,7 @@ function open(parser) {
end: name_end, end: name_end,
name name
}, },
typeParams: type_params,
parameters: function_expression.params, parameters: function_expression.params,
body: create_fragment(), body: create_fragment(),
metadata: { metadata: {

@ -1,34 +1,5 @@
const SQUARE_BRACKET_OPEN = '['; /** @import { Parser } from '../index.js' */
const SQUARE_BRACKET_CLOSE = ']'; import * as e from '../../../errors.js';
const CURLY_BRACKET_OPEN = '{';
const CURLY_BRACKET_CLOSE = '}';
const PARENTHESES_OPEN = '(';
const PARENTHESES_CLOSE = ')';
/** @param {string} char */
export function is_bracket_open(char) {
return char === SQUARE_BRACKET_OPEN || char === CURLY_BRACKET_OPEN;
}
/** @param {string} char */
export function is_bracket_close(char) {
return char === SQUARE_BRACKET_CLOSE || char === CURLY_BRACKET_CLOSE;
}
/** @param {string} open */
export function get_bracket_close(open) {
if (open === SQUARE_BRACKET_OPEN) {
return SQUARE_BRACKET_CLOSE;
}
if (open === CURLY_BRACKET_OPEN) {
return CURLY_BRACKET_CLOSE;
}
if (open === PARENTHESES_OPEN) {
return PARENTHESES_CLOSE;
}
}
/** /**
* @param {number} num * @param {number} num
@ -121,7 +92,7 @@ function count_leading_backslashes(string, search_start_index) {
* @returns {number | undefined} The index of the closing bracket, or undefined if not found. * @returns {number | undefined} The index of the closing bracket, or undefined if not found.
*/ */
export function find_matching_bracket(template, index, open) { export function find_matching_bracket(template, index, open) {
const close = get_bracket_close(open); const close = default_brackets[open];
let brackets = 1; let brackets = 1;
let i = index; let i = index;
while (brackets > 0 && i < template.length) { while (brackets > 0 && i < template.length) {
@ -162,3 +133,81 @@ export function find_matching_bracket(template, index, open) {
} }
return undefined; return undefined;
} }
/** @type {Record<string, string>} */
const default_brackets = {
'{': '}',
'(': ')',
'[': ']'
};
/**
* @param {Parser} parser
* @param {number} start
* @param {Record<string, string>} brackets
*/
export function match_bracket(parser, start, brackets = default_brackets) {
const close = Object.values(brackets);
const bracket_stack = [];
let i = start;
while (i < parser.template.length) {
let char = parser.template[i++];
if (char === "'" || char === '"' || char === '`') {
i = match_quote(parser, i, char);
continue;
}
if (char in brackets) {
bracket_stack.push(char);
} else if (close.includes(char)) {
const popped = /** @type {string} */ (bracket_stack.pop());
const expected = /** @type {string} */ (brackets[popped]);
if (char !== expected) {
e.expected_token(i - 1, expected);
}
if (bracket_stack.length === 0) {
return i;
}
}
}
e.unexpected_eof(parser.template.length);
}
/**
* @param {Parser} parser
* @param {number} start
* @param {string} quote
*/
function match_quote(parser, start, quote) {
let is_escaped = false;
let i = start;
while (i < parser.template.length) {
const char = parser.template[i++];
if (is_escaped) {
is_escaped = false;
continue;
}
if (char === quote) {
return i;
}
if (char === '\\') {
is_escaped = true;
}
if (quote === '`' && char === '$' && parser.template[i] === '{') {
i = match_bracket(parser, i);
}
}
e.unterminated_string_constant(start);
}

@ -18,6 +18,7 @@ import { extract_svelte_ignore } from '../../utils/extract_svelte_ignore.js';
import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js'; import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js';
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js'; import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js'; import { AssignmentExpression } from './visitors/AssignmentExpression.js';
import { AttachTag } from './visitors/AttachTag.js';
import { Attribute } from './visitors/Attribute.js'; import { Attribute } from './visitors/Attribute.js';
import { AwaitBlock } from './visitors/AwaitBlock.js'; import { AwaitBlock } from './visitors/AwaitBlock.js';
import { AwaitExpression } from './visitors/AwaitExpression.js'; import { AwaitExpression } from './visitors/AwaitExpression.js';
@ -44,9 +45,11 @@ import { ImportDeclaration } from './visitors/ImportDeclaration.js';
import { KeyBlock } from './visitors/KeyBlock.js'; import { KeyBlock } from './visitors/KeyBlock.js';
import { LabeledStatement } from './visitors/LabeledStatement.js'; import { LabeledStatement } from './visitors/LabeledStatement.js';
import { LetDirective } from './visitors/LetDirective.js'; import { LetDirective } from './visitors/LetDirective.js';
import { Literal } from './visitors/Literal.js';
import { MemberExpression } from './visitors/MemberExpression.js'; import { MemberExpression } from './visitors/MemberExpression.js';
import { NewExpression } from './visitors/NewExpression.js'; import { NewExpression } from './visitors/NewExpression.js';
import { OnDirective } from './visitors/OnDirective.js'; import { OnDirective } from './visitors/OnDirective.js';
import { PropertyDefinition } from './visitors/PropertyDefinition.js';
import { RegularElement } from './visitors/RegularElement.js'; import { RegularElement } from './visitors/RegularElement.js';
import { RenderTag } from './visitors/RenderTag.js'; import { RenderTag } from './visitors/RenderTag.js';
import { SlotElement } from './visitors/SlotElement.js'; import { SlotElement } from './visitors/SlotElement.js';
@ -64,6 +67,7 @@ import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js'; import { SvelteWindow } from './visitors/SvelteWindow.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js'; import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { TaggedTemplateExpression } from './visitors/TaggedTemplateExpression.js'; import { TaggedTemplateExpression } from './visitors/TaggedTemplateExpression.js';
import { TemplateElement } from './visitors/TemplateElement.js';
import { Text } from './visitors/Text.js'; import { Text } from './visitors/Text.js';
import { TitleElement } from './visitors/TitleElement.js'; import { TitleElement } from './visitors/TitleElement.js';
import { TransitionDirective } from './visitors/TransitionDirective.js'; import { TransitionDirective } from './visitors/TransitionDirective.js';
@ -132,6 +136,7 @@ const visitors = {
}, },
ArrowFunctionExpression, ArrowFunctionExpression,
AssignmentExpression, AssignmentExpression,
AttachTag,
Attribute, Attribute,
AwaitBlock, AwaitBlock,
AwaitExpression, AwaitExpression,
@ -158,9 +163,11 @@ const visitors = {
KeyBlock, KeyBlock,
LabeledStatement, LabeledStatement,
LetDirective, LetDirective,
Literal,
MemberExpression, MemberExpression,
NewExpression, NewExpression,
OnDirective, OnDirective,
PropertyDefinition,
RegularElement, RegularElement,
RenderTag, RenderTag,
SlotElement, SlotElement,
@ -178,6 +185,7 @@ const visitors = {
SvelteWindow, SvelteWindow,
SvelteBoundary, SvelteBoundary,
TaggedTemplateExpression, TaggedTemplateExpression,
TemplateElement,
Text, Text,
TransitionDirective, TransitionDirective,
TitleElement, TitleElement,
@ -259,7 +267,8 @@ export function analyze_module(ast, options) {
immutable: true, immutable: true,
tracing: false, tracing: false,
async_deriveds: new Set(), async_deriveds: new Set(),
context_preserving_awaits: new Set() context_preserving_awaits: new Set(),
classes: new Map()
}; };
walk( walk(
@ -268,7 +277,7 @@ export function analyze_module(ast, options) {
scope, scope,
scopes, scopes,
analysis: /** @type {ComponentAnalysis} */ (analysis), 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, // 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 // 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), ast_type: /** @type {any} */ (null),
@ -439,6 +448,7 @@ export function analyze_component(root, source, options) {
elements: [], elements: [],
runes, runes,
tracing: false, tracing: false,
classes: new Map(),
immutable: runes || options.immutable, immutable: runes || options.immutable,
exports: [], exports: [],
uses_props: false, uses_props: false,
@ -636,7 +646,7 @@ export function analyze_component(root, source, options) {
has_props_rune: false, has_props_rune: false,
component_slots: new Set(), component_slots: new Set(),
expression: null, expression: null,
derived_state: [], state_fields: new Map(),
function_depth: scope.function_depth, function_depth: scope.function_depth,
reactive_statement: null reactive_statement: null
}; };
@ -703,7 +713,7 @@ export function analyze_component(root, source, options) {
reactive_statement: null, reactive_statement: null,
component_slots: new Set(), component_slots: new Set(),
expression: null, expression: null,
derived_state: [], state_fields: new Map(),
function_depth: scope.function_depth function_depth: scope.function_depth
}; };

@ -1,6 +1,6 @@
import type { Scope } from '../scope.js'; import type { Scope } from '../scope.js';
import type { ComponentAnalysis, ReactiveStatement } from '../types.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 { export interface AnalysisState {
scope: Scope; scope: Scope;
@ -18,7 +18,10 @@ export interface AnalysisState {
component_slots: Set<string>; component_slots: Set<string>;
/** Information about the current expression/directive/block value */ /** Information about the current expression/directive/block value */
expression: ExpressionMetadata | null; expression: ExpressionMetadata | null;
derived_state: { name: string; private: boolean }[];
/** Used to analyze class state */
state_fields: Map<string, StateField>;
function_depth: number; function_depth: number;
// legacy stuff // legacy stuff

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

@ -0,0 +1,13 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.AttachTag} node
* @param {Context} context
*/
export function AttachTag(node, context) {
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
}

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

@ -158,7 +158,7 @@ export function BindDirective(node, context) {
return; return;
} }
validate_assignment(node, node.expression, context.state); validate_assignment(node, node.expression, context);
const assignee = node.expression; const assignee = node.expression;
const left = object(assignee); const left = object(assignee);

@ -115,12 +115,13 @@ export function CallExpression(node, context) {
case '$state': case '$state':
case '$state.raw': case '$state.raw':
case '$derived': case '$derived':
case '$derived.by': case '$derived.by': {
if ( const valid =
(parent.type !== 'VariableDeclarator' || is_variable_declaration(parent, context) ||
get_parent(context.path, -3).type === 'ConstTag') && is_class_property_definition(parent) ||
!(parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) is_class_property_assignment_at_constructor_root(parent, context);
) {
if (!valid) {
e.state_invalid_placement(node, rune); e.state_invalid_placement(node, rune);
} }
@ -131,6 +132,7 @@ export function CallExpression(node, context) {
} }
break; break;
}
case '$effect': case '$effect':
case '$effect.pre': case '$effect.pre':
@ -290,3 +292,40 @@ function get_function_label(nodes) {
return parent.id.name; 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 { Context } from '../types' */
import * as b from '#compiler/builders';
import { get_rune } from '../../scope.js'; 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 {ClassBody} node
* @param {Context} context * @param {Context} context
*/ */
export function ClassBody(node, context) { export function ClassBody(node, context) {
/** @type {{name: string, private: boolean}[]} */ if (!context.state.analysis.runes) {
const derived_state = []; context.next();
return;
}
/** @type {string[]} */
const private_ids = [];
for (const definition of node.body) { for (const prop of node.body) {
if ( if (
definition.type === 'PropertyDefinition' && (prop.type === 'MethodDefinition' || prop.type === 'PropertyDefinition') &&
(definition.key.type === 'PrivateIdentifier' || definition.key.type === 'Identifier') && prop.key.type === 'PrivateIdentifier'
definition.value?.type === 'CallExpression'
) { ) {
const rune = get_rune(definition.value, context.state.scope); private_ids.push(prop.key.name);
if (rune === '$derived' || rune === '$derived.by') { }
derived_state.push({ }
name: definition.key.name,
private: definition.key.type === 'PrivateIdentifier' /** @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 });
} }

@ -39,7 +39,7 @@ export function Identifier(node, context) {
if ( if (
is_rune(node.name) && is_rune(node.name) &&
context.state.scope.get(node.name) === null && context.state.scope.get(node.name) === null &&
context.state.scope.get(node.name.slice(1)) === null context.state.scope.get(node.name.slice(1))?.kind !== 'store_sub'
) { ) {
/** @type {Expression} */ /** @type {Expression} */
let current = node; let current = node;

@ -0,0 +1,14 @@
/** @import { Literal } from 'estree' */
import * as w from '../../../warnings.js';
import { regex_bidirectional_control_characters } from '../../patterns.js';
/**
* @param {Literal} node
*/
export function Literal(node) {
if (typeof node.value === 'string') {
if (regex_bidirectional_control_characters.test(node.value)) {
w.bidirectional_control_characters(node);
}
}
}

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

@ -0,0 +1,12 @@
/** @import { TemplateElement } from 'estree' */
import * as w from '../../../warnings.js';
import { regex_bidirectional_control_characters } from '../../patterns.js';
/**
* @param {TemplateElement} node
*/
export function TemplateElement(node) {
if (regex_bidirectional_control_characters.test(node.value.cooked ?? '')) {
w.bidirectional_control_characters(node);
}
}

@ -1,20 +1,52 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js'; import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js';
import { regex_not_whitespace } from '../../patterns.js'; import { regex_bidirectional_control_characters, regex_not_whitespace } from '../../patterns.js';
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { extract_svelte_ignore } from '../../../utils/extract_svelte_ignore.js';
/** /**
* @param {AST.Text} node * @param {AST.Text} node
* @param {Context} context * @param {Context} context
*/ */
export function Text(node, context) { export function Text(node, context) {
const in_template = context.path.at(-1)?.type === 'Fragment'; const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1));
if (in_template && context.state.parent_element && regex_not_whitespace.test(node.data)) { if (
parent.type === 'Fragment' &&
context.state.parent_element &&
regex_not_whitespace.test(node.data)
) {
const message = is_tag_valid_with_parent('#text', context.state.parent_element); const message = is_tag_valid_with_parent('#text', context.state.parent_element);
if (message) { if (message) {
e.node_invalid_placement(node, message); e.node_invalid_placement(node, message);
} }
} }
regex_bidirectional_control_characters.lastIndex = 0;
for (const match of node.data.matchAll(regex_bidirectional_control_characters)) {
let is_ignored = false;
// if we have a svelte-ignore comment earlier in the text, bail
// (otherwise we can only use svelte-ignore on parent elements/blocks)
if (parent.type === 'Fragment') {
for (const child of parent.nodes) {
if (child === node) break;
if (child.type === 'Comment') {
is_ignored ||= extract_svelte_ignore(
child.start + 4,
child.data,
context.state.analysis.runes
).includes('bidirectional_control_characters');
}
}
}
if (!is_ignored) {
let start = match.index + node.start;
w.bidirectional_control_characters({ start, end: start + match[0].length });
}
}
} }

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

@ -1,3 +1,4 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { AnalysisState, Context } from '../../types' */ /** @import { AnalysisState, Context } from '../../types' */
import * as e from '../../../../errors.js'; import * as e from '../../../../errors.js';
@ -74,7 +75,8 @@ export function visit_component(node, context) {
attribute.type !== 'SpreadAttribute' && attribute.type !== 'SpreadAttribute' &&
attribute.type !== 'LetDirective' && attribute.type !== 'LetDirective' &&
attribute.type !== 'OnDirective' && attribute.type !== 'OnDirective' &&
attribute.type !== 'BindDirective' attribute.type !== 'BindDirective' &&
attribute.type !== 'AttachTag'
) { ) {
e.component_invalid_directive(attribute); e.component_invalid_directive(attribute);
} }
@ -91,15 +93,10 @@ export function visit_component(node, context) {
validate_attribute(attribute, node); validate_attribute(attribute, node);
if (is_expression_attribute(attribute)) { if (is_expression_attribute(attribute)) {
const expression = get_attribute_expression(attribute); disallow_unparenthesized_sequences(
if (expression.type === 'SequenceExpression') { get_attribute_expression(attribute),
let i = /** @type {number} */ (expression.start); context.state.analysis.source
while (--i > 0) { );
const char = context.state.analysis.source[i];
if (char === '(') break; // parenthesized sequence expressions are ok
if (char === '{') e.attribute_invalid_sequence_expression(expression);
}
}
} }
} }
@ -113,6 +110,10 @@ export function visit_component(node, context) {
if (attribute.type === 'BindDirective' && attribute.name !== 'this') { if (attribute.type === 'BindDirective' && attribute.name !== 'this') {
context.state.analysis.uses_component_bindings = true; context.state.analysis.uses_component_bindings = true;
} }
if (attribute.type === 'AttachTag') {
disallow_unparenthesized_sequences(attribute.expression, context.state.analysis.source);
}
} }
// If the component has a slot attribute — `<Foo slot="whatever" .../>` — // If the component has a slot attribute — `<Foo slot="whatever" .../>` —
@ -158,3 +159,18 @@ export function visit_component(node, context) {
context.visit({ ...node.fragment, nodes: nodes[slot_name] }, state); context.visit({ ...node.fragment, nodes: nodes[slot_name] }, state);
} }
} }
/**
* @param {Expression} expression
* @param {string} source
*/
function disallow_unparenthesized_sequences(expression, source) {
if (expression.type === 'SequenceExpression') {
let i = /** @type {number} */ (expression.start);
while (--i > 0) {
const char = source[i];
if (char === '(') break; // parenthesized sequence expressions are ok
if (char === '{') e.attribute_invalid_sequence_expression(expression);
}
}
}

@ -4,24 +4,25 @@
/** @import { Scope } from '../../../scope' */ /** @import { Scope } from '../../../scope' */
/** @import { NodeLike } from '../../../../errors.js' */ /** @import { NodeLike } from '../../../../errors.js' */
import * as e 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 w from '../../../../warnings.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
import { get_name } from '../../../nodes.js';
/** /**
* @param {AssignmentExpression | UpdateExpression | AST.BindDirective} node * @param {AssignmentExpression | UpdateExpression | AST.BindDirective} node
* @param {Pattern | Expression} argument * @param {Pattern | Expression} argument
* @param {AnalysisState} state * @param {Context} context
*/ */
export function validate_assignment(node, argument, state) { export function validate_assignment(node, argument, context) {
validate_no_const_assignment(node, argument, state.scope, node.type === 'BindDirective'); validate_no_const_assignment(node, argument, context.state.scope, node.type === 'BindDirective');
if (argument.type === 'Identifier') { if (argument.type === 'Identifier') {
const binding = state.scope.get(argument.name); const binding = context.state.scope.get(argument.name);
if (state.analysis.runes) { if (context.state.analysis.runes) {
if (binding?.node === state.analysis.props_id) { if (binding?.node === context.state.analysis.props_id) {
e.constant_assignment(node, '$props.id()'); e.constant_assignment(node, '$props.id()');
} }
@ -34,6 +35,41 @@ export function validate_assignment(node, argument, state) {
e.snippet_parameter_assignment(node); 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;
}
}
}
}
} }
/** /**

@ -57,6 +57,7 @@ import { TitleElement } from './visitors/TitleElement.js';
import { TransitionDirective } from './visitors/TransitionDirective.js'; import { TransitionDirective } from './visitors/TransitionDirective.js';
import { UpdateExpression } from './visitors/UpdateExpression.js'; import { UpdateExpression } from './visitors/UpdateExpression.js';
import { UseDirective } from './visitors/UseDirective.js'; import { UseDirective } from './visitors/UseDirective.js';
import { AttachTag } from './visitors/AttachTag.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js'; import { VariableDeclaration } from './visitors/VariableDeclaration.js';
/** @type {Visitors} */ /** @type {Visitors} */
@ -133,6 +134,7 @@ const visitors = {
TransitionDirective, TransitionDirective,
UpdateExpression, UpdateExpression,
UseDirective, UseDirective,
AttachTag,
VariableDeclaration VariableDeclaration
}; };
@ -164,8 +166,7 @@ export function client_component(analysis, options) {
}, },
events: new Set(), events: new Set(),
preserve_whitespace: options.preserveWhitespace, preserve_whitespace: options.preserveWhitespace,
public_state: new Map(), state_fields: new Map(),
private_state: new Map(),
transform: {}, transform: {},
in_constructor: false, in_constructor: false,
instance_level_snippets: [], instance_level_snippets: [],
@ -698,8 +699,7 @@ export function client_module(analysis, options) {
options, options,
scope: analysis.module.scope, scope: analysis.module.scope,
scopes: analysis.module.scopes, scopes: analysis.module.scopes,
public_state: new Map(), state_fields: new Map(),
private_state: new Map(),
transform: {}, transform: {},
in_constructor: false in_constructor: false
}; };

@ -9,15 +9,12 @@ import type {
UpdateExpression, UpdateExpression,
VariableDeclaration VariableDeclaration
} from 'estree'; } 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 { TransformState } from '../types.js';
import type { ComponentAnalysis } from '../../types.js'; import type { ComponentAnalysis } from '../../types.js';
import type { SourceLocation } from '#shared'; import type { SourceLocation } from '#shared';
export interface ClientTransformState extends TransformState { 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 * `true` if the current lexical scope belongs to a class constructor. this allows
* us to rewrite `this.foo` as `this.#foo.value` * us to rewrite `this.foo` as `this.#foo.value`
@ -100,11 +97,6 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly module_level_snippets: VariableDeclaration[]; 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 Context = import('zimmerframe').Context<AST.SvelteNode, ClientTransformState>;
export type Visitors = import('zimmerframe').Visitors<AST.SvelteNode, any>; 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 { should_proxy } from '../utils.js';
import { visit_assignment_expression } from '../../shared/assignments.js'; import { visit_assignment_expression } from '../../shared/assignments.js';
import { validate_mutation } from './shared/utils.js'; import { validate_mutation } from './shared/utils.js';
import { get_rune } from '../../../scope.js';
import { get_name } from '../../../nodes.js';
/** /**
* @param {AssignmentExpression} node * @param {AssignmentExpression} node
@ -50,25 +52,42 @@ const callees = {
* @returns {Expression | null} * @returns {Expression | null}
*/ */
function build_assignment(operator, left, right, context) { function build_assignment(operator, left, right, context) {
// Handle class private/public state assignment cases if (context.state.analysis.runes && left.type === 'MemberExpression') {
if ( const name = get_name(left.property);
context.state.analysis.runes && const field = name && context.state.state_fields.get(name);
left.type === 'MemberExpression' &&
left.property.type === 'PrivateIdentifier' if (field) {
) { // special case — state declaration in class constructor
const private_state = context.state.private_state.get(left.property.name); 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) { // special case — assignment to private state field
let value = /** @type {Expression} */ ( if (left.property.type === 'PrivateIdentifier') {
context.visit(build_assignment_value(operator, left, right)) let value = /** @type {Expression} */ (
); context.visit(build_assignment_value(operator, left, right))
);
const needs_proxy = const needs_proxy =
private_state.kind === 'state' && field.type === '$state' &&
is_non_coercive_operator(operator) && is_non_coercive_operator(operator) &&
should_proxy(value, context.state.scope); 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);
}
} }
} }

@ -0,0 +1,21 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '../../../../utils/builders.js';
/**
* @param {AST.AttachTag} node
* @param {ComponentContext} context
*/
export function AttachTag(node, context) {
context.state.init.push(
b.stmt(
b.call(
'$.attach',
context.state.node,
b.thunk(/** @type {Expression} */ (context.visit(node.expression)))
)
)
);
context.next();
}

@ -4,19 +4,52 @@ import { dev, is_ignored } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
import { transform_inspect_rune } from '../../utils.js'; import { transform_inspect_rune } from '../../utils.js';
import { should_proxy } from '../utils.js';
/** /**
* @param {CallExpression} node * @param {CallExpression} node
* @param {Context} context * @param {Context} context
*/ */
export function CallExpression(node, 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': case '$host':
return b.id('$$props.$$host'); return b.id('$$props.$$host');
case '$effect.tracking': case '$effect.tracking':
return b.call('$.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': case '$state.snapshot':
return b.call( return b.call(
'$.snapshot', '$.snapshot',

@ -1,184 +1,96 @@
/** @import { ClassBody, Expression, Identifier, Literal, MethodDefinition, PrivateIdentifier, PropertyDefinition } from 'estree' */ /** @import { CallExpression, ClassBody, MethodDefinition, PropertyDefinition, StaticBlock } from 'estree' */
/** @import { Context, StateField } from '../types' */ /** @import { StateField } from '#compiler' */
/** @import { Context } from '../types' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { regex_invalid_identifier_chars } from '../../../patterns.js'; import { get_name } from '../../../nodes.js';
import { get_rune } from '../../../scope.js';
import { should_proxy } from '../utils.js';
/** /**
* @param {ClassBody} node * @param {ClassBody} node
* @param {Context} context * @param {Context} context
*/ */
export function ClassBody(node, 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(); context.next();
return; return;
} }
/** @type {Map<string, StateField>} */ /** @type {Array<MethodDefinition | PropertyDefinition | StaticBlock>} */
const public_state = new Map(); const body = [];
/** @type {Map<string, StateField>} */ const child_state = { ...context.state, state_fields };
const private_state = new Map();
/** @type {Map<(MethodDefinition|PropertyDefinition)["key"], string>} */ for (const [name, field] of state_fields) {
const definition_names = new Map(); if (name[0] === '#') {
continue;
}
/** @type {string[]} */ // insert backing fields for stuff declared in the constructor
const private_ids = []; if (field.node.type === 'AssignmentExpression') {
const member = b.member(b.this, field.key);
for (const definition of node.body) { const should_proxy = field.type === '$state' && true; // TODO
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);
}
}
}
}
}
// each `foo = $state()` needs a backing `#foo` field const key = b.key(name);
for (const [name, field] of public_state) {
let deconflicted = name;
while (private_ids.includes(deconflicted)) {
deconflicted = '_' + deconflicted;
}
private_ids.push(deconflicted); body.push(
field.id = b.private_id(deconflicted); b.prop_def(field.key, null),
}
/** @type {Array<MethodDefinition | PropertyDefinition>} */ b.method('get', key, [], [b.return(b.call('$.get', member))]),
const body = [];
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 // Replace parts of the class body
for (const definition of node.body) { for (const definition of node.body) {
if ( if (definition.type !== 'PropertyDefinition') {
definition.type === 'PropertyDefinition' && body.push(
(definition.key.type === 'Identifier' || /** @type {MethodDefinition | StaticBlock} */ (context.visit(definition, child_state))
definition.key.type === 'PrivateIdentifier' || );
definition.key.type === 'Literal') continue;
) {
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;
}
} }
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;
}
/** if (name[0] === '#') {
* @param {Identifier | PrivateIdentifier | Literal} node body.push(/** @type {PropertyDefinition} */ (context.visit(definition, child_state)));
* @param {Map<string, StateField>} public_state } else if (field.node === definition) {
*/ const member = b.member(b.this, field.key);
function get_name(node, public_state) {
if (node.type === 'Literal') { const should_proxy = field.type === '$state' && true; // TODO
let name = node.value?.toString().replace(regex_invalid_identifier_chars, '_');
body.push(
// the above could generate conflicts because it has to generate a valid identifier b.prop_def(
// so stuff like `0` and `1` or `state%` and `state^` will result in the same string field.key,
// so we have to de-conflict. We can only check `public_state` because private state /** @type {CallExpression} */ (context.visit(field.value, child_state))
// can't have literal keys ),
while (name && public_state.has(name)) {
name = '_' + name; 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) { export function MemberExpression(node, context) {
// rewrite `this.#foo` as `this.#foo.v` inside a constructor // rewrite `this.#foo` as `this.#foo.v` inside a constructor
if (node.property.type === 'PrivateIdentifier') { 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) { 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.member(node, 'v')
: b.call('$.get', node); : b.call('$.get', node);
} }

@ -83,7 +83,7 @@ export function RegularElement(node, context) {
/** @type {AST.StyleDirective[]} */ /** @type {AST.StyleDirective[]} */
const style_directives = []; const style_directives = [];
/** @type {Array<AST.AnimateDirective | AST.BindDirective | AST.OnDirective | AST.TransitionDirective | AST.UseDirective>} */ /** @type {Array<AST.AnimateDirective | AST.BindDirective | AST.OnDirective | AST.TransitionDirective | AST.UseDirective | AST.AttachTag>} */
const other_directives = []; const other_directives = [];
/** @type {ExpressionStatement[]} */ /** @type {ExpressionStatement[]} */
@ -153,6 +153,10 @@ export function RegularElement(node, context) {
has_use = true; has_use = true;
other_directives.push(attribute); other_directives.push(attribute);
break; break;
case 'AttachTag':
other_directives.push(attribute);
break;
} }
} }
@ -582,7 +586,7 @@ export function build_style_directives_object(style_directives, context) {
/** /**
* Serializes an assignment to an element property by adding relevant statements to either only * 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: * Resulting code for static looks something like this:
* ```js * ```js
* element.property = value; * element.property = value;

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

@ -2,7 +2,7 @@
/** @import { Binding } from '#compiler' */ /** @import { Binding } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */
import { dev, is_ignored, locate_node } from '../../../../state.js'; import { dev, is_ignored, locate_node } from '../../../../state.js';
import { extract_paths } from '../../../../utils/ast.js'; import { build_pattern, extract_paths } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import * as assert from '../../../../utils/assert.js'; import * as assert from '../../../../utils/assert.js';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
@ -141,20 +141,20 @@ export function VariableDeclaration(node, context) {
b.declarator(declarator.id, create_state_declarator(declarator.id, value)) b.declarator(declarator.id, create_state_declarator(declarator.id, value))
); );
} else { } else {
const tmp = context.state.scope.generate('tmp'); const [pattern, replacements] = build_pattern(declarator.id, context.state.scope);
const paths = extract_paths(declarator.id);
declarations.push( declarations.push(
b.declarator(b.id(tmp), value), b.declarator(pattern, value),
...paths.map((path) => { .../** @type {[Identifier, Identifier][]} */ ([...replacements]).map(
const value = path.expression?.(b.id(tmp)); ([original, replacement]) => {
const binding = context.state.scope.get(/** @type {Identifier} */ (path.node).name); const binding = context.state.scope.get(original.name);
return b.declarator( return b.declarator(
path.node, original,
binding?.kind === 'state' || binding?.kind === 'raw_state' binding?.kind === 'state' || binding?.kind === 'raw_state'
? create_state_declarator(binding.node, value) ? create_state_declarator(binding.node, replacement)
: value : replacement
); );
}) }
)
); );
} }
@ -196,8 +196,7 @@ export function VariableDeclaration(node, context) {
); );
} }
} else { } else {
const bindings = extract_paths(declarator.id); const [pattern, replacements] = build_pattern(declarator.id, context.state.scope);
const init = /** @type {CallExpression} */ (declarator.init); const init = /** @type {CallExpression} */ (declarator.init);
/** @type {Identifier} */ /** @type {Identifier} */
@ -215,10 +214,16 @@ export function VariableDeclaration(node, context) {
); );
} }
for (let i = 0; i < bindings.length; i++) { for (let i = 0; i < replacements.size; i++) {
const binding = bindings[i]; const [original, replacement] = [...replacements][i];
declarations.push( declarations.push(
b.declarator(binding.node, b.call('$.derived', b.thunk(binding.expression(rhs)))) b.declarator(
original,
b.call(
'$.derived',
b.arrow([], b.block([b.let(pattern, rhs), b.return(replacement)]))
)
)
); );
} }
} }
@ -330,19 +335,19 @@ function create_state_declarators(declarator, { scope, analysis }, value) {
]; ];
} }
const tmp = scope.generate('tmp'); const [pattern, replacements] = build_pattern(declarator.id, scope);
const paths = extract_paths(declarator.id);
return [ return [
b.declarator(b.id(tmp), value), b.declarator(pattern, value),
...paths.map((path) => { .../** @type {[Identifier, Identifier][]} */ ([...replacements]).map(
const value = path.expression?.(b.id(tmp)); ([original, replacement]) => {
const binding = scope.get(/** @type {Identifier} */ (path.node).name); const binding = scope.get(original.name);
return b.declarator( return b.declarator(
path.node, original,
binding?.kind === 'state' binding?.kind === 'state'
? b.call('$.mutable_source', value, analysis.immutable ? b.true : undefined) ? b.call('$.mutable_source', replacement, analysis.immutable ? b.true : undefined)
: value : replacement
); );
}) }
)
]; ];
} }

@ -281,6 +281,14 @@ export function build_component(node, component_name, context, anchor = context.
); );
} }
} }
} else if (attribute.type === 'AttachTag') {
let expression = /** @type {Expression} */ (context.visit(attribute.expression));
if (attribute.metadata.expression.has_state) {
expression = b.arrow([b.id('$$node')], b.call(expression, b.id('$$node')));
}
push_prop(b.prop('get', b.call('$.attachment'), expression, true));
} }
} }

@ -86,7 +86,7 @@ export function build_template_chunk(
// If we have a single expression, then pass that in directly to possibly avoid doing // If we have a single expression, then pass that in directly to possibly avoid doing
// extra work in the template_effect (instead we do the work in set_text). // extra work in the template_effect (instead we do the work in set_text).
if (evaluated.is_known) { if (evaluated.is_known) {
value = b.literal(evaluated.value); value = b.literal((evaluated.value ?? '') + '');
} }
return { value, has_state }; return { value, has_state };
@ -105,7 +105,7 @@ export function build_template_chunk(
} }
if (evaluated.is_known) { if (evaluated.is_known) {
quasi.value.cooked += evaluated.value + ''; quasi.value.cooked += (evaluated.value ?? '') + '';
} else { } else {
if (!evaluated.is_defined) { if (!evaluated.is_defined) {
// add `?? ''` where necessary // add `?? ''` where necessary
@ -342,15 +342,18 @@ export function validate_mutation(node, context, expression) {
const state = /** @type {ComponentClientTransformState} */ (context.state); const state = /** @type {ComponentClientTransformState} */ (context.state);
state.analysis.needs_mutation_validation = true; state.analysis.needs_mutation_validation = true;
/** @type {Array<Identifier | Literal>} */ /** @type {Array<Identifier | Literal | Expression>} */
const path = []; const path = [];
while (left.type === 'MemberExpression') { while (left.type === 'MemberExpression') {
if (left.property.type === 'Literal') { if (left.property.type === 'Literal') {
path.unshift(left.property); path.unshift(left.property);
} else if (left.property.type === 'Identifier') { } else if (left.property.type === 'Identifier') {
const transform = Object.hasOwn(context.state.transform, left.property.name)
? context.state.transform[left.property.name]
: null;
if (left.computed) { if (left.computed) {
path.unshift(left.property); path.unshift(transform?.read ? transform.read(left.property) : left.property);
} else { } else {
path.unshift(b.literal(left.property.name)); path.unshift(b.literal(left.property.name));
} }

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

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

@ -3,6 +3,8 @@
/** @import { Context, ServerTransformState } from '../types.js' */ /** @import { Context, ServerTransformState } from '../types.js' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { build_assignment_value } from '../../../../utils/ast.js'; 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'; import { visit_assignment_expression } from '../../shared/assignments.js';
/** /**
@ -22,6 +24,29 @@ export function AssignmentExpression(node, context) {
* @returns {Expression | null} * @returns {Expression | null}
*/ */
function build_assignment(operator, left, right, context) { 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; let object = left;
while (object.type === 'MemberExpression') { while (object.type === 'MemberExpression') {

@ -29,6 +29,15 @@ export function CallExpression(node, context) {
return b.false; return b.false;
} }
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') { if (rune === '$state.snapshot') {
return b.call( return b.call(
'$.snapshot', '$.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 { Context } from '../types.js' */
/** @import { StateField } from '../../client/types.js' */
import { dev } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js'; import { get_name } from '../../../nodes.js';
/** /**
* @param {ClassBody} node * @param {ClassBody} node
* @param {Context} context * @param {Context} context
*/ */
export function ClassBody(node, 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(); context.next();
return; return;
} }
/** @type {Map<string, StateField>} */ /** @type {Array<MethodDefinition | PropertyDefinition | StaticBlock>} */
const public_derived = new Map(); const body = [];
/** @type {Map<string, StateField>} */ const child_state = { ...context.state, state_fields };
const private_derived = new Map();
/** @type {string[]} */ for (const [name, field] of state_fields) {
const private_ids = []; if (name[0] === '#') {
continue;
}
for (const definition of node.body) { // insert backing fields for stuff declared in the constructor
if ( if (
definition.type === 'PropertyDefinition' && field &&
(definition.key.type === 'Identifier' || definition.key.type === 'PrivateIdentifier') field.node.type === 'AssignmentExpression' &&
(field.type === '$derived' || field.type === '$derived.by')
) { ) {
const { type, name } = definition.key; const member = b.member(b.this, field.key);
const is_private = type === 'PrivateIdentifier';
if (is_private) private_ids.push(name);
if (definition.value?.type === 'CallExpression') { body.push(
const rune = get_rune(definition.value, context.state.scope); b.prop_def(field.key, null),
if (rune === '$derived' || rune === '$derived.by') { b.method('get', b.key(name), [], [b.return(b.call(member))])
/** @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);
}
}
}
} }
} }
// 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 // Replace parts of the class body
for (const definition of node.body) { for (const definition of node.body) {
if ( if (definition.type !== 'PropertyDefinition') {
definition.type === 'PropertyDefinition' && body.push(
(definition.key.type === 'Identifier' || definition.key.type === 'PrivateIdentifier') /** @type {MethodDefinition | StaticBlock} */ (context.visit(definition, child_state))
) { );
const name = definition.key.name; continue;
}
const is_private = definition.key.type === 'PrivateIdentifier';
const field = (is_private ? private_derived : public_derived).get(name);
if (definition.value?.type === 'CallExpression' && field !== undefined) { const name = get_name(definition.key);
const init = /** @type {Expression} **/ ( const field = name && state_fields.get(name);
context.visit(definition.value.arguments[0], child_state)
);
const value =
field.kind === 'derived_by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init));
if (is_private) { if (!field) {
body.push(b.prop_def(field.id, value)); body.push(/** @type {PropertyDefinition} */ (context.visit(definition, child_state)));
} else { continue;
// #foo; }
const member = b.member(b.this, field.id);
body.push(b.prop_def(field.id, value));
// get foo() { return this.#foo; } if (name[0] === '#' || field.type === '$state' || field.type === '$state.raw') {
body.push(b.method('get', definition.key, [], [b.return(b.call(member))])); 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(
body.push( b.prop_def(
b.method( field.key,
'set', /** @type {CallExpression} */ (context.visit(field.value, child_state))
definition.key, ),
[b.id('_')],
[b.throw_error(`Cannot update a derived property ('${name}')`)]
)
);
}
}
continue; b.method('get', definition.key, [], [b.return(b.call(member))])
} );
} }
body.push(/** @type {MethodDefinition} **/ (context.visit(definition, child_state)));
} }
return { ...node, body }; 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,8 +1,9 @@
/** @import { VariableDeclaration, VariableDeclarator, Expression, CallExpression, Pattern, Identifier } from 'estree' */ /** @import { VariableDeclaration, VariableDeclarator, Expression, CallExpression, Pattern, Identifier } from 'estree' */
/** @import { Binding } from '#compiler' */ /** @import { Binding } from '#compiler' */
/** @import { Context } from '../types.js' */ /** @import { Context } from '../types.js' */
/** @import { ComponentAnalysis } from '../../../types.js' */
/** @import { Scope } from '../../../scope.js' */ /** @import { Scope } from '../../../scope.js' */
import { build_fallback, extract_paths } from '../../../../utils/ast.js'; import { build_pattern, build_fallback, extract_paths } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
@ -50,20 +51,26 @@ export function VariableDeclaration(node, context) {
} }
} }
}); });
// if `$$slots` is declared separately, deconflict
const slots_name = /** @type {ComponentAnalysis} */ (context.state.analysis).uses_slots
? b.id('$$slots_')
: b.id('$$slots');
if (id.type === 'ObjectPattern' && has_rest) { if (id.type === 'ObjectPattern' && has_rest) {
// If a rest pattern is used within an object pattern, we need to ensure we don't expose $$slots or $$events // If a rest pattern is used within an object pattern, we need to ensure we don't expose $$slots or $$events
id.properties.splice( id.properties.splice(
id.properties.length - 1, id.properties.length - 1,
0, 0,
// @ts-ignore // @ts-ignore
b.prop('init', b.id('$$slots'), b.id('$$slots')), b.prop('init', b.id('$$slots'), slots_name),
b.prop('init', b.id('$$events'), b.id('$$events')) b.prop('init', b.id('$$events'), b.id('$$events'))
); );
} else if (id.type === 'Identifier') { } else if (id.type === 'Identifier') {
// If $props is referenced as an identifier, we need to ensure we don't expose $$slots or $$events as properties // If $props is referenced as an identifier, we need to ensure we don't expose $$slots or $$events as properties
// on the identifier reference // on the identifier reference
id = b.object_pattern([ id = b.object_pattern([
b.prop('init', b.id('$$slots'), b.id('$$slots')), b.prop('init', b.id('$$slots'), slots_name),
b.prop('init', b.id('$$events'), b.id('$$events')), b.prop('init', b.id('$$events'), b.id('$$events')),
b.rest(b.id(id.name)) b.rest(b.id(id.name))
]); ]);
@ -181,13 +188,10 @@ function create_state_declarators(declarator, scope, value) {
return [b.declarator(declarator.id, value)]; return [b.declarator(declarator.id, value)];
} }
const tmp = scope.generate('tmp'); const [pattern, replacements] = build_pattern(declarator.id, scope);
const paths = extract_paths(declarator.id);
return [ return [
b.declarator(b.id(tmp), value), // TODO inject declarator for opts, so we can use it below b.declarator(pattern, value),
...paths.map((path) => { // TODO inject declarator for opts, so we can use it below
const value = path.expression?.(b.id(tmp)); ...[...replacements].map(([original, replacement]) => b.declarator(original, replacement))
return b.declarator(path.node, value);
})
]; ];
} }

@ -1,7 +1,7 @@
/** @import { AssignmentExpression, AssignmentOperator, Expression, Node, Pattern } from 'estree' */ /** @import { AssignmentExpression, AssignmentOperator, Expression, Identifier, Node, Pattern } from 'estree' */
/** @import { Context as ClientContext } from '../client/types.js' */ /** @import { Context as ClientContext } from '../client/types.js' */
/** @import { Context as ServerContext } from '../server/types.js' */ /** @import { Context as ServerContext } from '../server/types.js' */
import { extract_paths, is_expression_async } from '../../../utils/ast.js'; import { build_pattern, is_expression_async } from '../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
/** /**
@ -23,21 +23,23 @@ export function visit_assignment_expression(node, context, build_assignment) {
let changed = false; let changed = false;
const assignments = extract_paths(node.left).map((path) => { const [pattern, replacements] = build_pattern(node.left, context.state.scope);
const value = path.expression?.(rhs);
let assignment = build_assignment('=', path.node, value, context); const assignments = [
if (assignment !== null) changed = true; b.let(pattern, rhs),
...[...replacements].map(([original, replacement]) => {
return ( let assignment = build_assignment(node.operator, original, replacement, context);
assignment ?? if (assignment !== null) changed = true;
b.assignment( return b.stmt(
'=', assignment ??
/** @type {Pattern} */ (context.visit(path.node)), b.assignment(
/** @type {Expression} */ (context.visit(value)) node.operator,
) /** @type {Identifier} */ (context.visit(original)),
); /** @type {Expression} */ (context.visit(replacement))
}); )
);
})
];
if (!changed) { if (!changed) {
// No change to output -> nothing to transform -> we can keep the original assignment // No change to output -> nothing to transform -> we can keep the original assignment
@ -45,25 +47,36 @@ export function visit_assignment_expression(node, context, build_assignment) {
} }
const is_standalone = /** @type {Node} */ (context.path.at(-1)).type.endsWith('Statement'); const is_standalone = /** @type {Node} */ (context.path.at(-1)).type.endsWith('Statement');
const sequence = b.sequence(assignments); const block = b.block(assignments);
if (!is_standalone) { if (!is_standalone) {
// this is part of an expression, we need the sequence to end with the value // this is part of an expression, we need the sequence to end with the value
sequence.expressions.push(rhs); block.body.push(b.return(rhs));
} }
if (should_cache) { if (is_standalone && !should_cache) {
// the right hand side is a complex expression, wrap in an IIFE to cache it return block;
const iife = b.arrow([rhs], sequence); }
const iife_is_async = const iife = b.arrow(should_cache ? [rhs] : [], block);
is_expression_async(value) ||
assignments.some((assignment) => is_expression_async(assignment));
return iife_is_async ? b.await(b.call(b.async(iife), value)) : b.call(iife, value); const iife_is_async =
} is_expression_async(value) ||
assignments.some(
(assignment) =>
(assignment.type === 'ExpressionStatement' &&
is_expression_async(assignment.expression)) ||
(assignment.type === 'VariableDeclaration' &&
assignment.declarations.some(
(declaration) =>
is_expression_async(declaration.id) ||
(declaration.init && is_expression_async(declaration.init))
))
);
return sequence; return iife_is_async
? b.await(b.call(b.async(iife), should_cache ? value : undefined))
: b.call(iife, should_cache ? value : undefined);
} }
if (node.left.type !== 'Identifier' && node.left.type !== 'MemberExpression') { if (node.left.type !== 'Identifier' && node.left.type !== 'MemberExpression') {

@ -1,5 +1,5 @@
import type { Scope } from '../scope.js'; 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'; import type { Analysis } from '../types.js';
export interface TransformState { export interface TransformState {
@ -7,4 +7,6 @@ export interface TransformState {
readonly options: ValidatedModuleCompileOptions; readonly options: ValidatedModuleCompileOptions;
readonly scope: Scope; readonly scope: Scope;
readonly scopes: Map<AST.SvelteNode, 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' */ /** @import { AST, ExpressionMetadata } from '#compiler' */
/** /**
* All nodes that can appear elsewhere than the top level, have attributes and can contain children * All nodes that can appear elsewhere than the top level, have attributes and can contain children
*/ */
@ -65,3 +67,14 @@ export function create_expression_metadata() {
has_await: false has_await: 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;
}

@ -21,3 +21,5 @@ export const regex_invalid_identifier_chars = /(^[^a-zA-Z_$]|[^a-zA-Z0-9_$])/g;
export const regex_starts_with_vowel = /^[aeiou]/; export const regex_starts_with_vowel = /^[aeiou]/;
export const regex_heading_tags = /^h[1-6]$/; export const regex_heading_tags = /^h[1-6]$/;
export const regex_illegal_attribute_character = /(^[0-9-.])|[\^$@%&#?!|()[\]{}^*+~;]/; export const regex_illegal_attribute_character = /(^[0-9-.])|[\^$@%&#?!|()[\]{}^*+~;]/;
export const regex_bidirectional_control_characters =
/[\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069]+/g;

@ -23,12 +23,12 @@ export const STRING = Symbol('string');
/** @type {Record<string, [type: NUMBER | STRING | UNKNOWN, fn?: Function]>} */ /** @type {Record<string, [type: NUMBER | STRING | UNKNOWN, fn?: Function]>} */
const globals = { const globals = {
BigInt: [NUMBER, BigInt], BigInt: [NUMBER],
'Math.min': [NUMBER, Math.min], 'Math.min': [NUMBER, Math.min],
'Math.max': [NUMBER, Math.max], 'Math.max': [NUMBER, Math.max],
'Math.random': [NUMBER], 'Math.random': [NUMBER],
'Math.floor': [NUMBER, Math.floor], 'Math.floor': [NUMBER, Math.floor],
// @ts-expect-error // @ts-ignore
'Math.f16round': [NUMBER, Math.f16round], 'Math.f16round': [NUMBER, Math.f16round],
'Math.round': [NUMBER, Math.round], 'Math.round': [NUMBER, Math.round],
'Math.abs': [NUMBER, Math.abs], 'Math.abs': [NUMBER, Math.abs],
@ -630,9 +630,10 @@ export class Scope {
/** /**
* @param {string} preferred_name * @param {string} preferred_name
* @param {(name: string, counter: number) => string} [generator]
* @returns {string} * @returns {string}
*/ */
generate(preferred_name) { generate(preferred_name, generator = (name, counter) => `${name}_${counter}`) {
if (this.#porous) { if (this.#porous) {
return /** @type {Scope} */ (this.parent).generate(preferred_name); return /** @type {Scope} */ (this.parent).generate(preferred_name);
} }
@ -647,7 +648,7 @@ export class Scope {
this.root.conflicts.has(name) || this.root.conflicts.has(name) ||
is_reserved(name) is_reserved(name)
) { ) {
name = `${preferred_name}_${n++}`; name = generator(preferred_name, n++);
} }
this.references.set(name, []); this.references.set(name, []);

@ -1,7 +1,8 @@
import type { AST, Binding } from '#compiler'; import type { AST, Binding, StateField } from '#compiler';
import type { import type {
AwaitExpression, AwaitExpression,
CallExpression, CallExpression,
ClassBody,
Identifier, Identifier,
LabeledStatement, LabeledStatement,
Node, Node,
@ -37,6 +38,8 @@ export interface Analysis {
immutable: boolean; immutable: boolean;
tracing: boolean; tracing: boolean;
classes: Map<ClassBody, Map<string, StateField>>;
// TODO figure out if we can move this to ComponentAnalysis // TODO figure out if we can move this to ComponentAnalysis
accessors: boolean; accessors: boolean;

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

@ -178,6 +178,16 @@ export namespace AST {
}; };
} }
/** A `{@attach foo(...)} tag */
export interface AttachTag extends BaseNode {
type: 'AttachTag';
expression: Expression;
/** @internal */
metadata: {
expression: ExpressionMetadata;
};
}
/** An `animate:` directive */ /** An `animate:` directive */
export interface AnimateDirective extends BaseNode { export interface AnimateDirective extends BaseNode {
type: 'AnimateDirective'; type: 'AnimateDirective';
@ -277,7 +287,7 @@ export namespace AST {
interface BaseElement extends BaseNode { interface BaseElement extends BaseNode {
name: string; name: string;
attributes: Array<Attribute | SpreadAttribute | Directive>; attributes: Array<Attribute | SpreadAttribute | Directive | AttachTag>;
fragment: Fragment; fragment: Fragment;
} }
@ -471,6 +481,7 @@ export namespace AST {
type: 'SnippetBlock'; type: 'SnippetBlock';
expression: Identifier; expression: Identifier;
parameters: Pattern[]; parameters: Pattern[];
typeParams?: string;
body: Fragment; body: Fragment;
/** @internal */ /** @internal */
metadata: { metadata: {
@ -549,7 +560,13 @@ export namespace AST {
| AST.SvelteWindow | AST.SvelteWindow
| AST.SvelteBoundary; | 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 = export type TemplateNode =
| AST.Root | AST.Root
@ -559,6 +576,7 @@ export namespace AST {
| AST.Attribute | AST.Attribute
| AST.SpreadAttribute | AST.SpreadAttribute
| Directive | Directive
| AST.AttachTag
| AST.Comment | AST.Comment
| Block; | Block;

@ -1,7 +1,8 @@
/** @import { AST } from '#compiler' */ /** @import { AST, Scope } from '#compiler' */
/** @import * as ESTree from 'estree' */ /** @import * as ESTree from 'estree' */
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import is_reference from 'is-reference';
/** /**
* Gets the left-most identifier of a member expression or identifier. * Gets the left-most identifier of a member expression or identifier.
@ -128,6 +129,49 @@ export function unwrap_pattern(pattern, nodes = []) {
return nodes; return nodes;
} }
/**
* @param {ESTree.Pattern} id
* @param {Scope} scope
* @returns {[ESTree.Pattern, Map<ESTree.Identifier | ESTree.MemberExpression, ESTree.Identifier>]}
*/
export function build_pattern(id, scope) {
/** @type {Map<ESTree.Identifier | ESTree.MemberExpression, ESTree.Identifier>} */
const map = new Map();
/** @type {Map<string, string>} */
const names = new Map();
let counter = 0;
for (const node of unwrap_pattern(id)) {
const name = scope.generate(`$$${++counter}`, (_, counter) => `$$${counter}`);
map.set(node, b.id(name));
if (node.type === 'Identifier') {
names.set(node.name, name);
}
}
const pattern = walk(id, null, {
Identifier(node, context) {
if (is_reference(node, /** @type {ESTree.Pattern} */ (context.path.at(-1)))) {
const name = names.get(node.name);
if (name) return b.id(name);
}
},
MemberExpression(node, context) {
const n = map.get(node);
if (n) return n;
context.next();
}
});
return [pattern, map];
}
/** /**
* Extracts all identifiers from a pattern. * Extracts all identifiers from a pattern.
* @param {ESTree.Pattern} pattern * @param {ESTree.Pattern} pattern
@ -580,5 +624,7 @@ export function build_assignment_value(operator, left, right) {
return operator === '=' return operator === '='
? right ? right
: // turn something like x += 1 into x = x + 1 : // turn something like x += 1 into x = x + 1
b.binary(/** @type {ESTree.BinaryOperator} */ (operator.slice(0, -1)), left, right); ['||=', '&&=', '??='].includes(operator)
? b.logical(/** @type {ESTree.LogicalOperator} */ (operator.slice(0, -1)), left, right)
: b.binary(/** @type {ESTree.BinaryOperator} */ (operator.slice(0, -1)), left, right);
} }

@ -86,6 +86,7 @@ export const codes = [
'a11y_role_supports_aria_props_implicit', 'a11y_role_supports_aria_props_implicit',
'a11y_unknown_aria_attribute', 'a11y_unknown_aria_attribute',
'a11y_unknown_role', 'a11y_unknown_role',
'bidirectional_control_characters',
'legacy_code', 'legacy_code',
'unknown_code', 'unknown_code',
'options_deprecated_accessors', 'options_deprecated_accessors',
@ -113,6 +114,7 @@ export const codes = [
'bind_invalid_each_rest', 'bind_invalid_each_rest',
'block_empty', 'block_empty',
'component_name_lowercase', 'component_name_lowercase',
'element_implicitly_closed',
'element_invalid_self_closing_tag', 'element_invalid_self_closing_tag',
'event_directive_deprecated', 'event_directive_deprecated',
'node_invalid_placement_ssr', 'node_invalid_placement_ssr',
@ -506,6 +508,14 @@ export function a11y_unknown_role(node, role, suggestion) {
w(node, 'a11y_unknown_role', `${suggestion ? `Unknown role '${role}'. Did you mean '${suggestion}'?` : `Unknown role '${role}'`}\nhttps://svelte.dev/e/a11y_unknown_role`); w(node, 'a11y_unknown_role', `${suggestion ? `Unknown role '${role}'. Did you mean '${suggestion}'?` : `Unknown role '${role}'`}\nhttps://svelte.dev/e/a11y_unknown_role`);
} }
/**
* A bidirectional control character was detected in your code. These characters can be used to alter the visual direction of your code and could have unintended consequences
* @param {null | NodeLike} node
*/
export function bidirectional_control_characters(node) {
w(node, 'bidirectional_control_characters', `A bidirectional control character was detected in your code. These characters can be used to alter the visual direction of your code and could have unintended consequences\nhttps://svelte.dev/e/bidirectional_control_characters`);
}
/** /**
* `%code%` is no longer valid please use `%suggestion%` instead * `%code%` is no longer valid please use `%suggestion%` instead
* @param {null | NodeLike} node * @param {null | NodeLike} node
@ -737,6 +747,16 @@ export function component_name_lowercase(node, name) {
w(node, 'component_name_lowercase', `\`<${name}>\` will be treated as an HTML element unless it begins with a capital letter\nhttps://svelte.dev/e/component_name_lowercase`); w(node, 'component_name_lowercase', `\`<${name}>\` will be treated as an HTML element unless it begins with a capital letter\nhttps://svelte.dev/e/component_name_lowercase`);
} }
/**
* This element is implicitly closed by the following `%tag%`, which can cause an unexpected DOM structure. Add an explicit `%closing%` to avoid surprises.
* @param {null | NodeLike} node
* @param {string} tag
* @param {string} closing
*/
export function element_implicitly_closed(node, tag, closing) {
w(node, 'element_implicitly_closed', `This element is implicitly closed by the following \`${tag}\`, which can cause an unexpected DOM structure. Add an explicit \`${closing}\` to avoid surprises.\nhttps://svelte.dev/e/element_implicitly_closed`);
}
/** /**
* Self-closing HTML tags for non-void elements are ambiguous use `<%name% ...></%name%>` rather than `<%name% ... />` * Self-closing HTML tags for non-void elements are ambiguous use `<%name% ...></%name%>` rather than `<%name% ... />`
* @param {null | NodeLike} node * @param {null | NodeLike} node

@ -56,3 +56,5 @@ export const IGNORABLE_RUNTIME_WARNINGS = /** @type {const} */ ([
* TODO this is currently unused * TODO this is currently unused
*/ */
export const ELEMENTS_WITHOUT_TEXT = ['audio', 'datalist', 'dl', 'optgroup', 'select', 'video']; export const ELEMENTS_WITHOUT_TEXT = ['audio', 'datalist', 'dl', 'optgroup', 'select', 'video'];
export const ATTACHMENT_KEY = '@attach';

@ -13,6 +13,7 @@ import {
hydrate_next, hydrate_next,
hydrate_node, hydrate_node,
hydrating, hydrating,
read_hydration_instruction,
remove_nodes, remove_nodes,
set_hydrate_node, set_hydrate_node,
set_hydrating set_hydrating
@ -204,7 +205,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
let mismatch = false; let mismatch = false;
if (hydrating) { if (hydrating) {
var is_else = /** @type {Comment} */ (anchor).data === HYDRATION_START_ELSE; var is_else = read_hydration_instruction(anchor) === HYDRATION_START_ELSE;
if (is_else !== (length === 0)) { if (is_else !== (length === 0)) {
// hydration mismatch — remove the server-rendered DOM and start over // hydration mismatch — remove the server-rendered DOM and start over

@ -5,6 +5,7 @@ import {
hydrate_next, hydrate_next,
hydrate_node, hydrate_node,
hydrating, hydrating,
read_hydration_instruction,
remove_nodes, remove_nodes,
set_hydrate_node, set_hydrate_node,
set_hydrating set_hydrating
@ -100,7 +101,7 @@ export function if_block(node, fn, elseif = false) {
let mismatch = false; let mismatch = false;
if (hydrating) { if (hydrating) {
const is_else = /** @type {Comment} */ (anchor).data === HYDRATION_START_ELSE; const is_else = read_hydration_instruction(anchor) === HYDRATION_START_ELSE;
if (!!condition === is_else) { if (!!condition === is_else) {
// Hydration mismatch: remove everything inside the anchor and start fresh. // Hydration mismatch: remove everything inside the anchor and start fresh.

@ -0,0 +1,33 @@
/** @import { Effect } from '#client' */
import { block, branch, effect, destroy_effect } from '../../reactivity/effects.js';
// TODO in 6.0 or 7.0, when we remove legacy mode, we can simplify this by
// getting rid of the block/branch stuff and just letting the effect rip.
// see https://github.com/sveltejs/svelte/pull/15962
/**
* @param {Element} node
* @param {() => (node: Element) => void} get_fn
*/
export function attach(node, get_fn) {
/** @type {false | undefined | ((node: Element) => void)} */
var fn = undefined;
/** @type {Effect | null} */
var e;
block(() => {
if (fn !== (fn = get_fn())) {
if (e) {
destroy_effect(e);
e = null;
}
if (fn) {
e = branch(() => {
effect(() => /** @type {(node: Element) => void} */ (fn)(node));
});
}
}
});
}

@ -13,10 +13,11 @@ import {
set_active_effect, set_active_effect,
set_active_reaction set_active_reaction
} from '../../runtime.js'; } from '../../runtime.js';
import { attach } from './attachments.js';
import { clsx } from '../../../shared/attributes.js'; import { clsx } from '../../../shared/attributes.js';
import { set_class } from './class.js'; import { set_class } from './class.js';
import { set_style } from './style.js'; import { set_style } from './style.js';
import { NAMESPACE_HTML } from '../../../../constants.js'; import { ATTACHMENT_KEY, NAMESPACE_HTML } from '../../../../constants.js';
export const CLASS = Symbol('class'); export const CLASS = Symbol('class');
export const STYLE = Symbol('style'); export const STYLE = Symbol('style');
@ -446,6 +447,12 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal
set_hydrating(true); set_hydrating(true);
} }
for (let symbol of Object.getOwnPropertySymbols(next)) {
if (symbol.description === ATTACHMENT_KEY) {
attach(element, () => next[symbol]);
}
}
return current; return current;
} }

@ -62,7 +62,23 @@ export function bind_current_time(media, get, set = get) {
* @param {(array: Array<{ start: number; end: number }>) => void} set * @param {(array: Array<{ start: number; end: number }>) => void} set
*/ */
export function bind_buffered(media, set) { export function bind_buffered(media, set) {
listen(media, ['loadedmetadata', 'progress'], () => set(time_ranges_to_array(media.buffered))); /** @type {{ start: number; end: number; }[]} */
var current;
// `buffered` can update without emitting any event, so we check it on various events.
// By specs, `buffered` always returns a new object, so we have to compare deeply.
listen(media, ['loadedmetadata', 'progress', 'timeupdate', 'seeking'], () => {
var ranges = media.buffered;
if (
!current ||
current.length !== ranges.length ||
current.some((range, i) => ranges.start(i) !== range.start || ranges.end(i) !== range.end)
) {
current = time_ranges_to_array(ranges);
set(current);
}
});
} }
/** /**

@ -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')) { if (!hydrating || next_class_name !== dom.getAttribute('class')) {
// Removing the attribute when the value is only an empty string causes // Removing the attribute when the value is only an empty string causes
// performance issues vs simply making the className an empty string. So // 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 : // and there no hash/directives :
if (next_class_name == null) { if (next_class_name == null) {
dom.removeAttribute('class'); dom.removeAttribute('class');

@ -26,12 +26,8 @@ export const root_event_handles = new Set();
export function replay_events(dom) { export function replay_events(dom) {
if (!hydrating) return; if (!hydrating) return;
if (dom.onload) { dom.removeAttribute('onload');
dom.removeAttribute('onload'); dom.removeAttribute('onerror');
}
if (dom.onerror) {
dom.removeAttribute('onerror');
}
// @ts-expect-error // @ts-expect-error
const event = dom.__e; const event = dom.__e;
if (event !== undefined) { if (event !== undefined) {

@ -103,3 +103,16 @@ export function remove_nodes() {
node = next; node = next;
} }
} }
/**
*
* @param {TemplateNode} node
*/
export function read_hydration_instruction(node) {
if (!node || node.nodeType !== 8) {
w.hydration_mismatch();
throw HYDRATION_ERROR;
}
return /** @type {Comment} */ (node).data;
}

@ -1,3 +1,4 @@
export { createAttachmentKey as attachment } from '../../attachments/index.js';
export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js'; export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js';
export { push, pop } from './context.js'; export { push, pop } from './context.js';
export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js'; export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js';
@ -23,6 +24,7 @@ export { element } from './dom/blocks/svelte-element.js';
export { head } from './dom/blocks/svelte-head.js'; export { head } from './dom/blocks/svelte-head.js';
export { append_styles } from './dom/css.js'; export { append_styles } from './dom/css.js';
export { action } from './dom/elements/actions.js'; export { action } from './dom/elements/actions.js';
export { attach } from './dom/elements/attachments.js';
export { export {
remove_input_defaults, remove_input_defaults,
set_attribute, set_attribute,

@ -218,9 +218,15 @@ const spread_props_handler = {
for (let p of target.props) { for (let p of target.props) {
if (is_function(p)) p = p(); if (is_function(p)) p = p();
if (!p) continue;
for (const key in p) { for (const key in p) {
if (!keys.includes(key)) keys.push(key); if (!keys.includes(key)) keys.push(key);
} }
for (const key of Object.getOwnPropertySymbols(p)) {
if (!keys.includes(key)) keys.push(key);
}
} }
return keys; return keys;

@ -855,22 +855,25 @@ export function flushSync(fn) {
if (fn) { if (fn) {
is_flushing = true; is_flushing = true;
flush_queued_root_effects(); flush_queued_root_effects();
is_flushing = true;
result = fn(); result = fn();
} }
flush_tasks(); while (true) {
flush_tasks();
if (queued_root_effects.length === 0) {
if (batch === current_batch) {
batch.flush();
}
return /** @type {T} */ (result);
}
while (queued_root_effects.length > 0) {
is_flushing = true; is_flushing = true;
flush_queued_root_effects(); flush_queued_root_effects();
flush_tasks();
} }
if (batch === current_batch) {
batch.flush();
}
return /** @type {T} */ (result);
} }
/** /**

@ -3,6 +3,19 @@ import { ReactiveValue } from './reactive-value.js';
const parenthesis_regex = /\(.+\)/; 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. * 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 * @param {boolean} [fallback] Fallback value for the server
*/ */
constructor(query, fallback) { 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); const q = window.matchMedia(final_query);
super( super(
() => q.matches, () => q.matches,

@ -428,15 +428,19 @@ export function is_mathml(name) {
return MATHML_ELEMENTS.includes(name); return MATHML_ELEMENTS.includes(name);
} }
const RUNES = /** @type {const} */ ([ export const STATE_CREATION_RUNES = /** @type {const} */ ([
'$state', '$state',
'$state.raw', '$state.raw',
'$derived',
'$derived.by'
]);
const RUNES = /** @type {const} */ ([
...STATE_CREATION_RUNES,
'$state.snapshot', '$state.snapshot',
'$props', '$props',
'$props.id', '$props.id',
'$bindable', '$bindable',
'$derived',
'$derived.by',
'$effect', '$effect',
'$effect.pre', '$effect.pre',
'$effect.tracking', '$effect.tracking',
@ -448,12 +452,24 @@ const RUNES = /** @type {const} */ ([
'$host' '$host'
]); ]);
/** @typedef {RUNES[number]} RuneName */
/** /**
* @param {string} name * @param {string} name
* @returns {name is RUNES[number]} * @returns {name is RuneName}
*/ */
export function is_rune(name) { 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 */ /** 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. * The current version, as set in package.json.
* @type {string} * @type {string}
*/ */
export const VERSION = '5.28.2'; export const VERSION = '5.32.0';
export const PUBLIC_VERSION = '5'; export const PUBLIC_VERSION = '5';

@ -4,7 +4,7 @@ export default test({
error: { error: {
code: 'state_invalid_placement', code: 'state_invalid_placement',
message: 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] position: [33, 41]
} }
}); });

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save