Merge branch 'main' into portals

portals
Simon Holthausen 2 weeks ago
commit 57c2e7f457
No known key found for this signature in database

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

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: track SvelteDate snapshots in reactions

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: remove `<svelte:head>` anchors on unmount

@ -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
---
perf: reuse the cached value in the `<option>`/`<select>` value guard

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent malformed AST output for `<select>` with static `value` attribute

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: apply ownership mutation ignores to binding assignments

@ -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
---
perf: fold SSR block-open markers into the branch's first push

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

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: report `derived_invalid_export` for `export let x = $derived(...)` in runes mode

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

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: keep `defaultChecked` on hydrated radio inputs with spread attributes

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: accept `onfocusin`/`onfocusout` in `a11y_mouse_events_have_key_events`

@ -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: measure nested transitions before applying their starting styles

@ -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: emit `$.only_child` for elements with a single child

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: more robust rendering of Svelte custom element slots

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

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: end a restored reaction context at the end of its synchronous segment

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: keep the dependencies of a reaction that throws, so deriveds it read are neither leaked nor stuck in their error

@ -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
---
perf: use `$.comment()` for single-comment templates

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: store setters cache as `Set` instead of `Array`

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: transform derived assignments and select function bindings correctly during server-side rendering

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: sync `SvelteURL` port signal when the protocol setter clears the port

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: support `defaultValue` on `<select>`

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: block declaration tags and `{@const}` on async values read inside closures

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: avoid css tree-shaking for exported Snippet

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: treat `<img loading>` as a static element again

@ -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
---
fix: preserve line feed character references in attribute values

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: decode uppercase-`X` hex numeric character references (`&#X...;`)

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

1
.gitignore vendored

@ -27,3 +27,4 @@ packages/svelte/scripts/_baseline/
benchmarking/.profiles benchmarking/.profiles
benchmarking/compare/.results benchmarking/compare/.results
benchmarking/compare/.profiles benchmarking/compare/.profiles
benchmarking/compare/results.html

@ -0,0 +1,47 @@
import assert from 'node:assert';
import * as $ from 'svelte/internal/client';
import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js';
// Like `kairo_broad`, but each derived is also read by a block effect, as
// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better.
export default () => {
let head = $.state(0);
let last = head;
let counter = 0;
const destroy = $.effect_root(() => {
for (let i = 0; i < 50; i++) {
let current = $.derived(() => {
return $.get(head) + i;
});
let current2 = $.derived(() => {
return $.get(current) + 1;
});
block(() => {
$.get(current2);
});
$.render_effect(() => {
$.get(current2);
counter++;
});
last = current2;
}
});
return {
destroy,
run() {
$.flush(() => {
$.set(head, 1);
});
counter = 0;
for (let i = 0; i < 50; i++) {
$.flush(() => {
$.set(head, i);
});
assert.equal($.get(last), i + 50);
}
assert.equal(counter, 50 * 50);
}
};
};

@ -0,0 +1,48 @@
import assert from 'node:assert';
import * as $ from 'svelte/internal/client';
import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js';
let len = 50;
const iter = 50;
// Like `kairo_deep`, but the derived chain is also read by a block effect, as
// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better.
export default () => {
let head = $.state(0);
let current = head;
for (let i = 0; i < len; i++) {
let c = current;
current = $.derived(() => {
return $.get(c) + 1;
});
}
let counter = 0;
const destroy = $.effect_root(() => {
block(() => {
$.get(current);
});
$.render_effect(() => {
$.get(current);
counter++;
});
});
return {
destroy,
run() {
$.flush(() => {
$.set(head, 1);
});
counter = 0;
for (let i = 0; i < iter; i++) {
$.flush(() => {
$.set(head, i);
});
assert.equal($.get(current), len + i);
}
assert.equal(counter, iter);
}
};
};

