Merge branch 'main' into issue-18419-SvelteDate

pull/18509/head
JY Wey 3 weeks ago committed by GitHub
commit cfa5abe9dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: block template store subscriptions on the promise that assigns the store

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: route $derived teardown errors through invoke_error_boundary

@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": ["@svitejs/changesets-changelog-github-compact", { "repo": "sveltejs/svelte" }],
"changelog": ["@changesets/changelog-github", { "repo": "sveltejs/svelte", "template": "\n- {summary} {ref}" }],
"commit": false,
"fixed": [],
"linked": [],

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: warn on undeclared shorthand event handlers on `<svelte:window>`, `<svelte:document>` and `<svelte:body>`

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent onoutroend from firing twice when compilerOptions.hmr is true

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: preserve whitespace after inline elements when printing

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: run `onDestroy` callbacks when a server render throws

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: never apply class hash to elements inside `<svelte:head>`

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: O(n²)→O(n) Map lookups for legacy `$:` reactive statement ordering

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: distinct memoizer on style/class directives

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: don't turn component instances stored in `$state` into state proxies

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: export `RenderOutput`, `SyncRenderOutput`, `Csp` and `Sha256Source` from `svelte/server`

@ -0,0 +1,5 @@
---
"svelte": patch
---
perf: optimize simple object destructuring in `@const` tags

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: don't resurrect outroing elements when an ancestor block is paused and resumed

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent `selectedcontent` mutation from changing the selected option

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: avoid `NaN` keyframe values in `slide` transition for elements without a layout box

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: clarify when `$effect.pre` runs relative to DOM updates

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: scope SSR boundary failed snippets to their boundary

@ -28,7 +28,7 @@ jobs:
- name: Get PR ref
if: github.event_name != 'workflow_dispatch'
id: pr
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { data: pull } = await github.rest.pulls.get({
@ -46,12 +46,12 @@ jobs:
core.setFailed('PR is from a fork');
}
core.setOutput('ref', pull.head.ref);
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
if: github.event_name == 'workflow_dispatch' || steps.pr.outcome == 'success'
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || steps.pr.outputs.ref }}
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- uses: actions/setup-node@v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm

@ -32,9 +32,9 @@ jobs:
os: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
@ -48,9 +48,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: pnpm
@ -65,9 +65,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
@ -82,9 +82,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
@ -105,9 +105,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm

@ -17,7 +17,7 @@ jobs:
contents: read # to clone the repo
steps:
- name: Check User Permissions
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
id: check-permissions
with:
script: |
@ -56,7 +56,7 @@ jobs:
}
- name: Get PR Data
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
id: get-pr-data
with:
script: |
@ -106,7 +106,7 @@ jobs:
- name: Generate Token
id: generate-token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }}
private-key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }}
@ -115,7 +115,7 @@ jobs:
svelte-ecosystem-ci
- name: Trigger Downstream Workflow
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
id: trigger
env:
COMMENT: ${{ github.event.comment.body }}

@ -1,229 +0,0 @@
name: pkg.pr.new
on:
pull_request_target:
types: [opened, synchronize]
push:
branches: [main]
workflow_dispatch:
inputs:
sha:
description: 'Commit SHA to build'
required: true
type: string
pr:
description: 'PR number to comment on'
required: true
type: number
permissions: {}
jobs:
build:
# Skip pull_request_target events from forks — maintainers can use workflow_dispatch instead
if: >
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
# No permissions — this job runs user-controlled code
permissions: {}
steps:
- uses: actions/checkout@v6
with:
# For pull_request_target, check out the PR head.
# For workflow_dispatch, check out the manually specified SHA.
# For push, fall back to the push SHA.
ref: ${{ github.event.pull_request.head.sha || inputs.sha || github.sha }}
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
with:
node-version: 22.x
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- run: pnpx pkg-pr-new publish --comment=off --json output.json --compact --no-template './packages/svelte'
- name: Upload output
uses: actions/upload-artifact@v4
with:
name: output
path: ./output.json
# Sanitizes the untrusted output from the build job before it's consumed by
# jobs with elevated permissions. This ensures that only known package names
# and valid SHA prefixes make it through.
sanitize:
needs: build
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Download artifact
uses: actions/download-artifact@v7
with:
name: output
- name: Sanitize output
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const raw = JSON.parse(fs.readFileSync('output.json', 'utf8'));
const ALLOWED_PACKAGES = new Set(['svelte']);
const SHA_PATTERN = /^[0-9a-f]{7}$/;
const packages = (raw.packages || [])
.filter(p => {
if (!ALLOWED_PACKAGES.has(p.name)) {
console.log(`Skipping unexpected package: ${JSON.stringify(p.name)}`);
return false;
}
const sha = p.url?.replace(/^.+@([^@]+)$/, '$1');
if (!sha || !SHA_PATTERN.test(sha)) {
console.log(`Skipping package with invalid SHA: ${JSON.stringify(p.url)}`);
return false;
}
return true;
})
.map(p => ({
name: p.name,
sha: p.url.replace(/^.+@([^@]+)$/, '$1'),
}));
fs.writeFileSync('sanitized-output.json', JSON.stringify({ packages }), 'utf8');
- name: Upload sanitized output
uses: actions/upload-artifact@v4
with:
name: sanitized-output
path: ./sanitized-output.json
comment:
needs: sanitize
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Download sanitized artifact
uses: actions/download-artifact@v7
with:
name: sanitized-output
- name: Resolve PR number
id: pr
uses: actions/github-script@v8
with:
script: |
if (context.eventName === 'pull_request_target') {
core.setOutput('number', context.issue.number);
return;
}
// For workflow_dispatch, use the explicitly provided PR number.
// We can't use listPullRequestsAssociatedWithCommit because fork
// commits don't exist in the base repo, so the API returns nothing.
const pr = Number('${{ inputs.pr }}');
if (!pr || isNaN(pr)) {
core.setFailed('workflow_dispatch requires a valid pr input');
return;
}
core.setOutput('number', pr);
- name: Post or update comment
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8'));
if (packages.length === 0) {
console.log('No valid packages found. Skipping comment.');
return;
}
const issue_number = parseInt('${{ steps.pr.outputs.number }}', 10);
const bot_comment_identifier = `<!-- pkg.pr.new comment -->`;
const body = `${bot_comment_identifier}
[Playground](https://svelte.dev/playground?version=pr-${issue_number})
\`\`\`
${packages.map(p => `pnpm add https://pkg.pr.new/${p.name}@${issue_number}`).join('\n')}
\`\`\`
`;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
});
const existing = comments.data.find(c => c.body.includes(bot_comment_identifier));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
body,
});
}
log:
needs: sanitize
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Download sanitized artifact
uses: actions/download-artifact@v7
with:
name: sanitized-output
- name: Log publish info
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8'));
if (packages.length === 0) {
console.log('No valid packages found.');
return;
}
console.log('\n' + '='.repeat(50));
console.log('Publish Information');
console.log('='.repeat(50));
for (const p of packages) {
console.log(`${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.sha}`);
}
const svelte = packages.find(p => p.name === 'svelte');
if (svelte) {
console.log(`\nPlayground: https://svelte.dev/playground?version=commit-${svelte.sha}`);
}
console.log('='.repeat(50));

@ -23,13 +23,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24.x
cache: pnpm

