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

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

@ -17,7 +17,7 @@ jobs:
contents: read # to clone the repo contents: read # to clone the repo
steps: steps:
- name: Check User Permissions - name: Check User Permissions
uses: actions/github-script@v8 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
id: check-permissions id: check-permissions
with: with:
script: | script: |
@ -56,7 +56,7 @@ jobs:
} }
- name: Get PR Data - name: Get PR Data
uses: actions/github-script@v8 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
id: get-pr-data id: get-pr-data
with: with:
script: | script: |
@ -106,7 +106,7 @@ jobs:
- name: Generate Token - name: Generate Token
id: generate-token id: generate-token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with: with:
app-id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }} app-id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }}
private-key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }} private-key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }}
@ -115,7 +115,7 @@ jobs:
svelte-ecosystem-ci svelte-ecosystem-ci
- name: Trigger Downstream Workflow - name: Trigger Downstream Workflow
uses: actions/github-script@v8 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
id: trigger id: trigger
env: env:
COMMENT: ${{ github.event.comment.body }} 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 runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0 fetch-depth: 0
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: 24.x node-version: 24.x
cache: pnpm cache: pnpm

@ -205,6 +205,8 @@ In rare cases, you may need to run code _before_ the DOM updates. For this we ca
</div> </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`. Apart from the timing, `$effect.pre` works exactly like `$effect`.
## `$effect.tracking` ## `$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 /// file: FancyInput.svelte
let { value = $bindable('fallback'), ...props } = $props(); 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. - You can also use code blocks here.
- Usage: - Usage:
```html ```html
<Main name="Arethra"> <Main name="Aretha">
``` ```
--> -->
<script> <script>
@ -215,3 +215,14 @@ You can add a special comment starting with `@component` that will show up when
</h1> </h1>
</main> </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} {#each boxes as box}
{const area = box.width * box.height} {const area = box.width * box.height}
{const label = `${box.width} ${box.height} = ${area}`} {const label = `${box.width} × ${box.height} = ${area}`}
<p>{label}</p> <p>{label}</p>
{/each} {/each}

@ -53,6 +53,12 @@ Transitions can have parameters.
{/if} {/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 ## Custom transition functions
```js ```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. 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: 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. 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. `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(); 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: 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 ```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. 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? ## 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. 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 ### svelte_boundary_reset_noop
``` ```

@ -821,6 +821,18 @@ Cannot use `%rune%` rune in non-runes mode
Cannot use rune without parentheses 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 ### rune_removed
``` ```

@ -75,10 +75,10 @@ Certain lifecycle methods can only be used during component initialisation. To f
### missing_context ### 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 ### snippet_without_render_tag

@ -2,4 +2,6 @@
title: svelte/easing 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 > MODULE: svelte/easing

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

@ -1,5 +1,87 @@
# svelte # 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 ## 5.56.5
### Patch Changes ### 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. 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 ## svelte_boundary_reset_noop
> A `<svelte:boundary>` `reset` function only resets the boundary the first time it is called > 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 > 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 ## rune_removed
> The `%name%` rune has been 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 ## 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 ## snippet_without_render_tag

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

@ -261,7 +261,7 @@ declare function $effect(fn: () => void | (() => void)): void;
declare namespace $effect { 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. * 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: * Example:
* ```ts * ```ts

@ -141,7 +141,8 @@ export function parseCss(source) {
type: 'StyleSheetFile', type: 'StyleSheetFile',
start: 0, start: 0,
end: source.length, 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) { function extract_type_and_comment(declarator, state, path) {
const str = state.str; 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 // Try to find jsdoc above the declaration
let comment_node = /** @type {Node} */ (parent)?.leadingComments?.at(-1); 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); return /** @type {Program} */ (ast);
} catch (err) { } 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 // the `finally` block is regarded as unreachable
return handle_parse_error(err); return handle_parse_error(err);
} finally { } finally {

@ -50,6 +50,9 @@ export class Parser {
/** */ /** */
index = 0; index = 0;
/** @type {AST.CSS.CSSComment[]} */
css_comments = [];
/** /**
* Creates a minimal parser instance for CSS-only parsing. * Creates a minimal parser instance for CSS-only parsing.
* Skips Svelte component parsing setup. * Skips Svelte component parsing setup.
@ -61,6 +64,7 @@ export class Parser {
parser.template = source; parser.template = source;
parser.index = 0; parser.index = 0;
parser.loose = false; parser.loose = false;
parser.css_comments = [];
return parser; 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_ATTRIBUTE_FLAGS = /[a-zA-Z]+/y; // only `i` and `s` are valid today, but make it future-proof
const REGEX_COMBINATOR = /(\+|~|>|\|\|)/y; const REGEX_COMBINATOR = /(\+|~|>|\|\|)/y;
const REGEX_PERCENTAGE = /\d+(\.\d+)?%/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 = 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_WHITESPACE_OR_COLON = /[\s:]/;
const REGEX_LEADING_HYPHEN_OR_DIGIT = /-?\d/y; const REGEX_LEADING_HYPHEN_OR_DIGIT = /-?\d/y;
const REGEX_VALID_IDENTIFIER_CHAR = /[a-zA-Z0-9_-]/; 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) { export default function read_style(parser, start, attributes) {
const content_start = parser.index; const content_start = parser.index;
parser.css_comments = [];
const children = read_body(parser, (p) => p.match('</style') || p.index >= p.template.length); const children = read_body(parser, (p) => p.match('</style') || p.index >= p.template.length);
const content_end = parser.index; const content_end = parser.index;
@ -36,6 +40,7 @@ export default function read_style(parser, start, attributes) {
end: parser.index, end: parser.index,
attributes, attributes,
children, children,
comments: parser.css_comments,
content: { content: {
start: content_start, start: content_start,
end: content_end, end: content_end,
@ -202,15 +207,18 @@ function read_selector(parser, inside_pseudo_class = false) {
}); });
} else if (parser.eat('*')) { } else if (parser.eat('*')) {
let name = '*'; let name = '*';
/** @type {string | undefined} */
let namespace;
if (parser.eat('|')) { if (parser.eat('|')) {
// * is the namespace (which we ignore) namespace = name;
name = read_identifier(parser); name = parser.eat('*') ? '*' : read_identifier(parser);
} }
relative_selector.selectors.push({ relative_selector.selectors.push({
type: 'TypeSelector', type: 'TypeSelector',
name, name,
...(namespace !== undefined && { namespace }),
start, start,
end: parser.index end: parser.index
}); });
@ -229,18 +237,22 @@ function read_selector(parser, inside_pseudo_class = false) {
end: parser.index end: parser.index
}); });
} else if (parser.eat('::')) { } 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({ relative_selector.selectors.push({
type: 'PseudoElementSelector', type: 'PseudoElementSelector',
name: read_identifier(parser), name,
start, 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(':')) { } else if (parser.eat(':')) {
const name = read_identifier(parser); const name = read_identifier(parser);
@ -308,22 +320,25 @@ function read_selector(parser, inside_pseudo_class = false) {
}); });
} else if (!parser.match_regex(REGEX_COMBINATOR)) { } else if (!parser.match_regex(REGEX_COMBINATOR)) {
let name = read_identifier(parser); let name = read_identifier(parser);
/** @type {string | undefined} */
let namespace;
if (parser.eat('|')) { if (parser.eat('|')) {
// we ignore the namespace when trying to find matching element classes namespace = name;
name = read_identifier(parser); name = parser.eat('*') ? '*' : read_identifier(parser);
} }
relative_selector.selectors.push({ relative_selector.selectors.push({
type: 'TypeSelector', type: 'TypeSelector',
name, name,
...(namespace !== undefined && { namespace }),
start, start,
end: parser.index end: parser.index
}); });
} }
const index = 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('{'))) { if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) {
// rewind, so we know whether to continue building the selector list // 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. // 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 // this involves some duplicated work, but avoids a try-catch that would disguise errors
const start = parser.index; const start = parser.index;
read_value(parser); read_value(parser, false);
const char = parser.template[parser.index]; const char = parser.template[parser.index];
parser.index = start; parser.index = start;
@ -492,10 +507,13 @@ function read_declaration(parser) {
/** /**
* @param {Parser} parser * @param {Parser} parser
* @param {boolean} [capture_comments]
* @returns {string} * @returns {string}
*/ */
function read_value(parser) { function read_value(parser, capture_comments = true) {
let value = ''; let value = '';
/** @type {AST.CSS.CSSComment[]} */
const value_comments = [];
let escaped = false; let escaped = false;
let in_url = false; let in_url = false;
@ -523,6 +541,13 @@ function read_value(parser) {
} else if (char === '(' && value.slice(-3) === 'url') { } else if (char === '(' && value.slice(-3) === 'url') {
in_url = true; in_url = true;
} else if ((char === ';' || char === '{' || char === '}') && !in_url && !quote_mark) { } 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(); return value.trim();
} else if ( } else if (
char === '/' && char === '/' &&
@ -530,13 +555,11 @@ function read_value(parser) {
!quote_mark && !quote_mark &&
parser.template[parser.index + 1] === '*' parser.template[parser.index + 1] === '*'
) { ) {
parser.index += 2; const comment = read_comment(parser);
while (parser.index < parser.template.length) { if (capture_comments) {
if (parser.template[parser.index] === '*' && parser.template[parser.index + 1] === '/') { comment.position = value.length;
parser.index += 2; parser.css_comments.push(comment);
break; value_comments.push(comment);
}
parser.index++;
} }
continue; continue;
} }
@ -600,7 +623,8 @@ function read_identifier(parser) {
if (char === '\\') { if (char === '\\') {
const sequence = parser.match_regex(REGEX_UNICODE_SEQUENCE); const sequence = parser.match_regex(REGEX_UNICODE_SEQUENCE);
if (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; parser.index += sequence.length;
} else { } else {
identifier += '\\' + parser.template[parser.index + 1]; identifier += '\\' + parser.template[parser.index + 1];
@ -624,13 +648,16 @@ function read_identifier(parser) {
return identifier; 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(); parser.allow_whitespace();
while (parser.match('/*') || parser.match('<!--')) { while (parser.match('/*') || parser.match('<!--')) {
if (parser.eat('/*')) { if (parser.match('/*')) {
parser.read_until(REGEX_COMMENT_CLOSE); const comment = read_comment(parser);
parser.eat('*/', true); if (capture_comments) parser.css_comments.push(comment);
} }
if (parser.eat('<!--')) { 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>`). * Parse standalone CSS content (not wrapped in `<style>`).
* @param {Parser} parser * @param {Parser} parser

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

@ -122,6 +122,13 @@ const any_selector = {
*/ */
const seen = new Set(); 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 * @param {Compiler.AST.CSS.StyleSheet} stylesheet
@ -143,6 +150,9 @@ export function prune(stylesheet, elements) {
seen.clear(); seen.clear();
if ( 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( apply_selector(
selectors, selectors,
/** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule), /** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule),

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

@ -958,7 +958,7 @@ function calculate_blockers(instance, analysis) {
* @param {Set<Binding>} touched * @param {Set<Binding>} touched
* @param {Set<ESTree.Node>} seen * @param {Set<ESTree.Node>} seen
*/ */
const touch = (expression, scope, touched, seen = new Set()) => { const touch = (expression, scope, touched, seen) => {
if (seen.has(expression)) return; if (seen.has(expression)) return;
seen.add(expression); 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( walk(
node, node,
{ scope }, { scope },
@ -1044,13 +1051,7 @@ function calculate_blockers(instance, analysis) {
const rune = get_rune(node, context.state.scope); const rune = get_rune(node, context.state.scope);
if (rune === '$effect') return; if (rune === '$effect') return;
/** @type {Set<Binding>} */ touch(node, context.state.scope, writes, writes_seen);
const touched = new Set();
touch(node, context.state.scope, touched);
for (const b of touched) {
writes.add(b);
}
}, },
Identifier(node, context) { Identifier(node, context) {
const parent = /** @type {ESTree.Node} */ (context.path.at(-1)); 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: // might be called immediately, so we have to touch all references within it. Example:
// function foo() { return () => blocker; } foo(); // blocker is touched // function foo() { return () => blocker; } foo(); // blocker is touched
if (node.argument) { 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 // 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(); 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) { for (const fn of functions) {
/** @type {Set<Binding>} */ /** @type {Set<Binding>} */
const reads_writes = new Set(); const reads_writes = new Set();
@ -1231,12 +1253,15 @@ function calculate_blockers(instance, analysis) {
? /** @type {ESTree.FunctionExpression | ESTree.ArrowFunctionExpression} */ (fn.init) ? /** @type {ESTree.FunctionExpression | ESTree.ArrowFunctionExpression} */ (fn.init)
: fn; : fn;
trace_references( const fn_scope = /** @type {Scope} */ (instance.scopes.get(init));
init.body,
reads_writes, if (init.body.type === 'BlockStatement') {
reads_writes, trace_references(init.body, reads_writes, reads_writes, fn_scope);
/** @type {Scope} */ (instance.scopes.get(init)) } 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) => { const max = [...reads_writes].reduce((max, binding) => {
if (binding.blocker) { if (binding.blocker) {
@ -1310,7 +1335,8 @@ function order_reactive_statements(unsorted_reactive_declarations) {
* @returns * @returns
*/ */
const add_declaration = (node, declaration) => { 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) { for (const binding of declaration.dependencies) {
if (declaration.assignments.has(binding)) continue; if (declaration.assignments.has(binding)) continue;

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

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

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

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

@ -4,7 +4,11 @@
/** @import { Scope } from '../../../scope' */ /** @import { Scope } from '../../../scope' */
/** @import { NodeLike } from '../../../../errors.js' */ /** @import { NodeLike } from '../../../../errors.js' */
import * as e from '../../../../errors.js'; import * as e from '../../../../errors.js';
import { extract_identifiers, 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 w from '../../../../warnings.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
@ -298,3 +302,21 @@ export function validate_export(node, scope, name) {
e.state_invalid_export(node); 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) { 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) { if (statement === undefined) {
throw new Error('Could not find reactive statement'); throw new Error('Could not find reactive statement');
} }
instance.body.push(statement[1]); instance.body.push(statement);
} }
if (analysis.reactive_statements.size > 0) { 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 { AST } from '#compiler' */
/** @import { Context } from '../types.js' */ /** @import { Context } from '../types.js' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
@ -79,8 +79,13 @@ function build_assignment(operator, left, right, context) {
// special case — assignment to private state field // special case — assignment to private state field
if (left.property.type === 'PrivateIdentifier') { if (left.property.type === 'PrivateIdentifier') {
const logical_operator = ['||=', '&&=', '??='].includes(operator)
? /** @type {LogicalOperator} */ (operator.slice(0, -1))
: null;
let value = /** @type {Expression} */ ( 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 = const needs_proxy =
@ -88,7 +93,15 @@ function build_assignment(operator, left, right, context) {
is_non_coercive_operator(operator) && is_non_coercive_operator(operator) &&
should_proxy(value, context.state.scope); should_proxy(value, context.state.scope);
return b.call('$.set', left, value, needs_proxy && b.true); 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 transform
}); });
// TODO optimise the simple `{ x } = y` case — we can just return `y` const is_simple_object_pattern =
// instead of destructuring it only to return a new object 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( const init = build_expression(
{ ...context, state: child_state }, { ...context, state: child_state },
declaration.init, declaration.init,
node.metadata.expression 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.const(/** @type {Pattern} */ (context.visit(declaration.id, child_state)), init),
b.return(b.object(identifiers.map((node) => b.prop('init', node, node)))) b.return(b.object(identifiers.map((node) => b.prop('init', node, node))))
]); ]);

@ -4,7 +4,6 @@
/** @import { Scope } from '../../../scope' */ /** @import { Scope } from '../../../scope' */
import { import {
cannot_be_set_statically, cannot_be_set_statically,
is_boolean_attribute,
is_dom_property, is_dom_property,
is_load_error_element is_load_error_element
} from '../../../../../utils.js'; } 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 * as b from '#compiler/builders';
import { import {
create_attribute, create_attribute,
ExpressionMetadata,
is_custom_element_node, is_custom_element_node,
is_customizable_select_element is_customizable_select_element
} from '../../../nodes.js'; } from '../../../nodes.js';
@ -528,18 +526,12 @@ export function build_class_directives_object(
) { ) {
let properties = []; let properties = [];
const metadata = new ExpressionMetadata();
for (const d of class_directives) { for (const d of class_directives) {
metadata.merge(d.metadata.expression);
const expression = /** @type Expression */ (context.visit(d.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 b.object(properties);
return memoizer.add(directives, metadata);
} }
/** /**
@ -555,23 +547,17 @@ export function build_style_directives_object(
const normal = b.object([]); const normal = b.object([]);
const important = b.object([]); const important = b.object([]);
const metadata = new ExpressionMetadata();
for (const d of style_directives) { for (const d of style_directives) {
metadata.merge(d.metadata.expression);
const expression = const expression =
d.value === true d.value === true
? build_getter(b.id(d.name), context.state) ? build_getter(b.id(d.name), context.state)
: build_attribute_value(d.value, context).value; : build_attribute_value(d.value, context).value;
const object = d.modifiers.includes('important') ? important : normal; 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 important.properties.length ? b.array([normal, important]) : normal;
return memoizer.add(directives, metadata);
} }
/** /**
@ -664,14 +650,25 @@ function build_element_attribute_update(element, node_id, name, value, attribute
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
function build_custom_element_attribute_update_assignment(node_id, attribute, 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 // 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); 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, // this is different from other updates — it doesn't get grouped,
// because set_custom_element_data may not be idempotent // 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)); context.state.init.push(b.stmt(update));
} }

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

@ -355,7 +355,11 @@ const visitors = {
continue; 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); context.state.code.update(selector.start, selector.end, modifier);
} else { } else {
context.state.code.appendLeft(selector.end, modifier); 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') { if (node.name === 'is' || node.name === 'where' || node.name === 'has' || node.name === 'not') {
context.next(); 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 = []; const legacy_reactive_declarations = [];
for (const [node] of analysis.reactive_statements) { 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) { if (statement === undefined) {
throw new Error('Could not find reactive statement'); 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) { if (legacy_reactive_declarations.length > 0) {
@ -210,14 +210,25 @@ export function server_component(analysis, options) {
]; ];
} }
if ( const store_subs = [...analysis.instance.scope.declarations.values()].filter(
[...analysis.instance.scope.declarations.values()].some(
(binding) => binding.kind === 'store_sub' (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')); 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( 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) { if (should_inject_context) {
component_block = b.block([ component_block = b.block([

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

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

@ -3,7 +3,7 @@
/** @import { ComponentContext, ComponentServerTransformState } from '../../types.js' */ /** @import { ComponentContext, ComponentServerTransformState } from '../../types.js' */
import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js'; import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js';
import { binding_properties } from '../../../../bindings.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 { regex_starts_with_newline } from '../../../../patterns.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { import {
@ -21,6 +21,11 @@ import { escape_html } from '../../../../../../escaping.js';
const WHITESPACE_INSENSITIVE_ATTRIBUTES = ['class', 'style']; 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 * 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. * 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 * @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/ */
export function build_element_attributes(node, context, transform) { export function build_element_attributes(node, context, transform) {
/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */ /** @type {Array<AST.Attribute | AST.SpreadAttribute | TransformedAttribute>} */
const attributes = []; const attributes = [];
/** @type {AST.ClassDirective[]} */ /** @type {AST.ClassDirective[]} */
@ -145,42 +150,26 @@ export function build_element_attributes(node, context, transform) {
attr.value[0].data === 'checkbox' attr.value[0].data === 'checkbox'
); );
attributes.push( attributes.push({
create_attribute('checked', null, -1, -1, [ type: 'transformed',
{ name: 'checked',
type: 'ExpressionTag',
start: -1,
end: -1,
expression: is_checkbox expression: is_checkbox
? b.call( ? b.call(
b.member(attribute.expression, 'includes'), b.member(expression, 'includes'),
build_attribute_value(value_attribute.value, context, transform) build_attribute_value(value_attribute.value, context, transform)
) )
: b.binary( : b.binary(
'===', '===',
attribute.expression, expression,
build_attribute_value(value_attribute.value, context, transform) build_attribute_value(value_attribute.value, context, transform)
), )
metadata: { });
expression: new ExpressionMetadata()
}
}
])
);
} else { } else {
attributes.push( attributes.push({
create_attribute(attribute.name, null, -1, -1, [ type: 'transformed',
{ name: get_attribute_name(node, attribute),
type: 'ExpressionTag', expression
start: -1, });
end: -1,
expression,
metadata: {
expression: new ExpressionMetadata()
}
}
])
);
} }
} else if (attribute.type === 'SpreadAttribute') { } else if (attribute.type === 'SpreadAttribute') {
attributes.push(attribute); attributes.push(attribute);
@ -217,7 +206,21 @@ export function build_element_attributes(node, context, transform) {
} else { } else {
const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null; 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 name = get_attribute_name(node, attribute);
const can_use_literal = const can_use_literal =
(name !== 'class' || class_directives.length === 0) && (name !== 'class' || class_directives.length === 0) &&
@ -298,14 +301,16 @@ function get_attribute_name(element, attribute) {
/** /**
* @param {AST.RegularElement | AST.SvelteElement} element * @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 {ComponentContext} context
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform * @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/ */
export function build_spread_object(element, attributes, context, transform) { export function build_spread_object(element, attributes, context, transform) {
const object = b.object( const object = b.object(
attributes.map((attribute) => { 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 name = get_attribute_name(element, attribute);
const value = build_attribute_value( const value = build_attribute_value(
attribute.value, attribute.value,
@ -340,7 +345,7 @@ export function build_spread_object(element, attributes, context, transform) {
/** /**
* *
* @param {AST.RegularElement | AST.SvelteElement} element * @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.StyleDirective[]} style_directives
* @param {AST.ClassDirective[]} class_directives * @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context * @param {ComponentContext} context
@ -356,7 +361,7 @@ function build_element_spread_attributes(
) { ) {
const args = prepare_element_spread( const args = prepare_element_spread(
element, element,
/** @type {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective>} */ (attributes), attributes,
style_directives, style_directives,
class_directives, class_directives,
context, 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. * Prepare args for $.attributes(...): compute object, css_hash, classes, styles and flags.
* @param {AST.RegularElement | AST.SvelteElement} element * @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.StyleDirective[]} style_directives
* @param {AST.ClassDirective[]} class_directives * @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context * @param {ComponentContext} context

@ -286,7 +286,7 @@ export function build_getter(node, state) {
} }
if (binding.kind === 'derived') { 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; return node;

@ -7,6 +7,68 @@ import { is_void } from '../../utils.js';
/** Threshold for when content should be formatted on separate lines */ /** Threshold for when content should be formatted on separate lines */
const LINE_BREAK_THRESHOLD = 50; 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. * `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. * 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) { export function print(ast, options = undefined) {
const comments = (ast.type === 'Root' && ast.comments) || []; 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( return esrap.print(
ast, ast,
@ -28,18 +94,27 @@ export function print(ast, options = undefined) {
getLeadingComments: options?.getLeadingComments, getLeadingComments: options?.getLeadingComments,
getTrailingComments: options?.getTrailingComments getTrailingComments: options?.getTrailingComments
}), }),
...svelte_visitors(comments), ...svelte_visitors(comments, state),
...css_visitors ...css_visitors(css_comments, comments)
}) }),
{
indent: options?.indent
}
); );
} }
/** /**
* @param {Context} context * @param {Context} context
* @param {AST.SvelteNode} node * @param {AST.SvelteNode} node
* @param {boolean} preserve_whitespace
* @param {boolean} allow_inline * @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(); const child_context = context.new();
child_context.visit(node); child_context.visit(node);
@ -79,6 +154,7 @@ function attributes(node, attributes, context, comments) {
} }
const separator = context.new(); const separator = context.new();
let previous_attribute_end = node.start;
const children = attributes.map((attribute) => { const children = attributes.map((attribute) => {
const child_context = context.new(); const child_context = context.new();
@ -87,6 +163,9 @@ function attributes(node, attributes, context, comments) {
const comment = comments[comment_index]; const comment = comments[comment_index];
if (comment.start < attribute.start) { 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') { if (comment.type === 'Line') {
child_context.write('//' + comment.value); child_context.write('//' + comment.value);
child_context.newline(); child_context.newline();
@ -94,6 +173,7 @@ function attributes(node, attributes, context, comments) {
child_context.write('/*' + comment.value + '*/'); // TODO match indentation? child_context.write('/*' + comment.value + '*/'); // TODO match indentation?
child_context.append(separator); child_context.append(separator);
} }
}
comment_index += 1; comment_index += 1;
} else { } else {
@ -102,6 +182,7 @@ function attributes(node, attributes, context, comments) {
} }
child_context.visit(attribute); child_context.visit(attribute);
previous_attribute_end = attribute.end;
length += child_context.measure() + 1; length += child_context.measure() + 1;
@ -134,8 +215,9 @@ function attributes(node, attributes, context, comments) {
* @param {AST.BaseElement} node * @param {AST.BaseElement} node
* @param {Context} context * @param {Context} context
* @param {AST.JSComment[]} comments * @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(); const child_context = context.new();
child_context.write('<' + node.name); child_context.write('<' + node.name);
@ -161,18 +243,156 @@ function base_element(node, context, comments) {
child_context.write(`${multiline_attributes ? '' : ' '}/>`); child_context.write(`${multiline_attributes ? '' : ' '}/>`);
} else { } else {
child_context.write('>'); child_context.write('>');
block(child_context, node.fragment, true); block(child_context, node.fragment, state.preserve_whitespace > 0, true);
child_context.write(`</${node.name}>`); child_context.write(`</${node.name}>`);
} }
context.append(child_context); 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) { Atrule(node, context) {
context.write(`@${node.name}`); context.write(`@${escape_identifier(node.name)}`);
if (node.prelude) context.write(` ${node.prelude}`);
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) { if (node.block) {
context.write(' '); context.write(' ');
@ -183,13 +403,11 @@ const css_visitors = {
}, },
AttributeSelector(node, context) { AttributeSelector(node, context) {
context.write(`[${node.name}`); context.write(`[${escape_identifier(node.name)}`);
if (node.matcher) { if (node.matcher) {
context.write(node.matcher); context.write(node.matcher);
context.write(`"${node.value}"`); context.write(`"${node.value}"`);
if (node.flags) { if (node.flags) context.write(` ${node.flags}`);
context.write(` ${node.flags}`);
}
} }
context.write(']'); context.write(']');
}, },
@ -197,22 +415,10 @@ const css_visitors = {
Block(node, context) { Block(node, context) {
context.write('{'); context.write('{');
if (node.children.length > 0) { if (node.children.length > 0 || has_comment_before(node.end)) {
context.indent(); context.indent();
context.newline(); context.newline();
print_children(context, node.children, node.end, false);
let started = false;
for (const child of node.children) {
if (started) {
context.newline();
}
context.visit(child);
started = true;
}
context.dedent(); context.dedent();
context.newline(); context.newline();
} }
@ -221,21 +427,21 @@ const css_visitors = {
}, },
ClassSelector(node, context) { ClassSelector(node, context) {
context.write(`.${node.name}`); context.write(`.${escape_identifier(node.name)}`);
}, },
ComplexSelector(node, context) { ComplexSelector(node, context) {
for (const selector of node.children) { for (const selector of node.children) context.visit(selector);
context.visit(selector);
}
}, },
Declaration(node, context) { 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) { IdSelector(node, context) {
context.write(`#${node.name}`); context.write(`#${escape_identifier(node.name)}`);
}, },
NestingSelector(node, context) { NestingSelector(node, context) {
@ -251,84 +457,84 @@ const css_visitors = {
}, },
PseudoClassSelector(node, context) { PseudoClassSelector(node, context) {
context.write(`:${node.name}`); context.write(`:${escape_identifier(node.name)}`);
if (node.args) { if (node.args) {
context.write('('); context.write('(');
context.visit(node.args);
let started = false; if (has_comment_before(node.end)) {
context.write(' ');
for (const arg of node.args.children) { write_inline_comments(context, node.end);
if (started) {
context.write(', ');
}
context.visit(arg);
started = true;
} }
context.write(')'); context.write(')');
} }
}, },
PseudoElementSelector(node, context) { 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) { RelativeSelector(node, context) {
if (node.combinator) { if (node.combinator) {
if (node.combinator.name === ' ') { if (node.combinator.name === ' ') context.write(' ');
context.write(' '); else context.write(` ${node.combinator.name} `);
} else {
context.write(` ${node.combinator.name} `);
}
} }
for (const selector of node.selectors) { for (const selector of node.selectors) context.visit(selector);
context.visit(selector);
}
}, },
Rule(node, context) { Rule(node, context) {
let started = false; print_selector_list(node.prelude, context, true);
for (const selector of node.prelude.children) {
if (started) {
context.write(',');
context.newline();
}
context.visit(selector);
started = true;
}
context.write(' '); context.write(' ');
if (write_inline_comments(context, node.block.start)) context.write(' ');
context.visit(node.block); context.visit(node.block);
}, },
SelectorList(node, context) { SelectorList(node, context) {
let started = false; print_selector_list(node, context, false);
for (const selector of node.children) { },
if (started) {
context.write(', ');
}
context.visit(selector); StyleSheet(node, context) {
started = true; 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) { 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 {AST.JSComment[]} comments
* @param {{ preserve_whitespace: number }} state
* @returns {Visitors<AST.SvelteNode>} * @returns {Visitors<AST.SvelteNode>}
*/ */
const svelte_visitors = (comments) => ({ const svelte_visitors = (comments, state) => ({
Root(node, context) { Root(node, context) {
if (node.options) { if (node.options) {
context.write('<svelte:options'); context.write('<svelte:options');
@ -360,20 +566,40 @@ const svelte_visitors = (comments) => ({
context.write('<script'); context.write('<script');
attributes(node, node.attributes, context, comments); attributes(node, node.attributes, context, comments);
context.write('>'); context.write('>');
block(context, node.content); block(context, node.content, state.preserve_whitespace > 0);
context.write('</script>'); context.write('</script>');
}, },
Fragment(node, context) { 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 = []; const items = [];
/** @type {AST.SvelteNode[]} */ /** @type {AST.SvelteNode[]} */
let sequence = []; let sequence = [];
let leading_whitespace = false;
const flush = () => { const flush = () => {
items.push(sequence); if (sequence.length > 0) {
items.push({ nodes: sequence, leading_whitespace });
sequence = []; sequence = [];
leading_whitespace = false;
}
}; };
for (let i = 0; i < node.nodes.length; i += 1) { 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') { if (child_node.data.startsWith(' ') && prev && prev.type !== 'ExpressionTag') {
flush(); flush();
leading_whitespace = true;
child_node.data = child_node.data.trimStart(); child_node.data = child_node.data.trimStart();
} }
@ -440,35 +667,39 @@ const svelte_visitors = (comments) => ({
let multiline = false; let multiline = false;
let width = 0; let width = 0;
const child_contexts = items const child_contexts = items.map(({ nodes, leading_whitespace }) => {
.filter((x) => x.length > 0)
.map((sequence) => {
const child_context = context.new(); const child_context = context.new();
for (const node of sequence) { for (const node of nodes) {
child_context.visit(node); child_context.visit(node);
multiline ||= child_context.multiline; 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; 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) { for (let i = 0; i < child_contexts.length; i += 1) {
const prev = child_contexts[i]; const prev = child_contexts[i];
const next = child_contexts[i + 1]; const next = child_contexts[i + 1];
context.append(prev); context.append(prev.context);
if (next) { if (next) {
if (prev.multiline || next.multiline) { if (prev.context.multiline || next.context.multiline) {
context.margin(); context.margin();
context.newline(); context.newline();
} else if (multiline) { } else if (multiline) {
context.newline(); context.newline();
} else if (next.leading_whitespace) {
context.write(' ');
} }
} }
} }
@ -522,7 +753,7 @@ const svelte_visitors = (comments) => ({
if (node.pending) { if (node.pending) {
context.write('}'); context.write('}');
block(context, node.pending); block(context, node.pending, state.preserve_whitespace > 0);
context.write('{:'); context.write('{:');
} else { } else {
context.write(' '); context.write(' ');
@ -533,7 +764,7 @@ const svelte_visitors = (comments) => ({
if (node.value) context.visit(node.value); if (node.value) context.visit(node.value);
context.write('}'); context.write('}');
block(context, node.then); block(context, node.then, state.preserve_whitespace > 0);
if (node.catch) { if (node.catch) {
context.write('{:'); context.write('{:');
@ -541,11 +772,11 @@ const svelte_visitors = (comments) => ({
} }
if (node.catch) { if (node.catch) {
context.write(node.value ? 'catch ' : 'catch'); context.write(node.error ? 'catch ' : 'catch');
if (node.error) context.visit(node.error); if (node.error) context.visit(node.error);
context.write('}'); context.write('}');
block(context, node.catch); block(context, node.catch, state.preserve_whitespace > 0);
} }
context.write('{/await}'); context.write('{/await}');
@ -589,7 +820,7 @@ const svelte_visitors = (comments) => ({
}, },
Component(node, context) { Component(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
ConstTag(node, context) { ConstTag(node, context) {
@ -679,11 +910,11 @@ const svelte_visitors = (comments) => ({
context.write('}'); context.write('}');
block(context, node.body); block(context, node.body, state.preserve_whitespace > 0);
if (node.fallback) { if (node.fallback) {
context.write('{:else}'); context.write('{:else}');
block(context, node.fallback); block(context, node.fallback, state.preserve_whitespace > 0);
} }
context.write('{/each}'); context.write('{/each}');
@ -707,13 +938,13 @@ const svelte_visitors = (comments) => ({
context.visit(node.test); context.visit(node.test);
context.write('}'); context.write('}');
block(context, node.consequent); block(context, node.consequent, state.preserve_whitespace > 0);
} else { } else {
context.write('{#if '); context.write('{#if ');
context.visit(node.test); context.visit(node.test);
context.write('}'); context.write('}');
block(context, node.consequent); block(context, node.consequent, state.preserve_whitespace > 0);
} }
if (node.alternate !== null) { if (node.alternate !== null) {
@ -725,7 +956,7 @@ const svelte_visitors = (comments) => ({
) )
) { ) {
context.write('{:else}'); context.write('{:else}');
block(context, node.alternate); block(context, node.alternate, state.preserve_whitespace > 0);
} else { } else {
context.visit(node.alternate); context.visit(node.alternate);
} }
@ -740,7 +971,7 @@ const svelte_visitors = (comments) => ({
context.write('{#key '); context.write('{#key ');
context.visit(node.expression); context.visit(node.expression);
context.write('}'); context.write('}');
block(context, node.fragment); block(context, node.fragment, state.preserve_whitespace > 0);
context.write('{/key}'); context.write('{/key}');
}, },
@ -772,7 +1003,7 @@ const svelte_visitors = (comments) => ({
}, },
RegularElement(node, context) { RegularElement(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
RenderTag(node, context) { RenderTag(node, context) {
@ -782,7 +1013,7 @@ const svelte_visitors = (comments) => ({
}, },
SlotElement(node, context) { SlotElement(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
SnippetBlock(node, context) { SnippetBlock(node, context) {
@ -801,7 +1032,7 @@ const svelte_visitors = (comments) => ({
} }
context.write(')}'); context.write(')}');
block(context, node.body); block(context, node.body, state.preserve_whitespace > 0);
context.write('{/snippet}'); 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) { SvelteBody(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
SvelteBoundary(node, context) { SvelteBoundary(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
SvelteComponent(node, context) { SvelteComponent(node, context) {
@ -881,7 +1084,7 @@ const svelte_visitors = (comments) => ({
attributes(node, node.attributes, context, comments); attributes(node, node.attributes, context, comments);
if (node.fragment && node.fragment.nodes.length > 0) { if (node.fragment && node.fragment.nodes.length > 0) {
context.write('>'); context.write('>');
block(context, node.fragment, true); block(context, node.fragment, state.preserve_whitespace > 0, true);
context.write(`</svelte:component>`); context.write(`</svelte:component>`);
} else { } else {
context.write(' />'); context.write(' />');
@ -889,7 +1092,7 @@ const svelte_visitors = (comments) => ({
}, },
SvelteDocument(node, context) { SvelteDocument(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
SvelteElement(node, context) { SvelteElement(node, context) {
@ -902,7 +1105,7 @@ const svelte_visitors = (comments) => ({
if (node.fragment && node.fragment.nodes.length > 0) { if (node.fragment && node.fragment.nodes.length > 0) {
context.write('>'); context.write('>');
block(context, node.fragment); block(context, node.fragment, state.preserve_whitespace > 0);
context.write(`</svelte:element>`); context.write(`</svelte:element>`);
} else { } else {
context.write(' />'); context.write(' />');
@ -910,19 +1113,19 @@ const svelte_visitors = (comments) => ({
}, },
SvelteFragment(node, context) { SvelteFragment(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
SvelteHead(node, context) { SvelteHead(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
SvelteSelf(node, context) { SvelteSelf(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
SvelteWindow(node, context) { SvelteWindow(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
Text(node, context) { Text(node, context) {
@ -930,7 +1133,7 @@ const svelte_visitors = (comments) => ({
}, },
TitleElement(node, context) { TitleElement(node, context) {
base_element(node, context, comments); print_element(node, context, comments, state);
}, },
TransitionDirective(node, context) { TransitionDirective(node, context) {

@ -4,4 +4,5 @@ import type ts from 'esrap/languages/ts';
export type Options = { export type Options = {
getLeadingComments?: NonNullable<Parameters<typeof ts>[0]>['getLeadingComments'] | undefined; getLeadingComments?: NonNullable<Parameters<typeof ts>[0]>['getLeadingComments'] | undefined;
getTrailingComments?: NonNullable<Parameters<typeof ts>[0]>['getTrailingComments'] | 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 { export interface StyleSheetBase extends BaseNode {
children: Array<Atrule | Rule>; 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 { export interface StyleSheetFile extends StyleSheetBase {
@ -112,6 +121,7 @@ export namespace _CSS {
export interface TypeSelector extends BaseNode { export interface TypeSelector extends BaseNode {
type: 'TypeSelector'; type: 'TypeSelector';
name: string; name: string;
namespace?: string;
} }
export interface IdSelector extends BaseNode { export interface IdSelector extends BaseNode {
@ -135,6 +145,7 @@ export namespace _CSS {
export interface PseudoElementSelector extends BaseNode { export interface PseudoElementSelector extends BaseNode {
type: 'PseudoElementSelector'; type: 'PseudoElementSelector';
name: string; name: string;
args?: SelectorList;
} }
export interface PseudoClassSelector extends BaseNode { 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 * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -12,6 +14,8 @@ export function linear(t) {
} }
/** /**
* Rebound effect on start and end of value range.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -22,6 +26,8 @@ export function backInOut(t) {
} }
/** /**
* Rebound effect on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -31,6 +37,8 @@ export function backIn(t) {
} }
/** /**
* Rebound effect on end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -40,6 +48,8 @@ export function backOut(t) {
} }
/** /**
* Bounce effect on end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -61,6 +71,8 @@ export function bounceOut(t) {
} }
/** /**
* Bounce effect on start and end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -69,6 +81,8 @@ export function bounceInOut(t) {
} }
/** /**
* Bounce effect on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -77,6 +91,8 @@ export function bounceIn(t) {
} }
/** /**
* Circular effect, accelerate on start, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -86,6 +102,8 @@ export function circInOut(t) {
} }
/** /**
* Circular effect, accelerate on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -94,6 +112,8 @@ export function circIn(t) {
} }
/** /**
* Circular effect, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -102,6 +122,8 @@ export function circOut(t) {
} }
/** /**
* Cubic scaling, accelerate on start, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -110,6 +132,8 @@ export function cubicInOut(t) {
} }
/** /**
* Cubic scaling, accelerate on start
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -118,6 +142,8 @@ export function cubicIn(t) {
} }
/** /**
* Cubic scaling, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -127,6 +153,8 @@ export function cubicOut(t) {
} }
/** /**
* Elastic effect on start and end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -140,6 +168,8 @@ export function elasticInOut(t) {
} }
/** /**
* Elastic effect on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -148,6 +178,8 @@ export function elasticIn(t) {
} }
/** /**
* Elastic effect on end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -156,6 +188,8 @@ export function elasticOut(t) {
} }
/** /**
* Exponential effect on start and end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -168,6 +202,8 @@ export function expoInOut(t) {
} }
/** /**
* Exponential effect on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -176,6 +212,8 @@ export function expoIn(t) {
} }
/** /**
* Exponential effect on end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -184,6 +222,8 @@ export function expoOut(t) {
} }
/** /**
* Quadratic scaling, accelerate on start, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -195,6 +235,8 @@ export function quadInOut(t) {
} }
/** /**
* Quadratic scaling, accelerate on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -203,6 +245,8 @@ export function quadIn(t) {
} }
/** /**
* Quadratic scaling, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -211,6 +255,8 @@ export function quadOut(t) {
} }
/** /**
* Quartic scaling, accelerate on start, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -219,6 +265,8 @@ export function quartInOut(t) {
} }
/** /**
* Quartic scaling, accelerate on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -227,6 +275,8 @@ export function quartIn(t) {
} }
/** /**
* Quartic scaling, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -235,6 +285,8 @@ export function quartOut(t) {
} }
/** /**
* Quintic scaling, accelerate on start, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -244,6 +296,8 @@ export function quintInOut(t) {
} }
/** /**
* Quintic scaling, accelerate on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -252,6 +306,8 @@ export function quintIn(t) {
} }
/** /**
* Quintic scaling, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -260,6 +316,8 @@ export function quintOut(t) {
} }
/** /**
* Sinusoidal effect, accelerate on start, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -268,6 +326,8 @@ export function sineInOut(t) {
} }
/** /**
* Sinusoidal effect, accelerate on start.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */
@ -278,6 +338,8 @@ export function sineIn(t) {
} }
/** /**
* Sinusoidal effect, decelerate towards end.
*
* @param {number} t * @param {number} t
* @returns {number} * @returns {number}
*/ */

@ -15,6 +15,13 @@ export const BLOCK_EFFECT = 1 << 4;
export const BRANCH_EFFECT = 1 << 5; export const BRANCH_EFFECT = 1 << 5;
export const ROOT_EFFECT = 1 << 6; export const ROOT_EFFECT = 1 << 6;
export const BOUNDARY_EFFECT = 1 << 7; 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, * 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 * 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 ERROR_VALUE = 1 << 23;
export const STATE_SYMBOL = Symbol('$state'); 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 LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol(''); export const LOADING_ATTR_SYMBOL = Symbol('');
export const PROXY_PATH_SYMBOL = Symbol('proxy path'); 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 { create_user_effect } from './reactivity/effects.js';
import { async_mode_flag, legacy_mode_flag } from '../flags/index.js'; import { async_mode_flag, legacy_mode_flag } from '../flags/index.js';
import { FILENAME } from '../../constants.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} */ /** @type {ComponentContext | null} */
export let component_context = 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. * 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 * @template T
* @returns {[() => T, (context: T) => T]} * @returns {[() => T, (context: T) => T]}
* @since 5.40.0 * @since 5.40.0
*/ */
export function createContext() { export function createContext() {
const key = {}; return /** @type {[() => T, (context: T) => T]} */ (
create_context(getContext, setContext, hasContext)
return [ );
() => {
if (!hasContext(key)) {
e.missing_context();
}
return getContext(key);
},
(context) => setContext(key, context)
];
} }
/** /**
* 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. * Must be called during component initialisation.
* *
* [`createContext`](https://svelte.dev/docs/svelte/svelte#createContext) is a type-safe alternative. * [`createContext`](https://svelte.dev/docs/svelte/svelte#createContext) is a type-safe alternative.
@ -104,15 +100,15 @@ export function createContext() {
* @returns {T} * @returns {T}
*/ */
export function getContext(key) { 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)); const result = /** @type {T} */ (context_map.get(key));
return result; return result;
} }
/** /**
* Associates an arbitrary `context` object with the current component and the specified `key` * 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 * and returns that object. The context is then available to the component itself and all of its
* (including slotted content) with `getContext`. * descendants (including slotted content) with `getContext`.
* *
* Like lifecycle functions, this must be called during component initialisation. * Like lifecycle functions, this must be called during component initialisation.
* *
@ -124,7 +120,7 @@ export function getContext(key) {
* @returns {T} * @returns {T}
*/ */
export function setContext(key, context) { 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) { if (async_mode_flag) {
var flags = /** @type {Effect} */ (active_effect).f; 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. * Checks whether a given `key` has been set in the context of the current component or any of
* Must be called during component initialisation. * its ancestors. Must be called during component initialisation.
* *
* @param {any} key * @param {any} key
* @returns {boolean} * @returns {boolean}
*/ */
export function hasContext(key) { 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); return context_map.has(key);
} }
/** /**
* Retrieves the whole context map that belongs to the closest parent component. * Retrieves the whole context map that belongs to the current component, including entries
* Must be called during component initialisation. Useful, for example, if you * inherited from its ancestors. Must be called during component initialisation. Useful, for
* programmatically create a component and want to pass the existing context to it. * example, if you programmatically create a component and want to pass the existing context to it.
* *
* @template {Map<any, any>} [T=Map<any, any>] * @template {Map<any, any>} [T=Map<any, any>]
* @returns {T} * @returns {T}
*/ */
export function getAllContexts() { 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); return /** @type {T} */ (context_map);
} }
@ -222,38 +218,19 @@ export function pop(component) {
dev_current_component_function = component_context?.function ?? null; dev_current_component_function = component_context?.function ?? null;
} }
return component ?? /** @type {T} */ ({}); return mark_as_component(component);
}
/** @returns {boolean} */
export function is_runes() {
return !legacy_mode_flag || (component_context !== null && component_context.l === null);
} }
/** /**
* @param {string} name * Add a symbol to the object (or create one if undefined) to mark it as a component so it isn't proxified.
* @returns {Map<unknown, unknown>} * @param {any} component
*/ */
function get_or_init_context_map(name) { export function mark_as_component(component = {}) {
if (component_context === null) { define_property(component, COMPONENT_SYMBOL, { value: true });
e.lifecycle_outside_component(name); return component;
}
return (component_context.c ??= new Map(get_parent_context(component_context) || undefined));
} }
/** /** @returns {boolean} */
* @param {ComponentContext} component_context export function is_runes() {
* @returns {Map<unknown, unknown> | null} return !legacy_mode_flag || (component_context !== null && component_context.l === 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;
} }

@ -57,10 +57,21 @@ export function hmr(fn) {
if (ran) set_should_intro(true); if (ran) set_should_intro(true);
}); });
// Forward the nodes from the inner effect to the outer active effect which would // Forward the start/end DOM nodes from the inner effect to the outer active effect
// get them if the HMR wrapper wasn't there. Do this inside the block not outside // which would get them if the HMR wrapper wasn't there. Do this inside the block not
// so that HMR updates to the component will also update the nodes on the active effect. // outside so that HMR updates to the component will also update the nodes on the
/** @type {Effect} */ (active_effect).nodes = effect.nodes; // 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); }, EFFECT_TRANSPARENT);
ran = true; ran = true;

@ -199,17 +199,68 @@ export class Boundary {
*/ */
#hydrate_failed_content(error) { #hydrate_failed_content(error) {
const failed = this.#props.failed; 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; if (!failed) return;
this.#failed_effect = branch(() => { this.#failed_effect = branch(() => {
failed( failed(
this.#anchor, this.#anchor,
() => error, () => 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() { #hydrate_pending_content() {
const pending = this.#props.pending; const pending = this.#props.pending;
if (!pending) return; if (!pending) return;
@ -429,43 +480,13 @@ export class Boundary {
set_hydrate_node(skip_nodes()); set_hydrate_node(skip_nodes());
} }
var onerror = this.#props.onerror;
let failed = this.#props.failed; 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 */ /** @param {unknown} transformed_error */
const handle_error_result = (transformed_error) => { const handle_error_result = (transformed_error) => {
try { const { reset, invoke_onerror } = this.#create_reset(transformed_error);
calling_on_error = true;
onerror?.(transformed_error, reset); invoke_onerror();
calling_on_error = false;
} catch (error) {
invoke_error_boundary(error, this.#effect && this.#effect.parent);
}
if (failed) { if (failed) {
this.#failed_effect = this.#run(() => { this.#failed_effect = this.#run(() => {

@ -18,10 +18,10 @@ export function css_props(element, get_styles) {
for (var key in styles) { for (var key in styles) {
var value = styles[key]; var value = styles[key];
if (value) { if (value == null || value === '') {
element.style.setProperty(key, value);
} else {
element.style.removeProperty(key); 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 (remaining === 0) {
// If we're in a controlled each block (i.e. the block is the only child of an // 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, // 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 // we can use the fast path — emptying the element and replacing the anchor.
var fast_path = transitions.length === 0 && controlled_anchor !== null; // 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) { if (fast_path) {
var anchor = /** @type {Element} */ (controlled_anchor); var anchor = /** @type {Element} */ (controlled_anchor);

@ -2,14 +2,19 @@ import { DEV } from 'esm-env';
import { register_style } from '../dev/css.js'; import { register_style } from '../dev/css.js';
import { effect } from '../reactivity/effects.js'; import { effect } from '../reactivity/effects.js';
import { create_element } from './operations.js'; import { create_element } from './operations.js';
import { active_effect } from '../runtime.js';
/** /**
* @param {Node} anchor * @param {Node} anchor
* @param {{ hash: string, code: string }} css * @param {{ hash: string, code: string }} css
*/ */
export function append_styles(anchor, 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(() => { 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 root = anchor.getRootNode();
var target = /** @type {ShadowRoot} */ (root).host var target = /** @type {ShadowRoot} */ (root).host

@ -315,7 +315,8 @@ function set_attributes(
var is_option_element = element.nodeName === OPTION_TAG; var is_option_element = element.nodeName === OPTION_TAG;
for (var key in prev) { 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; next[key] = null;
} }
} }

@ -55,7 +55,12 @@ export function select_option(select, value, mounting = false) {
* @param {HTMLSelectElement} select * @param {HTMLSelectElement} select
*/ */
export function init_select(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 // @ts-ignore
select_option(select, select.__value); select_option(select, select.__value);
// Deliberately don't update the potential binding value, // Deliberately don't update the potential binding value,
@ -162,3 +167,22 @@ function get_option_value(option) {
return option.value; 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 { ComponentContext, Effect } from '#client' */
import { DESTROYING, STATE_SYMBOL } from '#client/constants'; 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 { effect, render_effect } from '../../../reactivity/effects.js';
import { active_effect, untrack } from '../../../runtime.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 all the parts of the each block context that are used in the expression
* @returns {void} * @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 component_effect = /** @type {ComponentContext} */ (component_context).r;
var parent = /** @type {Effect} */ (active_effect); var parent = /** @type {Effect} */ (active_effect);

@ -159,12 +159,15 @@ export function delegate(events) {
} }
// used to store the reference to the currently propagated event // 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 // If the event object is GCed too early, the expando __root property
// set on the event object is lost, causing the event delegation // set on the event object is lost, causing the event delegation
// to process the event twice // to process the event twice
let last_propagated_event = null; 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} * @this {EventTarget}
* @param {Event} event * @param {Event} event
@ -179,6 +182,21 @@ export function handle_event_propagation(event) {
last_propagated_event = 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. // 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 // 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 // parent of the `event_symbol` node, which indicates that there's nested

@ -3,7 +3,14 @@
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { FILENAME } from '../../constants.js'; import { FILENAME } from '../../constants.js';
import { is_firefox } from './dom/operations.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 { define_property, get_descriptor } from '../shared/utils.js';
import { active_effect, active_reaction } from './runtime.js'; import { active_effect, active_reaction } from './runtime.js';
@ -50,7 +57,8 @@ export function invoke_error_boundary(error, effect) {
} }
while (effect !== null) { 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) { if ((effect.f & REACTION_RAN) === 0) {
// we are still creating the boundary effect // we are still creating the boundary effect
throw error; 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, flush_eager_effects,
set_eager_effects_deferred set_eager_effects_deferred
} from './reactivity/sources.js'; } 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 { UNINITIALIZED } from '../../constants.js';
import * as e from './errors.js'; import * as e from './errors.js';
import { tag } from './dev/tracing.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} * @returns {T}
*/ */
export function proxy(value) { export function proxy(value) {
// if non-proxyable, or is already a proxy, return `value` // if non-proxyable, a component instance, or already a proxy, return `value`
if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) { if (
typeof value !== 'object' ||
value === null ||
STATE_SYMBOL in value ||
COMPONENT_SYMBOL in value
) {
return value; return value;
} }

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

@ -34,8 +34,10 @@ import {
ASYNC, ASYNC,
CONNECTED, CONNECTED,
MANAGED_EFFECT, MANAGED_EFFECT,
DESTROYING DESTROYING,
PAUSED
} from '#client/constants'; } from '#client/constants';
import { invoke_error_boundary } from '../error-handling.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js'; import { define_property } from '../../shared/utils.js';
@ -449,6 +451,11 @@ export function execute_effect_teardown(effect) {
set_active_reaction(null); set_active_reaction(null);
try { try {
teardown.call(null); 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 { } finally {
set_is_destroying_effect(previously_destroying_effect); set_is_destroying_effect(previously_destroying_effect);
set_active_reaction(previous_reaction); set_active_reaction(previous_reaction);
@ -610,6 +617,7 @@ export function pause_effect(effect, callback, destroy = true) {
/** @type {TransitionManager[]} */ /** @type {TransitionManager[]} */
var transitions = []; var transitions = [];
effect.f |= PAUSED;
pause_children(effect, transitions, true); pause_children(effect, transitions, true);
var fn = () => { var fn = () => {
@ -677,6 +685,7 @@ function pause_children(effect, transitions, local) {
* @param {Effect} effect * @param {Effect} effect
*/ */
export function resume_effect(effect) { export function resume_effect(effect) {
effect.f &= ~PAUSED;
resume_children(effect, true); resume_children(effect, true);
} }
@ -685,6 +694,10 @@ export function resume_effect(effect) {
* @param {boolean} local * @param {boolean} local
*/ */
function resume_children(effect, 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; if ((effect.f & INERT) === 0) return;
effect.f ^= INERT; 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) { export function internal_set(source, value, updated_during_traversal = null) {
if (!source.equals(value)) { 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(); var batch = Batch.ensure();
batch.capture(source, value); batch.capture(source, value);

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

@ -133,7 +133,7 @@ export function set_untracked_writes(value) {
**/ **/
export let write_version = 1; 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; let read_version = 0;
export let update_version = read_version; export let update_version = read_version;
@ -413,6 +413,8 @@ function remove_reaction(signal, dependency) {
without_reactive_context(() => { without_reactive_context(() => {
/** @type {AbortController} */ (derived.ac).abort(STALE_REACTION); /** @type {AbortController} */ (derived.ac).abort(STALE_REACTION);
derived.ac = null; 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 * 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} */ export function getAbortSignal() {
let controller = null; let context = ssr_context;
export function abort() { while (context !== null) {
controller?.abort(STALE_REACTION); if (context.r !== null) return context.r.global.get_abort_signal();
controller = null; context = context.p;
} }
export function getAbortSignal() { return new AbortController().signal;
return (controller ??= new AbortController()).signal;
} }

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

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

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

@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { Renderer, SSRState } from './renderer.js'; import { Renderer, SSRState } from './renderer.js';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js'; 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', () => { test('collects synchronous body content by default', () => {
const component = (renderer: Renderer) => { const component = (renderer: Renderer) => {
@ -466,4 +467,176 @@ describe('async', () => {
await Renderer.render(component as unknown as Component); await Renderer.render(component as unknown as Component);
expect(destroyed).toEqual(['c', 'e', 'a', 'b', 'b*', 'd']); 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; element?: Element;
} }
export type Csp = { nonce?: string; hash?: boolean };
export interface HydratableLookupEntry { export interface HydratableLookupEntry {
value: unknown; value: unknown;
serialized: string; serialized: string;
@ -34,19 +32,3 @@ export interface HydratableContext {
export interface RenderContext { export interface RenderContext {
hydratable: HydratableContext; 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) { if (value) {
// strip comments; surrounding whitespace is handled by the trims below (which is much faster than doing it through regex)
value = String(value) value = String(value)
.replaceAll(/\s*\/\*.*?\*\/\s*/g, '') .replaceAll(/\/\*.*?\*\//g, '')
.trim(); .trim();
/** @type {boolean | '"' | "'"} */ /** @type {boolean | '"' | "'"} */

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

Loading…
Cancel
Save