@ -2,15 +2,27 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
export function generate_report(outdir) { const REPORT_DATA_PLACEHOLDER = '%%REPORT_DATA%%';
const report_template = fs.readFileSync(
new URL('./results.template.html', import.meta.url),
'utf-8'
);
if (!report_template.includes(REPORT_DATA_PLACEHOLDER)) {
throw new Error(`Missing ${REPORT_DATA_PLACEHOLDER} in results.template.html`);
}
export function generate_report(outdir, branches) {
const result_files = fs const result_files = fs
.readdirSync(outdir) .readdirSync(outdir)
.filter((file) => file.endsWith('.json')) .filter((file) => file.endsWith('.json') && (!branches || branches.includes(file.slice(0, -5))))
.sort((a, b) => a.localeCompare(b)); .sort((a, b) => a.localeCompare(b));
const branches = result_files.map((file) => file.slice(0, -5)); // always do this so that ordering lines up (branches argument might be passed in a different order than the result files are sorted
branches = result_files.map((file) => file.slice(0, -5));
const results = result_files.map((file) => const results = result_files.map((file) =>
JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8')) JSON.parse(fs.readFileSync(path.join(outdir, file), 'utf-8'))
); );
if (results.length === 0) { if (results.length === 0) {
@ -33,11 +45,38 @@ export function generate_report(outdir) {
write(''); write('');
for (let i = 0; i < results[0].length; i += 1) { // match results by benchmark name — branches may have different benchmark
write(`${results[0][i].benchmark}`); // lists (e.g. a benchmark that only exists on one of the branches), so
// pairing by array index would misattribute results
const by_name = results.map((result) => new Map(result.map((r) => [r.benchmark, r])));
/** @type {string[]} */
const names = [];
for (const result of results) {
for (const { benchmark } of result) {
if (!names.includes(benchmark)) {
names.push(benchmark);
}
}
}
for (const name of names) {
const entries = by_name.map((map) => map.get(name));
const missing = entries
.map((entry, b) => (entry === undefined ? branches[b] : null))
.filter((branch) => branch !== null);
write(`${name}`);
if (missing.length > 0) {
write(` skipped (missing on ${missing.join(', ')})`);
write('');
continue;
}
for (const metric of ['time', 'gc_time']) { for (const metric of ['time', 'gc_time']) {
const times = results.map((result) => +result[i][metric]); const times = entries.map((entry) => +entry[metric]);
let min = Infinity; let min = Infinity;
let max = -Infinity; let max = -Infinity;
let min_index = -1; let min_index = -1;
@ -68,6 +107,32 @@ export function generate_report(outdir) {
write(''); write('');
} }
const benchmarks = names.map((name) => ({
name,
values: by_name.map((map) => {
const entry = map.get(name);
if (entry === undefined) return null;
return {
time: Number(entry.time),
gc_time: Number(entry.gc_time)
};
})
}));
const data = JSON.stringify({
generated_at: new Date().toISOString(),
branches,
benchmarks
})
.replaceAll('<', '\\u003c')
.replaceAll('\u2028', '\\u2028')
.replaceAll('\u2029', '\\u2029');
const html_file = path.resolve(outdir, '../results.html');
fs.writeFileSync(html_file, report_template.replace(REPORT_DATA_PLACEHOLDER, data));
console.log(`\nHTML report written to ${html_file}`);
} }
function char(i) { function char(i) {

@ -85,4 +85,4 @@ if (PROFILE_DIR !== null) {
console.log(`\nCPU profiles written to ${PROFILE_DIR}`); console.log(`\nCPU profiles written to ${PROFILE_DIR}`);
} }
generate_report(outdir); generate_report(outdir, requested_branches);

@ -0,0 +1,741 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Benchmark comparison</title>
<style>
:root {
color-scheme: light;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-variant-numeric: tabular-nums;
background: #f6f8fa;
color: #1f2328;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f6f8fa;
}
main {
width: min(1600px, 100%);
margin: 0 auto;
padding: 40px clamp(16px, 3vw, 48px) 64px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 8px;
font-size: clamp(28px, 4vw, 48px);
letter-spacing: -0.04em;
}
h2 {
margin-bottom: 14px;
font-size: 18px;
letter-spacing: -0.01em;
}
.intro {
max-width: 780px;
margin-bottom: 28px;
color: #59636e;
line-height: 1.6;
}
.summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 32px;
}
.card,
.panel {
border: 1px solid #d0d7de;
background: #ffffff;
box-shadow: 0 8px 24px rgb(140 149 159 / 16%);
}
.card {
min-height: 112px;
padding: 18px 20px;
border-radius: 8px;
}
.card-label {
margin-bottom: 10px;
color: #59636e;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.card-value {
font-size: 21px;
font-weight: 750;
letter-spacing: -0.02em;
}
.card-detail {
margin-top: 7px;
color: #59636e;
font-size: 13px;
}
.panel {
margin-bottom: 24px;
border-radius: 8px;
overflow: hidden;
}
.panel-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
padding: 18px 20px;
border-bottom: 1px solid #d8dee4;
}
.panel-heading h2,
.panel-heading p {
margin-bottom: 0;
}
.help {
max-width: 720px;
color: #59636e;
font-size: 13px;
line-height: 1.5;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid #d8dee4;
background: #f6f8fa;
}
label {
display: flex;
align-items: center;
gap: 8px;
color: #59636e;
font-size: 13px;
font-weight: 650;
}
select {
max-width: 250px;
padding: 7px 30px 7px 9px;
border: 1px solid #afb8c1;
border-radius: 5px;
background: #ffffff;
color: #1f2328;
font: inherit;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
th,
td {
padding: 11px 14px;
border-right: 1px solid #d8dee4;
border-bottom: 1px solid #d8dee4;
text-align: left;
vertical-align: middle;
}
th:last-child,
td:last-child {
border-right: 0;
}
tr:last-child td {
border-bottom: 0;
}
th {
background: #f6f8fa;
color: #59636e;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
th button {
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-align: inherit;
text-transform: inherit;
cursor: pointer;
}
th button:hover,
th button:focus-visible {
color: #1f2328;
}
.standings td:nth-child(n + 2),
.standings th:nth-child(n + 2) {
text-align: right;
}
.rank {
display: inline-grid;
width: 24px;
height: 24px;
margin-right: 10px;
place-items: center;
border-radius: 50%;
background: #eaeef2;
color: #424a53;
font-size: 12px;
font-weight: 750;
}
.branch-name,
.benchmark-name {
font-weight: 700;
}
.benchmark-table {
min-width: max(900px, 100%);
}
.benchmark-table thead {
position: sticky;
top: 0;
z-index: 3;
}
.benchmark-table th:first-child,
.benchmark-table td:first-child {
position: sticky;
left: 0;
z-index: 2;
min-width: 220px;
background: #ffffff;
}
.benchmark-table th:first-child {
z-index: 4;
background: #f6f8fa;
}
.benchmark-table th:nth-child(2),
.benchmark-table td:nth-child(2) {
min-width: 150px;
}
.benchmark-table th:not(:first-child),
.benchmark-table td:not(:first-child) {
min-width: 175px;
}
.metric {
position: relative;
isolation: isolate;
background: hsl(var(--heat, 210) 74% 94%);
}
.metric::after {
position: absolute;
z-index: -1;
inset: auto 0 0;
height: 3px;
background: hsl(var(--heat, 210) 65% 43%);
content: '';
}
.time {
font-size: 15px;
font-weight: 750;
}
.delta {
margin-left: 7px;
color: #424a53;
font-size: 12px;
font-weight: 650;
}
.secondary {
margin-top: 5px;
color: #59636e;
font-size: 12px;
}
.winner {
color: #1a7f37;
font-weight: 700;
}
.missing {
background: #f6f8fa;
color: #6e7781;
font-style: italic;
}
footer {
color: #6e7781;
font-size: 12px;
text-align: right;
}
@media (max-width: 760px) {
main {
padding-top: 24px;
}
.summary {
grid-template-columns: 1fr;
}
.panel-heading {
align-items: start;
flex-direction: column;
}
.controls,
label {
align-items: stretch;
flex-direction: column;
}
select {
max-width: none;
width: 100%;
}
}
</style>
</head>
<body>
<main>
<h1>Benchmark comparison</h1>
<p class="intro">
Runtime results across branches. Green cells are fastest for an entry and red cells expose
the largest regressions. Overall runtime normalizes every benchmark to its fastest result
before averaging, so long-running entries do not outweigh short ones.
</p>
<section class="summary" id="summary" aria-label="Comparison summary"></section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>Branch standings</h2>
<p class="help">
Wins count the fastest branch for each comparable entry. Normalized runtime is the
average slowdown against each entry's fastest result; lower is better. Click a heading
to sort.
</p>
</div>
</div>
<div class="table-wrap">
<table class="standings">
<thead>
<tr>
<th><button type="button" data-standing-sort="name">Branch</button></th>
<th><button type="button" data-standing-sort="wins">Wins</button></th>
<th>
<button type="button" data-standing-sort="score">Normalized runtime</button>
</th>
</tr>
</thead>
<tbody id="standings"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>Results by benchmark</h2>
<p class="help">
Each cell shows runtime, difference from the winner, and GC time. Missing entries are
excluded from both standings.
</p>
</div>
</div>
<div class="controls">
<label
>Sort benchmarks
<select id="benchmark-sort"></select
></label>
<label
>Order branches
<select id="branch-order">
<option value="name">Branch name</option>
<option value="wins">Overall winner by wins</option>
<option value="score" selected>Overall winner by normalized runtime</option>
</select></label
>
</div>
<div class="table-wrap">
<table class="benchmark-table" id="benchmark-table"></table>
</div>
</section>
<footer id="generated"></footer>
</main>
<script type="application/json" id="report-data">
%%REPORT_DATA%%
</script>
<script>
const report = JSON.parse(document.querySelector('#report-data').textContent);
const number = new Intl.NumberFormat('en', {
maximumFractionDigits: 2,
minimumFractionDigits: 2
});
const score_number = new Intl.NumberFormat('en', {
maximumFractionDigits: 3,
minimumFractionDigits: 3
});
function analyze(benchmark) {
const complete =
benchmark.values.length === report.branches.length &&
benchmark.values.every(function (value) {
return value !== null && Number.isFinite(value.time) && value.time > 0;
});
if (!complete) return { comparable: false, min: 0, max: 0, winners: [], ratios: [] };
const times = benchmark.values.map(function (value) {
return value.time;
});
const min = Math.min.apply(null, times);
const max = Math.max.apply(null, times);
const winners = [];
times.forEach(function (time, index) {
if (time === min) winners.push(index);
});
return {
comparable: true,
min: min,
max: max,
winners: winners,
ratios: times.map(function (time) {
return time / min;
})
};
}
const analyzed = report.benchmarks.map(function (benchmark, index) {
return { benchmark: benchmark, stats: analyze(benchmark), index: index };
});
const comparable = analyzed.filter(function (entry) {
return entry.stats.comparable;
});
const standings = report.branches.map(function (name, index) {
let wins = 0;
let score = 0;
comparable.forEach(function (entry) {
if (entry.stats.winners.includes(index)) wins += 1;
score += entry.stats.ratios[index];
});
return {
name: name,
index: index,
wins: wins,
score: comparable.length === 0 ? Infinity : score / comparable.length
};
});
function compare_name(a, b) {
return a.name.localeCompare(b.name, undefined, { numeric: true });
}
function order_standings(key, direction) {
return standings.slice().sort(function (a, b) {
let result;
if (key === 'name') result = compare_name(a, b);
else result = a[key] - b[key];
return result === 0 ? compare_name(a, b) : result * direction;
});
}
function best(key, direction) {
const ordered = order_standings(key, direction);
if (ordered.length === 0) return [];
return ordered.filter(function (entry) {
return entry[key] === ordered[0][key];
});
}
function names(entries) {
return entries
.map(function (entry) {
return entry.name;
})
.join(', ');
}
function make(tag, class_name, text) {
const node = document.createElement(tag);
if (class_name) node.className = class_name;
if (text !== undefined) node.textContent = text;
return node;
}
const wins_best = best('wins', -1);
const score_best = best('score', 1);
const summary = document.querySelector('#summary');
[
{
label: 'Most benchmark wins',
value: comparable.length === 0 ? 'No comparable results' : names(wins_best),
detail:
comparable.length === 0
? ''
: wins_best[0].wins + ' of ' + comparable.length + ' entries'
},
{
label: 'Best normalized runtime',
value:
score_best.length === 0 || !Number.isFinite(score_best[0].score)
? 'No comparable results'
: names(score_best),
detail:
score_best.length === 0 || !Number.isFinite(score_best[0].score)
? ''
: score_number.format(score_best[0].score) + 'x average runtime'
},
{
label: 'Coverage',
value: comparable.length + ' comparable entries',
detail:
report.branches.length +
' branches, ' +
(report.benchmarks.length - comparable.length) +
' incomplete entries'
}
].forEach(function (item) {
const card = make('article', 'card');
card.append(
make('div', 'card-label', item.label),
make('div', 'card-value', item.value),
make('div', 'card-detail', item.detail)
);
summary.append(card);
});
let standing_sort = { key: 'score', direction: 1 };
function render_standings() {
const body = document.querySelector('#standings');
body.replaceChildren();
const score_order = order_standings('score', 1);
order_standings(standing_sort.key, standing_sort.direction).forEach(function (entry) {
const row = document.createElement('tr');
const branch = make('td');
branch.append(
make('span', 'rank', String(score_order.indexOf(entry) + 1)),
make('span', 'branch-name', entry.name)
);
row.append(
branch,
make('td', '', String(entry.wins)),
make(
'td',
'',
Number.isFinite(entry.score) ? score_number.format(entry.score) + 'x' : 'n/a'
)
);
body.append(row);
});
document.querySelectorAll('[data-standing-sort]').forEach(function (button) {
const active = button.dataset.standingSort === standing_sort.key;
button
.closest('th')
.setAttribute(
'aria-sort',
active ? (standing_sort.direction === 1 ? 'ascending' : 'descending') : 'none'
);
button.textContent =
button.dataset.standingSort === 'name'
? 'Branch'
: button.dataset.standingSort === 'wins'
? 'Wins'
: 'Normalized runtime';
if (active) button.textContent += standing_sort.direction === 1 ? ' ↑' : ' ↓';
});
}
document.querySelectorAll('[data-standing-sort]').forEach(function (button) {
button.addEventListener('click', function () {
const key = button.dataset.standingSort;
if (standing_sort.key === key) standing_sort.direction *= -1;
else standing_sort = { key: key, direction: key === 'wins' ? -1 : 1 };
render_standings();
});
});
const benchmark_sort = document.querySelector('#benchmark-sort');
[
['original', 'Original run order'],
['name', 'Benchmark name'],
['winner', 'Winner for entry'],
['spread', 'Largest spread']
]
.concat(
report.branches.map(function (branch, index) {
return ['branch:' + index, branch + ': slowest relative result'];
})
)
.forEach(function (option) {
const node = make('option', '', option[1]);
node.value = option[0];
benchmark_sort.append(node);
});
function branch_order() {
const key = document.querySelector('#branch-order').value;
if (key === 'name') return order_standings('name', 1);
if (key === 'wins') return order_standings('wins', -1);
return order_standings('score', 1);
}
function benchmark_order() {
const key = benchmark_sort.value;
return analyzed.slice().sort(function (a, b) {
if (key === 'original') return a.index - b.index;
if (key === 'name') return compare_name(a.benchmark, b.benchmark);
if (key === 'winner') {
const a_name = a.stats.comparable ? report.branches[a.stats.winners[0]] : '\uffff';
const b_name = b.stats.comparable ? report.branches[b.stats.winners[0]] : '\uffff';
return a_name.localeCompare(b_name) || compare_name(a.benchmark, b.benchmark);
}
if (key === 'spread') {
const a_spread = a.stats.comparable ? a.stats.max / a.stats.min : -1;
const b_spread = b.stats.comparable ? b.stats.max / b.stats.min : -1;
return b_spread - a_spread || compare_name(a.benchmark, b.benchmark);
}
const branch = Number(key.slice('branch:'.length));
const a_ratio = a.stats.comparable ? a.stats.ratios[branch] : -1;
const b_ratio = b.stats.comparable ? b.stats.ratios[branch] : -1;
return b_ratio - a_ratio || compare_name(a.benchmark, b.benchmark);
});
}
function render_benchmarks() {
const table = document.querySelector('#benchmark-table');
const branches = branch_order();
const head = document.createElement('thead');
const head_row = document.createElement('tr');
head_row.append(make('th', '', 'Benchmark'), make('th', '', 'Winner'));
branches.forEach(function (branch) {
head_row.append(make('th', '', branch.name));
});
head.append(head_row);
const body = document.createElement('tbody');
benchmark_order().forEach(function (entry) {
const row = document.createElement('tr');
row.append(make('td', 'benchmark-name', entry.benchmark.name));
const winner_names = entry.stats.winners.map(function (index) {
return report.branches[index];
});
row.append(
make(
'td',
winner_names.length === 0 ? 'missing' : 'winner',
winner_names.length === 0 ? 'Incomplete' : winner_names.join(', ')
)
);
branches.forEach(function (branch) {
const value = entry.benchmark.values[branch.index];
if (value === null || !Number.isFinite(value.time)) {
row.append(make('td', 'missing', 'Not available'));
return;
}
const cell = make('td', entry.stats.comparable ? 'metric' : 'missing');
const time = make('span', 'time', number.format(value.time) + ' ms');
cell.append(time);
if (entry.stats.comparable) {
const ratio = entry.stats.ratios[branch.index];
const heat =
entry.stats.max === entry.stats.min
? 0
: (value.time - entry.stats.min) / (entry.stats.max - entry.stats.min);
cell.style.setProperty('--heat', String(Math.round(142 - heat * 137)));
cell.append(
make(
'span',
'delta',
ratio === 1 ? 'fastest' : '+' + number.format((ratio - 1) * 100) + '%'
)
);
}
const gc = Number.isFinite(value.gc_time)
? number.format(value.gc_time) + ' ms'
: 'n/a';
cell.append(make('div', 'secondary', 'GC ' + gc));
row.append(cell);
});
body.append(row);
});
table.replaceChildren(head, body);
}
benchmark_sort.addEventListener('change', render_benchmarks);
document.querySelector('#branch-order').addEventListener('change', render_benchmarks);
document.querySelector('#generated').textContent =
'Generated ' + new Date(report.generated_at).toLocaleString();
render_standings();
render_benchmarks();
</script>
</body>
</html>

@ -1,18 +1,67 @@
import { fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js'; import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js';
import { with_cpu_profile } from '../utils.js'; import { with_cpu_profile } from '../utils.js';
const PROFILE_DIR = process.env.BENCH_PROFILE_DIR ?? null;
const single = process.env.BENCH_SINGLE;
if (single) {
// child mode — run a single benchmark and report the result to the parent
const benchmark = reactivity_benchmarks.find((b) => b.label === single);
if (!benchmark) {
throw new Error(`Unknown benchmark ${single}`);
}
const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
// exit via the callback so the message is guaranteed to be delivered
/** @type {NodeJS.Process} */ (process).send(result, () => process.exit(0));
} else {
// parent mode — run every benchmark in its own child process, so that
// heap/GC/JIT state from one benchmark cannot contaminate the others
const filename = fileURLToPath(import.meta.url);
const results = []; const results = [];
const PROFILE_DIR = process.env.BENCH_PROFILE_DIR;
for (let i = 0; i < reactivity_benchmarks.length; i += 1) { for (let i = 0; i < reactivity_benchmarks.length; i += 1) {
const benchmark = reactivity_benchmarks[i]; const benchmark = reactivity_benchmarks[i];
process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `); process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `);
const result = await new Promise((fulfil, reject) => {
const child = fork(filename, [], {
env: {
...process.env,
BENCH_SINGLE: benchmark.label
}
});
/** @type {object | null} */
let message_received = null;
child.on('message', (message) => {
message_received = /** @type {object} */ (message);
});
child.on('error', reject);
child.on('exit', (code) => {
if (message_received === null) {
reject(new Error(`benchmark ${benchmark.label} exited with code ${code}`));
} else {
fulfil(message_received);
}
});
});
results.push({ results.push({
benchmark: benchmark.label, benchmark: benchmark.label,
...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn())) .../** @type {object} */ (result)
}); });
process.stderr.write('\x1b[2K\r'); process.stderr.write('\x1b[2K\r');
} }
process.send(results); /** @type {NodeJS.Process} */ (process).send(results);
}

@ -1,13 +1,37 @@
import { fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as $ from '../packages/svelte/src/internal/client/index.js'; import * as $ from '../packages/svelte/src/internal/client/index.js';
import { reactivity_benchmarks } from './benchmarks/reactivity/index.js'; import { reactivity_benchmarks } from './benchmarks/reactivity/index.js';
import { ssr_benchmarks } from './benchmarks/ssr/index.js'; import { ssr_benchmarks } from './benchmarks/ssr/index.js';
import { with_cpu_profile } from './utils.js'; import { with_cpu_profile } from './utils.js';
const PROFILE_DIR = './benchmarking/.profiles';
const single = process.env.BENCH_SINGLE;
if (single) {
// child mode — run a single benchmark and report the result to the parent
const benchmark = [...reactivity_benchmarks, ...ssr_benchmarks].find((b) => b.label === single);
if (!benchmark) {
throw new Error(`Unknown benchmark ${single}`);
}
$.push({}, true);
const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
$.pop();
// exit via the callback so the message is guaranteed to be delivered
/** @type {NodeJS.Process} */ (process).send(result, () => process.exit(0));
} else {
// parent mode — run every benchmark in its own child process, so that
// heap/GC/JIT state from one benchmark cannot contaminate the others
// e.g. `pnpm bench kairo` to only run the kairo benchmarks // e.g. `pnpm bench kairo` to only run the kairo benchmarks
const filters = process.argv.slice(2); const filters = process.argv.slice(2);
const PROFILE_DIR = './benchmarking/.profiles';
const suites = [ const suites = [
{ {
benchmarks: reactivity_benchmarks.filter( benchmarks: reactivity_benchmarks.filter(
@ -28,17 +52,51 @@ if (suites.length === 0) {
process.exit(1); process.exit(1);
} }
const filename = fileURLToPath(import.meta.url);
/**
* @param {string} label
* @returns {Promise<{ time: number, gc_time: number }>}
*/
const run_benchmark = (label) => {
return new Promise((fulfil, reject) => {
const child = fork(filename, [], {
env: {
...process.env,
BENCH_SINGLE: label
}
});
/** @type {{ time: number, gc_time: number } | null} */
let result = null;
child.on('message', (message) => {
result = /** @type {{ time: number, gc_time: number }} */ (message);
});
child.on('error', reject);
child.on('exit', (code) => {
if (result === null) {
reject(new Error(`benchmark ${label} exited with code ${code}`));
} else {
fulfil(result);
}
});
});
};
const COLUMN_WIDTHS = [25, 9, 9]; const COLUMN_WIDTHS = [25, 9, 9];
const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b); const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b);
/** @type {(str: string, n: number) => string} */
const pad_right = (str, n) => str + ' '.repeat(n - str.length); const pad_right = (str, n) => str + ' '.repeat(n - str.length);
/** @type {(str: string, n: number) => string} */
const pad_left = (str, n) => ' '.repeat(n - str.length) + str; const pad_left = (str, n) => ' '.repeat(n - str.length) + str;
let total_time = 0; let total_time = 0;
let total_gc_time = 0; let total_gc_time = 0;
$.push({}, true);
try { try {
for (const { benchmarks, name } of suites) { for (const { benchmarks, name } of suites) {
let suite_time = 0; let suite_time = 0;
@ -53,7 +111,7 @@ try {
console.log('='.repeat(TOTAL_WIDTH)); console.log('='.repeat(TOTAL_WIDTH));
for (const benchmark of benchmarks) { for (const benchmark of benchmarks) {
const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); const results = await run_benchmark(benchmark.label);
console.log( console.log(
pad_right(benchmark.label, COLUMN_WIDTHS[0]) + pad_right(benchmark.label, COLUMN_WIDTHS[0]) +
pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) + pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) +
@ -74,17 +132,13 @@ try {
console.log('='.repeat(TOTAL_WIDTH)); console.log('='.repeat(TOTAL_WIDTH));
} }
if (PROFILE_DIR !== null) {
console.log(`\nCPU profiles written to ${PROFILE_DIR}`); console.log(`\nCPU profiles written to ${PROFILE_DIR}`);
}
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(e); console.error(e);
process.exit(1); process.exit(1);
} }
$.pop();
console.log(''); console.log('');
console.log( console.log(
@ -92,3 +146,4 @@ console.log(
pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) + pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) +
pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2]) pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2])
); );
}

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

@ -251,6 +251,19 @@ You can give the `<select>` a default value by adding a `selected` attribute to
</select> </select>
``` ```
Since 5.57.0, if a `<select>` has a `defaultValue` and is part of a form, it will revert to that value instead of the empty string when the form is reset. Note that for the initial render the value of the binding takes precedence unless it is `null` or `undefined`.
```svelte
<form>
<select bind:value defaultValue="b">
<option>a</option>
<option>b</option>
<option>c</option>
</select>
<input type="reset" value="Reset">
</form>
```
## `<audio>` ## `<audio>`
`<audio>` elements have their own set of bindings — five two-way ones... `<audio>` elements have their own set of bindings — five two-way ones...

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

@ -165,9 +165,9 @@ Svelte will warn you if you get it wrong.
Similarly, to pass primitive values through context, use functions as described in [Passing state into functions]($state#Passing-state-into-functions). Similarly, to pass primitive values through context, use functions as described in [Passing state into functions]($state#Passing-state-into-functions).
## Component testing ## Mounting components with context
When writing [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), it can be useful to create a wrapper component that sets the context in order to check the behaviour of a component that uses it. As of version 5.49, you can do this sort of thing: To mount a component with specific context, create a wrapper component that sets the context before rendering the component. This is useful for [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), or any other scenario that needs to provide context through `mount`. As of version 5.49, you can do this sort of thing:
```js ```js
import { mount, unmount } from 'svelte'; import { mount, unmount } from 'svelte';
@ -193,6 +193,8 @@ test('MyComponent', () => {
This approach also works with [`hydrate`](imperative-component-api#hydrate) and [`render`](imperative-component-api#render). This approach also works with [`hydrate`](imperative-component-api#hydrate) and [`render`](imperative-component-api#render).
The context set by the wrapper only applies to that mounted component tree. Each call to `mount`, `hydrate` or `render` creates a separate wrapper instance, so the context does not leak into other mounted components.
## Replacing global state ## Replacing global state
When you have state shared by many different components, you might be tempted to put it in its own module and just import it wherever it's needed: When you have state shared by many different components, you might be tempted to put it in its own module and just import it wherever it's needed:

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

@ -360,7 +360,7 @@ Enforce that heading elements (`h1`, `h2`, etc.) and anchors have content and th
'%event%' event must be accompanied by '%accompanied_by%' event '%event%' event must be accompanied by '%accompanied_by%' event
``` ```
Enforce that `onmouseover` and `onmouseout` are accompanied by `onfocus` and `onblur`, respectively. This helps to ensure that any functionality triggered by these mouse events is also accessible to keyboard users. Enforce that `onmouseover` and `onmouseout` are accompanied by `onfocus` (or `onfocusin`) and `onblur` (or `onfocusout`), respectively. This helps to ensure that any functionality triggered by these mouse events is also accessible to keyboard users.
```svelte ```svelte
<!-- A11y: onmouseover must be accompanied by onfocus --> <!-- A11y: onmouseover must be accompanied by onfocus -->

@ -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,113 @@
# 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
### Patch Changes
- chore: drop dead code that make TSGO fail ([#18496](https://github.com/sveltejs/svelte/pull/18496))
- fix: don't (re)connect deriveds when read inside branch/root effects ([#18527](https://github.com/sveltejs/svelte/pull/18527))
- fix: skip unnecessary derived effect in earlier batch ([#18525](https://github.com/sveltejs/svelte/pull/18525))
- fix: avoid declaration tag warning in event handlers ([#18500](https://github.com/sveltejs/svelte/pull/18500))
- fix: abort deriveds own AbortSignal when it disconnects ([#18400](https://github.com/sveltejs/svelte/pull/18400))
- fix: ensure `$state.eager()` is correctly transormed for SSR output ([#18530](https://github.com/sveltejs/svelte/pull/18530))
- fix: correctly transform declaration tags during SSR ([#18492](https://github.com/sveltejs/svelte/pull/18492))
- fix: transform computed keys in keyed `{#each}` destructuring patterns ([#18521](https://github.com/sveltejs/svelte/pull/18521))
- fix: chain preprocessor sourcemaps with an empty `sources[0]` instead of dropping them ([#18518](https://github.com/sveltejs/svelte/pull/18518))
- fix: clear previous_task reference after abort in Tween to prevent memory leak on interrupted tweens ([#18541](https://github.com/sveltejs/svelte/pull/18541))
- fix: don't treat declaration tags as parts inside each blocks ([#18507](https://github.com/sveltejs/svelte/pull/18507))
## 5.56.4 ## 5.56.4
### Patch Changes ### Patch Changes

@ -1352,6 +1352,9 @@ export interface HTMLSelectAttributes extends HTMLAttributes<HTMLSelectElement>
required?: boolean | undefined | null; required?: boolean | undefined | null;
size?: number | undefined | null; size?: number | undefined | null;
value?: any; value?: any;
// needs both casing variants because language tools does lowercase names of non-shorthand attributes
defaultValue?: any;
defaultvalue?: any;
'on:change'?: ChangeEventHandler<HTMLSelectElement> | undefined | null; 'on:change'?: ChangeEventHandler<HTMLSelectElement> | undefined | null;
onchange?: ChangeEventHandler<HTMLSelectElement> | undefined | null; onchange?: ChangeEventHandler<HTMLSelectElement> | undefined | null;

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

@ -300,7 +300,7 @@ Enforce that heading elements (`h1`, `h2`, etc.) and anchors have content and th
> '%event%' event must be accompanied by '%accompanied_by%' event > '%event%' event must be accompanied by '%accompanied_by%' event
Enforce that `onmouseover` and `onmouseout` are accompanied by `onfocus` and `onblur`, respectively. This helps to ensure that any functionality triggered by these mouse events is also accessible to keyboard users. Enforce that `onmouseover` and `onmouseout` are accompanied by `onfocus` (or `onfocusin`) and `onblur` (or `onfocusout`), respectively. This helps to ensure that any functionality triggered by these mouse events is also accessible to keyboard users.
```svelte ```svelte
<!-- A11y: onmouseover must be accompanied by onfocus --> <!-- A11y: onmouseover must be accompanied by onfocus -->

@ -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.4", "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

@ -20,7 +20,7 @@ function reg_exp_entity(entity_name, is_attribute_value) {
/** @param {boolean} is_attribute_value */ /** @param {boolean} is_attribute_value */
function get_entity_pattern(is_attribute_value) { function get_entity_pattern(is_attribute_value) {
const reg_exp_num = '#(?:x[a-fA-F\\d]+|\\d+)(?:;)?'; const reg_exp_num = '#(?:[xX][a-fA-F\\d]+|\\d+)(?:;)?';
const reg_exp_entities = Object.keys(entities).map( const reg_exp_entities = Object.keys(entities).map(
/** @param {any} entity_name */ (entity_name) => reg_exp_entity(entity_name, is_attribute_value) /** @param {any} entity_name */ (entity_name) => reg_exp_entity(entity_name, is_attribute_value)
); );
@ -50,7 +50,7 @@ export function decode_character_references(html, is_attribute_value) {
// Handle named entities // Handle named entities
if (entity[0] !== '#') { if (entity[0] !== '#') {
code = entities[entity]; code = entities[entity];
} else if (entity[1] === 'x') { } else if (entity[1] === 'x' || entity[1] === 'X') {
code = parseInt(entity.substring(2), 16); code = parseInt(entity.substring(2), 16);
} else { } else {
code = parseInt(entity.substring(1), 10); code = parseInt(entity.substring(1), 10);
@ -60,7 +60,7 @@ export function decode_character_references(html, is_attribute_value) {
return match; return match;
} }
return String.fromCodePoint(validate_code(code)); return String.fromCodePoint(validate_code(code, is_attribute_value));
} }
); );
} }
@ -75,10 +75,15 @@ const NUL = 0;
// Also see: https://en.wikipedia.org/wiki/Plane_(Unicode) // Also see: https://en.wikipedia.org/wiki/Plane_(Unicode)
// Also see: https://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream // Also see: https://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream
/** @param {number} code */ /**
function validate_code(code) { * @param {number} code
// line feed becomes generic whitespace * @param {boolean} is_attribute_value
if (code === 10) { */
function validate_code(code, is_attribute_value) {
// line feed becomes generic whitespace, since it is collapsed along with the
// surrounding whitespace anyway. In an attribute value it is significant, so it
// is left alone there
if (code === 10 && !is_attribute_value) {
return 32; return 32;
} }

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

@ -289,7 +289,7 @@ export function analyze_module(source, options) {
runes: true, runes: true,
immutable: true, immutable: true,
tracing: false, tracing: false,
async_deriveds: new Set(), async_deriveds: new Map(),
comments, comments,
classes: new Map(), classes: new Map(),
pickled_awaits: new Set() pickled_awaits: new Set()
@ -561,7 +561,7 @@ export function analyze_component(root, source, options) {
source, source,
snippet_renderers: new Map(), snippet_renderers: new Map(),
snippets: new Set(), snippets: new Set(),
async_deriveds: new Set(), async_deriveds: new Map(),
pickled_awaits: new Set(), pickled_awaits: new Set(),
instance_body: { instance_body: {
sync: [], sync: [],
@ -836,6 +836,11 @@ export function analyze_component(root, source, options) {
} else { } else {
e.export_undefined(specifier, name); e.export_undefined(specifier, name);
} }
} else if (binding.initial?.type === 'SnippetBlock') {
// If a snippet is exported, a consumer could only import this named export and not the default export (the component).
// In this case we need to set hasGlobal of our output to true so that e.g. vite-plugin-svelte does not tell Vite to
// tree-shake the CSS if the default export is not used.
analysis.css.has_global = true;
} }
} }
} }
@ -962,7 +967,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);
@ -1019,6 +1024,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 },
@ -1048,13 +1060,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));
@ -1070,7 +1076,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
@ -1225,8 +1231,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();
@ -1235,12 +1262,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) {
@ -1314,7 +1344,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;

@ -1,6 +1,7 @@
/** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */ /** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ExpressionMetadata } from '../../nodes.js' */
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
/** /**
@ -10,15 +11,20 @@ import * as e from '../../../errors.js';
export function AwaitExpression(node, context) { export function AwaitExpression(node, context) {
const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1; const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1;
// preserve context for awaits that precede other expressions in template or `$derived(...)`
if ( if (
is_reactive_expression( is_reactive_expression(
context.path, context.path,
context.state.derived_function_depth === context.state.function_depth context.state.derived_function_depth === context.state.function_depth
) && )
!is_last_evaluated_expression(context.path, node)
) { ) {
const expression = /** @type {ExpressionMetadata} */ (context.state.expression);
// preserve context for awaits that precede other expressions in template or `$derived(...)`,
// and for any await that follows one, so the restored context ends at the next suspension
if (expression.has_pickled_await || !is_last_evaluated_expression(context.path, node)) {
context.state.analysis.pickled_awaits.add(node); context.state.analysis.pickled_awaits.add(node);
expression.has_pickled_await = true;
}
} }
let suspend = tla; let suspend = tla;

@ -253,7 +253,7 @@ export function CallExpression(node, context) {
}); });
if (expression.has_await) { if (expression.has_await) {
context.state.analysis.async_deriveds.add(node); context.state.analysis.async_deriveds.set(node, expression);
} }
// Tell surrounding declaration tag about metadata for correct calculation of blockers etc // Tell surrounding declaration tag about metadata for correct calculation of blockers etc

@ -43,7 +43,8 @@ export function DeclarationTag(node, context) {
*/ */
export function mark_async_declaration(context, metadata, declarations) { export function mark_async_declaration(context, metadata, declarations) {
const has_await = metadata.expression.has_await; const has_await = metadata.expression.has_await;
const blockers = [...metadata.expression.dependencies] // reads inside closures must block too, like they do in template expressions
const blockers = [...metadata.expression.references]
.map((dep) => dep.blocker) .map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id); .filter((b) => b !== null && b.object !== context.state.async_consts?.id);

@ -1,4 +1,4 @@
/** @import { ExportNamedDeclaration, Identifier } from 'estree' */ /** @import { ExportNamedDeclaration, Identifier, VariableDeclaration } from 'estree' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
import { extract_identifiers } from '../../../utils/ast.js'; import { extract_identifiers } from '../../../utils/ast.js';
@ -23,15 +23,6 @@ export function ExportNamedDeclaration(node, context) {
} }
if (node.declaration?.type === 'VariableDeclaration') { if (node.declaration?.type === 'VariableDeclaration') {
// in runes mode, forbid `export let`
if (
context.state.analysis.runes &&
context.state.ast_type === 'instance' &&
node.declaration.kind === 'let'
) {
e.legacy_export_invalid(node);
}
for (const declarator of node.declaration.declarations) { for (const declarator of node.declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) { for (const id of extract_identifiers(declarator.id)) {
const binding = context.state.scope.get(id.name); const binding = context.state.scope.get(id.name);
@ -46,6 +37,15 @@ export function ExportNamedDeclaration(node, context) {
} }
} }
} }
// in runes mode, forbid `export let`
if (
context.state.analysis.runes &&
context.state.ast_type === 'instance' &&
node.declaration.kind === 'let'
) {
e.legacy_export_invalid(node);
}
} }
if (context.state.analysis.runes) { if (context.state.analysis.runes) {

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

@ -373,11 +373,23 @@ export function check_element(node, context) {
} }
} }
if (!has_spread && handlers.has('mouseover') && !handlers.has('focus')) { // `focus`/`blur` don't bubble, so an element that isn't focusable itself can only be notified
// about focus changes inside it through `focusin`/`focusout` — accept those as well
if (
!has_spread &&
handlers.has('mouseover') &&
!handlers.has('focus') &&
!handlers.has('focusin')
) {
w.a11y_mouse_events_have_key_events(node, 'mouseover', 'focus'); w.a11y_mouse_events_have_key_events(node, 'mouseover', 'focus');
} }
if (!has_spread && handlers.has('mouseout') && !handlers.has('blur')) { if (
!has_spread &&
handlers.has('mouseout') &&
!handlers.has('blur') &&
!handlers.has('focusout')
) {
w.a11y_mouse_events_have_key_events(node, 'mouseout', 'blur'); w.a11y_mouse_events_have_key_events(node, 'mouseout', 'blur');
} }

@ -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') {

@ -18,7 +18,9 @@ export function visit_function(node, context) {
context.next({ context.next({
...context.state, ...context.state,
function_depth: context.state.function_depth + 1, // we generally want to use scope.function_depth unless we specifically increased
// that in state.function_depth (e.g. a derived)
function_depth: Math.max(context.state.scope.function_depth, context.state.function_depth) + 1,
expression: null expression: null
}); });
} }

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

Loading…
Cancel
Save