@ -205,6 +205,8 @@ In rare cases, you may need to run code _before_ the DOM updates. For this we ca
</div>
```
`$effect.pre` runs before DOM updates that are scheduled after it, not before every DOM mutation in the flush - DOM of parent components may already be updated. When using [await expressions](await-expressions), block updates like `{#if ...}` and `{#each ...}` in the same component also run before `$effect.pre`.
Apart from the timing, `$effect.pre` works exactly like `$effect`.
## `$effect.tracking`

@ -52,3 +52,5 @@ In this case, you can specify a fallback value for when no prop is passed at all
/// file: FancyInput.svelte
let { value = $bindable('fallback'), ...props } = $props();
```
When a bindable prop has a fallback value, the parent must pass a value other than `undefined` if it uses `bind:`. This avoids ambiguity about which value should apply, since the parent and child should share the same value for a binding.

@ -202,7 +202,7 @@ You can add a special comment starting with `@component` that will show up when
- You can also use code blocks here.
- Usage:
```html
<Main name="Arethra">
<Main name="Aretha">
```
-->
<script>
@ -215,3 +215,14 @@ You can add a special comment starting with `@component` that will show up when
</h1>
</main>
````
You can also put JavaScript-style comments within tags between attributes:
```svelte
<div
// this is a comment!
data-foo="bar"
>
foo bar
</div>
```

@ -13,7 +13,7 @@ Declaration tags define local variables inside markup with `const` or `let`:
{#each boxes as box}
{const area = box.width * box.height}
{const label = `${box.width} ${box.height} = ${area}`}
{const label = `${box.width} × ${box.height} = ${area}`}
<p>{label}</p>
{/each}

@ -53,6 +53,12 @@ Transitions can have parameters.
{/if}
```
## Accessibility
Transitions are driven by the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) rather than by CSS. A global `@media (prefers-reduced-motion: reduce)` rule that zeroes `transition-duration` and `animation-duration` therefore has no effect on them.
Use [`prefersReducedMotion`](svelte-motion#prefersReducedMotion) to adjust (or completely disable) the transition accordingly for devices who request reduced motion.
## Custom transition functions
```js

@ -109,7 +109,7 @@ By default, error boundaries have no effect on the server — if an error occurs
Since 5.51 you can control this behaviour for boundaries with a `failed` snippet, by calling [`render(...)`](imperative-component-api#render) with a `transformError` function.
> [!NOTE] If you're using Svelte via a framework such as SvelteKit, you most likely don't have direct access to the `render(...)` call — the framework must configure `transformError` on your behalf. SvelteKit will add support for this in the near future, via the [`handleError`](../kit/hooks#Shared-hooks-handleError) hook.
> [!NOTE] If you're using Svelte via a framework such as SvelteKit, you most likely don't have direct access to the `render(...)` call — the framework must configure `transformError` on your behalf. SvelteKit will add support for this in the near future, via the [`handleError`](../kit/hooks#handleError) hook.
The `transformError` function must return a JSON-stringifiable object which will be used to render the `failed` snippet. This object will be serialized and used to hydrate the snippet in the browser:

@ -102,7 +102,7 @@ To implement a chat window that autoscrolls to the bottom when new messages appe
In Svelte 4, we do this with `beforeUpdate`, but this is a flawed approach — it fires before _every_ update, whether it's relevant or not. In the example below, we need to introduce checks like `updatingMessages` to make sure we don't mess with the scroll position when someone toggles dark mode.
With runes, we can use `$effect.pre`, which behaves the same as `$effect` but runs before the DOM is updated. As long as we explicitly reference `messages` inside the effect body, it will run whenever `messages` changes, but _not_ when `theme` changes.
With runes, we can use `$effect.pre`, which behaves the same as `$effect` but runs before DOM updates scheduled after it (see [$effect.pre]($effect#$effect.pre) for the exact ordering). As long as we explicitly reference `messages` inside the effect body, it will run whenever `messages` changes, but _not_ when `theme` changes.
`beforeUpdate`, and its equally troublesome counterpart `afterUpdate`, are therefore deprecated in Svelte 5.

@ -170,6 +170,12 @@ If you don't give `$state` an initial value, part of its types will be `undefine
let count: number = $state();
```
You can pass the type directly as a generic parameter to safely handle this. TypeScript will infer the variable as `number | undefined`.
```ts
let count = $state<number>();
```
If you know that the variable _will_ be defined before you first use it, use an `as` casting. This is especially useful in the context of classes:
```ts

@ -99,7 +99,9 @@ However, you can use any router library. A sampling of available routers are hig
While most mobile apps are written without using JavaScript, if you'd like to leverage your existing Svelte components and knowledge of Svelte when building mobile apps, you can turn a [SvelteKit SPA](https://kit.svelte.dev/docs/single-page-apps) into a mobile app with [Tauri](https://v2.tauri.app/start/frontend/sveltekit/) or [Capacitor](https://capacitorjs.com/solution/svelte). Mobile features like the camera, geolocation, and push notifications are available via plugins for both platforms.
Some work has been completed towards [custom renderer support in Svelte 5](https://github.com/sveltejs/svelte/issues/15470), but this feature is not yet available. The custom rendering API would support additional mobile frameworks like Lynx JS and Svelte Native. Svelte Native was an option available for Svelte 4, but Svelte 5 does not currently support it. Svelte Native lets you write NativeScript apps using Svelte components that contain [NativeScript UI components](https://docs.nativescript.org/ui/) rather than DOM elements, which may be familiar for users coming from React Native.
You can also write apps in Svelte that compiles to native components by using [Symbiote Native](https://docs.symbiote-native.dev/), which leverages the infrastructure provided by React Native.
Work has been completed towards [custom renderer support in Svelte 5](https://github.com/sveltejs/svelte/issues/15470), but this feature is not yet merged. The custom rendering API will allow support in additional mobile frameworks like Lynx JS and Svelte Native. Symbiote Native will also adopt this API. Svelte Native was an option available for Svelte 4, but Svelte 5 does not currently support it. Svelte Native lets you write NativeScript apps using Svelte components that contain [NativeScript UI components](https://docs.nativescript.org/ui/) rather than DOM elements.
## Can I tell Svelte not to remove my unused styles?

@ -339,27 +339,6 @@ Reactive `$state(...)` proxies and the values they proxy have different identiti
To resolve this, ensure you're comparing values where both values were created with `$state(...)`, or neither were. Note that `$state.raw(...)` will _not_ create a state proxy.
### state_proxy_unmount
```
Tried to unmount a state proxy, rather than a component
```
`unmount` was called with a state proxy:
```js
import { mount, unmount } from 'svelte';
import Component from './Component.svelte';
let target = document.body;
// ---cut---
let component = $state(mount(Component, { target }));
// later...
unmount(component);
```
Avoid using `$state` here. If `component` _does_ need to be reactive for some reason, use `$state.raw` instead.
### svelte_boundary_reset_noop
```

@ -821,6 +821,18 @@ Cannot use `%rune%` rune in non-runes mode
Cannot use rune without parentheses
```
Runes are keywords rather than values — they can't be assigned to a variable or passed to a function, only called. Referencing one without parentheses is therefore an error...
```js
let count = $state;
```
...whether it's a rune like `$state` or one reached through a property, like `$derived.by`. Add the parentheses, along with any arguments the rune expects:
```js
let count = $state(0);
```
### rune_removed
```

@ -75,10 +75,10 @@ Certain lifecycle methods can only be used during component initialisation. To f
### missing_context
```
Context was not set in a parent component
Context was not set in the current component or any of its ancestors
```
The [`createContext()`](svelte#createContext) utility returns a `[get, set]` pair of functions. `get` will throw an error if `set` was not used to set the context in a parent component.
The [`createContext()`](svelte#createContext) utility returns a `[get, set]` pair of functions. `get` will throw an error if `set` was not used to set the context in the current component or any of its ancestors.
### snippet_without_render_tag

@ -2,4 +2,6 @@
title: svelte/easing
---
This module provides a set of functions that allow you to manipulate time values in different ways. Its particularly useful for animations when combined with the `motion` module.
> MODULE: svelte/easing

@ -5,7 +5,7 @@
"private": true,
"type": "module",
"license": "MIT",
"packageManager": "pnpm@10.4.0",
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800",
"engines": {
"pnpm": ">=9.0.0"
},
@ -26,10 +26,10 @@
"bench:debug": "NODE_ENV=production node --allow-natives-syntax --inspect-brk ./benchmarking/run.js"
},
"devDependencies": {
"@changesets/changelog-github": "1.0.0-next.6",
"@changesets/cli": "^2.29.8",
"@eslint/js": "^10.0.0",
"@sveltejs/eslint-config": "^9.0.0",
"@svitejs/changesets-changelog-github-compact": "^1.1.0",
"@types/node": "^20.11.5",
"@types/picomatch": "^4.0.2",
"@vitest/coverage-v8": "^4.1.7",

@ -1,5 +1,87 @@
# svelte
## 5.56.10
### Patch Changes
- fix: preserve CSS escape sequences when printing selectors ([#18667](https://github.com/sveltejs/svelte/pull/18667))
- fix: parse `:nth-child(2n of.foo)` where `of` is not followed by whitespace ([#18611](https://github.com/sveltejs/svelte/pull/18611))
- fix: transform expressions inside labeled statements during server compilation ([#18617](https://github.com/sveltejs/svelte/pull/18617))
- docs: clarify that context lookup includes the current component and all ancestors ([#18581](https://github.com/sveltejs/svelte/pull/18581))
- fix: apply CSS custom properties with falsy values on components ([#18634](https://github.com/sveltejs/svelte/pull/18634))
- fix: correctly print `{#await ... catch x}` et al ([#18645](https://github.com/sveltejs/svelte/pull/18645))
- fix: ignore comments of Program node during migration script ([#18656](https://github.com/sveltejs/svelte/pull/18656))
- fix: reliably resolve append_style to its correct root ([#18614](https://github.com/sveltejs/svelte/pull/18614))
- fix: clean up removed capture event handlers from spread attributes ([#18618](https://github.com/sveltejs/svelte/pull/18618))
- fix: don't corrupt renderer type during SSR's legacy `bind:` retry loop ([#18616](https://github.com/sveltejs/svelte/pull/18616))
- fix: treat concise arrow function bodies as implicit returns when calculating blockers ([#18613](https://github.com/sveltejs/svelte/pull/18613))
- fix: give effect teardowns the value from before the first write in a flush ([#18620](https://github.com/sveltejs/svelte/pull/18620))
- fix: avoid double-calling a derived reference when destructuring `$derived` of another `$derived` during server-side rendering ([#18668](https://github.com/sveltejs/svelte/pull/18668))
- fix: preserve namespaces in CSS type selectors ([#18678](https://github.com/sveltejs/svelte/pull/18678))
- fix: increment private state fields through a non-`this` receiver ([#18622](https://github.com/sveltejs/svelte/pull/18622))
- chore: deduplicate client and server context helpers ([#18580](https://github.com/sveltejs/svelte/pull/18580))
- fix: release `last_propagated_event` after event propagation settles so it no longer retains the last event's target subtree ([#18569](https://github.com/sveltejs/svelte/pull/18569))
- fix: allow custom elements to receive async values as props ([#18661](https://github.com/sveltejs/svelte/pull/18661))
- fix: strip comments from inline `style` values in linear time ([#18553](https://github.com/sveltejs/svelte/pull/18553))
- fix: prevent declaration comments from breaking server derived references ([#18641](https://github.com/sveltejs/svelte/pull/18641))
- perf: make async blocker analysis scale linearly with the number of top-level references ([#18549](https://github.com/sveltejs/svelte/pull/18549))
- fix: preserve short-circuiting for logical assignments to private state fields ([#18594](https://github.com/sveltejs/svelte/pull/18594))
## 5.56.9
### Patch Changes
- fix: skip controlled each fast path while another batch is pending ([#18625](https://github.com/sveltejs/svelte/pull/18625))
- fix: better whitespace handling inside printer ([#18638](https://github.com/sveltejs/svelte/pull/18638))
- fix: don't duplicate comments in attributes ([#18636](https://github.com/sveltejs/svelte/pull/18636))
- fix: preserve CSS comments in the AST printer ([#18637](https://github.com/sveltejs/svelte/pull/18637))
## 5.56.8
### Patch Changes
- fix: call `onerror` and provide a working `reset` when hydrating a failed boundary ([#18556](https://github.com/sveltejs/svelte/pull/18556))
- fix: preserve select selection when spread attributes omit value ([#18561](https://github.com/sveltejs/svelte/pull/18561))
## 5.56.7
### Patch Changes
- chore: provide `indent` option for `print` ([#18474](https://github.com/sveltejs/svelte/pull/18474))
## 5.56.6
### Patch Changes
- perf: skip unnecessary blocker analysis when compiling components without top-level await ([#18548](https://github.com/sveltejs/svelte/pull/18548))
- fix: rerun derived that had an abort controller on reconnection ([#18551](https://github.com/sveltejs/svelte/pull/18551))
## 5.56.5
### Patch Changes

@ -295,25 +295,6 @@ To silence the warning, ensure that `value`:
To resolve this, ensure you're comparing values where both values were created with `$state(...)`, or neither were. Note that `$state.raw(...)` will _not_ create a state proxy.
## state_proxy_unmount
> Tried to unmount a state proxy, rather than a component
`unmount` was called with a state proxy:
```js
import { mount, unmount } from 'svelte';
import Component from './Component.svelte';
let target = document.body;
// ---cut---
let component = $state(mount(Component, { target }));
// later...
unmount(component);
```
Avoid using `$state` here. If `component` _does_ need to be reactive for some reason, use `$state.raw` instead.
## svelte_boundary_reset_noop
> A `<svelte:boundary>` `reset` function only resets the boundary the first time it is called

@ -186,6 +186,18 @@ This turned out to be buggy and unpredictable, particularly when working with de
> Cannot use rune without parentheses
Runes are keywords rather than values — they can't be assigned to a variable or passed to a function, only called. Referencing one without parentheses is therefore an error...
```js
let count = $state;
```
...whether it's a rune like `$state` or one reached through a property, like `$derived.by`. Add the parentheses, along with any arguments the rune expects:
```js
let count = $state(0);
```
## rune_removed
> The `%name%` rune has been removed

@ -62,9 +62,9 @@ Certain lifecycle methods can only be used during component initialisation. To f
## missing_context
> Context was not set in a parent component
> Context was not set in the current component or any of its ancestors
The [`createContext()`](svelte#createContext) utility returns a `[get, set]` pair of functions. `get` will throw an error if `set` was not used to set the context in a parent component.
The [`createContext()`](svelte#createContext) utility returns a `[get, set]` pair of functions. `get` will throw an error if `set` was not used to set the context in the current component or any of its ancestors.
## snippet_without_render_tag

@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
"version": "5.56.5",
"version": "5.56.10",
"type": "module",
"types": "./types/index.d.ts",
"engines": {

@ -261,7 +261,7 @@ declare function $effect(fn: () => void | (() => void)): void;
declare namespace $effect {
/**
* Runs code right before a component is mounted to the DOM, and then whenever its dependencies change, i.e. `$state` or `$derived` values.
* The timing of the execution is right before the DOM is updated.
* The timing of the execution is right before the DOM that comes after it is updated; parent DOM may already have been updated by the time it runs.
*
* Example:
* ```ts

@ -141,7 +141,8 @@ export function parseCss(source) {
type: 'StyleSheetFile',
start: 0,
end: source.length,
children
children,
comments: parser.css_comments
};
}

@ -1590,7 +1590,12 @@ function migrate_slot_usage(node, path, state) {
*/
function extract_type_and_comment(declarator, state, path) {
const str = state.str;
const parent = path.at(-1);
let parent = path.at(-1);
if (parent?.type === 'Program') {
// We don't want comments from the program node
parent = undefined;
}
// Try to find jsdoc above the declaration
let comment_node = /** @type {Node} */ (parent)?.leadingComments?.at(-1);

@ -59,7 +59,7 @@ export function parse(source, comments, typescript, is_script) {
return /** @type {Program} */ (ast);
} catch (err) {
// TODO the `return` in necessary for TS<7 due to a bug; otherwise
// TODO the `return` is necessary for TS<7 due to a bug; otherwise
// the `finally` block is regarded as unreachable
return handle_parse_error(err);
} finally {

@ -50,6 +50,9 @@ export class Parser {
/** */
index = 0;
/** @type {AST.CSS.CSSComment[]} */
css_comments = [];
/**
* Creates a minimal parser instance for CSS-only parsing.
* Skips Svelte component parsing setup.
@ -61,6 +64,7 @@ export class Parser {
parser.template = source;
parser.index = 0;
parser.loose = false;
parser.css_comments = [];
return parser;
}

@ -7,8 +7,11 @@ const REGEX_CLOSING_BRACKET = /[\s\]]/;
const REGEX_ATTRIBUTE_FLAGS = /[a-zA-Z]+/y; // only `i` and `s` are valid today, but make it future-proof
const REGEX_COMBINATOR = /(\+|~|>|\|\|)/y;
const REGEX_PERCENTAGE = /\d+(\.\d+)?%/y;
// `of` must be preceded by whitespace, otherwise it would be part of the `<an+b>` token
// (`2nof` is a single dimension token). It does not need to be followed by whitespace,
// because a `.`, `#`, `[`, `*`, `:` or `&` already ends the `of` identifier — minifiers rely on that
const REGEX_NTH_OF =
/(even|odd|\+?(\d+|\d*n(\s*[+-]\s*\d+)?)|-\d*n(\s*\+\s*\d+))((?=\s*[,)])|\s+of\s+)/y;
/(even|odd|\+?(\d+|\d*n(\s*[+-]\s*\d+)?)|-\d*n(\s*\+\s*\d+))((?=\s*[,)])|\s+of(\s+|(?=[.#[*:&])))/y;
const REGEX_WHITESPACE_OR_COLON = /[\s:]/;
const REGEX_LEADING_HYPHEN_OR_DIGIT = /-?\d/y;
const REGEX_VALID_IDENTIFIER_CHAR = /[a-zA-Z0-9_-]/;
@ -24,6 +27,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/;
*/
export default function read_style(parser, start, attributes) {
const content_start = parser.index;
parser.css_comments = [];
const children = read_body(parser, (p) => p.match('</style') || p.index >= p.template.length);
const content_end = parser.index;
@ -36,6 +40,7 @@ export default function read_style(parser, start, attributes) {
end: parser.index,
attributes,
children,
comments: parser.css_comments,
content: {
start: content_start,
end: content_end,
@ -202,15 +207,18 @@ function read_selector(parser, inside_pseudo_class = false) {
});
} else if (parser.eat('*')) {
let name = '*';
/** @type {string | undefined} */
let namespace;
if (parser.eat('|')) {
// * is the namespace (which we ignore)
name = read_identifier(parser);
namespace = name;
name = parser.eat('*') ? '*' : read_identifier(parser);
}
relative_selector.selectors.push({
type: 'TypeSelector',
name,
...(namespace !== undefined && { namespace }),
start,
end: parser.index
});
@ -229,18 +237,22 @@ function read_selector(parser, inside_pseudo_class = false) {
end: parser.index
});
} else if (parser.eat('::')) {
const name = read_identifier(parser);
/** @type {AST.CSS.SelectorList | null} */
let args = null;
if (parser.eat('(')) {
args = read_selector_list(parser, true);
parser.eat(')', true);
}
relative_selector.selectors.push({
type: 'PseudoElementSelector',
name: read_identifier(parser),
name,
start,
end: parser.index
end: parser.index,
...(args && { args })
});
// We read the inner selectors of a pseudo element to ensure it parses correctly,
// but we don't do anything with the result.
if (parser.eat('(')) {
read_selector_list(parser, true);
parser.eat(')', true);
}
} else if (parser.eat(':')) {
const name = read_identifier(parser);
@ -308,22 +320,25 @@ function read_selector(parser, inside_pseudo_class = false) {
});
} else if (!parser.match_regex(REGEX_COMBINATOR)) {
let name = read_identifier(parser);
/** @type {string | undefined} */
let namespace;
if (parser.eat('|')) {
// we ignore the namespace when trying to find matching element classes
name = read_identifier(parser);
namespace = name;
name = parser.eat('*') ? '*' : read_identifier(parser);
}
relative_selector.selectors.push({
type: 'TypeSelector',
name,
...(namespace !== undefined && { namespace }),
start,
end: parser.index
});
}
const index = parser.index;
allow_comment_or_whitespace(parser);
allow_comment_or_whitespace(parser, false);
if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) {
// rewind, so we know whether to continue building the selector list
@ -449,7 +464,7 @@ function read_block_item(parser) {
// read ahead to understand whether we're dealing with a declaration or a nested rule.
// this involves some duplicated work, but avoids a try-catch that would disguise errors
const start = parser.index;
read_value(parser);
read_value(parser, false);
const char = parser.template[parser.index];
parser.index = start;
@ -492,10 +507,13 @@ function read_declaration(parser) {
/**
* @param {Parser} parser
* @param {boolean} [capture_comments]
* @returns {string}
*/
function read_value(parser) {
function read_value(parser, capture_comments = true) {
let value = '';
/** @type {AST.CSS.CSSComment[]} */
const value_comments = [];
let escaped = false;
let in_url = false;
@ -523,6 +541,13 @@ function read_value(parser) {
} else if (char === '(' && value.slice(-3) === 'url') {
in_url = true;
} else if ((char === ';' || char === '{' || char === '}') && !in_url && !quote_mark) {
const leading_whitespace = value.length - value.trimStart().length;
for (const comment of value_comments) {
comment.position = Math.max(
0,
/** @type {number} */ (comment.position) - leading_whitespace
);
}
return value.trim();
} else if (
char === '/' &&
@ -530,13 +555,11 @@ function read_value(parser) {
!quote_mark &&
parser.template[parser.index + 1] === '*'
) {
parser.index += 2;
while (parser.index < parser.template.length) {
if (parser.template[parser.index] === '*' && parser.template[parser.index + 1] === '/') {
parser.index += 2;
break;
}
parser.index++;
const comment = read_comment(parser);
if (capture_comments) {
comment.position = value.length;
parser.css_comments.push(comment);
value_comments.push(comment);
}
continue;
}
@ -600,7 +623,8 @@ function read_identifier(parser) {
if (char === '\\') {
const sequence = parser.match_regex(REGEX_UNICODE_SEQUENCE);
if (sequence) {
identifier += String.fromCodePoint(parseInt(sequence.slice(1), 16));
const character = String.fromCodePoint(parseInt(sequence.slice(1), 16));
identifier += character === '\\' ? '\\\\' : character;
parser.index += sequence.length;
} else {
identifier += '\\' + parser.template[parser.index + 1];
@ -624,13 +648,16 @@ function read_identifier(parser) {
return identifier;
}
/** @param {Parser} parser */
function allow_comment_or_whitespace(parser) {
/**
* @param {Parser} parser
* @param {boolean} [capture_comments]
*/
function allow_comment_or_whitespace(parser, capture_comments = true) {
parser.allow_whitespace();
while (parser.match('/*') || parser.match('<!--')) {
if (parser.eat('/*')) {
parser.read_until(REGEX_COMMENT_CLOSE);
parser.eat('*/', true);
if (parser.match('/*')) {
const comment = read_comment(parser);
if (capture_comments) parser.css_comments.push(comment);
}
if (parser.eat('<!--')) {
@ -642,6 +669,25 @@ function allow_comment_or_whitespace(parser) {
}
}
/**
* @param {Parser} parser
* @returns {AST.CSS.CSSComment}
*/
function read_comment(parser) {
const start = parser.index;
parser.eat('/*', true);
const value = parser.read_until(REGEX_COMMENT_CLOSE);
parser.eat('*/', true);
const end = parser.index;
return {
type: 'CSSComment',
value,
start,
end
};
}
/**
* Parse standalone CSS content (not wrapped in `<style>`).
* @param {Parser} parser

@ -312,6 +312,9 @@ const css_visitors = {
}
context.next();
},
PseudoElementSelector() {
// Don't analyse these
}
};

@ -122,6 +122,13 @@ const any_selector = {
*/
const seen = new Set();
/**
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement | Compiler.AST.RenderTag | Compiler.AST.Component | Compiler.AST.SvelteComponent | Compiler.AST.SvelteSelf} node
*/
function is_inside_svelte_head(node) {
return node.metadata.path.some((ancestor) => ancestor.type === 'SvelteHead');
}
/**
*
* @param {Compiler.AST.CSS.StyleSheet} stylesheet
@ -143,6 +150,9 @@ export function prune(stylesheet, elements) {
seen.clear();
if (
// Elements rendered through <svelte:head> are not style-scopable.
// Prevent css hash injection (class="s-...") on tags like <meta>, <link>, <script>.
!is_inside_svelte_head(element) &&
apply_selector(
selectors,
/** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule),

@ -23,6 +23,9 @@ const visitors = {
context.next();
}
},
PseudoElementSelector() {
// Don't analyse these
},
ComplexSelector(node, context) {
if (
!node.metadata.used &&

@ -958,7 +958,7 @@ function calculate_blockers(instance, analysis) {
* @param {Set<Binding>} touched
* @param {Set<ESTree.Node>} seen
*/
const touch = (expression, scope, touched, seen = new Set()) => {
const touch = (expression, scope, touched, seen) => {
if (seen.has(expression)) return;
seen.add(expression);
@ -1015,6 +1015,13 @@ function calculate_blockers(instance, analysis) {
}
}
// Share seen nodes across calls so transitive assignments are only visited once.
// Keep separate read/write state because the target sets can differ.
/** @type {Set<ESTree.Node>} */
const writes_seen = new Set();
/** @type {Set<ESTree.Node>} */
const reads_seen = new Set();
walk(
node,
{ scope },
@ -1044,13 +1051,7 @@ function calculate_blockers(instance, analysis) {
const rune = get_rune(node, context.state.scope);
if (rune === '$effect') return;
/** @type {Set<Binding>} */
const touched = new Set();
touch(node, context.state.scope, touched);
for (const b of touched) {
writes.add(b);
}
touch(node, context.state.scope, writes, writes_seen);
},
Identifier(node, context) {
const parent = /** @type {ESTree.Node} */ (context.path.at(-1));
@ -1066,7 +1067,7 @@ function calculate_blockers(instance, analysis) {
// might be called immediately, so we have to touch all references within it. Example:
// function foo() { return () => blocker; } foo(); // blocker is touched
if (node.argument) {
touch(node.argument, context.state.scope, reads);
touch(node.argument, context.state.scope, reads, reads_seen);
}
},
// don't look inside functions until they are called
@ -1221,8 +1222,29 @@ function calculate_blockers(instance, analysis) {
}
}
// With no top-level await, no binding can have a blocker and function tracing
// cannot affect the output.
if (!awaited) return;
flush_sync_group();
// a store subscription must wait on whatever blocks the store itself; this must happen
// before function tracing so that functions reading `$store` inherit the blocker
for (const [name, binding] of instance.scope.declarations) {
if (binding.kind !== 'store_sub') continue;
const store_blocker = instance.scope.get(name.slice(1))?.blocker;
if (!store_blocker) continue;
if (
!binding.blocker ||
/** @type {ESTree.SimpleLiteral & { value: number }} */ (binding.blocker.property).value <
/** @type {ESTree.SimpleLiteral & { value: number }} */ (store_blocker.property).value
) {
binding.blocker = store_blocker;
}
}
for (const fn of functions) {
/** @type {Set<Binding>} */
const reads_writes = new Set();
@ -1231,12 +1253,15 @@ function calculate_blockers(instance, analysis) {
? /** @type {ESTree.FunctionExpression | ESTree.ArrowFunctionExpression} */ (fn.init)
: fn;
trace_references(
init.body,
reads_writes,
reads_writes,
/** @type {Scope} */ (instance.scopes.get(init))
);
const fn_scope = /** @type {Scope} */ (instance.scopes.get(init));
if (init.body.type === 'BlockStatement') {
trace_references(init.body, reads_writes, reads_writes, fn_scope);
} else {
// A concise arrow body is an implicit return, so treat it like the
// `ReturnStatement` visitor in `trace_references` would.
touch(init.body, fn_scope, reads_writes, new Set());
}
const max = [...reads_writes].reduce((max, binding) => {
if (binding.blocker) {
@ -1310,7 +1335,8 @@ function order_reactive_statements(unsorted_reactive_declarations) {
* @returns
*/
const add_declaration = (node, declaration) => {
if ([...reactive_declarations.values()].includes(declaration)) return;
// Visited set: each ReactiveStatement is stored under exactly one LabeledStatement node
if (reactive_declarations.has(node)) return;
for (const binding of declaration.dependencies) {
if (declaration.assignments.has(binding)) continue;

@ -3,6 +3,7 @@
import * as e from '../../../errors.js';
import { is_event_attribute } from '../../../utils/ast.js';
import { disallow_children } from './shared/special-element.js';
import { check_global_event_reference } from './shared/utils.js';
/**
* @param {AST.SvelteBody} node
@ -11,10 +12,9 @@ import { disallow_children } from './shared/special-element.js';
export function SvelteBody(node, context) {
disallow_children(node);
for (const attribute of node.attributes) {
if (
attribute.type === 'SpreadAttribute' ||
(attribute.type === 'Attribute' && !is_event_attribute(attribute))
) {
if (attribute.type === 'Attribute' && is_event_attribute(attribute)) {
check_global_event_reference(attribute, context);
} else if (attribute.type === 'SpreadAttribute' || attribute.type === 'Attribute') {
e.svelte_body_illegal_attribute(attribute);
}
}

@ -3,6 +3,7 @@
import { disallow_children } from './shared/special-element.js';
import * as e from '../../../errors.js';
import { is_event_attribute } from '../../../utils/ast.js';
import { check_global_event_reference } from './shared/utils.js';
/**
* @param {AST.SvelteDocument} node
@ -12,10 +13,9 @@ export function SvelteDocument(node, context) {
disallow_children(node);
for (const attribute of node.attributes) {
if (
attribute.type === 'SpreadAttribute' ||
(attribute.type === 'Attribute' && !is_event_attribute(attribute))
) {
if (attribute.type === 'Attribute' && is_event_attribute(attribute)) {
check_global_event_reference(attribute, context);
} else if (attribute.type === 'SpreadAttribute' || attribute.type === 'Attribute') {
e.illegal_element_attribute(attribute, 'svelte:document');
}
}

@ -3,6 +3,7 @@
import { disallow_children } from './shared/special-element.js';
import * as e from '../../../errors.js';
import { is_event_attribute } from '../../../utils/ast.js';
import { check_global_event_reference } from './shared/utils.js';
/**
* @param {AST.SvelteWindow} node
@ -12,10 +13,9 @@ export function SvelteWindow(node, context) {
disallow_children(node);
for (const attribute of node.attributes) {
if (
attribute.type === 'SpreadAttribute' ||
(attribute.type === 'Attribute' && !is_event_attribute(attribute))
) {
if (attribute.type === 'Attribute' && is_event_attribute(attribute)) {
check_global_event_reference(attribute, context);
} else if (attribute.type === 'SpreadAttribute' || attribute.type === 'Attribute') {
e.illegal_element_attribute(attribute, 'svelte:window');
}
}

@ -9,6 +9,7 @@ import {
validate_attribute_name,
validate_slot_attribute
} from './attribute.js';
import { check_global_event_reference } from './utils.js';
const EVENT_MODIFIERS = [
'preventDefault',
@ -64,14 +65,7 @@ export function validate_element(node, context) {
e.attribute_invalid_event_handler(attribute);
}
const value = get_attribute_expression(attribute);
if (
value.type === 'Identifier' &&
value.name === attribute.name &&
!context.state.scope.get(value.name)
) {
w.attribute_global_event_reference(attribute, attribute.name);
}
check_global_event_reference(attribute, context);
}
if (attribute.name === 'slot') {

@ -4,7 +4,11 @@
/** @import { Scope } from '../../../scope' */
/** @import { NodeLike } from '../../../../errors.js' */
import * as e from '../../../../errors.js';
import { extract_identifiers, get_parent } from '../../../../utils/ast.js';
import {
extract_identifiers,
get_attribute_expression,
get_parent
} from '../../../../utils/ast.js';
import * as w from '../../../../warnings.js';
import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js';
@ -298,3 +302,21 @@ export function validate_export(node, scope, name) {
e.state_invalid_export(node);
}
}
/**
* Warns when an event attribute uses the shorthand form (`{onclick}`) but the
* referenced name isn't declared, so it silently resolves to the global handler.
* @param {AST.Attribute & { value: [AST.ExpressionTag] | AST.ExpressionTag }} attribute
* @param {Context} context
*/
export function check_global_event_reference(attribute, context) {
const value = get_attribute_expression(attribute);
if (
value.type === 'Identifier' &&
value.name === attribute.name &&
!context.state.scope.get(value.name)
) {
w.attribute_global_event_reference(attribute, attribute.name);
}
}

@ -247,11 +247,11 @@ export function client_component(analysis, options) {
}
for (const [node] of analysis.reactive_statements) {
const statement = [...state.legacy_reactive_statements].find(([n]) => n === node);
const statement = state.legacy_reactive_statements.get(node);
if (statement === undefined) {
throw new Error('Could not find reactive statement');
}
instance.body.push(statement[1]);
instance.body.push(statement);
}
if (analysis.reactive_statements.size > 0) {

@ -1,4 +1,4 @@
/** @import { AssignmentExpression, AssignmentOperator, Expression, Identifier, Pattern } from 'estree' */
/** @import { AssignmentExpression, AssignmentOperator, Expression, Identifier, LogicalOperator, Pattern } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types.js' */
import * as b from '#compiler/builders';
@ -79,8 +79,13 @@ function build_assignment(operator, left, right, context) {
// special case — assignment to private state field
if (left.property.type === 'PrivateIdentifier') {
const logical_operator = ['||=', '&&=', '??='].includes(operator)
? /** @type {LogicalOperator} */ (operator.slice(0, -1))
: null;
let value = /** @type {Expression} */ (
context.visit(build_assignment_value(operator, left, right))
context.visit(
logical_operator === null ? build_assignment_value(operator, left, right) : right
)
);
const needs_proxy =
@ -88,7 +93,15 @@ function build_assignment(operator, left, right, context) {
is_non_coercive_operator(operator) &&
should_proxy(value, context.state.scope);
return b.call('$.set', left, value, needs_proxy && b.true);
const assignment = b.call('$.set', left, value, needs_proxy && b.true);
return logical_operator === null
? assignment
: b.logical(
logical_operator,
/** @type {Expression} */ (context.visit(left)),
assignment
);
}
}
}

@ -45,15 +45,26 @@ export function ConstTag(node, context) {
transform
});
// TODO optimise the simple `{ x } = y` case — we can just return `y`
// instead of destructuring it only to return a new object
const is_simple_object_pattern =
declaration.id.type === 'ObjectPattern' &&
declaration.id.properties.every(
(p) =>
p.type === 'Property' &&
!p.computed &&
p.key.type === 'Identifier' &&
p.value.type === 'Identifier' &&
p.key.name === p.value.name
);
const init = build_expression(
{ ...context, state: child_state },
declaration.init,
node.metadata.expression
);
const block = b.block([
const block = is_simple_object_pattern
? b.block([b.return(init)])
: b.block([
b.const(/** @type {Pattern} */ (context.visit(declaration.id, child_state)), init),
b.return(b.object(identifiers.map((node) => b.prop('init', node, node))))
]);

@ -4,7 +4,6 @@
/** @import { Scope } from '../../../scope' */
import {
cannot_be_set_statically,
is_boolean_attribute,
is_dom_property,
is_load_error_element
} from '../../../../../utils.js';
@ -13,7 +12,6 @@ import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js'
import * as b from '#compiler/builders';
import {
create_attribute,
ExpressionMetadata,
is_custom_element_node,
is_customizable_select_element
} from '../../../nodes.js';
@ -528,18 +526,12 @@ export function build_class_directives_object(
) {
let properties = [];
const metadata = new ExpressionMetadata();
for (const d of class_directives) {
metadata.merge(d.metadata.expression);
const expression = /** @type Expression */ (context.visit(d.expression));
properties.push(b.init(d.name, expression));
properties.push(b.init(d.name, memoizer.add(expression, d.metadata.expression)));
}
const directives = b.object(properties);
return memoizer.add(directives, metadata);
return b.object(properties);
}
/**
@ -555,23 +547,17 @@ export function build_style_directives_object(
const normal = b.object([]);
const important = b.object([]);
const metadata = new ExpressionMetadata();
for (const d of style_directives) {
metadata.merge(d.metadata.expression);
const expression =
d.value === true
? build_getter(b.id(d.name), context.state)
: build_attribute_value(d.value, context).value;
const object = d.modifiers.includes('important') ? important : normal;
object.properties.push(b.init(d.name, expression));
object.properties.push(b.init(d.name, memoizer.add(expression, d.metadata.expression)));
}
const directives = important.properties.length ? b.array([normal, important]) : normal;
return memoizer.add(directives, metadata);
return important.properties.length ? b.array([normal, important]) : normal;
}
/**
@ -664,14 +650,25 @@ function build_element_attribute_update(element, node_id, name, value, attribute
* @param {ComponentContext} context
*/
function build_custom_element_attribute_update_assignment(node_id, attribute, context) {
const { value, has_state } = build_attribute_value(attribute.value, context);
const memoizer = new Memoizer();
const { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) =>
memoizer.add(value, metadata)
);
// don't lowercase name, as we set the element's property, which might be case sensitive
const call = b.call('$.set_custom_element_data', node_id, b.literal(attribute.name), value);
// this is different from other updates — it doesn't get grouped,
// because set_custom_element_data may not be idempotent
const update = has_state ? b.call('$.template_effect', b.thunk(call)) : call;
const update = has_state
? b.call(
'$.template_effect',
b.arrow(memoizer.apply(), call),
memoizer.sync_values(),
memoizer.async_values(),
memoizer.blockers()
)
: call;
context.state.init.push(b.stmt(update));
}

@ -13,7 +13,6 @@ export function UpdateExpression(node, context) {
if (
argument.type === 'MemberExpression' &&
argument.object.type === 'ThisExpression' &&
argument.property.type === 'PrivateIdentifier' &&
context.state.state_fields.has('#' + argument.property.name)
) {

@ -355,7 +355,11 @@ const visitors = {
continue;
}
if (selector.type === 'TypeSelector' && selector.name === '*') {
if (
selector.type === 'TypeSelector' &&
selector.name === '*' &&
selector.namespace === undefined
) {
context.state.code.update(selector.start, selector.end, modifier);
} else {
context.state.code.appendLeft(selector.end, modifier);
@ -374,6 +378,9 @@ const visitors = {
if (node.name === 'is' || node.name === 'where' || node.name === 'has' || node.name === 'not') {
context.next();
}
},
PseudoElementSelector() {
// Functional pseudo-element arguments are not scoped as selectors.
}
};

@ -148,7 +148,7 @@ export function server_component(analysis, options) {
const legacy_reactive_declarations = [];
for (const [node] of analysis.reactive_statements) {
const statement = [...state.legacy_reactive_statements].find(([n]) => n === node);
const statement = state.legacy_reactive_statements.get(node);
if (statement === undefined) {
throw new Error('Could not find reactive statement');
}
@ -165,7 +165,7 @@ export function server_component(analysis, options) {
}
}
instance.body.push(statement[1]);
instance.body.push(statement);
}
if (legacy_reactive_declarations.length > 0) {
@ -210,14 +210,25 @@ export function server_component(analysis, options) {
];
}
if (
[...analysis.instance.scope.declarations.values()].some(
const store_subs = [...analysis.instance.scope.declarations.values()].filter(
(binding) => binding.kind === 'store_sub'
)
) {
);
// a blocked subscription is only created once its promise resolves, so its teardown must wait until the render is done
const defer_store_teardown = store_subs.some((binding) => binding.blocker);
if (store_subs.length > 0) {
instance.body.unshift(b.var('$$store_subs'));
const unsubscribe = b.if(
b.id('$$store_subs'),
b.stmt(b.call('$.unsubscribe_stores', b.id('$$store_subs')))
);
template.body.push(
b.if(b.id('$$store_subs'), b.stmt(b.call('$.unsubscribe_stores', b.id('$$store_subs'))))
defer_store_teardown
? b.stmt(b.call('$$renderer.on_destroy', b.arrow([], b.block([unsubscribe]))))
: unsubscribe
);
}
@ -257,7 +268,7 @@ export function server_component(analysis, options) {
);
}
let should_inject_context = dev || analysis.needs_context;
let should_inject_context = dev || analysis.needs_context || defer_store_teardown;
if (should_inject_context) {
component_block = b.block([

@ -8,6 +8,7 @@ import * as b from '#compiler/builders';
*/
export function LabeledStatement(node, context) {
if (context.state.analysis.runes || context.path.length > 1 || node.label.name !== '$') {
context.next();
return;
}

@ -1,4 +1,4 @@
/** @import { BlockStatement } from 'estree' */
/** @import { BlockStatement, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
@ -84,6 +84,9 @@ export function SvelteBoundary(node, context) {
}
const props = b.object([]);
/** @type {Statement[]} */
const init = [];
if (failed_attribute && !failed_snippet) {
const failed_callee = build_attribute_value(
failed_attribute.value,
@ -95,13 +98,15 @@ export function SvelteBoundary(node, context) {
props.properties.push(b.init('failed', failed_callee));
} else if (failed_snippet) {
context.visit(failed_snippet, context.state);
context.visit(failed_snippet, { ...context.state, init });
props.properties.push(b.init('failed', failed_snippet.expression));
}
context.state.template.push(
b.stmt(b.call('$$renderer.boundary', props, b.arrow([b.id('$$renderer')], children_body)))
const boundary = b.stmt(
b.call('$$renderer.boundary', props, b.arrow([b.id('$$renderer')], children_body))
);
context.state.template.push(init.length > 0 ? b.block([...init, boundary]) : boundary);
}
/**

@ -102,9 +102,11 @@ export function VariableDeclaration(node, context) {
} else {
const call = /** @type {CallExpression} */ (declarator.init);
let rhs = value;
// - cannot be a SpreadElement because refused during analysis
// - use args[0] rather than value to avoid visiting twice (above in const value = ... and below in for-ofs)
let rhs = /** @type {Expression} */ (call.arguments[0]);
if (rune !== '$derived' || call.arguments[0].type !== 'Identifier') {
if (rune === '$derived.by' || call.arguments[0].type !== 'Identifier') {
const id = b.id(context.state.scope.generate('$$d'));
rhs = b.call(id);

@ -3,7 +3,7 @@
/** @import { ComponentContext, ComponentServerTransformState } from '../../types.js' */
import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js';
import { binding_properties } from '../../../../bindings.js';
import { create_attribute, ExpressionMetadata, is_custom_element_node } from '../../../../nodes.js';
import { ExpressionMetadata, is_custom_element_node } from '../../../../nodes.js';
import { regex_starts_with_newline } from '../../../../patterns.js';
import * as b from '#compiler/builders';
import {
@ -21,6 +21,11 @@ import { escape_html } from '../../../../../../escaping.js';
const WHITESPACE_INSENSITIVE_ATTRIBUTES = ['class', 'style'];
/**
* @typedef {{ type: 'transformed', name: string, expression: Expression }} TransformedAttribute
* An attribute whose expression has already been transformed and must not be visited again.
*/
/**
* Writes the output to the template output. Some elements may have attributes on them that require the
* their output to be the child content instead. In this case, an object is returned.
@ -29,7 +34,7 @@ const WHITESPACE_INSENSITIVE_ATTRIBUTES = ['class', 'style'];
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/
export function build_element_attributes(node, context, transform) {
/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */
/** @type {Array<AST.Attribute | AST.SpreadAttribute | TransformedAttribute>} */
const attributes = [];
/** @type {AST.ClassDirective[]} */
@ -145,42 +150,26 @@ export function build_element_attributes(node, context, transform) {
attr.value[0].data === 'checkbox'
);
attributes.push(
create_attribute('checked', null, -1, -1, [
{
type: 'ExpressionTag',
start: -1,
end: -1,
attributes.push({
type: 'transformed',
name: 'checked',
expression: is_checkbox
? b.call(
b.member(attribute.expression, 'includes'),
b.member(expression, 'includes'),
build_attribute_value(value_attribute.value, context, transform)
)
: b.binary(
'===',
attribute.expression,
expression,
build_attribute_value(value_attribute.value, context, transform)
),
metadata: {
expression: new ExpressionMetadata()
}
}
])
);
)
});
} else {
attributes.push(
create_attribute(attribute.name, null, -1, -1, [
{
type: 'ExpressionTag',
start: -1,
end: -1,
expression,
metadata: {
expression: new ExpressionMetadata()
}
}
])
);
attributes.push({
type: 'transformed',
name: get_attribute_name(node, attribute),
expression
});
}
} else if (attribute.type === 'SpreadAttribute') {
attributes.push(attribute);
@ -217,7 +206,21 @@ export function build_element_attributes(node, context, transform) {
} else {
const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null;
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
for (const attribute of /** @type {Array<AST.Attribute | TransformedAttribute>} */ (
attributes
)) {
if (attribute.type === 'transformed') {
context.state.template.push(
b.call(
'$.attr',
b.literal(attribute.name),
attribute.expression,
is_boolean_attribute(attribute.name) && b.true
)
);
continue;
}
const name = get_attribute_name(node, attribute);
const can_use_literal =
(name !== 'class' || class_directives.length === 0) &&
@ -298,14 +301,16 @@ function get_attribute_name(element, attribute) {
/**
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective>} attributes
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective | TransformedAttribute>} attributes
* @param {ComponentContext} context
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/
export function build_spread_object(element, attributes, context, transform) {
const object = b.object(
attributes.map((attribute) => {
if (attribute.type === 'Attribute') {
if (attribute.type === 'transformed') {
return b.prop('init', b.key(attribute.name), attribute.expression);
} else if (attribute.type === 'Attribute') {
const name = get_attribute_name(element, attribute);
const value = build_attribute_value(
attribute.value,
@ -340,7 +345,7 @@ export function build_spread_object(element, attributes, context, transform) {
/**
*
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes
* @param {Array<AST.Attribute | AST.SpreadAttribute | TransformedAttribute>} attributes
* @param {AST.StyleDirective[]} style_directives
* @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context
@ -356,7 +361,7 @@ function build_element_spread_attributes(
) {
const args = prepare_element_spread(
element,
/** @type {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective>} */ (attributes),
attributes,
style_directives,
class_directives,
context,
@ -410,7 +415,7 @@ export function prepare_element_spread_object(element, context, transform) {
/**
* Prepare args for $.attributes(...): compute object, css_hash, classes, styles and flags.
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective>} attributes
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective | TransformedAttribute>} attributes
* @param {AST.StyleDirective[]} style_directives
* @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context

@ -286,7 +286,7 @@ export function build_getter(node, state) {
}
if (binding.kind === 'derived') {
return (binding.declaration_kind === 'var' ? b.maybe_call : b.call)(binding.node);
return (binding.declaration_kind === 'var' ? b.maybe_call : b.call)(node);
}
return node;

@ -7,6 +7,68 @@ import { is_void } from '../../utils.js';
/** Threshold for when content should be formatted on separate lines */
const LINE_BREAK_THRESHOLD = 50;
/** Characters that are valid in a CSS identifier without escaping */
const REGEX_IDENTIFIER_CHAR = /^[a-zA-Z0-9_-]$/;
/** Hex digits — a backslash followed by one of these is read as a hex escape */
const REGEX_HEX_DIGIT = /[0-9a-fA-F]/;
/**
* Re-escape a CSS identifier name so that it prints as valid CSS.
*
* `parse` decodes CSS escape sequences when building the AST `\31` becomes `1`,
* `\a` becomes a newline but keeps single-character escapes such as `\.` and
* escaped backslashes intact. When printing we therefore only need to escape the
* characters that would be illegal in a bare identifier: a leading digit, `-`
* followed by a digit, whitespace and control characters, and anything else that
* is not already escaped.
* @param {string} name
*/
function escape_identifier(name) {
let escaped = '';
let i = 0;
while (i < name.length) {
const char = name[i];
if (char === '\\') {
const next = name.charAt(i + 1);
if (next === '' || REGEX_HEX_DIGIT.test(next)) {
// A literal backslash in a name must itself be escaped: `\5c `
// re-parses to a backslash, whereas a backslash followed by a hex
// digit (or by nothing) would be read back as a hex escape.
escaped += '\\5c ';
i += 1;
continue;
}
// Already escaped — copy the backslash and the escaped character as-is.
escaped += '\\' + next;
i += 2;
continue;
}
const code = /** @type {number} */ (char.codePointAt(0));
const is_leading_digit = i === 0 && char >= '0' && char <= '9';
const is_leading_hyphen_digit =
i === 0 && char === '-' && name.charAt(i + 1) >= '0' && name.charAt(i + 1) <= '9';
if (
is_leading_digit ||
is_leading_hyphen_digit ||
!(REGEX_IDENTIFIER_CHAR.test(char) || code >= 160)
) {
escaped += `\\${code.toString(16)} `;
} else {
escaped += char;
}
i += 1;
}
return escaped;
}
/**
* `print` converts a Svelte AST node back into Svelte source code.
* It is primarily intended for tools that parse and transform components using the compilers modern AST representation.
@ -19,6 +81,10 @@ const LINE_BREAK_THRESHOLD = 50;
*/
export function print(ast, options = undefined) {
const comments = (ast.type === 'Root' && ast.comments) || [];
const state = { preserve_whitespace: 0 };
const css_comments =
(ast.type === 'Root' ? ast.css?.comments : ast.type === 'StyleSheet' ? ast.comments : null) ||
[];
return esrap.print(
ast,
@ -28,18 +94,27 @@ export function print(ast, options = undefined) {
getLeadingComments: options?.getLeadingComments,
getTrailingComments: options?.getTrailingComments
}),
...svelte_visitors(comments),
...css_visitors
})
...svelte_visitors(comments, state),
...css_visitors(css_comments, comments)
}),
{
indent: options?.indent
}
);
}
/**
* @param {Context} context
* @param {AST.SvelteNode} node
* @param {boolean} preserve_whitespace
* @param {boolean} allow_inline
*/
function block(context, node, allow_inline = false) {
function block(context, node, preserve_whitespace = false, allow_inline = false) {
if (preserve_whitespace) {
context.visit(node);
return;
}
const child_context = context.new();
child_context.visit(node);
@ -79,6 +154,7 @@ function attributes(node, attributes, context, comments) {
}
const separator = context.new();
let previous_attribute_end = node.start;
const children = attributes.map((attribute) => {
const child_context = context.new();
@ -87,6 +163,9 @@ function attributes(node, attributes, context, comments) {
const comment = comments[comment_index];
if (comment.start < attribute.start) {
// Inside a previous attribute's value can be comments which don't
// advance comment_index, therefore this additional check
if (comment.start >= previous_attribute_end) {
if (comment.type === 'Line') {
child_context.write('//' + comment.value);
child_context.newline();
@ -94,6 +173,7 @@ function attributes(node, attributes, context, comments) {
child_context.write('/*' + comment.value + '*/'); // TODO match indentation?
child_context.append(separator);
}
}
comment_index += 1;
} else {
@ -102,6 +182,7 @@ function attributes(node, attributes, context, comments) {
}
child_context.visit(attribute);
previous_attribute_end = attribute.end;
length += child_context.measure() + 1;
@ -134,8 +215,9 @@ function attributes(node, attributes, context, comments) {
* @param {AST.BaseElement} node
* @param {Context} context
* @param {AST.JSComment[]} comments
* @param {{ preserve_whitespace: number }} state
*/
function base_element(node, context, comments) {
function base_element(node, context, comments, state) {
const child_context = context.new();
child_context.write('<' + node.name);
@ -161,18 +243,156 @@ function base_element(node, context, comments) {
child_context.write(`${multiline_attributes ? '' : ' '}/>`);
} else {
child_context.write('>');
block(child_context, node.fragment, true);
block(child_context, node.fragment, state.preserve_whitespace > 0, true);
child_context.write(`</${node.name}>`);
}
context.append(child_context);
}
/** @type {Visitors<AST.SvelteNode>} */
const css_visitors = {
/**
* @param {AST.BaseElement} node
* @param {Context} context
* @param {AST.JSComment[]} comments
* @param {{ preserve_whitespace: number }} state
*/
function print_element(node, context, comments, state) {
const name = node.name.toLowerCase();
const preserve =
(node.type === 'RegularElement' || node.type === 'TitleElement') &&
(name === 'pre' || name === 'textarea' || name === 'title');
if (preserve) state.preserve_whitespace += 1;
base_element(node, context, comments, state);
if (preserve) state.preserve_whitespace -= 1;
}
/**
* @param {AST.CSS.CSSComment[]} comments
* @param {AST.JSComment[]} js_comments
* @returns {Visitors<AST.SvelteNode>}
*/
function css_visitors(comments, js_comments) {
let comment_index = 0;
/** @param {number} end */
const has_comment_before = (end) => comments[comment_index]?.start < end;
/**
* @param {Context} context
* @param {AST.CSS.CSSComment} comment
*/
function write_comment(context, comment) {
context.write(`/*${comment.value}*/`);
}
/**
* @param {Context} context
* @param {number} end
*/
function write_inline_comments(context, end) {
let written = false;
while (has_comment_before(end)) {
if (written) context.write(' ');
write_comment(context, comments[comment_index++]);
written = true;
}
return written;
}
/**
* @param {Context} context
* @param {string} value
* @param {number} end
*/
function write_value(context, value, end) {
let offset = 0;
while (has_comment_before(end)) {
const comment = comments[comment_index++];
const position = Math.max(offset, Math.min(comment.position ?? 0, value.length));
context.write(value.slice(offset, position));
write_comment(context, comment);
offset = position;
}
context.write(value.slice(offset));
}
/**
* @param {Context} context
* @param {Array<AST.CSS.Rule | AST.CSS.Atrule | AST.CSS.Declaration>} children
* @param {number} end
* @param {boolean} margins
*/
function print_children(context, children, end, margins) {
let started = false;
const separate = () => {
if (!started) return;
if (margins) context.margin();
context.newline();
};
for (const child of children) {
while (has_comment_before(child.start)) {
separate();
write_comment(context, comments[comment_index++]);
started = true;
}
separate();
context.visit(child);
started = true;
}
while (has_comment_before(end)) {
separate();
write_comment(context, comments[comment_index++]);
started = true;
}
}
/**
* @param {AST.CSS.SelectorList} node
* @param {Context} context
* @param {boolean} multiline
*/
function print_selector_list(node, context, multiline) {
let needs_separator = false;
let remaining_selectors = node.children.length;
for (const selector of node.children) {
while (has_comment_before(selector.start)) {
if (needs_separator) context.write(' ');
write_comment(context, comments[comment_index++]);
needs_separator = true;
}
if (needs_separator) {
if (multiline) context.newline();
else context.write(' ');
}
context.visit(selector);
needs_separator = true;
remaining_selectors -= 1;
if (remaining_selectors > 0) context.write(',');
}
}
return {
Atrule(node, context) {
context.write(`@${node.name}`);
if (node.prelude) context.write(` ${node.prelude}`);
context.write(`@${escape_identifier(node.name)}`);
const prelude_end = node.block?.start ?? node.end;
if (node.prelude || has_comment_before(prelude_end)) {
context.write(' ');
write_value(context, node.prelude, prelude_end);
}
if (node.block) {
context.write(' ');
@ -183,13 +403,11 @@ const css_visitors = {
},
AttributeSelector(node, context) {
context.write(`[${node.name}`);
context.write(`[${escape_identifier(node.name)}`);
if (node.matcher) {
context.write(node.matcher);
context.write(`"${node.value}"`);
if (node.flags) {
context.write(` ${node.flags}`);
}
if (node.flags) context.write(` ${node.flags}`);
}
context.write(']');
},
@ -197,22 +415,10 @@ const css_visitors = {
Block(node, context) {
context.write('{');
if (node.children.length > 0) {
if (node.children.length > 0 || has_comment_before(node.end)) {
context.indent();
context.newline();
let started = false;
for (const child of node.children) {
if (started) {
context.newline();
}
context.visit(child);
started = true;
}
print_children(context, node.children, node.end, false);
context.dedent();
context.newline();
}
@ -221,21 +427,21 @@ const css_visitors = {
},
ClassSelector(node, context) {
context.write(`.${node.name}`);
context.write(`.${escape_identifier(node.name)}`);
},
ComplexSelector(node, context) {
for (const selector of node.children) {
context.visit(selector);
}
for (const selector of node.children) context.visit(selector);
},
Declaration(node, context) {
context.write(`${node.property}: ${node.value};`);
context.write(`${node.property}: `);
write_value(context, node.value, node.end);
context.write(';');
},
IdSelector(node, context) {
context.write(`#${node.name}`);
context.write(`#${escape_identifier(node.name)}`);
},
NestingSelector(node, context) {
@ -251,84 +457,84 @@ const css_visitors = {
},
PseudoClassSelector(node, context) {
context.write(`:${node.name}`);
context.write(`:${escape_identifier(node.name)}`);
if (node.args) {
context.write('(');
let started = false;
for (const arg of node.args.children) {
if (started) {
context.write(', ');
}
context.visit(arg);
started = true;
context.visit(node.args);
if (has_comment_before(node.end)) {
context.write(' ');
write_inline_comments(context, node.end);
}
context.write(')');
}
},
PseudoElementSelector(node, context) {
context.write(`::${node.name}`);
context.write(`::${escape_identifier(node.name)}`);
if (node.args) {
context.write('(');
context.visit(node.args);
if (has_comment_before(node.end)) {
context.write(' ');
write_inline_comments(context, node.end);
}
context.write(')');
}
},
RelativeSelector(node, context) {
if (node.combinator) {
if (node.combinator.name === ' ') {
context.write(' ');
} else {
context.write(` ${node.combinator.name} `);
}
if (node.combinator.name === ' ') context.write(' ');
else context.write(` ${node.combinator.name} `);
}
for (const selector of node.selectors) {
context.visit(selector);
}
for (const selector of node.selectors) context.visit(selector);
},
Rule(node, context) {
let started = false;
for (const selector of node.prelude.children) {
if (started) {
context.write(',');
context.newline();
}
context.visit(selector);
started = true;
}
print_selector_list(node.prelude, context, true);
context.write(' ');
if (write_inline_comments(context, node.block.start)) context.write(' ');
context.visit(node.block);
},
SelectorList(node, context) {
let started = false;
for (const selector of node.children) {
if (started) {
context.write(', ');
}
print_selector_list(node, context, false);
},
context.visit(selector);
started = true;
StyleSheet(node, context) {
context.write('<style');
attributes(node, node.attributes, context, js_comments);
context.write('>');
if (node.children.length > 0 || node.comments.length > 0) {
context.indent();
context.newline();
print_children(context, node.children, node.content.end, true);
context.dedent();
context.newline();
}
context.write('</style>');
},
TypeSelector(node, context) {
context.write(node.name);
if (node.namespace !== undefined) {
context.write(node.namespace === '*' ? '*' : escape_identifier(node.namespace));
context.write('|');
}
context.write(node.name === '*' ? node.name : escape_identifier(node.name));
}
};
}
/**
* @param {AST.JSComment[]} comments
* @param {{ preserve_whitespace: number }} state
* @returns {Visitors<AST.SvelteNode>}
*/
const svelte_visitors = (comments) => ({
const svelte_visitors = (comments, state) => ({
Root(node, context) {
if (node.options) {
context.write('<svelte:options');
@ -360,20 +566,40 @@ const svelte_visitors = (comments) => ({
context.write('<script');
attributes(node, node.attributes, context, comments);
context.write('>');
block(context, node.content);
block(context, node.content, state.preserve_whitespace > 0);
context.write('</script>');
},
Fragment(node, context) {
/** @type {AST.SvelteNode[][]} */
if (state.preserve_whitespace > 0) {
for (const child of node.nodes) {
context.visit(child);
context.multiline ||= child.type === 'Text' && /[\r\n]/.test(child.data);
}
return;
}
const first = node.nodes[0];
const last = node.nodes.at(-1);
const has_surrounding_whitespace =
first?.type === 'Text' &&
/^\s/.test(first.data) &&
last?.type === 'Text' &&
/\s$/.test(last.data);
/** @type {{ nodes: AST.SvelteNode[]; leading_whitespace: boolean }[]} */
const items = [];
/** @type {AST.SvelteNode[]} */
let sequence = [];
let leading_whitespace = false;
const flush = () => {
items.push(sequence);
if (sequence.length > 0) {
items.push({ nodes: sequence, leading_whitespace });
sequence = [];
leading_whitespace = false;
}
};
for (let i = 0; i < node.nodes.length; i += 1) {
@ -402,6 +628,7 @@ const svelte_visitors = (comments) => ({
if (child_node.data.startsWith(' ') && prev && prev.type !== 'ExpressionTag') {
flush();
leading_whitespace = true;
child_node.data = child_node.data.trimStart();
}
@ -440,35 +667,39 @@ const svelte_visitors = (comments) => ({
let multiline = false;
let width = 0;
const child_contexts = items
.filter((x) => x.length > 0)
.map((sequence) => {
const child_contexts = items.map(({ nodes, leading_whitespace }) => {
const child_context = context.new();
for (const node of sequence) {
for (const node of nodes) {
child_context.visit(node);
multiline ||= child_context.multiline;
}
width += child_context.measure();
width += child_context.measure() + (leading_whitespace ? 1 : 0);
return child_context;
return { context: child_context, leading_whitespace };
});
multiline ||= width > LINE_BREAK_THRESHOLD;
// Normally context.newline() also makes context.multiline true, but the below loop only
// does that if we have more than one child context. If there's one long text block inside
// with whitespace at the edges we wanna split that up, too.
context.multiline ||= has_surrounding_whitespace && width > LINE_BREAK_THRESHOLD * 2;
for (let i = 0; i < child_contexts.length; i += 1) {
const prev = child_contexts[i];
const next = child_contexts[i + 1];
context.append(prev);
context.append(prev.context);
if (next) {
if (prev.multiline || next.multiline) {
if (prev.context.multiline || next.context.multiline) {
context.margin();
context.newline();
} else if (multiline) {
context.newline();
} else if (next.leading_whitespace) {
context.write(' ');
}
}
}
@ -522,7 +753,7 @@ const svelte_visitors = (comments) => ({
if (node.pending) {
context.write('}');
block(context, node.pending);
block(context, node.pending, state.preserve_whitespace > 0);
context.write('{:');
} else {
context.write(' ');
@ -533,7 +764,7 @@ const svelte_visitors = (comments) => ({
if (node.value) context.visit(node.value);
context.write('}');
block(context, node.then);
block(context, node.then, state.preserve_whitespace > 0);
if (node.catch) {
context.write('{:');
@ -541,11 +772,11 @@ const svelte_visitors = (comments) => ({
}
if (node.catch) {
context.write(node.value ? 'catch ' : 'catch');
context.write(node.error ? 'catch ' : 'catch');
if (node.error) context.visit(node.error);
context.write('}');
block(context, node.catch);
block(context, node.catch, state.preserve_whitespace > 0);
}
context.write('{/await}');
@ -589,7 +820,7 @@ const svelte_visitors = (comments) => ({
},
Component(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
ConstTag(node, context) {
@ -679,11 +910,11 @@ const svelte_visitors = (comments) => ({
context.write('}');
block(context, node.body);
block(context, node.body, state.preserve_whitespace > 0);
if (node.fallback) {
context.write('{:else}');
block(context, node.fallback);
block(context, node.fallback, state.preserve_whitespace > 0);
}
context.write('{/each}');
@ -707,13 +938,13 @@ const svelte_visitors = (comments) => ({
context.visit(node.test);
context.write('}');
block(context, node.consequent);
block(context, node.consequent, state.preserve_whitespace > 0);
} else {
context.write('{#if ');
context.visit(node.test);
context.write('}');
block(context, node.consequent);
block(context, node.consequent, state.preserve_whitespace > 0);
}
if (node.alternate !== null) {
@ -725,7 +956,7 @@ const svelte_visitors = (comments) => ({
)
) {
context.write('{:else}');
block(context, node.alternate);
block(context, node.alternate, state.preserve_whitespace > 0);
} else {
context.visit(node.alternate);
}
@ -740,7 +971,7 @@ const svelte_visitors = (comments) => ({
context.write('{#key ');
context.visit(node.expression);
context.write('}');
block(context, node.fragment);
block(context, node.fragment, state.preserve_whitespace > 0);
context.write('{/key}');
},
@ -772,7 +1003,7 @@ const svelte_visitors = (comments) => ({
},
RegularElement(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
RenderTag(node, context) {
@ -782,7 +1013,7 @@ const svelte_visitors = (comments) => ({
},
SlotElement(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
SnippetBlock(node, context) {
@ -801,7 +1032,7 @@ const svelte_visitors = (comments) => ({
}
context.write(')}');
block(context, node.body);
block(context, node.body, state.preserve_whitespace > 0);
context.write('{/snippet}');
},
@ -836,40 +1067,12 @@ const svelte_visitors = (comments) => ({
}
},
StyleSheet(node, context) {
context.write('<style');
attributes(node, node.attributes, context, comments);
context.write('>');
if (node.children.length > 0) {
context.indent();
context.newline();
let started = false;
for (const child of node.children) {
if (started) {
context.margin();
context.newline();
}
context.visit(child);
started = true;
}
context.dedent();
context.newline();
}
context.write('</style>');
},
SvelteBody(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
SvelteBoundary(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
SvelteComponent(node, context) {
@ -881,7 +1084,7 @@ const svelte_visitors = (comments) => ({
attributes(node, node.attributes, context, comments);
if (node.fragment && node.fragment.nodes.length > 0) {
context.write('>');
block(context, node.fragment, true);
block(context, node.fragment, state.preserve_whitespace > 0, true);
context.write(`</svelte:component>`);
} else {
context.write(' />');
@ -889,7 +1092,7 @@ const svelte_visitors = (comments) => ({
},
SvelteDocument(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
SvelteElement(node, context) {
@ -902,7 +1105,7 @@ const svelte_visitors = (comments) => ({
if (node.fragment && node.fragment.nodes.length > 0) {
context.write('>');
block(context, node.fragment);
block(context, node.fragment, state.preserve_whitespace > 0);
context.write(`</svelte:element>`);
} else {
context.write(' />');
@ -910,19 +1113,19 @@ const svelte_visitors = (comments) => ({
},
SvelteFragment(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
SvelteHead(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
SvelteSelf(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
SvelteWindow(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
Text(node, context) {
@ -930,7 +1133,7 @@ const svelte_visitors = (comments) => ({
},
TitleElement(node, context) {
base_element(node, context, comments);
print_element(node, context, comments, state);
},
TransitionDirective(node, context) {

@ -4,4 +4,5 @@ import type ts from 'esrap/languages/ts';
export type Options = {
getLeadingComments?: NonNullable<Parameters<typeof ts>[0]>['getLeadingComments'] | undefined;
getTrailingComments?: NonNullable<Parameters<typeof ts>[0]>['getTrailingComments'] | undefined;
indent?: string; // default tab
};

@ -8,6 +8,15 @@ export namespace _CSS {
export interface StyleSheetBase extends BaseNode {
children: Array<Atrule | Rule>;
/** CSS comments in source order */
comments: CSSComment[];
}
export interface CSSComment extends BaseNode {
type: 'CSSComment';
value: string;
/** Character offset in a containing declaration value or at-rule prelude */
position?: number;
}
export interface StyleSheetFile extends StyleSheetBase {
@ -112,6 +121,7 @@ export namespace _CSS {
export interface TypeSelector extends BaseNode {
type: 'TypeSelector';
name: string;
namespace?: string;
}
export interface IdSelector extends BaseNode {
@ -135,6 +145,7 @@ export namespace _CSS {
export interface PseudoElementSelector extends BaseNode {
type: 'PseudoElementSelector';
name: string;
args?: SelectorList;
}
export interface PseudoClassSelector extends BaseNode {

@ -4,6 +4,8 @@ Distributed under MIT License https://github.com/mattdesl/eases/blob/master/LICE
*/
/**
* Returns value as is.
*
* @param {number} t
* @returns {number}
*/
@ -12,6 +14,8 @@ export function linear(t) {
}
/**
* Rebound effect on start and end of value range.
*
* @param {number} t
* @returns {number}
*/
@ -22,6 +26,8 @@ export function backInOut(t) {
}
/**
* Rebound effect on start.
*
* @param {number} t
* @returns {number}
*/
@ -31,6 +37,8 @@ export function backIn(t) {
}
/**
* Rebound effect on end.
*
* @param {number} t
* @returns {number}
*/
@ -40,6 +48,8 @@ export function backOut(t) {
}
/**
* Bounce effect on end.
*
* @param {number} t
* @returns {number}
*/
@ -61,6 +71,8 @@ export function bounceOut(t) {
}
/**
* Bounce effect on start and end.
*
* @param {number} t
* @returns {number}
*/
@ -69,6 +81,8 @@ export function bounceInOut(t) {
}
/**
* Bounce effect on start.
*
* @param {number} t
* @returns {number}
*/
@ -77,6 +91,8 @@ export function bounceIn(t) {
}
/**
* Circular effect, accelerate on start, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -86,6 +102,8 @@ export function circInOut(t) {
}
/**
* Circular effect, accelerate on start.
*
* @param {number} t
* @returns {number}
*/
@ -94,6 +112,8 @@ export function circIn(t) {
}
/**
* Circular effect, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -102,6 +122,8 @@ export function circOut(t) {
}
/**
* Cubic scaling, accelerate on start, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -110,6 +132,8 @@ export function cubicInOut(t) {
}
/**
* Cubic scaling, accelerate on start
*
* @param {number} t
* @returns {number}
*/
@ -118,6 +142,8 @@ export function cubicIn(t) {
}
/**
* Cubic scaling, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -127,6 +153,8 @@ export function cubicOut(t) {
}
/**
* Elastic effect on start and end.
*
* @param {number} t
* @returns {number}
*/
@ -140,6 +168,8 @@ export function elasticInOut(t) {
}
/**
* Elastic effect on start.
*
* @param {number} t
* @returns {number}
*/
@ -148,6 +178,8 @@ export function elasticIn(t) {
}
/**
* Elastic effect on end.
*
* @param {number} t
* @returns {number}
*/
@ -156,6 +188,8 @@ export function elasticOut(t) {
}
/**
* Exponential effect on start and end.
*
* @param {number} t
* @returns {number}
*/
@ -168,6 +202,8 @@ export function expoInOut(t) {
}
/**
* Exponential effect on start.
*
* @param {number} t
* @returns {number}
*/
@ -176,6 +212,8 @@ export function expoIn(t) {
}
/**
* Exponential effect on end.
*
* @param {number} t
* @returns {number}
*/
@ -184,6 +222,8 @@ export function expoOut(t) {
}
/**
* Quadratic scaling, accelerate on start, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -195,6 +235,8 @@ export function quadInOut(t) {
}
/**
* Quadratic scaling, accelerate on start.
*
* @param {number} t
* @returns {number}
*/
@ -203,6 +245,8 @@ export function quadIn(t) {
}
/**
* Quadratic scaling, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -211,6 +255,8 @@ export function quadOut(t) {
}
/**
* Quartic scaling, accelerate on start, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -219,6 +265,8 @@ export function quartInOut(t) {
}
/**
* Quartic scaling, accelerate on start.
*
* @param {number} t
* @returns {number}
*/
@ -227,6 +275,8 @@ export function quartIn(t) {
}
/**
* Quartic scaling, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -235,6 +285,8 @@ export function quartOut(t) {
}
/**
* Quintic scaling, accelerate on start, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -244,6 +296,8 @@ export function quintInOut(t) {
}
/**
* Quintic scaling, accelerate on start.
*
* @param {number} t
* @returns {number}
*/
@ -252,6 +306,8 @@ export function quintIn(t) {
}
/**
* Quintic scaling, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -260,6 +316,8 @@ export function quintOut(t) {
}
/**
* Sinusoidal effect, accelerate on start, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/
@ -268,6 +326,8 @@ export function sineInOut(t) {
}
/**
* Sinusoidal effect, accelerate on start.
*
* @param {number} t
* @returns {number}
*/
@ -278,6 +338,8 @@ export function sineIn(t) {
}
/**
* Sinusoidal effect, decelerate towards end.
*
* @param {number} t
* @returns {number}
*/

@ -15,6 +15,13 @@ export const BLOCK_EFFECT = 1 << 4;
export const BRANCH_EFFECT = 1 << 5;
export const ROOT_EFFECT = 1 << 6;
export const BOUNDARY_EFFECT = 1 << 7;
/**
* Set on the effect that `pause_effect` was called on, i.e. the root of a paused subtree,
* as opposed to its descendants which are merely `INERT`. This allows `resume_effect` on
* an ancestor to skip subtrees that were paused for their own reasons (such as a block
* whose condition is still false) rather than resurrecting them
*/
export const PAUSED = 1 << 8;
/**
* Indicates that a reaction is connected to an effect root either it is an effect,
* or it is a derived that is depended on by at least one effect. If a derived has
@ -60,6 +67,8 @@ export const ASYNC = 1 << 22;
export const ERROR_VALUE = 1 << 23;
export const STATE_SYMBOL = Symbol('$state');
/** Marks component export objects, so that `proxy(...)` leaves them untouched */
export const COMPONENT_SYMBOL = Symbol('component');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
export const PROXY_PATH_SYMBOL = Symbol('proxy path');

@ -5,7 +5,9 @@ import { active_effect, active_reaction } from './runtime.js';
import { create_user_effect } from './reactivity/effects.js';
import { async_mode_flag, legacy_mode_flag } from '../flags/index.js';
import { FILENAME } from '../../constants.js';
import { BRANCH_EFFECT } from './constants.js';
import { BRANCH_EFFECT, COMPONENT_SYMBOL } from './constants.js';
import { define_property } from '../shared/utils.js';
import { create_context, get_or_init_context_map } from '../shared/context.js';
/** @type {ComponentContext | null} */
export let component_context = null;
@ -72,29 +74,23 @@ export function set_dev_current_component_function(fn) {
/**
* Returns a `[get, set]` pair of functions for working with context in a type-safe way.
*
* `get` will throw an error if no parent component called `set`.
* `get` will throw an error if `set` has not yet been called in the current component or any of
* its ancestors.
*
* @template T
* @returns {[() => T, (context: T) => T]}
* @since 5.40.0
*/
export function createContext() {
const key = {};
return [
() => {
if (!hasContext(key)) {
e.missing_context();
}
return getContext(key);
},
(context) => setContext(key, context)
];
return /** @type {[() => T, (context: T) => T]} */ (
create_context(getContext, setContext, hasContext)
);
}
/**
* Retrieves the context that belongs to the closest parent component with the specified `key`.
* Retrieves the context set with the specified `key` in the current component or any of its
* ancestors. If multiple components set the same key, the value from the closest one is returned.
* A `setContext` call in the current component is only visible to `getContext` calls that run after it.
* Must be called during component initialisation.
*
* [`createContext`](https://svelte.dev/docs/svelte/svelte#createContext) is a type-safe alternative.
@ -104,15 +100,15 @@ export function createContext() {
* @returns {T}
*/
export function getContext(key) {
const context_map = get_or_init_context_map('getContext');
const context_map = get_or_init_context_map(component_context, 'getContext');
const result = /** @type {T} */ (context_map.get(key));
return result;
}
/**
* Associates an arbitrary `context` object with the current component and the specified `key`
* and returns that object. The context is then available to children of the component
* (including slotted content) with `getContext`.
* and returns that object. The context is then available to the component itself and all of its
* descendants (including slotted content) with `getContext`.
*
* Like lifecycle functions, this must be called during component initialisation.
*
@ -124,7 +120,7 @@ export function getContext(key) {
* @returns {T}
*/
export function setContext(key, context) {
const context_map = get_or_init_context_map('setContext');
const context_map = get_or_init_context_map(component_context, 'setContext');
if (async_mode_flag) {
var flags = /** @type {Effect} */ (active_effect).f;
@ -144,27 +140,27 @@ export function setContext(key, context) {
}
/**
* Checks whether a given `key` has been set in the context of a parent component.
* Must be called during component initialisation.
* Checks whether a given `key` has been set in the context of the current component or any of
* its ancestors. Must be called during component initialisation.
*
* @param {any} key
* @returns {boolean}
*/
export function hasContext(key) {
const context_map = get_or_init_context_map('hasContext');
const context_map = get_or_init_context_map(component_context, 'hasContext');
return context_map.has(key);
}
/**
* Retrieves the whole context map that belongs to the closest parent component.
* Must be called during component initialisation. Useful, for example, if you
* programmatically create a component and want to pass the existing context to it.
* Retrieves the whole context map that belongs to the current component, including entries
* inherited from its ancestors. Must be called during component initialisation. Useful, for
* example, if you programmatically create a component and want to pass the existing context to it.
*
* @template {Map<any, any>} [T=Map<any, any>]
* @returns {T}
*/
export function getAllContexts() {
const context_map = get_or_init_context_map('getAllContexts');
const context_map = get_or_init_context_map(component_context, 'getAllContexts');
return /** @type {T} */ (context_map);
}
@ -222,38 +218,19 @@ export function pop(component) {
dev_current_component_function = component_context?.function ?? null;
}
return component ?? /** @type {T} */ ({});
}
/** @returns {boolean} */
export function is_runes() {
return !legacy_mode_flag || (component_context !== null && component_context.l === null);
return mark_as_component(component);
}
/**
* @param {string} name
* @returns {Map<unknown, unknown>}
* Add a symbol to the object (or create one if undefined) to mark it as a component so it isn't proxified.
* @param {any} component
*/
function get_or_init_context_map(name) {
if (component_context === null) {
e.lifecycle_outside_component(name);
export function mark_as_component(component = {}) {
define_property(component, COMPONENT_SYMBOL, { value: true });
return component;
}
return (component_context.c ??= new Map(get_parent_context(component_context) || undefined));
}
/**
* @param {ComponentContext} component_context
* @returns {Map<unknown, unknown> | null}
*/
function get_parent_context(component_context) {
let parent = component_context.p;
while (parent !== null) {
const context_map = parent.c;
if (context_map !== null) {
return context_map;
}
parent = parent.p;
}
return null;
/** @returns {boolean} */
export function is_runes() {
return !legacy_mode_flag || (component_context !== null && component_context.l === null);
}

@ -57,10 +57,21 @@ export function hmr(fn) {
if (ran) set_should_intro(true);
});
// Forward the nodes from the inner effect to the outer active effect which would
// get them if the HMR wrapper wasn't there. Do this inside the block not outside
// so that HMR updates to the component will also update the nodes on the active effect.
/** @type {Effect} */ (active_effect).nodes = effect.nodes;
// Forward the start/end DOM nodes from the inner effect to the outer active effect
// which would get them if the HMR wrapper wasn't there. Do this inside the block not
// outside so that HMR updates to the component will also update the nodes on the
// active effect. We copy only start/end, not the full nodes object, so that
// pause_children does not collect transitions from both effects and fire outroend twice.
var inner_nodes = effect.nodes;
if (inner_nodes) {
var ae = /** @type {Effect} */ (active_effect);
if (ae.nodes) {
ae.nodes.start = inner_nodes.start;
ae.nodes.end = inner_nodes.end;
} else {
ae.nodes = { start: inner_nodes.start, end: inner_nodes.end, a: null, t: null };
}
}
}, EFFECT_TRANSPARENT);
ran = true;

@ -199,17 +199,68 @@ export class Boundary {
*/
#hydrate_failed_content(error) {
const failed = this.#props.failed;
const { reset, invoke_onerror } = this.#create_reset(error);
// `onerror` may mutate state, which is disallowed while hydrating
queue_micro_task(invoke_onerror);
if (!failed) return;
this.#failed_effect = branch(() => {
failed(
this.#anchor,
() => error,
() => () => {}
() => reset
);
});
}
/**
* Creates the `reset` function for a failed boundary, along with a function
* that invokes `onerror` with it (if provided)
* @param {unknown} error
* @returns {{ reset: () => void, invoke_onerror: () => void }}
*/
#create_reset(error) {
var did_reset = false;
var calling_on_error = false;
const reset = () => {
if (did_reset) {
w.svelte_boundary_reset_noop();
return;
}
did_reset = true;
if (calling_on_error) {
e.svelte_boundary_reset_onerror();
}
if (this.#failed_effect !== null) {
pause_effect(this.#failed_effect, () => {
this.#failed_effect = null;
});
}
this.#run(() => {
this.#render();
});
};
const invoke_onerror = () => {
try {
calling_on_error = true;
this.#props.onerror?.(error, reset);
calling_on_error = false;
} catch (err) {
invoke_error_boundary(err, this.#effect && this.#effect.parent);
}
};
return { reset, invoke_onerror };
}
#hydrate_pending_content() {
const pending = this.#props.pending;
if (!pending) return;
@ -429,43 +480,13 @@ export class Boundary {
set_hydrate_node(skip_nodes());
}
var onerror = this.#props.onerror;
let failed = this.#props.failed;
var did_reset = false;
var calling_on_error = false;
const reset = () => {
if (did_reset) {
w.svelte_boundary_reset_noop();
return;
}
did_reset = true;
if (calling_on_error) {
e.svelte_boundary_reset_onerror();
}
if (this.#failed_effect !== null) {
pause_effect(this.#failed_effect, () => {
this.#failed_effect = null;
});
}
this.#run(() => {
this.#render();
});
};
/** @param {unknown} transformed_error */
const handle_error_result = (transformed_error) => {
try {
calling_on_error = true;
onerror?.(transformed_error, reset);
calling_on_error = false;
} catch (error) {
invoke_error_boundary(error, this.#effect && this.#effect.parent);
}
const { reset, invoke_onerror } = this.#create_reset(transformed_error);
invoke_onerror();
if (failed) {
this.#failed_effect = this.#run(() => {

@ -18,10 +18,10 @@ export function css_props(element, get_styles) {
for (var key in styles) {
var value = styles[key];
if (value) {
element.style.setProperty(key, value);
} else {
if (value == null || value === '') {
element.style.removeProperty(key);
} else {
element.style.setProperty(key, value);
}
}
});

@ -103,8 +103,12 @@ function pause_effects(state, to_destroy, controlled_anchor) {
if (remaining === 0) {
// If we're in a controlled each block (i.e. the block is the only child of an
// element), and we are removing all items, _and_ there are no out transitions,
// we can use the fast path — emptying the element and replacing the anchor
var fast_path = transitions.length === 0 && controlled_anchor !== null;
// we can use the fast path — emptying the element and replacing the anchor.
// Skip the fast path when another batch is still pending on this each block:
// that batch's keys still reference EachItems in `state.items`, which
// `destroy_effects` needs to preserve offscreen (see #18610).
var fast_path =
transitions.length === 0 && controlled_anchor !== null && state.pending.size === 0;
if (fast_path) {
var anchor = /** @type {Element} */ (controlled_anchor);

@ -2,14 +2,19 @@ import { DEV } from 'esm-env';
import { register_style } from '../dev/css.js';
import { effect } from '../reactivity/effects.js';
import { create_element } from './operations.js';
import { active_effect } from '../runtime.js';
/**
* @param {Node} anchor
* @param {{ hash: string, code: string }} css
*/
export function append_styles(anchor, css) {
// Use `queue_micro_task` to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results
// Use an effect to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results
effect(() => {
// Bit of a hack: branches.js/each.js use offscreen fragments with temporary text nodes that will
// never be connected to the real dom. Therfore walk up to the branch that has created the component
// whose styles we want to append, and check its node instead. It will be connected by the time we get here.
anchor = active_effect?.parent?.nodes?.start ?? anchor;
var root = anchor.getRootNode();
var target = /** @type {ShadowRoot} */ (root).host

@ -315,7 +315,8 @@ function set_attributes(
var is_option_element = element.nodeName === OPTION_TAG;
for (var key in prev) {
if (!(key in next)) {
// don't null our internal $$onX listeners
if (!(key in next) && key[0] + key[1] !== '$$') {
next[key] = null;
}
}

@ -55,7 +55,12 @@ export function select_option(select, value, mounting = false) {
* @param {HTMLSelectElement} select
*/
export function init_select(select) {
var observer = new MutationObserver(() => {
var observer = new MutationObserver((entries) => {
// Mutations related to `<selectedcontent>` can never affect the option list.
// Reacting to them could revert a user-initiated selection change, because the
// records are delivered as soon as any listener returns (e.g. a delegated `input`
// handler), which can happen before the `change` handler has updated `__value`
if (entries.every(is_selectedcontent_mutation) || !('__value' in select)) return;
// @ts-ignore
select_option(select, select.__value);
// Deliberately don't update the potential binding value,
@ -162,3 +167,22 @@ function get_option_value(option) {
return option.value;
}
}
/**
* Returns `true` if the mutation stems from the browser mirroring the selected
* option's content into `<selectedcontent>`, or from us replacing the
* `<selectedcontent>` element with a clone of itself
* @param {MutationRecord} entry
*/
function is_selectedcontent_mutation(entry) {
if (/** @type {Element} */ (entry.target).closest('selectedcontent') !== null) {
return true;
}
if (entry.type === 'childList') {
var nodes = [...entry.addedNodes, ...entry.removedNodes];
return nodes.length > 0 && nodes.every((node) => node.nodeName === 'SELECTEDCONTENT');
}
return false;
}

@ -1,6 +1,6 @@
/** @import { ComponentContext, Effect } from '#client' */
import { DESTROYING, STATE_SYMBOL } from '#client/constants';
import { component_context } from '../../../context.js';
import { component_context, mark_as_component } from '../../../context.js';
import { effect, render_effect } from '../../../reactivity/effects.js';
import { active_effect, untrack } from '../../../runtime.js';
@ -23,7 +23,12 @@ function is_bound_this(bound_value, element_or_component) {
* returns all the parts of the each block context that are used in the expression
* @returns {void}
*/
export function bind_this(element_or_component = {}, update, get_value, get_parts) {
export function bind_this(
element_or_component = mark_as_component(),
update,
get_value,
get_parts
) {
var component_effect = /** @type {ComponentContext} */ (component_context).r;
var parent = /** @type {Effect} */ (active_effect);

@ -159,12 +159,15 @@ export function delegate(events) {
}
// used to store the reference to the currently propagated event
// to prevent garbage collection between microtasks in Firefox
// to prevent garbage collection between microtasks in Firefox (<= 141)
// If the event object is GCed too early, the expando __root property
// set on the event object is lost, causing the event delegation
// to process the event twice
let last_propagated_event = null;
// whether a task is already queued to clear `last_propagated_event`
let last_propagated_event_clear_scheduled = false;
/**
* @this {EventTarget}
* @param {Event} event
@ -179,6 +182,21 @@ export function handle_event_propagation(event) {
last_propagated_event = event;
// The reference is only needed while the event can still reach another
// delegated root, i.e. during the current (synchronous) dispatch and its
// microtask checkpoints. Clearing it in a later task preserves the
// Firefox workaround while making sure the slot doesn't retain the last
// event forever — through `event.target` it would otherwise keep the
// entire detached subtree of whatever the user last clicked in alive
// until the next delegated event happens to arrive.
if (!last_propagated_event_clear_scheduled) {
last_propagated_event_clear_scheduled = true;
setTimeout(() => {
last_propagated_event_clear_scheduled = false;
last_propagated_event = null;
});
}
// composedPath contains list of nodes the event has propagated through.
// We check `event_symbol` to skip all nodes below it in case this is a
// parent of the `event_symbol` node, which indicates that there's nested

@ -3,7 +3,14 @@
import { DEV } from 'esm-env';
import { FILENAME } from '../../constants.js';
import { is_firefox } from './dom/operations.js';
import { ERROR_VALUE, BOUNDARY_EFFECT, REACTION_RAN, EFFECT, DESTROYED } from './constants.js';
import {
ERROR_VALUE,
BOUNDARY_EFFECT,
REACTION_RAN,
EFFECT,
DESTROYED,
DESTROYING
} from './constants.js';
import { define_property, get_descriptor } from '../shared/utils.js';
import { active_effect, active_reaction } from './runtime.js';
@ -50,7 +57,8 @@ export function invoke_error_boundary(error, effect) {
}
while (effect !== null) {
if ((effect.f & BOUNDARY_EFFECT) !== 0) {
// Skip boundaries that are destroyed/destroying and cannot meaningfully handle the error.
if ((effect.f & BOUNDARY_EFFECT) !== 0 && (effect.f & (DESTROYED | DESTROYING)) === 0) {
if ((effect.f & REACTION_RAN) === 0) {
// we are still creating the boundary effect
throw error;

@ -0,0 +1,39 @@
import { assert, test } from 'vitest';
import { BOUNDARY_EFFECT, DESTROYED, REACTION_RAN } from './constants';
import { invoke_error_boundary } from './error-handling';
import type { Effect } from './types';
test('ignores errors from a destroyed entry effect', () => {
const error = new Error('original');
let handled = null;
const boundary = {
f: BOUNDARY_EFFECT | REACTION_RAN,
b: { error: (error: unknown) => (handled = error) },
parent: null
} as unknown as Effect;
const effect = { f: DESTROYED, parent: boundary } as Effect;
invoke_error_boundary(error, effect);
assert.equal(handled, null);
});
test('skips destroyed boundary ancestors without masking the error', () => {
const error = new Error('original');
let handled = null;
const live_boundary = {
f: BOUNDARY_EFFECT | REACTION_RAN,
b: { error: (error: unknown) => (handled = error) },
parent: null
} as unknown as Effect;
const destroyed_boundary = {
f: BOUNDARY_EFFECT | DESTROYED | REACTION_RAN,
b: null,
parent: live_boundary
} as unknown as Effect;
const effect = { f: 0, parent: destroyed_boundary } as Effect;
invoke_error_boundary(error, effect);
assert.equal(handled, error);
});

@ -22,7 +22,7 @@ import {
flush_eager_effects,
set_eager_effects_deferred
} from './reactivity/sources.js';
import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants';
import { COMPONENT_SYMBOL, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants';
import { UNINITIALIZED } from '../../constants.js';
import * as e from './errors.js';
import { tag } from './dev/tracing.js';
@ -38,8 +38,13 @@ const regex_is_valid_identifier = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/;
* @returns {T}
*/
export function proxy(value) {
// if non-proxyable, or is already a proxy, return `value`
if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
// if non-proxyable, a component instance, or already a proxy, return `value`
if (
typeof value !== 'object' ||
value === null ||
STATE_SYMBOL in value ||
COMPONENT_SYMBOL in value
) {
return value;
}

@ -420,6 +420,7 @@ export class Batch {
}
if (next_batch !== null) {
old_values.clear();
next_batch.#process();
}
}

@ -34,8 +34,10 @@ import {
ASYNC,
CONNECTED,
MANAGED_EFFECT,
DESTROYING
DESTROYING,
PAUSED
} from '#client/constants';
import { invoke_error_boundary } from '../error-handling.js';
import * as e from '../errors.js';
import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
@ -449,6 +451,11 @@ export function execute_effect_teardown(effect) {
set_active_reaction(null);
try {
teardown.call(null);
} catch (error) {
// Route teardown errors through the boundary system so that a live
// ancestor <svelte:boundary> can handle them. Boundaries that are
// themselves mid-teardown are skipped by invoke_error_boundary.
invoke_error_boundary(error, effect.parent);
} finally {
set_is_destroying_effect(previously_destroying_effect);
set_active_reaction(previous_reaction);
@ -610,6 +617,7 @@ export function pause_effect(effect, callback, destroy = true) {
/** @type {TransitionManager[]} */
var transitions = [];
effect.f |= PAUSED;
pause_children(effect, transitions, true);
var fn = () => {
@ -677,6 +685,7 @@ function pause_children(effect, transitions, local) {
* @param {Effect} effect
*/
export function resume_effect(effect) {
effect.f &= ~PAUSED;
resume_children(effect, true);
}
@ -685,6 +694,10 @@ export function resume_effect(effect) {
* @param {boolean} local
*/
function resume_children(effect, local) {
// this subtree was paused for its own reasons (e.g. a block whose condition
// is still false) — its controller will resume or destroy it
if ((effect.f & PAUSED) !== 0) return;
if ((effect.f & INERT) === 0) return;
effect.f ^= INERT;

@ -180,7 +180,13 @@ export function set(source, value, should_proxy = false) {
*/
export function internal_set(source, value, updated_during_traversal = null) {
if (!source.equals(value)) {
old_values.set(source, is_destroying_effect ? value : source.v);
if (is_destroying_effect) {
old_values.set(source, value);
} else if (!old_values.has(source)) {
// only record the value from before the first write in this flush, otherwise a
// teardown would see the value from before whichever write happened to be last
old_values.set(source, source.v);
}
var batch = Batch.ensure();
batch.capture(source, value);

@ -10,7 +10,7 @@ import {
} from './dom/operations.js';
import { HYDRATION_END, HYDRATION_ERROR, HYDRATION_START } from '../../constants.js';
import { active_effect } from './runtime.js';
import { push, pop, component_context } from './context.js';
import { push, pop, component_context, mark_as_component } from './context.js';
import { component_root } from './reactivity/effects.js';
import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from './dom/hydration.js';
import { array_from } from '../shared/utils.js';
@ -23,7 +23,7 @@ import * as w from './warnings.js';
import * as e from './errors.js';
import { assign_nodes } from './dom/template.js';
import { is_passive_event } from '../../utils.js';
import { COMMENT_NODE, STATE_SYMBOL, TEXT_CACHE } from './constants.js';
import { COMMENT_NODE, TEXT_CACHE } from './constants.js';
import { boundary } from './dom/blocks/boundary.js';
/**
@ -193,7 +193,7 @@ function _mount(
should_intro = intro;
// @ts-expect-error the public typings are not what the actual function looks like
component = Component(anchor_node, props) || {};
component = Component(anchor_node, props) || mark_as_component();
should_intro = true;
if (hydrating) {
@ -323,12 +323,8 @@ export function unmount(component, options) {
}
if (DEV) {
if (STATE_SYMBOL in component) {
w.state_proxy_unmount();
} else {
w.lifecycle_double_unmount();
}
}
return Promise.resolve();
}

@ -133,7 +133,7 @@ export function set_untracked_writes(value) {
**/
export let write_version = 1;
/** @type {number} Used to version each read of a source of derived to avoid duplicating depedencies inside a reaction */
/** @type {number} Used to version each read of a source of derived to avoid duplicating dependencies inside a reaction */
let read_version = 0;
export let update_version = read_version;
@ -413,6 +413,8 @@ function remove_reaction(signal, dependency) {
without_reactive_context(() => {
/** @type {AbortController} */ (derived.ac).abort(STALE_REACTION);
derived.ac = null;
// ensure it reruns right away next time instead of potentially returning a rejected promise as its value
set_signal_status(derived, DIRTY);
});
}

@ -247,17 +247,6 @@ export function state_proxy_equality_mismatch(operator) {
}
}
/**
* Tried to unmount a state proxy, rather than a component
*/
export function state_proxy_unmount() {
if (DEV) {
console.warn(`%c[svelte] state_proxy_unmount\n%cTried to unmount a state proxy, rather than a component\nhttps://svelte.dev/e/state_proxy_unmount`, bold, normal);
} else {
console.warn(`https://svelte.dev/e/state_proxy_unmount`);
}
}
/**
* A `<svelte:boundary>` `reset` function only resets the boundary the first time it is called
*/

@ -1,13 +1,12 @@
import { STALE_REACTION } from '#client/constants';
import { ssr_context } from './context.js';
/** @type {AbortController | null} */
let controller = null;
export function getAbortSignal() {
let context = ssr_context;
export function abort() {
controller?.abort(STALE_REACTION);
controller = null;
while (context !== null) {
if (context.r !== null) return context.r.global.get_abort_signal();
context = context.p;
}
export function getAbortSignal() {
return (controller ??= new AbortController()).signal;
return new AbortController().signal;
}

@ -1,6 +1,6 @@
/** @import { SSRContext } from '#server' */
import { DEV } from 'esm-env';
import * as e from './errors.js';
import { create_context, get_or_init_context_map } from '../shared/context.js';
/** @type {SSRContext | null} */
export var ssr_context = null;
@ -16,18 +16,9 @@ export function set_ssr_context(v) {
* @since 5.40.0
*/
export function createContext() {
const key = {};
return [
() => {
if (!hasContext(key)) {
e.missing_context();
}
return getContext(key);
},
(context) => setContext(key, context)
];
return /** @type {[() => T, (context: T) => T]} */ (
create_context(getContext, setContext, hasContext)
);
}
/**
@ -36,7 +27,7 @@ export function createContext() {
* @returns {T}
*/
export function getContext(key) {
const context_map = get_or_init_context_map('getContext');
const context_map = get_or_init_context_map(ssr_context, 'getContext');
const result = /** @type {T} */ (context_map.get(key));
return result;
@ -49,7 +40,7 @@ export function getContext(key) {
* @returns {T}
*/
export function setContext(key, context) {
get_or_init_context_map('setContext').set(key, context);
get_or_init_context_map(ssr_context, 'setContext').set(key, context);
return context;
}
@ -58,24 +49,12 @@ export function setContext(key, context) {
* @returns {boolean}
*/
export function hasContext(key) {
return get_or_init_context_map('hasContext').has(key);
return get_or_init_context_map(ssr_context, 'hasContext').has(key);
}
/** @returns {Map<any, any>} */
export function getAllContexts() {
return get_or_init_context_map('getAllContexts');
}
/**
* @param {string} name
* @returns {Map<unknown, unknown>}
*/
function get_or_init_context_map(name) {
if (ssr_context === null) {
e.lifecycle_outside_component(name);
}
return (ssr_context.c ??= new Map(get_parent_context(ssr_context) || undefined));
return get_or_init_context_map(ssr_context, 'getAllContexts');
}
/**
@ -94,24 +73,6 @@ export function pop() {
ssr_context = /** @type {SSRContext} */ (ssr_context).p;
}
/**
* @param {SSRContext} ssr_context
* @returns {Map<unknown, unknown> | null}
*/
function get_parent_context(ssr_context) {
let parent = ssr_context.p;
while (parent !== null) {
const context_map = parent.c;
if (context_map !== null) {
return context_map;
}
parent = parent.p;
}
return null;
}
/**
* Wraps an `await` expression in such a way that the component context that was
* active before the expression evaluated can be reapplied afterwards

@ -1,5 +1,5 @@
/** @import { ComponentType, SvelteComponent, Component } from 'svelte' */
/** @import { Csp, RenderOutput } from '#server' */
/** @import { Csp, RenderOutput } from '../../server/public.js' */
/** @import { Store } from '#shared' */
export { FILENAME, HMR } from '../../constants.js';
import { attr, clsx, to_class, to_style } from '../shared/attributes.js';

@ -1,8 +1,9 @@
/** @import { Component } from 'svelte' */
/** @import { Csp, HydratableContext, RenderOutput, SSRContext, SyncRenderOutput, Sha256Source } from './types.js' */
/** @import { HydratableContext, SSRContext } from './types.js' */
/** @import { Csp, RenderOutput, SyncRenderOutput, Sha256Source } from '../../server/public.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.js';
import { STALE_REACTION } from '../client/constants.js';
import { pop, push, set_ssr_context, ssr_context } from './context.js';
import * as e from './errors.js';
import * as w from './warnings.js';
@ -179,7 +180,7 @@ export class Renderer {
// prevent unhandled rejections, and attach the promise to the renderer instance
// so that rejections correctly cause rendering to fail
promise.catch(noop);
this.promise = promise;
this.promise = this.global.track(promise);
return promises;
}
@ -224,7 +225,7 @@ export class Renderer {
e.await_invalid();
}
child.promise = result;
child.promise = child.global.track(result);
}
return child;
@ -272,7 +273,7 @@ export class Renderer {
e.await_invalid();
}
result.catch(noop);
child.promise = result;
child.promise = child.global.track(result);
}
} catch (error) {
// synchronous errors are handled here, async errors will be handled in #collect_content_async
@ -292,12 +293,14 @@ export class Renderer {
e.await_invalid();
}
child.promise = /** @type {Promise<unknown>} */ (result).then((transformed) => {
child.promise = child.global.track(
/** @type {Promise<unknown>} */ (result).then((transformed) => {
set_ssr_context(parent_context);
child.#out.push(Renderer.#serialize_failed_boundary(transformed));
failed_snippet(child, transformed, noop);
child.#out.push(BLOCK_CLOSE);
});
})
);
child.promise.catch(noop);
} else {
child.#out.push(Renderer.#serialize_failed_boundary(result));
@ -316,8 +319,11 @@ export class Renderer {
*/
component(fn, component_fn) {
push(component_fn);
const child = this.child(fn);
child.#is_component_body = true;
// mark before running so `onDestroy` callbacks are still collected if `fn` throws
this.child((renderer) => {
renderer.#is_component_body = true;
return fn(renderer);
});
pop();
}
@ -451,6 +457,7 @@ export class Renderer {
*/
copy() {
const copy = new Renderer(this.global, this.#parent);
copy.type = this.type;
copy.#out = this.#out.map((item) => (item instanceof Renderer ? item.copy() : item));
copy.promise = this.promise;
return copy;
@ -624,6 +631,49 @@ export class Renderer {
}
}
/**
* Runs every `onDestroy` callback in this renderer tree. On a failed render,
* cleanup errors are suppressed so they do not mask the render error.
* @param {boolean} suppress_errors
*/
#run_on_destroy(suppress_errors) {
let first_error;
let has_error = false;
for (const cleanup of this.#collect_on_destroy()) {
try {
cleanup();
} catch (error) {
if (!suppress_errors && !has_error) {
first_error = error;
has_error = true;
}
}
}
if (has_error) throw first_error;
}
/**
* @param {'sync' | 'async'} mode
* @param {{ idPrefix?: string; csp?: Csp; transformError?: (error: unknown) => unknown }} options
* @returns {Renderer}
*/
static #create(mode, options) {
if (options.idPrefix?.includes('--')) {
e.invalid_id_prefix();
}
return new Renderer(
new SSRState(
mode,
options.idPrefix ? options.idPrefix + '-' : '',
options.csp,
options.transformError
)
);
}
/**
* Render a component. Throws if any of the children are performing asynchronous work.
*
@ -634,13 +684,27 @@ export class Renderer {
*/
static #render(component, options) {
var previous_context = ssr_context;
const renderer = Renderer.#create('sync', options);
/** @type {AccumulatedContent | undefined} */
let result;
let render_error;
let failed = false;
try {
try {
const renderer = Renderer.#open_render('sync', component, options);
Renderer.#open_render(renderer, component, options);
result = Renderer.#close_render(renderer.#collect_content(), renderer);
} catch (error) {
render_error = error;
failed = true;
}
renderer.#run_on_destroy(failed);
if (failed) throw render_error;
const content = renderer.#collect_content();
return Renderer.#close_render(content, renderer);
return /** @type {AccumulatedContent} */ (result);
} finally {
abort();
renderer.global.abort();
set_ssr_context(previous_context);
}
}
@ -655,18 +719,35 @@ export class Renderer {
*/
static async #render_async(component, options) {
const previous_context = ssr_context;
const renderer = Renderer.#create('async', options);
/** @type {(AccumulatedContent & { hashes: { script: Sha256Source[] } }) | undefined} */
let result;
let render_error;
let failed = false;
try {
const renderer = Renderer.#open_render('async', component, options);
try {
Renderer.#open_render(renderer, component, options);
const content = await renderer.#collect_content_async();
const hydratables = await renderer.#collect_hydratables();
if (hydratables !== null) {
content.head = hydratables + content.head;
}
return Renderer.#close_render(content, renderer);
result = Renderer.#close_render(content, renderer);
} catch (error) {
render_error = error;
failed = true;
renderer.global.abort();
await renderer.global.settle();
}
renderer.#run_on_destroy(failed);
if (failed) throw render_error;
return /** @type {AccumulatedContent & { hashes: { script: Sha256Source[] } }} */ (result);
} finally {
set_ssr_context(previous_context);
abort();
renderer.global.abort();
}
}
@ -758,28 +839,15 @@ export class Renderer {
/**
* @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode
* @param {Renderer} renderer
* @param {import('svelte').Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp; transformError?: (error: unknown) => unknown }} options
* @returns {Renderer}
* @returns {void}
*/
static #open_render(mode, component, options) {
if (options.idPrefix?.includes('--')) {
e.invalid_id_prefix();
}
static #open_render(renderer, component, options) {
var previous_context = ssr_context;
try {
const renderer = new Renderer(
new SSRState(
mode,
options.idPrefix ? options.idPrefix + '-' : '',
options.csp,
options.transformError
)
);
/** @type {SSRContext} */
const context = { p: null, c: options.context ?? null, r: renderer };
set_ssr_context(context);
@ -788,8 +856,6 @@ export class Renderer {
// @ts-expect-error
component(renderer, options.props ?? {});
renderer.push(BLOCK_CLOSE);
return renderer;
} finally {
set_ssr_context(previous_context);
}
@ -801,10 +867,6 @@ export class Renderer {
* @returns {AccumulatedContent & { hashes: { script: Sha256Source[] } }}
*/
static #close_render(content, renderer) {
for (const cleanup of renderer.#collect_on_destroy()) {
cleanup();
}
let head = content.head + renderer.global.get_title();
let body = content.body;
@ -888,6 +950,14 @@ export class SSRState {
/** @readonly @type {Set<{ hash: string; code: string }>} */
css = new Set();
/** @type {Set<Promise<unknown>>} */
#pending = new Set();
/** @type {AbortController | null} */
#controller = null;
#aborted = false;
/**
* `transformError` passed to `render`. Called when an error boundary catches an error.
* Throws by default if unset in `render`.
@ -918,6 +988,38 @@ export class SSRState {
this.uid = () => `${id_prefix}s${uid++}`;
}
/**
* @template T
* @param {Promise<T>} promise
* @returns {Promise<T>}
*/
track(promise) {
this.#pending.add(promise);
promise.then(
() => this.#pending.delete(promise),
() => this.#pending.delete(promise)
);
return promise;
}
async settle() {
while (this.#pending.size > 0) {
await Promise.allSettled([...this.#pending]);
}
}
abort() {
if (this.#aborted) return;
this.#aborted = true;
this.#controller?.abort(STALE_REACTION);
}
get_abort_signal() {
const controller = (this.#controller ??= new AbortController());
if (this.#aborted) controller.abort(STALE_REACTION);
return controller.signal;
}
get_title() {
return this.#title.value;
}

@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { Renderer, SSRState } from './renderer.js';
import type { Component } from 'svelte';
import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js';
import { getAbortSignal } from './abort-signal.js';
test('collects synchronous body content by default', () => {
const component = (renderer: Renderer) => {
@ -466,4 +467,176 @@ describe('async', () => {
await Renderer.render(component as unknown as Component);
expect(destroyed).toEqual(['c', 'e', 'a', 'b', 'b*', 'd']);
});
test('on_destroy callbacks run when a sync render throws', () => {
const destroyed: string[] = [];
const component = (renderer: Renderer) => {
renderer.component((renderer) => {
renderer.on_destroy(() => destroyed.push('a'));
renderer.child(() => {
throw new Error('boom');
});
});
};
expect(() => Renderer.render(component as unknown as Component).body).toThrow('boom');
expect(destroyed).toEqual(['a']);
});
test('on_destroy callbacks run when an async render rejects', async () => {
const destroyed: string[] = [];
const component = (renderer: Renderer) => {
renderer.component((renderer) => {
renderer.on_destroy(() => destroyed.push('a'));
renderer.child(async () => {
await Promise.resolve();
throw new Error('boom');
});
});
};
await expect(Renderer.render(component as unknown as Component)).rejects.toThrow('boom');
expect(destroyed).toEqual(['a']);
});
test('on_destroy waits for in-flight renderers when an async render rejects', async () => {
const events: string[] = [];
let initialised = false;
const component = (renderer: Renderer) => {
renderer.component((renderer) => {
// rejects while the sibling component below is still in flight
renderer.child(async () => {
await Promise.resolve();
throw new Error('boom');
});
renderer.component((renderer) => {
renderer.on_destroy(() => events.push(`before-await (initialised: ${initialised})`));
renderer.child(async () => {
await new Promise((f) => setTimeout(f, 10));
initialised = true;
renderer.on_destroy(() => events.push('after-await'));
});
});
});
};
await expect(Renderer.render(component as unknown as Component)).rejects.toThrow('boom');
expect(events).toEqual(['before-await (initialised: true)', 'after-await']);
});
test('aborts in-flight renderers before waiting for them', async () => {
const events: string[] = [];
const component = (renderer: Renderer) => {
renderer.component((renderer) => {
renderer.child(async () => {
await Promise.resolve();
throw new Error('boom');
});
renderer.component((renderer) => {
renderer.on_destroy(() => events.push('destroyed'));
renderer.child(async () => {
const signal = getAbortSignal();
await new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
});
});
};
await expect(Renderer.render(component as unknown as Component)).rejects.toThrow('boom');
expect(events).toEqual(['destroyed']);
});
test('on_destroy waits for every run invocation when an async render rejects', async () => {
const events: string[] = [];
let initialised = false;
const component = (renderer: Renderer) => {
renderer.component((renderer) => {
renderer.on_destroy(() => events.push(`destroyed (initialised: ${initialised})`));
renderer.run([
async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
initialised = true;
renderer.on_destroy(() => events.push('destroyed after await'));
}
]);
renderer.run([
async () => {
await Promise.resolve();
throw new Error('boom');
}
]);
});
};
await expect(Renderer.render(component as unknown as Component)).rejects.toThrow('boom');
expect(events).toEqual(['destroyed (initialised: true)', 'destroyed after await']);
});
test('abort signals are scoped to a render', async () => {
let signal: AbortSignal;
let start!: () => void;
let resume!: () => void;
const started = new Promise<void>((resolve) => (start = resolve));
const resumed = new Promise<void>((resolve) => (resume = resolve));
const component = (renderer: Renderer) => {
renderer.child(async () => {
signal = getAbortSignal();
start();
await resumed;
});
};
const first_render = Promise.resolve(Renderer.render(component as unknown as Component));
await started;
await Renderer.render((() => {}) as unknown as Component);
expect(signal!.aborted).toBe(false);
resume();
await first_render;
expect(signal!.aborted).toBe(true);
});
test('a throwing on_destroy callback does not mask a sync render error', () => {
const destroyed: string[] = [];
const component = (renderer: Renderer) => {
renderer.component((renderer) => {
renderer.on_destroy(() => {
destroyed.push('a');
throw new Error('cleanup failed');
});
renderer.on_destroy(() => destroyed.push('b'));
renderer.child(() => {
throw new Error('boom');
});
});
};
expect(() => Renderer.render(component as unknown as Component).body).toThrow('boom');
expect(destroyed).toEqual(['a', 'b']);
});
test('a throwing on_destroy callback does not mask an async render error', async () => {
const destroyed: string[] = [];
const component = (renderer: Renderer) => {
renderer.component((renderer) => {
renderer.on_destroy(() => {
destroyed.push('a');
throw new Error('cleanup failed');
});
renderer.on_destroy(() => destroyed.push('b'));
renderer.child(async () => {
await Promise.resolve();
throw new Error('boom');
});
});
};
await expect(Renderer.render(component as unknown as Component)).rejects.toThrow('boom');
expect(destroyed).toEqual(['a', 'b']);
});
});

@ -15,8 +15,6 @@ export interface SSRContext {
element?: Element;
}
export type Csp = { nonce?: string; hash?: boolean };
export interface HydratableLookupEntry {
value: unknown;
serialized: string;
@ -34,19 +32,3 @@ export interface HydratableContext {
export interface RenderContext {
hydratable: HydratableContext;
}
export type Sha256Source = `sha256-${string}`;
export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */
head: string;
/** @deprecated use `body` instead */
html: string;
/** HTML that goes somewhere into the `<body>` */
body: string;
hashes: {
script: Sha256Source[];
};
}
export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;

@ -142,8 +142,9 @@ export function to_style(value, styles) {
}
if (value) {
// strip comments; surrounding whitespace is handled by the trims below (which is much faster than doing it through regex)
value = String(value)
.replaceAll(/\s*\/\*.*?\*\/\s*/g, '')
.replaceAll(/\/\*.*?\*\//g, '')
.trim();
/** @type {boolean | '"' | "'"} */

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

Loading…
Cancel
Save