Merge branch 'main' into remove-was-marked

pull/18127/head
Simon H 1 day ago committed by GitHub
commit 1e5aa2fe1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: leave stale promises to wait for a later resolution, instead of rejecting

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: cancel deferred event listeners during cleanup

@ -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": [],

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: reapply context after transforming error during SSR

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: speed up parser interactions with Acorn or avoid them where possible

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: don't rebase just-created batches

@ -1,5 +0,0 @@
---
'svelte': patch
---
chore: allow `null` for `pending` in typings

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: flush eager effects in production

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: rethrow error of failed iterable after calling `return()`

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: account for proxified instance when updating `bind:this`

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: serialize input default values during server rendering

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: ensure scheduled batch is flushed if not obsolete

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: resolve stale deriveds with latest value

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: throw `set_context_after_init` when `setContext` is called after an `await` during SSR

@ -1,5 +0,0 @@
---
'svelte': patch
---
chore: remove unnecessary `increment_pending` calls

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: correctly compile component member expressions for SSR

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: reset `source.updated` stack traces after `flush`

@ -1,5 +0,0 @@
---
"svelte": patch
---
fix: replacing async 'blocking' strategy with 'merging'

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: allow `@debug` tags to reference awaited variables

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: re-run fallback props if dependencies update

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: abort running obsolete async branches

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: avoid regex matching in parser where possible

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: ignore comments when reading CSS values

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: wrap `Promise.all` in `save` during SSR

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: in non-async mode, only push variable to current_sources when active_reaction is updating

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: ignore false-positive errors of `$inspect` dependencies

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: recognise `aria-braillelabel` and `aria-brailleroledescription` as known ARIA attributes

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

@ -18,12 +18,12 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- node-version: 18 # Vitest 4 requires Node 20+, so tests run on 20/22/24. The published
# Svelte package still supports Node >=18 (see packages/svelte/package.json).
- node-version: 20
os: windows-latest os: windows-latest
- node-version: 18 - node-version: 20
os: macOS-latest os: macOS-latest
- node-version: 18
os: ubuntu-latest
- node-version: 20 - node-version: 20
os: ubuntu-latest os: ubuntu-latest
- node-version: 22 - node-version: 22
@ -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
@ -80,11 +80,11 @@ jobs:
Lint: Lint:
permissions: {} permissions: {}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 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
@ -98,14 +98,16 @@ jobs:
- name: build and check generated types - name: build and check generated types
if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail
run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); } run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); }
- name: check browser-support docs page is up to date
run: '{ [ "`git status --porcelain=v1 documentation/docs/07-misc/.generated/`" == "" ] || (echo "The browser-support docs page is out of date — please regenerate it locally with \`cd packages/svelte && pnpm generate:browser-support\` and commit the changes"; git diff documentation/docs/07-misc/.generated/; exit 1); }'
Benchmarks: Benchmarks:
permissions: {} permissions: {}
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

2
.gitignore vendored

@ -22,7 +22,9 @@ coverage
.DS_Store .DS_Store
tmp tmp
packages/svelte/scripts/_baseline/
benchmarking/.profiles benchmarking/.profiles
benchmarking/compare/.results benchmarking/compare/.results
benchmarking/compare/.profiles benchmarking/compare/.profiles
benchmarking/compare/results.html

@ -8,6 +8,7 @@ packages/**/config/*.js
# packages/svelte # packages/svelte
packages/svelte/messages/**/*.md packages/svelte/messages/**/*.md
packages/svelte/scripts/_bundle.js packages/svelte/scripts/_bundle.js
packages/svelte/scripts/_baseline/*.ts
packages/svelte/src/compiler/errors.js packages/svelte/src/compiler/errors.js
packages/svelte/src/compiler/warnings.js packages/svelte/src/compiler/warnings.js
packages/svelte/src/internal/client/errors.js packages/svelte/src/internal/client/errors.js

@ -4,6 +4,8 @@ This guide is for AI coding agents working in the Svelte monorepo.
**Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here. **Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here.
When submitting a PR, you **MUST** read [`PULL_REQUEST_TEMPLATE.md`](./.github/PULL_REQUEST_TEMPLATE.md) and fill it out correctly. **DO NOT** submit a PR without running the full test suite.
## Quick Reference ## Quick Reference
If asked to do a performance investigation, use the `performance-investigation` skill. If asked to do a performance investigation, use the `performance-investigation` skill.

@ -43,7 +43,7 @@ The maintainers meet on the final Saturday of each month. While these meetings a
### Prioritization ### Prioritization
We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active ones repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch.
## Bugs ## Bugs

@ -0,0 +1,346 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(fileURLToPath(import.meta.url), '../..');
const compiler_path = 'packages/svelte/src/compiler/';
/**
* Merge V8 profiles from a Vitest run into a compiler-only flame graph and hotspot summary.
* Samples retain callees outside the compiler once execution has entered compiler code.
* @param {string} run_dir
*/
export function analyze_compiler_profiles(run_dir) {
const raw_dir = path.join(run_dir, 'raw');
const profile_files = fs
.readdirSync(raw_dir)
.filter((file) => file.endsWith('.cpuprofile'))
.sort();
if (profile_files.length === 0) {
throw new Error(`No CPU profiles found in ${raw_dir}`);
}
/** @type {Array<{ name: string, file?: string, line?: number, col?: number }>} */
const frames = [];
/** @type {Map<string, number>} */
const frame_indices = new Map();
/** @type {Map<string, { stack: number[], weight: number }>} */
const stacks = new Map();
/** @type {Map<string, Hotspot>} */
const hotspots = new Map();
/** @type {Map<string, Hotspot>} */
const files = new Map();
let total_time = 0;
let compiler_time = 0;
let garbage_collection_time = 0;
let total_samples = 0;
let compiler_samples = 0;
for (const profile_file of profile_files) {
let profile;
try {
profile = JSON.parse(fs.readFileSync(path.join(raw_dir, profile_file), 'utf8'));
} catch (error) {
throw new Error(`Could not parse CPU profile ${profile_file}`, { cause: error });
}
const nodes = Array.isArray(profile.nodes) ? profile.nodes : [];
const samples = Array.isArray(profile.samples) ? profile.samples : [];
const time_deltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : [];
const nodes_by_id = new Map(nodes.map((node) => [node.id, node]));
const parents = new Map();
for (const node of nodes) {
for (const child of node.children || []) {
parents.set(child, node.id);
}
}
for (let i = 0; i < samples.length; i += 1) {
const weight = typeof time_deltas[i] === 'number' ? time_deltas[i] : 1;
const stack = get_stack(samples[i], nodes_by_id, parents);
total_time += weight;
total_samples += 1;
if (stack.at(-1)?.function_name === '(garbage collector)') {
garbage_collection_time += weight;
}
const compiler_index = stack.findIndex((frame) => frame.url.includes(compiler_path));
if (compiler_index === -1) continue;
const compiler_stack = stack.slice(compiler_index);
const stack_indices = compiler_stack.map(get_frame_index);
const stack_key = stack_indices.join(',');
const existing_stack = stacks.get(stack_key);
if (existing_stack) {
existing_stack.weight += weight;
} else {
stacks.set(stack_key, { stack: stack_indices, weight });
}
compiler_time += weight;
compiler_samples += 1;
const leaf = compiler_stack.at(-1);
if (leaf) {
get_hotspot(hotspots, frame_key(leaf), leaf).self += weight;
get_hotspot(files, leaf.url || '(native)', {
...leaf,
function_name: leaf.url || '(native)'
}).self += weight;
}
const seen_frames = new Set();
const seen_files = new Set();
for (const frame of compiler_stack) {
const key = frame_key(frame);
if (!seen_frames.has(key)) {
get_hotspot(hotspots, key, frame).inclusive += weight;
seen_frames.add(key);
}
const file = frame.url || '(native)';
if (!seen_files.has(file)) {
get_hotspot(files, file, { ...frame, function_name: file }).inclusive += weight;
seen_files.add(file);
}
}
}
}
if (compiler_time === 0) {
throw new Error('The CPU profiles contain no samples from packages/svelte/src/compiler');
}
const hotspot_rows = rank(hotspots, compiler_time);
const file_rows = rank(files, compiler_time);
const compact_stacks = [...stacks.values()];
const summary = {
profile_files: profile_files.length,
total_samples,
compiler_samples,
total_time_microseconds: total_time,
compiler_time_microseconds: compiler_time,
garbage_collection_time_microseconds: garbage_collection_time,
compiler_share_percent: (compiler_time * 100) / total_time,
hotspots: hotspot_rows,
files: file_rows
};
const speedscope = {
$schema: 'https://www.speedscope.app/file-format-schema.json',
name: `Svelte compiler: ${path.basename(run_dir)}`,
exporter: 'Svelte compiler test profiler',
activeProfileIndex: 0,
shared: { frames },
profiles: [
{
type: 'sampled',
name: 'Compiler-active samples from pnpm test',
unit: 'microseconds',
startValue: 0,
endValue: compiler_time,
samples: compact_stacks.map((entry) => entry.stack),
weights: compact_stacks.map((entry) => entry.weight)
}
]
};
fs.writeFileSync(
path.join(run_dir, 'flamegraph.speedscope.json'),
`${JSON.stringify(speedscope)}\n`
);
fs.writeFileSync(path.join(run_dir, 'summary.json'), `${JSON.stringify(summary, null, '\t')}\n`);
fs.writeFileSync(path.join(run_dir, 'summary.md'), render_markdown(summary));
return summary;
/** @param {Frame} frame */
function get_frame_index(frame) {
const key = frame_key(frame);
const existing = frame_indices.get(key);
if (existing !== undefined) return existing;
const index = frames.length;
const location = frame.url ? ` (${frame.url}:${frame.line})` : '';
frames.push({
name: `${frame.function_name}${location}`,
...(frame.url ? { file: frame.url, line: frame.line, col: frame.column } : {})
});
frame_indices.set(key, index);
return index;
}
}
/**
* @param {number} leaf_id
* @param {Map<number, any>} nodes_by_id
* @param {Map<number, number>} parents
* @returns {Frame[]}
*/
function get_stack(leaf_id, nodes_by_id, parents) {
/** @type {Frame[]} */
const stack = [];
const seen = new Set();
let id = leaf_id;
while (typeof id === 'number' && !seen.has(id)) {
seen.add(id);
const node = nodes_by_id.get(id);
if (!node) break;
const call_frame = node.callFrame || {};
stack.push({
function_name: call_frame.functionName || '(anonymous)',
url: normalize_url(call_frame.url || ''),
line: typeof call_frame.lineNumber === 'number' ? call_frame.lineNumber + 1 : 0,
column: typeof call_frame.columnNumber === 'number' ? call_frame.columnNumber + 1 : 0
});
id = parents.get(id);
}
return stack.reverse();
}
/** @param {string} url */
function normalize_url(url) {
if (!url) return '';
let pathname = url.replace(/^\/\@fs\//, '/').replace(/[?#].*$/, '');
if (pathname.startsWith('file://')) {
try {
pathname = fileURLToPath(pathname);
} catch {
return url;
}
}
if (path.isAbsolute(pathname)) {
const relative = path.relative(root, pathname);
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
pathname = relative;
}
}
return pathname.replaceAll(path.sep, '/');
}
/** @param {Frame} frame */
function frame_key(frame) {
return `${frame.function_name}\0${frame.url}\0${frame.line}\0${frame.column}`;
}
/**
* @param {Map<string, Hotspot>} collection
* @param {string} key
* @param {Frame} frame
*/
function get_hotspot(collection, key, frame) {
let hotspot = collection.get(key);
if (!hotspot) {
hotspot = { ...frame, self: 0, inclusive: 0 };
collection.set(key, hotspot);
}
return hotspot;
}
/**
* @param {Map<string, Hotspot>} collection
* @param {number} total
*/
function rank(collection, total) {
return [...collection.values()]
.map((hotspot) => ({
function: hotspot.function_name,
url: hotspot.url,
line: hotspot.line,
column: hotspot.column,
self_microseconds: hotspot.self,
self_percent: (hotspot.self * 100) / total,
inclusive_microseconds: hotspot.inclusive,
inclusive_percent: (hotspot.inclusive * 100) / total
}))
.sort(
(a, b) =>
b.self_microseconds - a.self_microseconds ||
b.inclusive_microseconds - a.inclusive_microseconds
);
}
/** @param {ReturnType<typeof analyze_compiler_profiles>} summary */
function render_markdown(summary) {
const lines = [
'# Svelte compiler CPU profile',
'',
'Open `flamegraph.speedscope.json` in [Speedscope](https://www.speedscope.app/) for the interactive flame graph.',
'',
'## Coverage',
'',
'| Metric | Value |',
'| --- | ---: |',
`| Raw profiles | ${summary.profile_files} |`,
`| All profiled CPU time | ${format_time(summary.total_time_microseconds)} |`,
`| Compiler-active CPU time | ${format_time(summary.compiler_time_microseconds)} |`,
`| Compiler share | ${summary.compiler_share_percent.toFixed(2)}% |`,
`| All-process garbage collection | ${format_time(summary.garbage_collection_time_microseconds)} |`,
'',
'Compiler-active time includes external callees while a compiler frame is on the stack. Garbage collection is reported for context but cannot be attributed to compiler stacks by V8.',
'',
'## Top Self Hotspots',
'',
'| Rank | Function | Location | Self | Self % | Inclusive | Inclusive % |',
'| ---: | --- | --- | ---: | ---: | ---: | ---: |'
];
for (const [index, row] of summary.hotspots.slice(0, 50).entries()) {
lines.push(render_row(index, row));
}
lines.push(
'',
'## Top Files',
'',
'| Rank | File | Self | Self % | Inclusive | Inclusive % |',
'| ---: | --- | ---: | ---: | ---: | ---: |'
);
for (const [index, row] of summary.files.slice(0, 50).entries()) {
lines.push(
`| ${index + 1} | ${escape_cell(row.function)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |`
);
}
return `${lines.join('\n')}\n`;
}
/** @param {number} index @param {ReturnType<typeof rank>[number]} row */
function render_row(index, row) {
const location = row.url ? `${row.url}:${row.line}:${row.column}` : '(native)';
return `| ${index + 1} | ${escape_cell(row.function)} | ${escape_cell(location)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |`;
}
/** @param {number} microseconds */
function format_time(microseconds) {
return `${(microseconds / 1000).toFixed(1)} ms`;
}
/** @param {string} value */
function escape_cell(value) {
return value.replaceAll('|', '\\|');
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const run_dir = process.argv[2];
if (!run_dir) {
console.error('Usage: node benchmarking/analyze-compiler-profile.js <profile-run-directory>');
process.exit(1);
}
analyze_compiler_profiles(path.resolve(run_dir));
}
/**
* @typedef {{ function_name: string, url: string, line: number, column: number }} Frame
* @typedef {Frame & { self: number, inclusive: number }} Hotspot
*/

@ -0,0 +1,3 @@
import { parser_benchmarks } from './parser.bench.js';
export const compiler_benchmarks = parser_benchmarks;

@ -0,0 +1,58 @@
import { parse } from '../../../packages/svelte/src/compiler/phases/1-parse/index.js';
import { fastest_test } from '../../utils.js';
const elements_source = Array.from(
{ length: 1000 },
(_, i) =>
`<!-- item ${i} --><section data-index="${i}" class="card card-${i}" title="plain text ${i}"><h2>Item ${i} has a reasonably long entity-free text value</h2><input disabled value="prefix {value} suffix"><textarea>plain {value} text</textarea></section>`
).join('\n');
const script_style_source = `<script>
// line comment
/* block comment */
const value = 42;
</script>
<style>
${Array.from(
{ length: 1000 },
(_, i) => `.item-${i} { /* property ${i} */ color: red; margin-inline: ${i}px; }`
).join('\n')}
</style>`;
const typescript_source = `${Array.from(
{ length: 1000 },
(_, i) => `<div data-index="${i}">Item ${i}</div>`
).join('\n')}
{value as number}
<script lang="ts">
let value: number = 42;
</script>${' \n'.repeat(1000)}`;
/**
* @param {string} label
* @param {string} source
* @param {number} iterations
*/
function create_parser_benchmark(label, source, iterations) {
return {
label,
fn: async () => {
for (let i = 0; i < iterations; i++) {
parse(source);
}
return await fastest_test(10, () => {
for (let i = 0; i < iterations; i++) {
parse(source);
}
});
}
};
}
export const parser_benchmarks = [
create_parser_benchmark('parser_elements', elements_source, 5),
create_parser_benchmark('parser_script_style', script_style_source, 50),
create_parser_benchmark('parser_typescript', typescript_source, 25)
];

@ -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 results = []; const PROFILE_DIR = process.env.BENCH_PROFILE_DIR ?? null;
const PROFILE_DIR = process.env.BENCH_PROFILE_DIR; const single = process.env.BENCH_SINGLE;
for (let i = 0; i < reactivity_benchmarks.length; i += 1) { 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 = [];
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);
}

@ -0,0 +1,43 @@
# Compiler CPU profiling
Run the full test suite with V8 CPU profiling enabled in every Vitest worker:
```sh
pnpm profile:compiler
```
Arguments are passed to `pnpm test`, so a smaller smoke run can target a suite:
```sh
pnpm profile:compiler snapshot
```
Each run is stored under `benchmarking/.profiles/compiler-tests/<timestamp>`. Set
`SVELTE_PROFILE_NAME` to give a run a stable name; existing runs are never overwritten.
`benchmarking/.profiles/compiler-tests/latest.txt` contains the latest successful run name.
Each run contains:
- `raw/*.cpuprofile`: the original V8 profile from every Vitest worker
- `flamegraph.speedscope.json`: compiler-active stacks merged for use in
[Speedscope](https://www.speedscope.app/)
- `summary.md`: ranked self/inclusive hotspots and files
- `summary.json`: the same measurements for further analysis
- `manifest.json`: the command, revision, timestamps, and test result
The merged profile includes downstream callees while a compiler source frame is on the stack.
This captures parser, walker, and printer dependencies without including unrelated test runtime.
V8 reports garbage collection outside the originating stack, so the summary reports process-wide
GC separately rather than attributing it to the compiler.
The default sampling interval is 5 ms. It can be changed for shorter, focused runs:
```sh
SVELTE_CPU_PROF_INTERVAL=1000 pnpm profile:compiler snapshot
```
Existing raw profiles can be analyzed again without rerunning tests:
```sh
node benchmarking/analyze-compiler-profile.js benchmarking/.profiles/compiler-tests/<run>
```

@ -0,0 +1,94 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { analyze_compiler_profiles } from './analyze-compiler-profile.js';
const root = path.resolve(fileURLToPath(import.meta.url), '../..');
const profiles_root = path.join(root, 'benchmarking/.profiles/compiler-tests');
const requested_name = process.env.SVELTE_PROFILE_NAME;
const run_name = safe(requested_name || new Date().toISOString().replaceAll(':', '-'));
const run_dir = path.join(profiles_root, run_name);
const raw_dir = path.join(run_dir, 'raw');
const test_args = process.argv.slice(2);
const sampling_interval = process.env.SVELTE_CPU_PROF_INTERVAL || '5000';
if (fs.existsSync(run_dir)) {
console.error(`Profile run already exists: ${path.relative(root, run_dir)}`);
process.exit(1);
}
fs.mkdirSync(raw_dir, { recursive: true });
const revision = spawnSync('git', ['rev-parse', 'HEAD'], {
cwd: root,
encoding: 'utf8'
});
const started_at = new Date().toISOString();
const manifest_file = path.join(run_dir, 'manifest.json');
const command = [
'pnpm',
'test',
'--execArgv=--cpu-prof',
`--execArgv=--cpu-prof-dir=${raw_dir}`,
`--execArgv=--cpu-prof-interval=${sampling_interval}`,
...test_args
];
write_manifest({
command,
revision: revision.status === 0 ? revision.stdout.trim() : null,
started_at,
status: 'running'
});
console.log(`Compiler profiles will be written to ${path.relative(root, run_dir)}`);
const result = spawnSync(command[0], command.slice(1), {
cwd: root,
stdio: 'inherit'
});
let analysis_error = null;
try {
analyze_compiler_profiles(run_dir);
fs.writeFileSync(path.join(profiles_root, 'latest.txt'), `${run_name}\n`);
} catch (error) {
analysis_error = error instanceof Error ? error.stack || error.message : String(error);
console.error(analysis_error);
}
write_manifest({
command,
revision: revision.status === 0 ? revision.stdout.trim() : null,
started_at,
finished_at: new Date().toISOString(),
status: result.status === 0 && analysis_error === null ? 'completed' : 'failed',
test_exit_code: result.status,
test_signal: result.signal,
analysis_error
});
if (result.error) {
console.error(result.error);
}
if (result.status === 0 && analysis_error === null) {
console.log(
`Compiler flame graph: ${path.relative(root, path.join(run_dir, 'flamegraph.speedscope.json'))}`
);
console.log(`Compiler hotspot summary: ${path.relative(root, path.join(run_dir, 'summary.md'))}`);
} else {
process.exitCode = result.status || 1;
}
/** @param {string} value */
function safe(value) {
return value.replace(/[^a-z0-9._-]+/gi, '_');
}
/** @param {Record<string, unknown>} manifest */
function write_manifest(manifest) {
fs.writeFileSync(manifest_file, `${JSON.stringify(manifest, null, '\t')}\n`);
}

@ -1,14 +1,48 @@
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 { compiler_benchmarks } from './benchmarks/compiler/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';
// e.g. `pnpm bench kairo` to only run the kairo benchmarks
const filters = process.argv.slice(2);
const PROFILE_DIR = './benchmarking/.profiles'; const PROFILE_DIR = './benchmarking/.profiles';
const suites = [ const single = process.env.BENCH_SINGLE;
if (single) {
// child mode — run a single benchmark and report the result to the parent
const benchmark = [...compiler_benchmarks, ...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
const filters = process.argv.slice(2);
const suites = [
// Commenting out because we rarely need to run it
// {
// benchmarks: compiler_benchmarks.filter(
// (b) => filters.length === 0 || filters.some((f) => b.label.includes(f))
// ),
// name: 'compiler benchmarks'
// },
{ {
benchmarks: reactivity_benchmarks.filter( benchmarks: reactivity_benchmarks.filter(
(b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) (b) => filters.length === 0 || filters.some((f) => b.label.includes(f))
@ -21,25 +55,59 @@ const suites = [
), ),
name: 'server-side rendering benchmarks' name: 'server-side rendering benchmarks'
} }
].filter((suite) => suite.benchmarks.length > 0); ].filter((suite) => suite.benchmarks.length > 0);
if (suites.length === 0) { if (suites.length === 0) {
console.log('No benchmarks matched provided filters'); console.log('No benchmarks matched provided filters');
process.exit(1); process.exit(1);
} }
const COLUMN_WIDTHS = [25, 9, 9]; const filename = fileURLToPath(import.meta.url);
const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b);
/**
* @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;
const pad_right = (str, n) => str + ' '.repeat(n - str.length); child.on('message', (message) => {
const pad_left = (str, n) => ' '.repeat(n - str.length) + str; result = /** @type {{ time: number, gc_time: number }} */ (message);
});
let total_time = 0; child.on('error', reject);
let total_gc_time = 0;
child.on('exit', (code) => {
if (result === null) {
reject(new Error(`benchmark ${label} exited with code ${code}`));
} else {
fulfil(result);
}
});
});
};
$.push({}, true); const COLUMN_WIDTHS = [25, 9, 9];
const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b);
try { /** @type {(str: string, n: number) => string} */
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;
let total_time = 0;
let total_gc_time = 0;
try {
for (const { benchmarks, name } of suites) { for (const { benchmarks, name } of suites) {
let suite_time = 0; let suite_time = 0;
let suite_gc_time = 0; let suite_gc_time = 0;
@ -53,7 +121,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,21 +142,18 @@ 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(
pad_right('total', COLUMN_WIDTHS[0]) + pad_right('total', COLUMN_WIDTHS[0]) +
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>
```

@ -106,7 +106,7 @@ In case you just want to render something `n` times, you can omit the `as` part:
.chess-board { .chess-board {
display: grid; display: grid;
grid-template-columns: repeat(8, 1fr); grid-template-columns: repeat(8, 1fr);
rows: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr);
border: 1px solid black; border: 1px solid black;
aspect-ratio: 1; aspect-ratio: 1;

@ -2,6 +2,8 @@
title: {@const ...} title: {@const ...}
--- ---
> [!NOTE] `{@const x = y}` is legacy syntax — use [`{const x = $derived(y)}`](declaration-tags) instead
The `{@const ...}` tag defines a local constant. The `{@const ...}` tag defines a local constant.
```svelte ```svelte

@ -0,0 +1,72 @@
---
title: {let/const ...}
---
Declaration tags define local variables inside markup with `const` or `let`:
<!-- codeblock:start {"title":"Declaration tags"} -->
```svelte
<!--- file: App.svelte --->
<script>
let boxes = [{ width: 10, height: 10 }, { width: 15, height: 15 }];
</script>
{#each boxes as box}
{const area = box.width * box.height}
{const label = `${box.width} × ${box.height} = ${area}`}
<p>{label}</p>
{/each}
```
<!-- codeblock:end -->
> [!NOTE] Declaration tags are available since Svelte 5.56.
> [!NOTE] The [`{@const ...}`](@const) syntax is considered legacy — use declaration tags instead.
When values should be reactive, you can use `$state` and `$derived`:
<!-- codeblock:start {"title":"Reactive declaration tags"} -->
```svelte
<!--- file: App.svelte --->
<script>
let user = $state({ name: 'Svelte' });
let editing = $state(false);
</script>
<p>Hello {user.name}</p>
<button onclick={() => editing = true}>edit name</button>
{#if editing}
{let name = $state(user.name)}
{const greeting = $derived(`Hello ${name}`)}
<hr>
<input bind:value={name} />
<p>{greeting}</p>
<button onclick={() => {
user.name = name;
editing = false;
}}>save</button>
{/if}
```
<!-- codeblock:end -->
Declaration tags can be used anywhere inside the component. They can reference values declared outside themselves (for example in the `<script>` tag or in `{#each ...}` blocks) and are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings):
<!-- codeblock:start {"title":"Declaration tag scope"} -->
```svelte
<!--- file: App.svelte --->
{const hello = 'hello'}
{hello} <!-- 'hello' -->
<div>
{const hello = 'hi'}
{hello} <!-- 'hi' -->
<div>
{hello} <!-- 'hi' -->
</div>
</div>
{hello} <!-- 'hello' -->
```
<!-- codeblock:end -->

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

@ -4,7 +4,7 @@ title: Context
Context allows components to access values owned by parent components without passing them down as props (potentially through many layers of intermediate components, known as 'prop-drilling'). Context allows components to access values owned by parent components without passing them down as props (potentially through many layers of intermediate components, known as 'prop-drilling').
By creating a `[get, set]` pair of functions with `createContext`, you can set the context in a parent component and get it in a child component: By creating a `[get, set, has]` triplet of functions with `createContext`, you can set the context in a parent component and get it in a child component:
<!-- codeblock:start {"title":"Context","selected":"context.ts"} --> <!-- codeblock:start {"title":"Context","selected":"context.ts"} -->
```svelte ```svelte
@ -163,9 +163,11 @@ export const [getCounter, setCounter] = createContext<Counter>();
Svelte will warn you if you get it wrong. Svelte will warn you if you get it wrong.
## Component testing Similarly, to pass primitive values through context, use functions as described in [Passing state into functions]($state#Passing-state-into-functions).
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: ## Mounting components with context
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';
@ -191,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.

@ -0,0 +1,7 @@
<!-- generated in ../../../../../packages/svelte/scripts/generate-browser-support.ts. do not edit -->
| Feature | Chrome/Edge | Firefox | Safari |
| - | - | - | - |
| [`$state.snapshot`](/docs/svelte/$state#$state.snapshot) | 98 | 94 | 15.4 |
| [`bind:devicePixelContentBoxSize`](/docs/svelte/bind#Dimensions) | <span style="color: var(--sk-fg-4)"></span> | 93 | not supported |
| [`flip` from `svelte/animate`](/docs/svelte/svelte-animate#flip) | <span style="color: var(--sk-fg-4)"></span> | 126 | <span style="color: var(--sk-fg-4)"></span> |

@ -0,0 +1,15 @@
<!-- generated in ../../../../../packages/svelte/scripts/generate-browser-support.ts. do not edit -->
| Browser | Minimum version |
| - | - |
| Chrome/Edge | 87 |
| Firefox | 83 |
| Safari | 14 |
| Opera | 73 |
| Opera (Android) | 62 |
| Samsung Internet | 14.0 |
| Android WebView | 87 |
| Internet Explorer | not supported |
> [!NOTE] This equates to a <a href="https://web-platform-dx.github.io/baseline/">Baseline</a> target of 2020.

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

@ -0,0 +1,15 @@
---
title: Browser support
---
The table below shows the minimum browser versions Svelte is expected to work in, derived from the browser APIs used by Svelte's internal code.
@include .generated/browser-support.md
This table only covers Svelte itself. It does not include [SvelteKit](/docs/kit), other Svelte libraries, or your own code.
## Exceptions
A few Svelte features require a higher minimum browser version. You'll only need to take the following table into consideration if you use these specific features.
@include .generated/browser-support-features.md

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

@ -88,6 +88,10 @@ Effect cannot be created inside a `$derived` value that was not itself created i
`%rune%` can only be used inside an effect (e.g. during component initialisation) `%rune%` can only be used inside an effect (e.g. during component initialisation)
``` ```
Effects can only be created while a parent effect is running. This means that they cannot, for example, be created inside an event handler or after an `await` expression (unless the `await` occurs directly inside a component's `<script>` tag, and not inside an async function).
In very rare cases, it is appropriate to use [`$effect.root`]($effect#$effect.root) so that you can create effects outside the normal component lifecycle.
### effect_pending_outside_reaction ### effect_pending_outside_reaction
``` ```
@ -221,14 +225,6 @@ Rest element properties of `$props()` such as `%property%` are readonly
The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files
``` ```
### set_context_after_init
```
`setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression
```
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
### state_descriptors_fixed ### state_descriptors_fixed
``` ```

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

@ -399,6 +399,18 @@ Invalid selector
Cannot declare a variable with the same name as an import from `<script module>` Cannot declare a variable with the same name as an import from `<script module>`
``` ```
### declaration_tag_invalid_type
```
Declaration tags must be `let` or `const` declarations
```
### declaration_tag_no_legacy_mode
```
Declaration tags cannot be used in legacy mode
```
### derived_invalid_export ### derived_invalid_export
``` ```
@ -809,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
``` ```

@ -62,7 +62,7 @@ Enforce that `autofocus` is not used on elements. Autofocusing elements can caus
### a11y_click_events_have_key_events ### a11y_click_events_have_key_events
``` ```
Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate Visible, non-interactive element `<%element%>` with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate
``` ```
Enforce that visible, non-interactive elements with an `onclick` event are accompanied by a keyboard event handler. Enforce that visible, non-interactive elements with an `onclick` event are accompanied by a keyboard event handler.
@ -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 -->
@ -842,7 +842,7 @@ Reassignments of module-level declarations will not cause reactive statements to
### script_unknown_attribute ### script_unknown_attribute
``` ```
Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it
``` ```
### slot_element_deprecated ### slot_element_deprecated

@ -75,10 +75,18 @@ 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, has]` triplet 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.
### set_context_after_init
```
`setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression
```
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
### 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

@ -90,6 +90,7 @@ export default [
'**/tests', '**/tests',
'packages/svelte/scripts/process-messages/templates/*.js', 'packages/svelte/scripts/process-messages/templates/*.js',
'packages/svelte/scripts/_bundle.js', 'packages/svelte/scripts/_bundle.js',
'packages/svelte/scripts/_baseline/**',
'packages/svelte/src/compiler/errors.js', 'packages/svelte/src/compiler/errors.js',
'packages/svelte/src/internal/client/errors.js', 'packages/svelte/src/internal/client/errors.js',
'packages/svelte/src/internal/client/warnings.js', 'packages/svelte/src/internal/client/warnings.js',

@ -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"
}, },
@ -19,6 +19,7 @@
"lint": "eslint && prettier --check .", "lint": "eslint && prettier --check .",
"format": "prettier --write .", "format": "prettier --write .",
"test": "vitest run", "test": "vitest run",
"profile:compiler": "node ./benchmarking/profile-compiler.js",
"changeset:version": "changeset version && pnpm -r generate:version && git add --all", "changeset:version": "changeset version && pnpm -r generate:version && git add --all",
"changeset:publish": "changeset publish", "changeset:publish": "changeset publish",
"bench": "NODE_ENV=production node --allow-natives-syntax ./benchmarking/run.js", "bench": "NODE_ENV=production node --allow-natives-syntax ./benchmarking/run.js",
@ -26,24 +27,24 @@
"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": "^2.1.9", "@vitest/coverage-v8": "^4.1.7",
"eslint": "^10.0.0", "eslint": "^10.0.0",
"eslint-plugin-lube": "^0.5.1", "eslint-plugin-lube": "^0.5.1",
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.15.0",
"jsdom": "25.0.1", "jsdom": "25.0.1",
"playwright": "^1.58.0", "playwright": "^1.62.0",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "workspace:^", "svelte": "workspace:^",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"typescript-eslint": "^8.56.0", "typescript-eslint": "^8.56.0",
"v8-natives": "^1.2.5", "v8-natives": "^1.2.5",
"vitest": "^2.1.9" "vitest": "^4.1.7"
} }
} }

@ -1,5 +1,407 @@
# svelte # svelte
## 5.57.0
### Minor Changes
- feat: export `RenderOutput`, `SyncRenderOutput`, `Csp` and `Sha256Source` from `svelte/server` ([#18648](https://github.com/sveltejs/svelte/pull/18648))
- feat: add `has` function to `createContext` ([#18472](https://github.com/sveltejs/svelte/pull/18472))
- feat: support `defaultValue` on `<select>` ([#18591](https://github.com/sveltejs/svelte/pull/18591))
- feat: add getOrInsert/getOrInsertComputed to SvelteMap ([#18728](https://github.com/sveltejs/svelte/pull/18728))
### Patch Changes
- fix: block template store subscriptions on the promise that assigns the store ([#18582](https://github.com/sveltejs/svelte/pull/18582))
- fix: route $derived teardown errors through invoke_error_boundary ([#18486](https://github.com/sveltejs/svelte/pull/18486))
- fix: track SvelteDate snapshots in reactions ([#18700](https://github.com/sveltejs/svelte/pull/18700))
- fix: remove `<svelte:head>` anchors on unmount ([#18697](https://github.com/sveltejs/svelte/pull/18697))
- fix: warn on undeclared shorthand event handlers on `<svelte:window>`, `<svelte:document>` and `<svelte:body>` ([#18480](https://github.com/sveltejs/svelte/pull/18480))
- perf: reuse the cached value in the `<option>`/`<select>` value guard ([#18713](https://github.com/sveltejs/svelte/pull/18713))
- fix: prevent malformed AST output for `<select>` with static `value` attribute ([#18449](https://github.com/sveltejs/svelte/pull/18449))
- fix: apply ownership mutation ignores to binding assignments ([#18718](https://github.com/sveltejs/svelte/pull/18718))
- fix: prevent onoutroend from firing twice when compilerOptions.hmr is true ([#18655](https://github.com/sveltejs/svelte/pull/18655))
- fix: preserve whitespace after inline elements when printing ([#18685](https://github.com/sveltejs/svelte/pull/18685))
- perf: fold SSR block-open markers into the branch's first push ([#18712](https://github.com/sveltejs/svelte/pull/18712))
- fix: run `onDestroy` callbacks when a server render throws ([#18585](https://github.com/sveltejs/svelte/pull/18585))
- fix: report `derived_invalid_export` for `export let x = $derived(...)` in runes mode ([#18692](https://github.com/sveltejs/svelte/pull/18692))
- fix: never apply class hash to elements inside `<svelte:head>` ([#18160](https://github.com/sveltejs/svelte/pull/18160))
- fix: keep `defaultChecked` on hydrated radio inputs with spread attributes ([#18701](https://github.com/sveltejs/svelte/pull/18701))
- fix: accept `onfocusin`/`onfocusout` in `a11y_mouse_events_have_key_events` ([#18689](https://github.com/sveltejs/svelte/pull/18689))
- perf: O(n²)→O(n) Map lookups for legacy `$:` reactive statement ordering ([#18602](https://github.com/sveltejs/svelte/pull/18602))
- fix: distinct memoizer on style/class directives ([#18466](https://github.com/sveltejs/svelte/pull/18466))
- fix: measure nested transitions before applying their starting styles ([#18647](https://github.com/sveltejs/svelte/pull/18647))
- fix: don't turn component instances stored in `$state` into state proxies ([#18646](https://github.com/sveltejs/svelte/pull/18646))
- perf: emit `$.only_child` for elements with a single child ([#18717](https://github.com/sveltejs/svelte/pull/18717))
- fix: omit `bind:focused` from SSR output (it has no HTML attribute) ([#18724](https://github.com/sveltejs/svelte/pull/18724))
- fix: more robust rendering of Svelte custom element slots ([#18710](https://github.com/sveltejs/svelte/pull/18710))
- perf: optimize simple object destructuring in `@const` tags ([#18390](https://github.com/sveltejs/svelte/pull/18390))
- fix: properly apply static textarea value attribute during CSR ([#18727](https://github.com/sveltejs/svelte/pull/18727))
- fix: end a restored reaction context at the end of its synchronous segment ([#18694](https://github.com/sveltejs/svelte/pull/18694))
- fix: keep the dependencies of a reaction that throws, so deriveds it read are neither leaked nor stuck in their error ([#18703](https://github.com/sveltejs/svelte/pull/18703))
- fix: don't resurrect outroing elements when an ancestor block is paused and resumed ([#18431](https://github.com/sveltejs/svelte/pull/18431))
- perf: use `$.comment()` for single-comment templates ([#18714](https://github.com/sveltejs/svelte/pull/18714))
- chore: move `@types/trusted-types` to devDependencies ([#18730](https://github.com/sveltejs/svelte/pull/18730))
- perf: store setters cache as `Set` instead of `Array` ([#18251](https://github.com/sveltejs/svelte/pull/18251))
- fix: transform derived assignments and select function bindings correctly during server-side rendering ([#18669](https://github.com/sveltejs/svelte/pull/18669))
- fix: keep boolean attributes with an empty string value when rendering attribute objects on the server ([#18721](https://github.com/sveltejs/svelte/pull/18721))
- fix: sync `SvelteURL` port signal when the protocol setter clears the port ([#18705](https://github.com/sveltejs/svelte/pull/18705))
- fix: block declaration tags and `{@const}` on async values read inside closures ([#18533](https://github.com/sveltejs/svelte/pull/18533))
- fix: avoid css tree-shaking for exported Snippet ([#18540](https://github.com/sveltejs/svelte/pull/18540))
- perf: treat `<img loading>` as a static element again ([#18711](https://github.com/sveltejs/svelte/pull/18711))
- fix: prevent `selectedcontent` mutation from changing the selected option ([#18495](https://github.com/sveltejs/svelte/pull/18495))
- fix: avoid `NaN` keyframe values in `slide` transition for elements without a layout box ([#18430](https://github.com/sveltejs/svelte/pull/18430))
- fix: preserve line feed character references in attribute values ([#18691](https://github.com/sveltejs/svelte/pull/18691))
- fix: decode uppercase-`X` hex numeric character references (`&#X...;`) ([#18708](https://github.com/sveltejs/svelte/pull/18708))
- chore: clarify when `$effect.pre` runs relative to DOM updates ([#18534](https://github.com/sveltejs/svelte/pull/18534))
- fix: scope SSR boundary failed snippets to their boundary ([#18593](https://github.com/sveltejs/svelte/pull/18593))
## 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
### Patch Changes
- fix: include wrapping parentheses in `{@const}` declarator `end` position ([#18436](https://github.com/sveltejs/svelte/pull/18436))
- fix: always unset reactivity context after restoring it ([#18453](https://github.com/sveltejs/svelte/pull/18453))
- fix: don't notify `searchParams` subscribers when the URL changes without affecting the search string ([#18425](https://github.com/sveltejs/svelte/pull/18425))
- fix: strip `?` from optional parameters in `<script lang="ts">` so generated JavaScript is valid ([#18448](https://github.com/sveltejs/svelte/pull/18448))
## 5.56.3
### Patch Changes
- fix: ignore errors that occur in destroyed effects ([#18384](https://github.com/sveltejs/svelte/pull/18384))
- fix: type BigInts in `$state.snapshot(...)` return values ([#18388](https://github.com/sveltejs/svelte/pull/18388))
## 5.56.2
### Patch Changes
- fix: properly track effect end node for async sibling component ([#18371](https://github.com/sveltejs/svelte/pull/18371))
- fix: prevent false-positive reactivity loss warning ([#18373](https://github.com/sveltejs/svelte/pull/18373))
- chore: bump esrap dependency ([#18372](https://github.com/sveltejs/svelte/pull/18372))
- fix: ignore declaration tags for animation directive ([#18366](https://github.com/sveltejs/svelte/pull/18366))
- fix: reject pending async deriveds on discard ([#18308](https://github.com/sveltejs/svelte/pull/18308))
## 5.56.1
### Patch Changes
- fix: error at compile time on duplicate snippet/declaration tag definitions ([#18351](https://github.com/sveltejs/svelte/pull/18351))
- fix: parse declaration tag contents more robustly ([#18353](https://github.com/sveltejs/svelte/pull/18353))
- fix: correctly transform references to earlier declarators in a declaration tag (e.g. `{let a = $state(0), b = $derived(a * 2)}`) ([#18348](https://github.com/sveltejs/svelte/pull/18348))
- fix: avoid spurious `state_referenced_locally` warnings for `$derived` declarations in declaration tags ([#18348](https://github.com/sveltejs/svelte/pull/18348))
- fix: tolerate whitespace before `let`/`const` in declaration tags ([#18348](https://github.com/sveltejs/svelte/pull/18348))
- fix: prevent infinite loop when a tag's expression ends with a trailing `/` at the end of the input ([#18350](https://github.com/sveltejs/svelte/pull/18350))
- fix: more robust parsing of declaration tags with regards to `type` ([#18330](https://github.com/sveltejs/svelte/pull/18330))
- fix: preserve newlines in spread input values when the `type` attribute is applied after `value` ([#18345](https://github.com/sveltejs/svelte/pull/18345))
- fix: update `SvelteURLSearchParams` when setting duplicate keys to the same joined value ([#18336](https://github.com/sveltejs/svelte/pull/18336))
- fix: check references for blockers on server, too ([#18352](https://github.com/sveltejs/svelte/pull/18352))
## 5.56.0
### Minor Changes
- feat: allow declarations in the template ([#18282](https://github.com/sveltejs/svelte/pull/18282))
### Patch Changes
- perf: use `createElement` instead of `createElementNS` for HTML elements ([#18262](https://github.com/sveltejs/svelte/pull/18262))
- perf: store `current_sources` as a `Set` for O(1) membership checks ([#18278](https://github.com/sveltejs/svelte/pull/18278))
- perf: deduplicate identical hoisted templates within a component ([#18320](https://github.com/sveltejs/svelte/pull/18320))
- perf: hoist `rest_props` exclude list as a module-scope `Set` ([#18252](https://github.com/sveltejs/svelte/pull/18252))
## 5.55.10
### Patch Changes
- fix: unlink errored and otherwise finished batch ([#18264](https://github.com/sveltejs/svelte/pull/18264))
- perf: walk composedPath() directly in delegated event propagation ([#18268](https://github.com/sveltejs/svelte/pull/18268))
- fix: transfer effects when merging batches ([#18254](https://github.com/sveltejs/svelte/pull/18254))
- fix: allow `$derived(await ...)` in disconnected effect roots ([#18273](https://github.com/sveltejs/svelte/pull/18273))
- fix: remove temporary raw-text hydration markers ([#18269](https://github.com/sveltejs/svelte/pull/18269))
- fix: propagate async `@const` blockers through closure references so template expressions like `{(() => host)()}` correctly wait for the awaited value ([#18309](https://github.com/sveltejs/svelte/pull/18309))
- fix: properly unlink batches ([#18298](https://github.com/sveltejs/svelte/pull/18298))
- fix: settle discarded batch ([#18290](https://github.com/sveltejs/svelte/pull/18290))
- fix: declare `let:` directives before `{@const}` declarations on slotted elements ([#18271](https://github.com/sveltejs/svelte/pull/18271))
- fix: resume outro-ed branches if they were kept around ([#18291](https://github.com/sveltejs/svelte/pull/18291))
- fix: avoid waterfall-warning when async resolves to same value ([#18297](https://github.com/sveltejs/svelte/pull/18297))
- fix: correctly coordinate component-level effects inside async blocks ([#18260](https://github.com/sveltejs/svelte/pull/18260))
- fix: make unnecessary commit work less likely ([#18263](https://github.com/sveltejs/svelte/pull/18263))
- chore: add tag name to `a11y_click_events_have_key_events` warning ([#18272](https://github.com/sveltejs/svelte/pull/18272))
- fix: catch rejected promises while merging/committing ([#18266](https://github.com/sveltejs/svelte/pull/18266))
## 5.55.9
### Patch Changes
- fix: don't unset batch when calling `{#await ...}` promise ([#18243](https://github.com/sveltejs/svelte/pull/18243))
- fix: promise-ify `{#await await ...}` expressions on the server and correctly hydrate them on the client ([#18243](https://github.com/sveltejs/svelte/pull/18243))
- fix: deduplicate dependencies that are added outside the init/update cycle ([#18243](https://github.com/sveltejs/svelte/pull/18243))
- fix: avoid false-positive batch invariant error ([#18246](https://github.com/sveltejs/svelte/pull/18246))
- fix: inline primitive constants in attribute values during SSR ([#18232](https://github.com/sveltejs/svelte/pull/18232))
## 5.55.8
### Patch Changes
- fix(print): handle `svelte:body` and fix keyframe percentage double-printing ([#18234](https://github.com/sveltejs/svelte/pull/18234))
- fix: execute uninitialized derived even if it's destroyed ([#18228](https://github.com/sveltejs/svelte/pull/18228))
- fix: use named symbols everywhere ([#18238](https://github.com/sveltejs/svelte/pull/18238))
- fix: don't run teardown effects when deriveds are unfreezed ([#18227](https://github.com/sveltejs/svelte/pull/18227))
- fix: unset context synchronously in `run` ([#18236](https://github.com/sveltejs/svelte/pull/18236))
## 5.55.7
### Patch Changes
- fix: prevent XSS on `hydratable` from user contents ([`a16ebc67bbcf8f708360195687e1b2719463e1a4`](https://github.com/sveltejs/svelte/commit/a16ebc67bbcf8f708360195687e1b2719463e1a4))
- chore: bump devalue ([#18219](https://github.com/sveltejs/svelte/pull/18219))
- fix: disallow empty attribute names during SSR ([`547853e2406a2147ad7fb5ffeba95b01bd9642da`](https://github.com/sveltejs/svelte/commit/547853e2406a2147ad7fb5ffeba95b01bd9642da))
- fix: harden regex ([`d2375e2ebcab5c88feb5652f1a9d621b8f06b259`](https://github.com/sveltejs/svelte/commit/d2375e2ebcab5c88feb5652f1a9d621b8f06b259))
- fix: move Svelte runtime properties to symbols ([`e1cbbd96441e82c9eb8a23a2903c0d06d3cda991`](https://github.com/sveltejs/svelte/commit/e1cbbd96441e82c9eb8a23a2903c0d06d3cda991))
## 5.55.6
### Patch Changes
- fix: leave stale promises to wait for a later resolution, instead of rejecting ([#18180](https://github.com/sveltejs/svelte/pull/18180))
- fix: keep dependencies of `$state.eager/pending` ([#18218](https://github.com/sveltejs/svelte/pull/18218))
- fix: reapply context after transforming error during SSR ([#18099](https://github.com/sveltejs/svelte/pull/18099))
- fix: don't rebase just-created batches ([#18117](https://github.com/sveltejs/svelte/pull/18117))
- chore: allow `null` for `pending` in typings ([#18201](https://github.com/sveltejs/svelte/pull/18201))
- fix: flush eager effects in production ([#18107](https://github.com/sveltejs/svelte/pull/18107))
- fix: rethrow error of failed iterable after calling `return()` ([#18169](https://github.com/sveltejs/svelte/pull/18169))
- fix: account for proxified instance when updating `bind:this` ([#18147](https://github.com/sveltejs/svelte/pull/18147))
- fix: ensure scheduled batch is flushed if not obsolete ([#18131](https://github.com/sveltejs/svelte/pull/18131))
- fix: resolve stale deriveds with latest value ([#18167](https://github.com/sveltejs/svelte/pull/18167))
- chore: remove unnecessary `increment_pending` calls ([#18183](https://github.com/sveltejs/svelte/pull/18183))
- fix: correctly compile component member expressions for SSR ([#18192](https://github.com/sveltejs/svelte/pull/18192))
- fix: reset `source.updated` stack traces after `flush` ([#18196](https://github.com/sveltejs/svelte/pull/18196))
- fix: replacing async 'blocking' strategy with 'merging' ([#18205](https://github.com/sveltejs/svelte/pull/18205))
- fix: allow `@debug` tags to reference awaited variables ([#18138](https://github.com/sveltejs/svelte/pull/18138))
- fix: re-run fallback props if dependencies update ([#18146](https://github.com/sveltejs/svelte/pull/18146))
- fix: abort running obsolete async branches ([#18118](https://github.com/sveltejs/svelte/pull/18118))
- fix: ignore comments when reading CSS values ([#18153](https://github.com/sveltejs/svelte/pull/18153))
- fix: wrap `Promise.all` in `save` during SSR ([#18178](https://github.com/sveltejs/svelte/pull/18178))
- fix: ignore false-positive errors of `$inspect` dependencies ([#18106](https://github.com/sveltejs/svelte/pull/18106))
## 5.55.5 ## 5.55.5
### 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;

@ -60,6 +60,10 @@ The key expression in a keyed each block must return the same value when called
> `%rune%` can only be used inside an effect (e.g. during component initialisation) > `%rune%` can only be used inside an effect (e.g. during component initialisation)
Effects can only be created while a parent effect is running. This means that they cannot, for example, be created inside an event handler or after an `await` expression (unless the `await` occurs directly inside a component's `<script>` tag, and not inside an async function).
In very rare cases, it is appropriate to use [`$effect.root`]($effect#$effect.root) so that you can create effects outside the normal component lifecycle.
## effect_pending_outside_reaction ## effect_pending_outside_reaction
> `$effect.pending()` can only be called inside an effect or derived > `$effect.pending()` can only be called inside an effect or derived
@ -167,12 +171,6 @@ This can happen if you render a hydratable on the client that was not rendered o
> The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files > The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files
## set_context_after_init
> `setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
## state_descriptors_fixed ## state_descriptors_fixed
> Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`. > Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.

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

@ -191,6 +191,14 @@ The same applies to components:
> {@debug ...} arguments must be identifiers, not arbitrary expressions > {@debug ...} arguments must be identifiers, not arbitrary expressions
## declaration_tag_invalid_type
> Declaration tags must be `let` or `const` declarations
## declaration_tag_no_legacy_mode
> Declaration tags cannot be used in legacy mode
## directive_invalid_value ## directive_invalid_value
> Directive value must be a JavaScript expression enclosed in curly braces > Directive value must be a JavaScript expression enclosed in curly braces

@ -49,7 +49,7 @@ Enforce that `autofocus` is not used on elements. Autofocusing elements can caus
## a11y_click_events_have_key_events ## a11y_click_events_have_key_events
> Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate > Visible, non-interactive element `<%element%>` with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate
Enforce that visible, non-interactive elements with an `onclick` event are accompanied by a keyboard event handler. Enforce that visible, non-interactive elements with an `onclick` event are accompanied by a keyboard event handler.
@ -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 -->

@ -107,7 +107,7 @@ This code will work when the component is rendered on the client (which is why t
## script_unknown_attribute ## script_unknown_attribute
> Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it > Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it
## slot_element_deprecated ## slot_element_deprecated

@ -62,9 +62,15 @@ 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, has]` triplet 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.
## set_context_after_init
> `setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression
This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6.
## 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.55.5", "version": "5.57.0",
"type": "module", "type": "module",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"engines": { "engines": {
@ -138,47 +138,50 @@
"templating" "templating"
], ],
"scripts": { "scripts": {
"build": "rollup -c && pnpm generate", "build": "rollup -c && pnpm generate && node scripts/check-treeshakeability.js",
"dev": "node scripts/process-messages -w & rollup -cw", "dev": "node scripts/process-messages -w & rollup -cw",
"check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc", "check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc",
"check:tsgo": "tsgo --project tsconfig.runtime.json --skipLibCheck && tsgo --skipLibCheck", "check:tsgo": "tsgo --project tsconfig.runtime.json --skipLibCheck && tsgo --skipLibCheck",
"check:watch": "tsc --watch", "check:watch": "tsc --watch",
"generate": "node scripts/process-messages && node ./scripts/generate-types.js", "generate": "node scripts/process-messages && node ./scripts/generate-types.js && pnpm generate:browser-support",
"generate:version": "node ./scripts/generate-version.js", "generate:version": "node ./scripts/generate-version.js",
"generate:types": "node ./scripts/generate-types.js && tsc -p tsconfig.generated.json", "generate:types": "node ./scripts/generate-types.js && tsc -p tsconfig.generated.json",
"prepublishOnly": "pnpm build && node scripts/check-treeshakeability.js", "generate:browser-support": "node ./scripts/generate-browser-support.ts",
"prepublishOnly": "pnpm build",
"knip": "pnpm dlx knip" "knip": "pnpm dlx knip"
}, },
"devDependencies": { "devDependencies": {
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@playwright/test": "^1.58.0", "@playwright/test": "^1.62.0",
"@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-virtual": "^3.0.2", "@rollup/plugin-virtual": "^3.0.2",
"@types/aria-query": "^5.0.4", "@types/aria-query": "^5.0.4",
"@types/node": "^20.11.5", "@types/node": "^20.11.5",
"@types/trusted-types": "^2.0.7",
"baseline-browser-mapping": "^2.10.32",
"dts-buddy": "^0.5.5", "dts-buddy": "^0.5.5",
"esbuild": "^0.25.10", "esbuild": "^0.28.1",
"rollup": "^4.59.0", "rollup": "^4.59.0",
"source-map": "^0.7.4", "source-map": "^0.7.4",
"tinyglobby": "^0.2.12", "tinyglobby": "^0.2.12",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vitest": "^2.1.9" "vitest": "^4.1.7",
"web-features": "^3.29.0"
}, },
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.5",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.6.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.13",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.9",
"@types/trusted-types": "^2.0.7", "acorn": "^8.18.0",
"acorn": "^8.12.1",
"aria-query": "5.3.1", "aria-query": "5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.6.4", "devalue": "^5.9.2",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.2.4", "esrap": "^2.3.6",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",

@ -0,0 +1,417 @@
import ts from 'typescript';
import { features } from 'web-features';
/**
* Maps compat-key suffixes under `javascript.operators` and
* `javascript.statements` (and a few other `javascript.*` subtrees) to
* detection callbacks. The callback receives a TS AST node and returns
* true if that node represents the operator or statement.
*
* @type {Record<string, (node: ts.Node) => boolean>}
*/
const SYNTAX_PREDICATES = {
nullish_coalescing: (node) =>
ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken,
nullish_coalescing_assignment: (node) =>
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken,
logical_or_assignment: (node) =>
ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken,
logical_and_assignment: (node) =>
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken,
optional_chaining: (node) =>
(ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node) ||
ts.isCallExpression(node)) &&
node.questionDotToken !== undefined,
spread: (node) => ts.isSpreadElement(node) || ts.isSpreadAssignment(node),
destructuring: (node) => ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node),
arrow_functions: (node) => ts.isArrowFunction(node),
try_catch_optional_binding: (node) =>
ts.isCatchClause(node) && node.variableDeclaration === undefined,
async_iteration: (node) => ts.isForOfStatement(node) && node.awaitModifier !== undefined,
for_await: (node) => ts.isForOfStatement(node) && node.awaitModifier !== undefined,
private_class_fields: (node) => ts.isPrivateIdentifier(node),
async_generator_function: (node) =>
(ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isMethodDeclaration(node)) &&
node.asteriskToken !== undefined &&
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false),
generator_function: (node) =>
(ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isMethodDeclaration(node)) &&
node.asteriskToken !== undefined &&
!(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false),
async_function: (node) =>
(ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node)) &&
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false) &&
node.asteriskToken === undefined,
classes: (node) => ts.isClassDeclaration(node) || ts.isClassExpression(node),
let_const: (node) =>
ts.isVariableDeclarationList(node) &&
((node.flags & ts.NodeFlags.Let) !== 0 || (node.flags & ts.NodeFlags.Const) !== 0),
template_literals: (node) =>
ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)
};
/**
* Walk `web-features` once and partition every `compat_features` path
* into the lookup tables the AST walker uses.
*/
function build_detection_maps() {
/** @type {Map<string, string>} identifier name → feature_id */
const globals = new Map();
/** @type {Map<string, Map<string, string>>} type → member → feature_id */
const members = new Map();
/** @type {Map<string, string>} string-literal value → feature_id */
const string_literals = new Map();
/** @type {Array<{ predicate: (n: ts.Node) => boolean, feature_id: string }>} */
const syntax_predicates = [];
const add_member = (
/** @type {string} */ type,
/** @type {string} */ member,
/** @type {string} */ feature_id
) => {
let by_member = members.get(type);
if (!by_member) {
by_member = new Map();
members.set(type, by_member);
}
// Only set if not already present — the first feature claiming a
// (type, member) pair wins. (Multiple features can map to the same
// pair via duplicated compat paths; we just need any.)
if (!by_member.has(member)) by_member.set(member, feature_id);
};
for (const [feature_id, feature] of Object.entries(features)) {
if (!('compat_features' in feature) || !feature.compat_features) continue;
for (const path of feature.compat_features) {
const parts = path.split('.');
// `api.X` → global identifier (constructor or function), e.g.
// `api.ResizeObserver`, `api.structuredClone`.
if (parts[0] === 'api' && parts.length === 2) {
globals.set(parts[1], feature_id);
continue;
}
// `api.X.Y` → member access on type X. E.g.
// `api.HTMLElement.inert`, `api.ResizeObserverEntry.contentBoxSize`.
if (parts[0] === 'api' && parts.length === 3) {
add_member(parts[1], parts[2], feature_id);
continue;
}
// `javascript.builtins.X` → global like Promise, Symbol, Proxy.
if (parts[0] === 'javascript' && parts[1] === 'builtins' && parts.length === 3) {
globals.set(parts[2], feature_id);
continue;
}
// `javascript.builtins.X.Y` → method/property on type X.
// We also accept the `ArrayConstructor`-style mapping for static
// methods: when the AST walker sees `Array.from(...)`, the
// receiver type's symbol name is `ArrayConstructor`, not `Array`.
if (parts[0] === 'javascript' && parts[1] === 'builtins' && parts.length === 4) {
add_member(parts[2], parts[3], feature_id);
add_member(`${parts[2]}Constructor`, parts[3], feature_id);
continue;
}
// `javascript.*` syntax: try the second segment first (covers
// `javascript.operators.X` and `javascript.statements.X`), then
// the last segment (covers `javascript.classes.private_class_fields`
// and similar).
if (parts[0] === 'javascript' && parts.length >= 3) {
const candidates = [parts[2], parts[parts.length - 1]];
for (const key of candidates) {
if (Object.hasOwn(SYNTAX_PREDICATES, key)) {
syntax_predicates.push({ predicate: SYNTAX_PREDICATES[key], feature_id });
break;
}
}
continue;
}
}
}
return { globals, members, string_literals, syntax_predicates };
}
const MAPS = build_detection_maps();
/**
* Versions and friendly names for synthetic feature IDs registered via
* `register_extra_rules` APIs the type-aware walker can see but that
* `web-features` doesn't (yet) catalogue. `versions_for_feature` consults
* this map before falling back to `web-features` lookups.
*
* @type {Map<string, { name: string, versions: Record<string, string | null>, baseline_year: number | null }>}
*/
const EXTRA_FEATURE_INFO = new Map();
/**
* Register additional detection rules for APIs the `web-features` dataset
* doesn't track yet. Two rule shapes are accepted:
*
* - **Member access**: `{ receiver_type, member, ... }` flags
* `expr.member` when `expr`'s type resolves to `receiver_type`.
* Equivalent to a `api.X.Y` rule auto-derived from web-features.
*
* - **String literal**: `{ string_literal, ... }` flags any
* occurrence of the literal value in source. Used for API options
* that are string-typed (e.g. `{ box: 'device-pixel-content-box' }`).
* The walker can be tightened later with a contextual-type check if
* false positives ever surface; for now an exact match is enough
* (these strings are too specific to occur incidentally).
*
* Each rule contributes its `feature_id`, per-browser versions,
* baseline year, and display name to the shared lookup tables.
*
* @param {Array<{
* feature_id: string,
* name: string,
* baseline_year: number,
* versions: Record<string, string | null>,
* receiver_type?: string,
* member?: string,
* string_literal?: string
* }>} rules
*/
export function register_extra_rules(rules) {
for (const rule of rules) {
if (rule.receiver_type && rule.member) {
let by_member = MAPS.members.get(rule.receiver_type);
if (!by_member) {
by_member = new Map();
MAPS.members.set(rule.receiver_type, by_member);
}
// Don't overwrite an existing web-features rule; that's canonical.
if (!by_member.has(rule.member)) {
by_member.set(rule.member, rule.feature_id);
}
} else if (rule.string_literal) {
if (!MAPS.string_literals.has(rule.string_literal)) {
MAPS.string_literals.set(rule.string_literal, rule.feature_id);
}
}
EXTRA_FEATURE_INFO.set(rule.feature_id, {
name: rule.name,
baseline_year: rule.baseline_year,
versions: rule.versions
});
}
}
/**
* Compile bundle files into a single `ts.Program` so type-checking is
* amortised across all of them.
*
* @param {string[]} files
*/
function build_program(files) {
const program = ts.createProgram(files, {
allowJs: true,
checkJs: false,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
strict: false,
noEmit: true,
skipLibCheck: true,
isolatedModules: true,
noErrorTruncation: true
});
const checker = program.getTypeChecker();
return { program, checker };
}
/**
* Collect the names of a type and its base types so a `member` lookup
* keyed on (say) `HTMLElement` matches a receiver typed as
* `HTMLDivElement`. Also includes the apparent type to catch primitives
* (`'foo'` apparent-types to `String`).
*
* @param {ts.Type} type
* @param {ts.TypeChecker} checker
*/
function get_type_names(type, checker) {
const names = new Set();
const constituents = type.isUnionOrIntersection() ? type.types : [type];
for (const t of constituents) {
const symbol = t.getSymbol() ?? t.aliasSymbol;
if (symbol) names.add(symbol.getName());
for (const base of t.getBaseTypes?.() ?? []) {
const base_symbol = base.getSymbol() ?? base.aliasSymbol;
if (base_symbol) names.add(base_symbol.getName());
}
const apparent = checker.getApparentType(t);
if (apparent && apparent !== t) {
const apparent_symbol = apparent.getSymbol() ?? apparent.aliasSymbol;
if (apparent_symbol) names.add(apparent_symbol.getName());
}
}
return names;
}
/**
* Cheap test for whether a symbol refers to a global binding (declared
* in a lib.d.ts or ambient module) rather than a user-defined local.
*
* @param {ts.Symbol | undefined} symbol
*/
function is_global_binding(symbol) {
if (!symbol) return true; // unresolved → assume global
const declarations = symbol.getDeclarations() ?? [];
if (declarations.length === 0) return true;
for (const decl of declarations) {
const file_name = decl.getSourceFile().fileName;
if (file_name.includes('/lib.') && file_name.endsWith('.d.ts')) return true;
}
return false;
}
/**
* Walk a TS source file emitting feature IDs as they're discovered.
*
* @param {ts.SourceFile} source
* @param {ts.TypeChecker | null} checker
* When `null`, type-aware checks (member access) are skipped. Used for
* the compiler-output fixtures which are parsed without a Program.
* @param {(feature_id: string) => void} emit
*/
function walk_source(source, checker, emit) {
/** @param {ts.Node} node */
function visit(node) {
// Syntax predicates run regardless of whether we have a checker.
for (const { predicate, feature_id } of MAPS.syntax_predicates) {
if (predicate(node)) emit(feature_id);
}
// Global identifier detection.
if (
ts.isIdentifier(node) &&
!(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) &&
!ts.isPropertyAssignment(node.parent) &&
!ts.isMethodDeclaration(node.parent) &&
!ts.isPropertySignature(node.parent)
) {
const feature_id = MAPS.globals.get(node.text);
if (feature_id) {
if (!checker || is_global_binding(checker.getSymbolAtLocation(node))) {
emit(feature_id);
}
}
}
// String-literal detection. The walker matches by value alone;
// false positives are theoretically possible but the literal
// values we care about (e.g. `'device-pixel-content-box'`) are
// distinctive enough that one hasn't been observed.
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
const feature_id = MAPS.string_literals.get(node.text);
if (feature_id) emit(feature_id);
}
// Member access detection (requires the checker).
if (checker && ts.isPropertyAccessExpression(node)) {
const member_name = node.name.text;
const receiver_type = checker.getTypeAtLocation(node.expression);
const type_names = get_type_names(receiver_type, checker);
for (const type_name of type_names) {
const by_member = MAPS.members.get(type_name);
if (!by_member) continue;
const feature_id = by_member.get(member_name);
if (feature_id) {
emit(feature_id);
break;
}
}
}
ts.forEachChild(node, visit);
}
visit(source);
}
/**
* Detect features used in a set of bundle files. Returns the union of
* web-features IDs flagged across all files.
*
* @param {string[]} bundle_files Absolute paths to JS/TS files.
* @returns {Set<string>}
*/
export function detect_features(bundle_files) {
const { program, checker } = build_program(bundle_files);
const flagged = new Set();
for (const file of bundle_files) {
const source = program.getSourceFile(file);
if (!source) continue;
walk_source(source, checker, (id) => flagged.add(id));
}
return flagged;
}
/**
* Detect features used in a single in-memory source string. No type
* checker (so member-based rules silently skip) useful for the
* compiler-output fixtures, where syntax-level detection is sufficient.
*
* @param {string} source_text
* @returns {Set<string>}
*/
export function detect_features_in_text(source_text) {
const source = ts.createSourceFile(
'fixture.js',
source_text,
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.JS
);
const flagged = new Set();
walk_source(source, null, (id) => flagged.add(id));
return flagged;
}
/**
* Per-browser minimum versions for a feature ID. Consults
* supplemental rules first (from `register_extra_rules`), then falls
* back to `web-features`. Returns null when neither has data.
*
* @param {string} feature_id
*/
export function versions_for_feature(feature_id) {
const extra = EXTRA_FEATURE_INFO.get(feature_id);
if (extra) return extra.versions;
const feature = features[feature_id];
if (!feature || !('status' in feature)) return null;
return /** @type {Record<string, string> | null} */ (
/** @type {unknown} */ (feature.status.support)
);
}
/**
* Baseline year for a feature. Returns `null` for features without a
* Baseline date (limited availability, supplemental rules without a
* year, or absent from the dataset).
*
* @param {string} feature_id
*/
export function baseline_year_for_feature(feature_id) {
const extra = EXTRA_FEATURE_INFO.get(feature_id);
if (extra) return extra.baseline_year;
const feature = features[feature_id];
if (!feature || !('status' in feature)) return null;
const status = /** @type {{ baseline_low_date?: string }} */ (feature.status);
if (!status.baseline_low_date) return null;
return Number(status.baseline_low_date.slice(0, 4));
}

@ -0,0 +1,877 @@
/* eslint-disable no-console */
// Regenerates `documentation/docs/07-misc/05-browser-support.md`.
//
// Pipeline:
// 1. Bundle each runtime entry point with rollup using production export
// conditions, then walk the resulting JS with TypeScript's compiler
// API + TypeChecker. The walker (see `browser-support.detector.js`)
// flags any web-features ID the runtime references.
// 2. Verify each entry in `BEHAVIORAL_IGNORE` is still flagged by the
// detector — if not, the entry can be removed.
// 3. Enumerate every user-facing feature (each `bind:*`, every public
// subpackage export, every rune from the compiler's `RUNES` array,
// and the handful of directives that need their own fixtures). For
// each: compile, bundle, walk. If the bundle requires browser
// versions newer than the runtime floor, emit a row in the
// conditional-features table. Blind-spot regexes pick up APIs the
// AST walker can't see (string-literal constructor options,
// `getComputedStyle(...).zoom` reads).
// 4. Translate floors into concrete browser versions via `web-features`
// data (exact per-feature versions), falling back to
// `baseline-browser-mapping` for year-only resolution.
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { rollup, type OutputChunk } from 'rollup';
import virtual from '@rollup/plugin-virtual';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { getCompatibleVersions } from 'baseline-browser-mapping';
import {
detect_features,
detect_features_in_text,
versions_for_feature,
baseline_year_for_feature,
register_extra_rules
} from './browser-support.detector.js';
import { binding_properties } from '../src/compiler/phases/bindings.js';
import { RUNES } from '../src/utils.js';
import { compile as svelte_compile } from '../src/compiler/index.js';
type BindingProperty = import('../src/compiler/phases/bindings.js').BindingProperty;
type PackageExport = string | { browser?: string; default?: string };
type CompilerFixture = { filename: string; code: string };
type Feature = { name: string; source: string; kind: 'svelte' | 'js' };
type BrowserVersions = Record<string, string | null>;
type RuntimeFloor = number | 'newly';
type ConditionalRow = {
name: string;
doc_link: string | null;
versions: BrowserVersions;
baseline_year: RuntimeFloor;
};
const doc_links: Record<string, string | null> = {
'`$state.snapshot`': '/docs/svelte/$state#$state.snapshot',
'`bind:devicePixelContentBoxSize`': '/docs/svelte/bind#Dimensions',
'`flip` from `svelte/animate`': '/docs/svelte/svelte-animate#flip'
};
// Supplemental detection rules for APIs `web-features` doesn't track
// yet. Each rule is checked with full TS type-aware precision — the
// only reason it lives here instead of being auto-derived is that no
// compat key in `web-features` covers the API.
register_extra_rules([
{
// `getComputedStyle(current).zoom` walk in `svelte/animate`'s
// `flip` fallback path. Firefox didn't expose `.zoom` on
// `CSSStyleDeclaration` until v126 (May 2024) — pre-126 the read
// yields an empty string, breaking the animation math. No entry
// for it exists in `web-features` (CSS `zoom` is an IDL accessor
// without its own Baseline feature record).
receiver_type: 'CSSStyleDeclaration',
member: 'zoom',
feature_id: 'extra:css-zoom-read',
name: 'CSS zoom property reads (getComputedStyle(...).zoom)',
baseline_year: 2024,
versions: { firefox: '126' }
},
{
// `box: 'device-pixel-content-box'` in `bind_resize_observer`
// (size.js). The TS DOM lib declares the option value as part of
// the `ResizeObserverBoxOptions` union — we could check the
// contextual type, but matching the literal value is enough since
// the string is too specific to occur incidentally. Per MDN BCD:
// - constructor option: Chrome 84, Firefox 93, Safari 15.4
// - `ResizeObserverEntry.devicePixelContentBoxSize`: Safari NOT
// SUPPORTED (`version_added: false`)
// Safari therefore silently accepts the option from 15.4 onwards
// but never exposes the matching entry property, so the binding
// reads `undefined` on any Safari.
string_literal: 'device-pixel-content-box',
feature_id: 'extra:device-pixel-content-box',
name: 'ResizeObserver `box: device-pixel-content-box` option + `entry.devicePixelContentBoxSize`',
baseline_year: 2023,
versions: {
chrome: '84',
edge: '84',
firefox: '93',
safari: null,
safari_ios: null
}
}
]);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkg_dir = path.resolve(__dirname, '..');
const repo_root = path.resolve(pkg_dir, '..', '..');
const docs_dir = path.join(repo_root, 'documentation/docs/07-misc/.generated');
const snapshot_dir = path.join(pkg_dir, 'tests/snapshot/samples');
const tmp_dir = path.join(__dirname, '_baseline');
const pkg = JSON.parse(fs.readFileSync(path.join(pkg_dir, 'package.json'), 'utf-8')) as {
exports: Record<string, PackageExport>;
};
/**
* Suppressions that should NEVER affect the floor regardless of whether
* the runtime currently uses the API. Two reasons an entry belongs here:
*
* - The `web-features` dataset misclassifies the API (e.g.
* `devicepixelratio` is marked Baseline `false` because Safari is
* missing from its `support` map, but the property has shipped in
* every Safari for over a decade).
* - Svelte feature-detects the API at runtime with `?.` and degrades
* gracefully when it's unavailable. Example: `trusted-types` in
* `src/internal/client/dom/reconciler.js`.
*
* These are exempt from the staleness check.
*/
const SAFE_TO_IGNORE = new Set(['devicepixelratio', 'trusted-types']);
/**
* Suppressions for features that DO live in the runtime but are reached
* only via a specific code path documented in the per-feature table on
* the docs page.
*/
const BEHAVIORAL_IGNORE = new Set([
'structured-clone',
'extra:css-zoom-read',
'extra:device-pixel-content-box'
]);
/** Aggregate ignore set — used for the headline floor. */
const AGGREGATE_IGNORE = new Set([...SAFE_TO_IGNORE, ...BEHAVIORAL_IGNORE]);
/**
* Subpaths in `pkg.exports` whose runtime is Node-only. Can't be derived
* from the exports map alone `./compiler` has a `require:` field that
* hints at CJS, but `./server` and `./internal/server` are plain `default`
* entries indistinguishable from a browser module.
*/
const NODE_ONLY_EXPORTS = new Set(['./compiler', './server', './internal/server']);
/**
* Every subpath in `pkg.exports` that ships browser JS. Type-only entries
* (`./action`, `./elements`) and the `./package.json` re-export filter out
* naturally on the `.js` check; only Node-only subpaths need an explicit
* exception, so new browser exports are picked up automatically.
*/
function browser_subpaths(): string[] {
const subpaths: string[] = [];
for (const [subpath, conditions] of Object.entries(pkg.exports)) {
if (NODE_ONLY_EXPORTS.has(subpath)) continue;
if (typeof conditions !== 'object' || conditions === null) continue;
const file = conditions.browser ?? conditions.default;
if (typeof file !== 'string' || !file.endsWith('.js')) continue;
subpaths.push(subpath);
}
return subpaths;
}
/**
* `.` `svelte`, `./animate` `svelte/animate`, etc.
*/
function importee_for(subpath: string): string {
return subpath === '.' ? 'svelte' : `svelte${subpath.slice(1)}`;
}
/**
* True if a subpath represents a public, user-facing subpackage whose
* named exports should each get their own per-feature fixture. Excludes
* the main entry (covered by the aggregate scan), the `./legacy` shim,
* and everything under `./internal/`.
*/
function is_public_subpackage(subpath: string): boolean {
return subpath !== '.' && subpath !== './legacy' && !subpath.startsWith('./internal');
}
/**
* For each public subpackage, dynamically import the module and return
* its named exports. Driven entirely by `pkg.exports`, so a new
* subpackage is picked up the next time the script runs.
*/
async function enumerate_subpackage_exports(): Promise<Record<string, string[]>> {
const result: Record<string, string[]> = {};
for (const subpath of browser_subpaths()) {
if (!is_public_subpackage(subpath)) continue;
const module_id = importee_for(subpath);
try {
const ns = await import(module_id);
const names = Object.keys(ns)
.filter((k) => k !== 'default')
.sort();
if (names.length > 0) result[module_id] = names;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(` (could not enumerate ${module_id}: ${message.split('\n')[0]})`);
}
}
return result;
}
const rune_fixtures: Record<(typeof RUNES)[number], string> = {
$state: `<script>let v = $state(0); console.log(v);</script>`,
'$state.raw': `<script>let v = $state.raw({}); console.log(v);</script>`,
'$state.eager': `<script>let v = $state.eager(0); console.log(v);</script>`,
'$state.snapshot': `<script>const v = $state({}); const snap = $state.snapshot(v); console.log(snap);</script>`,
$derived: `<script>let a = $state(0); let d = $derived(a + 1); console.log(d);</script>`,
'$derived.by': `<script>let a = $state(0); let d = $derived.by(() => a + 1); console.log(d);</script>`,
$props: `<script>let { x } = $props(); console.log(x);</script>`,
'$props.id': `<script>const id = $props.id(); console.log(id);</script>`,
$bindable: `<script>let { v = $bindable() } = $props(); console.log(v);</script>`,
$effect: `<script>$effect(() => { console.log('e'); });</script>`,
'$effect.pre': `<script>$effect.pre(() => { console.log('p'); });</script>`,
'$effect.tracking': `<script>$effect(() => { console.log($effect.tracking()); });</script>`,
'$effect.root': `<script>const stop = $effect.root(() => () => {}); stop();</script>`,
'$effect.pending': `<script>$effect(() => { console.log($effect.pending()); });</script>`,
$inspect: `<script>let v = $state(0); $inspect(v);</script>`,
'$inspect().with': `<script>let v = $state(0); $inspect(v).with(() => {});</script>`,
'$inspect.trace': `<script>$effect(() => { $inspect.trace(); });</script>`,
$host: `<svelte:options customElement="x-y" />\n<script>const h = $host(); console.log(h);</script>`
};
function rune_fixture(rune: (typeof RUNES)[number]): string {
if (!Object.hasOwn(rune_fixtures, rune)) {
throw new Error(`Fixture missing for ${rune}`);
}
return rune_fixtures[rune];
}
/**
* Compiled-fixture sources for directives. Bindings are covered by the
* `binding_properties` enumeration; transitions, animate, actions, and
* `@attach` need explicit fixtures because they require accompanying
* imports or surrounding markup.
*/
const TESTED_DIRECTIVES = [
{
name: '`transition:` / `in:` / `out:`',
source: `<script>import { fade } from 'svelte/transition'; let show = $state(false);</script>{#if show}<div transition:fade></div>{/if}`
},
{
name: '`animate:`',
source: `<script>const flip = () => {}; let items = $state([1,2,3]);</script>{#each items as item (item)}<div animate:flip>{item}</div>{/each}`
},
{
name: '`use:` actions',
source: `<script>function action(node){return {destroy(){}}}</script><div use:action></div>`
},
{
name: '`@attach`',
source: `<script>const attachment = (node) => () => {};</script><div {@attach attachment}></div>`
},
{
name: '`{@html ...}`',
source: `<script>let html = $state('<b>x</b>');</script>{@html html}`
},
{
name: 'Custom elements (`<svelte:options customElement>`)',
source: `<svelte:options customElement="my-el" />\n<div></div>`
}
];
/**
* Filesystem-safe identifier for an importee like `svelte/internal/client`.
*/
function safe_name(importee: string): string {
return importee.replace(/[^a-z0-9]+/gi, '_');
}
/**
* Bundle an entry the way users receive it, so we scan the same code the
* browser does. Mirrors `check-treeshakeability.js`.
*
* `entry_code` is virtual module source: typically `export * from
* 'svelte/...'` for a runtime entry, or compiled fixture JS for a per-feature
* scan. `silent` suppresses rollup's circular-dependency warnings used for
* fixture bundles where they're known and noisy.
*/
async function bundle(entry_code: string, options: { silent?: boolean } = {}): Promise<string> {
const built = await rollup({
input: '__entry__',
plugins: [
virtual({ __entry__: entry_code }),
{
name: 'resolve-svelte',
resolveId(id: string) {
if (id.startsWith('svelte')) {
const entry = pkg.exports[id.replace('svelte', '.')];
if (!entry) return;
if (typeof entry === 'string') return path.resolve(pkg_dir, entry);
const file = entry.browser ?? entry.default;
if (file) return path.resolve(pkg_dir, file);
}
}
},
nodeResolve({ exportConditions: ['production', 'import', 'browser', 'default'] })
],
// Treat optional peers / Node-only branches as external so we only scan
// code that actually runs in the browser.
external: ['esm-env'],
onwarn: options.silent
? () => {}
: (warning, handler) => {
if (warning.code === 'CIRCULAR_DEPENDENCY') return;
handler(warning);
}
});
const { output } = await built.generate({ format: 'esm' });
await built.close();
return output
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
.map((chunk) => chunk.code)
.join('\n');
}
/**
* Read every compiler-emitted client file from the snapshot tests. These
* fixtures cover the full range of patterns the compiler emits bindings,
* transitions, `<svelte:element>`, async derived, hydration markers, etc.
*/
function load_compiler_output_fixtures(): CompilerFixture[] {
const fixtures: CompilerFixture[] = [];
for (const sample of fs.readdirSync(snapshot_dir)) {
const client_dir = path.join(snapshot_dir, sample, '_expected/client');
if (!fs.existsSync(client_dir)) continue;
for (const file of fs.readdirSync(client_dir)) {
if (!file.endsWith('.js')) continue;
fixtures.push({
filename: `${sample}/${file}`,
code: fs.readFileSync(path.join(client_dir, file), 'utf-8')
});
}
}
return fixtures;
}
/**
* Combine per-feature version data into a single Record. Takes the max
* (strictest) version per browser across the input feature IDs.
*
* `null` propagates as "not supported" if any contributing feature
* marks a browser unsupported, the merged record does too.
*
* Returns `null` if NONE of the IDs have versions in `web-features` or
* supplemental rules, so callers can fall back to year-based mapping.
*/
function versions_from_features(ids: Iterable<string>): BrowserVersions | null {
const merged: BrowserVersions = {};
let any_found = false;
for (const id of ids) {
const support = versions_for_feature(id);
if (!support) continue;
any_found = true;
for (const [browser, version] of Object.entries(support)) {
const current = merged[browser];
// `null` means "not supported"; propagate it directly.
if (version === null) {
merged[browser] = null;
continue;
}
if (current === null) continue; // already known unsupported
if (current === undefined || Number(version) > Number(current)) {
merged[browser] = version;
}
}
}
return any_found ? merged : null;
}
/**
* Highest baseline year among `detected`, and the set of feature IDs that
* drove it. Used both for the aggregate runtime floor and for per-fixture
* scans the two differ only in their ignore set.
*/
function compute_floor(
detected: Iterable<string>,
ignore: Set<string>
): { year: number; drivers: Set<string> } {
let year = 0;
const drivers = new Set<string>();
for (const id of detected) {
if (ignore.has(id)) continue;
const y = baseline_year_for_feature(id);
if (!y) continue;
if (y > year) {
year = y;
drivers.clear();
}
if (y === year) drivers.add(id);
}
return { year, drivers };
}
/**
* Run the TS-based detector across the runtime bundles and the compiler-
* output fixtures, then compute the highest baseline year among the
* detected features (after subtracting `AGGREGATE_IGNORE`).
*
* `runtime_files` are absolute paths to runtime bundle files. Returns the
* minimum Baseline year the combined code satisfies.
*/
function find_minimum_target(
runtime_files: string[],
compiler_fixtures: CompilerFixture[]
): number {
// Type-aware walk over the runtime bundles.
const detected = detect_features(runtime_files);
// Syntax-only walk over the compiler-output fixtures (text only, no
// program context; the bare TS source-file parser handles the syntax
// features the compiler emits).
for (const fixture of compiler_fixtures) {
for (const id of detect_features_in_text(fixture.code)) detected.add(id);
}
const { year, drivers } = compute_floor(detected, AGGREGATE_IGNORE);
// Floor at 2015 so the docs never claim a pre-ES6 target if every
// detected feature happens to lack a Baseline year.
const final_year = Math.max(year, 2015);
console.log(`${final_year} (features that drove the floor:)`);
for (const id of [...drivers].sort()) {
console.log(` - ${id}`);
}
return final_year;
}
/**
* Verify every entry in `BEHAVIORAL_IGNORE` is actually used by the
* runtime. Without this, a behavioural suppression can outlive the API
* it suppresses the comment stays in the config pointing at code that
* no longer exists.
*
* `SAFE_TO_IGNORE` entries are exempt: they're safe to carry regardless
* of whether the runtime currently uses the API.
*/
function validate_ignore_features(runtime_files: string[]): void {
if (BEHAVIORAL_IGNORE.size === 0) return;
const detected = detect_features(runtime_files);
const stale = [...BEHAVIORAL_IGNORE].filter((id) => !detected.has(id));
if (stale.length > 0) {
throw new Error(
`BEHAVIORAL_IGNORE contains entries that the detector does not flag — ` +
`they can be removed:\n` +
stale.map((id) => ` - ${id}`).join('\n') +
`\n\nEdit \`packages/svelte/scripts/generate-browser-support.js\` ` +
`and delete the stale entries. If the API was removed from the runtime ` +
`as part of this change, that is exactly the intended signal.`
);
}
}
/**
* Build the full list of user-facing features to test for conditional
* floor bumps. Each feature gets a self-contained fixture, compiled and
* bundled like real user code, then scanned. If the bundle's floor
* exceeds the runtime floor, a row is auto-emitted in the docs.
*
* `subpackage_exports` maps subpath list of exported symbols, produced by
* `enumerate_subpackage_exports`. Passed in rather than computed here so the
* dynamic-import discovery can happen once in `main`.
*/
function enumerate_features(subpackage_exports: Record<string, string[]>): Feature[] {
const features: Feature[] = [];
// Every `bind:*` accepted by the compiler. Element selection respects
// the `valid_elements` constraint declared in `binding_properties`.
for (const [name, props] of Object.entries(binding_properties)) {
const fixture = binding_fixture(name, props);
if (fixture) {
features.push({
name: `\`bind:${name}\``,
kind: 'svelte',
source: fixture
});
}
}
for (const [module, exports] of Object.entries(subpackage_exports)) {
for (const exp of exports) {
features.push({
name: `\`${exp}\` from \`${module}\``,
kind: 'js',
source: `import { ${exp} } from '${module}'; export const _ = ${exp};`
});
}
}
for (const rune of RUNES) {
features.push({
name: `\`${rune}\``,
kind: 'svelte',
source: rune_fixture(rune)
});
}
for (const directive of TESTED_DIRECTIVES) {
features.push({ name: directive.name, kind: 'svelte', source: directive.source });
}
return features;
}
/**
* Produce the `.svelte` source for a single binding fixture. Returns
* `null` for bindings the compiler treats as elements rather than
* properties (none currently, but defensive).
*/
function binding_fixture(name: string, props: BindingProperty): string {
// Map declared `valid_elements` to a concrete element + minimal attrs
// so the compiler accepts the binding.
const tag = (props.valid_elements ?? ['div'])[0];
const reactive = `let v = $state();`;
if (tag === 'svelte:window') {
return `<script>${reactive}</script><svelte:window bind:${name}={v} />`;
}
if (tag === 'svelte:document') {
return `<script>${reactive}</script><svelte:document bind:${name}={v} />`;
}
if (tag === 'input') {
// `bind:checked` and `bind:group` require type="checkbox" | "radio"
const type =
name === 'checked' || name === 'indeterminate'
? ' type="checkbox"'
: name === 'group'
? ' type="radio" value="a"'
: name === 'files'
? ' type="file"'
: '';
return `<script>${reactive}</script><input${type} bind:${name}={v} />`;
}
if (tag === 'details') {
return `<script>${reactive}</script><details bind:${name}={v}><summary>x</summary></details>`;
}
return `<script>${reactive}</script><${tag} bind:${name}={v}></${tag}>`;
}
/**
* Compile a `.svelte` fixture to JS (no-op for `.js` fixtures), then
* bundle the result through the shared `bundle` helper. Fixtures are tiny
* so circular-dep warnings from the Svelte runtime are silenced.
*/
async function bundle_fixture(feature: Feature): Promise<string> {
const entry_code =
feature.kind === 'svelte'
? svelte_compile(feature.source, {
generate: 'client',
filename: 'Fixture.svelte',
dev: false
}).js.code
: feature.source;
return bundle(entry_code, { silent: true });
}
/**
* Detect features in a single fixture bundle and report the per-fixture
* floor year along with the IDs that drove it. Used for the per-feature
* conditional table.
*
* `fixture_file` is the absolute path to the `.ts` bundle.
*/
function scan_fixture(fixture_file: string): {
year: number;
driving_ids: string[];
} {
const { year, drivers } = compute_floor(detect_features([fixture_file]), SAFE_TO_IGNORE);
return {
year,
driving_ids: [...drivers]
};
}
/**
* Iterate every feature, bundle its fixture, scan it. Return the rows
* that need to appear in the conditional-features table.
*/
async function find_all_conditional_features(
runtime_floor: RuntimeFloor,
subpackage_exports: Record<string, string[]>
): Promise<ConditionalRow[]> {
const runtime_year = typeof runtime_floor === 'number' ? runtime_floor : Infinity;
const features = enumerate_features(subpackage_exports);
const rows: ConditionalRow[] = [];
const missing_doc_links: string[] = [];
for (let i = 0; i < features.length; i++) {
const feature = features[i];
process.stdout.write(`\r ${i + 1}/${features.length} ${feature.name}`.padEnd(80));
let bundle_code;
try {
bundle_code = await bundle_fixture(feature);
} catch {
continue; // some fixtures (rare element combos) may fail to compile
}
// Write the bundle so the type-aware scanner can resolve its types.
const fixture_file = path.join(tmp_dir, `fixture_${i}.ts`);
fs.writeFileSync(fixture_file, bundle_code);
const scanned = scan_fixture(fixture_file);
const final_year = scanned.year;
// Skip features at or below the runtime floor — they don't need a row.
if (final_year <= runtime_year || final_year === 0) continue;
// Use exact per-feature versions where available (from web-features
// or supplemental rules), falling back to the conservative year
// mapping only if no feature has explicit version data.
let versions = versions_from_features(scanned.driving_ids);
if (!versions) {
try {
versions = browser_versions_for(final_year);
} catch {
continue;
}
}
let doc_link = doc_links[feature.name];
if (doc_link === undefined) {
doc_link = null;
missing_doc_links.push(feature.name);
}
rows.push({
name: feature.name,
doc_link,
versions,
baseline_year: final_year
});
}
process.stdout.write('\n');
if (missing_doc_links.length) {
throw new Error(`Missing documentation url for some features.
Add them to the \`doc_links\` map in \`scripts/generate-browser-support.ts\`, or add an explicit \`null\` if they don't have a documentation url.
${missing_doc_links.map((name) => ` - "${name}"`).join('\n')}`);
}
return rows;
}
function render_conditional_table(features: ConditionalRow[], runtime_floor: RuntimeFloor): string {
if (features.length === 0) {
return '_No features currently require browser versions newer than the runtime floor._';
}
features.sort((a, b) => a.name.localeCompare(b.name));
const browsers = [
['chrome', 'Chrome/Edge'],
['firefox', 'Firefox'],
['safari', 'Safari']
] as const;
const floor_versions = browser_versions_for(runtime_floor);
const rows: string[][] = [];
for (const row of features) {
const name_cell = row.doc_link ? `[${row.name}](${row.doc_link})` : row.name;
const versions = browsers.map(([key]) => {
const v = row.versions[key];
if (v === null) return 'not supported';
if (v === undefined) return '<span style="color: var(--sk-fg-4)">—</span>';
const floor_v = floor_versions[key];
if (floor_v && Number(v) <= Number(floor_v))
return '<span style="color: var(--sk-fg-4)">—</span>';
return v;
});
rows.push([name_cell, ...versions]);
}
return render_markdown_table(['Feature', ...browsers.map(([, label]) => `${label}`)], rows);
}
function browser_versions_for(target: RuntimeFloor): Record<string, string> {
// `targetYear` returns the minimum versions in which every feature that
// reached Baseline by the end of that year is supported. If the lint
// search fell through to `'newly'`, we use the current year — that gives
// the most recent Newly-available cutoff, which is the strongest
// statement `baseline-browser-mapping` is able to make.
const target_year = typeof target === 'number' ? target : new Date().getFullYear();
const versions = getCompatibleVersions({
targetYear: target_year,
includeDownstreamBrowsers: true
});
// The core Baseline browsers plus the downstream browsers worth listing
// in the docs. Downstream browsers come from `baseline-browser-mapping`'s
// dataset and represent the highest-traffic Chromium derivatives; the
// long tail (UC, QQ, Yandex, in-app Facebook/Instagram browsers, etc.)
// is omitted to keep the table focused.
const visible_browsers = new Set([
'chrome',
'chrome_android',
'edge',
'firefox',
'firefox_android',
'safari',
'safari_ios',
'opera',
'opera_android',
'samsunginternet_android',
'webview_android'
]);
const suffixes = ['_android', '_ios'];
const lookup: Record<string, string> = {};
outer: for (const { browser, version } of versions) {
if (visible_browsers.has(browser)) {
for (const suffix of suffixes) {
// skip e.g. 'Chrome (Android)' if it matches Chrome
if (browser.endsWith(suffix) && version === lookup[browser.replace(suffix, '')]) {
continue outer;
}
}
lookup[browser] = version;
}
}
return lookup;
}
const BROWSER = {
chrome: 'Chrome',
edge: 'Edge',
firefox: 'Firefox',
safari: 'Safari',
opera: 'Opera',
samsung_internet: 'Samsung Internet',
webview_android: 'Android WebView',
internet_explorer: 'Internet Explorer'
};
function render_browser_table(versions: Record<string, string>, target: RuntimeFloor): string {
const rows: Array<[string, string]> = [
[BROWSER.chrome, versions.chrome],
[`${BROWSER.chrome} (Android)`, versions.chrome_android]
];
if (versions.chrome === versions.edge) {
rows[0][0] += `/${BROWSER.edge}`;
} else {
rows.push([BROWSER.edge, versions.edge]);
}
rows.push(
[BROWSER.firefox, versions.firefox],
[`${BROWSER.firefox} (Android)`, versions.firefox_android],
[BROWSER.safari, versions.safari],
[`${BROWSER.safari} (iOS)`, versions.safari_ios],
[BROWSER.opera, versions.opera],
[`${BROWSER.opera} (Android)`, versions.opera_android],
[BROWSER.samsung_internet, versions.samsunginternet_android],
[BROWSER.webview_android, versions.webview_android],
[BROWSER.internet_explorer, 'not supported']
);
const target_label = target === 'newly' ? '"newly available"' : target;
return (
render_markdown_table(
['Browser', 'Minimum version'],
rows.filter(([, version]) => version !== undefined)
) +
`\n\n> [!NOTE] This equates to a <a href="https://web-platform-dx.github.io/baseline/">Baseline</a> target of ${target_label}.`
);
}
function render_markdown_table(headers: string[], rows: string[][]): string {
return `| ${headers.join(' | ')} |
| ${headers.map(() => '-').join(' | ')} |
${rows.map((row) => `| ${row.join(' | ')} |`).join('\n')}
`;
}
async function main() {
console.log('Preparing scratch directory…');
// Wipe and recreate so stale bundles can't leak into the next scan.
fs.rmSync(tmp_dir, { recursive: true, force: true });
fs.mkdirSync(tmp_dir, { recursive: true });
try {
console.log('Bundling runtime entries…');
const runtime_files: string[] = [];
for (const importee of browser_subpaths().map(importee_for)) {
// `import * as` + re-export keeps default and named exports
// alive, so flag modules (only a default export) don't produce
// empty chunks but their code still ends up in the scan.
const code = await bundle(`import * as __ns from '${importee}'; export default __ns;`);
const file = path.join(tmp_dir, `${safe_name(importee)}.ts`);
fs.writeFileSync(file, code);
runtime_files.push(file);
}
console.log('Loading compiler-output fixtures…');
const compiler_fixtures = load_compiler_output_fixtures();
console.log(` (${compiler_fixtures.length} fixtures found)`);
console.log('Searching for the minimum Baseline target (type-aware)…');
const target = find_minimum_target(runtime_files, compiler_fixtures);
console.log('Checking BEHAVIORAL_IGNORE for stale entries…');
validate_ignore_features(runtime_files);
console.log(' no stale entries');
console.log('Enumerating subpackage exports…');
const subpackage_exports = await enumerate_subpackage_exports();
const total_exports = Object.values(subpackage_exports).reduce((n, list) => n + list.length, 0);
console.log(
` ${total_exports} export(s) across ${Object.keys(subpackage_exports).length} subpackage(s)`
);
console.log('Scanning per-feature fixtures for conditional requirements…');
const conditional_rows = await find_all_conditional_features(target, subpackage_exports);
console.log(
` ${conditional_rows.length} feature(s) require browsers newer than the runtime floor`
);
console.log('Resolving browser versions…');
const versions = browser_versions_for(target);
console.log('Rewriting docs page…');
generate('browser-support.md', render_browser_table(versions, target));
generate('browser-support-features.md', render_conditional_table(conditional_rows, target));
console.log('Done.');
} finally {
fs.rmSync(tmp_dir, { recursive: true, force: true });
}
}
function generate(file: string, content: string): void {
const filename = path.join(docs_dir, file);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
} catch {}
const backlink = path.relative(filename, fileURLToPath(import.meta.url));
fs.writeFileSync(filename, `<!-- generated in ${backlink}. do not edit -->\n\n${content}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

@ -378,7 +378,6 @@ function run() {
}; };
const block = esrap.print( const block = esrap.print(
// @ts-expect-error some bullshit
/** @type {ESTree.Program} */ ({ ...ast, body: [clone] }), /** @type {ESTree.Program} */ ({ ...ast, body: [clone] }),
ts({ comments: [jsdoc_clone] }) ts({ comments: [jsdoc_clone] })
).code; ).code;

@ -24,7 +24,7 @@ declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined; declare function $state<T>(): T | undefined;
declare namespace $state { declare namespace $state {
type Primitive = string | number | boolean | null | undefined; type Primitive = string | number | bigint | boolean | null | undefined;
type TypedArray = type TypedArray =
| Int8Array | Int8Array
@ -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

@ -1004,6 +1004,24 @@ export function debug_tag_invalid_arguments(node) {
e(node, 'debug_tag_invalid_arguments', `{@debug ...} arguments must be identifiers, not arbitrary expressions\nhttps://svelte.dev/e/debug_tag_invalid_arguments`); e(node, 'debug_tag_invalid_arguments', `{@debug ...} arguments must be identifiers, not arbitrary expressions\nhttps://svelte.dev/e/debug_tag_invalid_arguments`);
} }
/**
* Declaration tags must be `let` or `const` declarations
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function declaration_tag_invalid_type(node) {
e(node, 'declaration_tag_invalid_type', `Declaration tags must be \`let\` or \`const\` declarations\nhttps://svelte.dev/e/declaration_tag_invalid_type`);
}
/**
* Declaration tags cannot be used in legacy mode
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function declaration_tag_no_legacy_mode(node) {
e(node, 'declaration_tag_no_legacy_mode', `Declaration tags cannot be used in legacy mode\nhttps://svelte.dev/e/declaration_tag_no_legacy_mode`);
}
/** /**
* Directive value must be a JavaScript expression enclosed in curly braces * Directive value must be a JavaScript expression enclosed in curly braces
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node

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

@ -262,6 +262,10 @@ export function convert(source, ast) {
}; };
}, },
// @ts-ignore // @ts-ignore
DeclarationTag(node) {
return node;
},
// @ts-ignore
KeyBlock(node, { visit }) { KeyBlock(node, { visit }) {
remove_surrounding_whitespace_nodes(node.fragment.nodes); remove_surrounding_whitespace_nodes(node.fragment.nodes);
return { return {

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

@ -1,10 +1,11 @@
/** @import { Comment, Program } from 'estree' */ /** @import { Comment, Program, Statement } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { Parser } from './index.js' */ /** @import { Parser } from './index.js' */
import * as acorn from 'acorn'; import * as acorn from 'acorn';
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import { tsPlugin } from '@sveltejs/acorn-typescript'; import { tsPlugin } from '@sveltejs/acorn-typescript';
import * as e from '../../errors.js'; import * as e from '../../errors.js';
import { locator } from '../../state.js';
const JSParser = acorn.Parser; const JSParser = acorn.Parser;
const TSParser = JSParser.extend(tsPlugin()); const TSParser = JSParser.extend(tsPlugin());
@ -59,7 +60,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 {
@ -87,7 +88,8 @@ export function parse_expression_at(parser, source, index) {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 16, ecmaVersion: 16,
locations: true, locations: true,
preserveParens: true preserveParens: true,
startLocation: start_location(parser, index)
}); });
add_comments(ast); add_comments(ast);
@ -98,6 +100,68 @@ export function parse_expression_at(parser, source, index) {
} }
} }
/**
* @param {Parser} parser
* @param {string} source
* @param {number} index
* @returns {Statement}
*/
export function parse_statement_at(parser, source, index) {
// cast to `any`: acorn's Parser constructor and parseStatement/nextToken aren't in its public types
const acorn = /** @type {any} */ (parser.ts ? TSParser : JSParser);
const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index);
try {
// This is like parseExpressionAt but for statements
const p = new acorn(
{
onComment,
sourceType: 'module',
ecmaVersion: 16,
locations: true,
startLocation: start_location(parser, index)
},
source,
index
);
p.nextToken();
const statement = /** @type {Statement} */ (p.parseStatement(null, true, Object.create(null)));
add_comments(/** @type {acorn.Node} */ (statement));
return statement;
} catch (err) {
// A statement that runs to the end of the source (e.g. an unterminated declaration tag)
// is an EOF, not a stray token; preserve the friendlier `unexpected_eof` diagnostic.
if (/** @type {any} */ (err).pos === source.length) e.unexpected_eof(source.length);
handle_parse_error(err);
}
}
const regex_non_lf_line_break = /\r(?!\n)|[\u2028\u2029]/;
let last_template = '';
let lf_only = true;
/**
* Without `startLocation`, acorn counts the lines before `index` on every call
* @param {Parser} parser
* @param {number} index
*/
function start_location(parser, index) {
return has_lf_line_breaks_only(parser) ? locator(index) : undefined;
}
/**
* acorn breaks lines on bare `\r`, `\u2028` and `\u2029`, which the locator doesn't
* @param {Parser} parser
*/
export function has_lf_line_breaks_only(parser) {
if (parser.template !== last_template) {
last_template = parser.template;
lf_only = !regex_non_lf_line_break.test(last_template);
}
return lf_only;
}
const regex_position_indicator = / \(\d+:\d+\)$/; const regex_position_indicator = / \(\d+:\d+\)$/;
/** /**

@ -10,25 +10,7 @@ import read_options from './read/options.js';
import { is_reserved } from '../../../utils.js'; import { is_reserved } from '../../../utils.js';
import { disallow_children } from '../2-analyze/visitors/shared/special-element.js'; import { disallow_children } from '../2-analyze/visitors/shared/special-element.js';
import * as state from '../../state.js'; import * as state from '../../state.js';
import { is_whitespace } from './utils/whitespace.js';
/** @param {number} cc */
function is_whitespace(cc) {
// fast path for common whitespace
if (cc === 32 || (cc <= 13 && cc >= 9)) return true;
// rare whitespace — \u00a0, \u1680, \u2000-\u200a, \u2028, \u2029, \u202f, \u205f, \u3000, \ufeff
if (cc < 160) return false;
return (
cc === 160 ||
cc === 5760 ||
(cc >= 8192 && cc <= 8202) ||
cc === 8232 ||
cc === 8233 ||
cc === 8239 ||
cc === 8287 ||
cc === 12288 ||
cc === 65279
);
}
const regex_lang_attribute = const regex_lang_attribute =
/<!--[^]*?-->|<script\s+(?:[^>]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s]+)\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/g; /<!--[^]*?-->|<script\s+(?:[^>]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s]+)\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/g;
@ -50,6 +32,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 +46,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;
} }
@ -274,8 +260,27 @@ export class Parser {
}; };
} }
/** @param {string} delimiter */
read_until(delimiter) {
if (this.index >= this.template.length) {
if (this.loose) return '';
e.unexpected_eof(this.template.length);
}
const start = this.index;
const index = this.template.indexOf(delimiter, start);
if (index !== -1) {
this.index = index;
return this.template.slice(start, this.index);
}
this.index = this.template.length;
return this.template.slice(start);
}
/** @param {RegExp} pattern */ /** @param {RegExp} pattern */
read_until(pattern) { read_until_regex(pattern) {
if (this.index >= this.template.length) { if (this.index >= this.template.length) {
if (this.loose) return ''; if (this.loose) return '';
e.unexpected_eof(this.template.length); e.unexpected_eof(this.template.length);
@ -298,11 +303,15 @@ export class Parser {
e.expected_whitespace(this.index); e.expected_whitespace(this.index);
} }
this.index++;
this.allow_whitespace(); this.allow_whitespace();
} }
pop() { pop() {
this.fragments.pop(); const fragment = this.fragments.pop();
if (fragment?.metadata.transparent && fragment.nodes.some((n) => n.type === 'DeclarationTag')) {
fragment.metadata.transparent = false;
}
return this.stack.pop(); return this.stack.pop();
} }

@ -2,7 +2,6 @@
/** @import { Parser } from '../index.js' */ /** @import { Parser } from '../index.js' */
import { match_bracket } from '../utils/bracket.js'; import { match_bracket } from '../utils/bracket.js';
import { parse_expression_at, remove_parens } from '../acorn.js'; import { parse_expression_at, remove_parens } from '../acorn.js';
import { regex_not_newline_characters } from '../../patterns.js';
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
/** /**
@ -33,24 +32,10 @@ export default function read_pattern(parser) {
i = match_bracket(parser, start); i = match_bracket(parser, start);
parser.index = i; parser.index = i;
const pattern_string = parser.template.slice(start, i); // acorn never reads before `start`, so the template itself can serve as the prefix
// the length of the `space_with_newline` has to be start - 1
// because we added a `(` in front of the pattern_string,
// which shifted the entire string to right by 1
// so we offset it by removing 1 character in the `space_with_newline`
// to achieve that, we remove the 1st space encountered,
// so it will not affect the `column` of the node
let space_with_newline = parser.template
.slice(0, start)
.replace(regex_not_newline_characters, ' ');
const first_space = space_with_newline.indexOf(' ');
space_with_newline =
space_with_newline.slice(0, first_space) + space_with_newline.slice(first_space + 1);
/** @type {any} */ /** @type {any} */
let expression = remove_parens( let expression = remove_parens(
parse_expression_at(parser, `${space_with_newline}(${pattern_string} = 1)`, start - 1) parse_expression_at(parser, parser.template.slice(0, i) + ' = 1', start)
); );
expression = expression.left; expression = expression.left;
@ -80,7 +65,7 @@ function read_type_annotation(parser) {
const insert = '_ as '; const insert = '_ as ';
let a = parser.index - insert.length; let a = parser.index - insert.length;
const template = const template =
parser.template.slice(0, a).replace(/[^\n]/g, ' ') + parser.template.slice(0, a) +
insert + insert +
// If this is a type annotation for a function parameter, Acorn-TS will treat subsequent // If this is a type annotation for a function parameter, Acorn-TS will treat subsequent
// parameters as part of a sequence expression instead, and will then error on optional // parameters as part of a sequence expression instead, and will then error on optional

@ -1,9 +1,13 @@
/** @import { Expression } from 'estree' */ /** @import { Expression, Identifier } from 'estree' */
/** @import { Parser } from '../index.js' */ /** @import { Parser } from '../index.js' */
import { parse_expression_at, remove_parens } from '../acorn.js'; // @ts-expect-error acorn type definitions are borked in the release we use
import { isIdentifierStart, isIdentifierChar } from 'acorn';
import { has_lf_line_breaks_only, parse_expression_at, remove_parens } from '../acorn.js';
import { regex_whitespace } from '../../patterns.js'; import { regex_whitespace } from '../../patterns.js';
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
import { find_matching_bracket } from '../utils/bracket.js'; import { find_matching_bracket } from '../utils/bracket.js';
import { is_reserved } from '../../../../utils.js';
import { locator } from '../../../state.js';
/** /**
* @param {Parser} parser * @param {Parser} parser
@ -33,6 +37,9 @@ export function get_loose_identifier(parser, opening_token) {
* @returns {Expression} * @returns {Expression}
*/ */
export default function read_expression(parser, opening_token, disallow_loose) { export default function read_expression(parser, opening_token, disallow_loose) {
const simple = read_simple_expression(parser);
if (simple) return simple;
try { try {
const node = parse_expression_at(parser, parser.template, parser.index); const node = parse_expression_at(parser, parser.template, parser.index);
@ -57,3 +64,104 @@ export default function read_expression(parser, opening_token, disallow_loose) {
throw err; throw err;
} }
} }
/**
* Most template expressions are an identifier or a `a.b.c` member chain followed by `}`.
* Those are built directly for better parse performance, with the same shape acorn would produce; anything else goes to acorn
* @param {Parser} parser
* @returns {Expression | null}
*/
function read_simple_expression(parser) {
if (!has_lf_line_breaks_only(parser)) return null;
const template = parser.template;
const index = parser.index;
parser.allow_whitespace();
const start = parser.index;
let end = read_word(template, start);
if (end === -1 || is_reserved(template.slice(start, end))) {
parser.index = index;
return null;
}
/** @type {Expression} */
let node = identifier(template, start, end);
while (template[end] === '.') {
const property_end = read_word(template, end + 1);
if (property_end === -1) {
parser.index = index;
return null;
}
node = {
type: 'MemberExpression',
start,
end: property_end,
loc: { start: position(start), end: position(property_end) },
object: node,
property: identifier(template, end + 1, property_end),
computed: false,
optional: false
};
end = property_end;
}
parser.index = end;
parser.allow_whitespace();
if (!parser.match('}')) {
parser.index = index;
return null;
}
parser.index = end;
return node;
}
/**
* @param {string} template
* @param {number} start
* @returns {number} the end of the identifier starting at `start`, or -1
*/
function read_word(template, start) {
if (start >= template.length) return -1;
const code = /** @type {number} */ (template.codePointAt(start));
if (!isIdentifierStart(code, true)) return -1;
let end = start + (code <= 0xffff ? 1 : 2);
while (end < template.length) {
const code = /** @type {number} */ (template.codePointAt(end));
if (!isIdentifierChar(code, true)) break;
end += code <= 0xffff ? 1 : 2;
}
return end;
}
/**
* @param {string} template
* @param {number} start
* @param {number} end
* @returns {Identifier}
*/
function identifier(template, start, end) {
return {
type: 'Identifier',
start,
end,
loc: { start: position(start), end: position(end) },
name: template.slice(start, end)
};
}
/** @param {number} index */
function position(index) {
const { line, column } = locator(index);
return { line, column };
}

@ -22,7 +22,7 @@ const ALLOWED_ATTRIBUTES = ['context', 'generics', 'lang', 'module'];
*/ */
export function read_script(parser, start, attributes) { export function read_script(parser, start, attributes) {
const script_start = parser.index; const script_start = parser.index;
const data = parser.read_until(regex_closing_script_tag); const data = parser.read_until_regex(regex_closing_script_tag);
if (parser.index >= parser.template.length) { if (parser.index >= parser.template.length) {
e.element_unclosed(parser.template.length, 'script'); e.element_unclosed(parser.template.length, 'script');
} }

@ -7,14 +7,15 @@ 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_-]/;
const REGEX_UNICODE_SEQUENCE = /\\[0-9a-fA-F]{1,6}(\r\n|\s)?/y; const REGEX_UNICODE_SEQUENCE = /\\[0-9a-fA-F]{1,6}(\r\n|\s)?/y;
const REGEX_COMMENT_CLOSE = /\*\//;
const REGEX_HTML_COMMENT_CLOSE = /-->/;
/** /**
* @param {Parser} parser * @param {Parser} parser
@ -24,6 +25,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 +38,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 +205,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 +235,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 +318,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 +462,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;
@ -463,7 +476,7 @@ function read_block_item(parser) {
function read_declaration(parser) { function read_declaration(parser) {
const start = parser.index; const start = parser.index;
const property = parser.read_until(REGEX_WHITESPACE_OR_COLON); const property = parser.read_until_regex(REGEX_WHITESPACE_OR_COLON);
parser.allow_whitespace(); parser.allow_whitespace();
parser.eat(':'); parser.eat(':');
let index = parser.index; let index = parser.index;
@ -492,10 +505,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 +539,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 +553,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 +621,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,17 +646,20 @@ 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('<!--')) {
parser.read_until(REGEX_HTML_COMMENT_CLOSE); parser.read_until('-->');
parser.eat('-->', true); parser.eat('-->', true);
} }
@ -642,6 +667,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('*/');
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

@ -30,6 +30,12 @@ const visitors = {
delete n.readonly; delete n.readonly;
delete n.definite; delete n.definite;
delete n.override; delete n.override;
// `optional` is reused by JS optional chaining (`a?.b`, `a?.()`), so only
// strip the TypeScript optional marker (`x?: T`, `m?(): T`, `x?: T` fields)
if (n.type !== 'MemberExpression' && n.type !== 'CallExpression') {
delete n.optional;
}
}, },
Decorator(node) { Decorator(node) {
e.typescript_invalid_feature(node, 'decorators (related TSC proposal is not stage 4 yet)'); e.typescript_invalid_feature(node, 'decorators (related TSC proposal is not stage 4 yet)');

@ -15,13 +15,10 @@ import { get_attribute_expression, is_expression_attribute } from '../../../util
import { closing_tag_omitted } from '../../../../html-tree-validation.js'; import { closing_tag_omitted } from '../../../../html-tree-validation.js';
import { list } from '../../../utils/string.js'; import { list } from '../../../utils/string.js';
import { locator } from '../../../state.js'; import { locator } from '../../../state.js';
import * as b from '#compiler/builders'; import { is_whitespace } from '../utils/whitespace.js';
const regex_invalid_unquoted_attribute_value = /(\/>|[\s"'=<>`])/y; const regex_invalid_unquoted_attribute_value = /(\/>|[\s"'=<>`])/y;
const regex_closing_textarea_tag = /<\/textarea(\s[^>]*)?>/iy; const regex_closing_textarea_tag = /<\/textarea(\s[^>]*)?>/iy;
const regex_closing_comment = /-->/;
const regex_whitespace_or_slash_or_closing_tag = /(\s|\/|>)/;
const regex_token_ending_character = /[\s=/>"']/;
const regex_starts_with_quote_characters = /["']/y; const regex_starts_with_quote_characters = /["']/y;
const regex_attribute_value = /(?:"([^"]*)"|'([^'])*'|([^>\s]+))/y; const regex_attribute_value = /(?:"([^"]*)"|'([^'])*'|([^>\s]+))/y;
const regex_doctype_name = /^![a-zA-Z]+$/; const regex_doctype_name = /^![a-zA-Z]+$/;
@ -67,7 +64,7 @@ export default function element(parser) {
let parent = parser.current(); let parent = parser.current();
if (parser.eat('!--')) { if (parser.eat('!--')) {
const data = parser.read_until(regex_closing_comment); const data = parser.read_until('-->');
parser.eat('-->', true); parser.eat('-->', true);
parser.append({ parser.append({
@ -81,7 +78,7 @@ export default function element(parser) {
} }
if (parser.eat('/')) { if (parser.eat('/')) {
const name = parser.read_until(regex_whitespace_or_slash_or_closing_tag); const name = read_tag_name(parser);
parser.allow_whitespace(); parser.allow_whitespace();
parser.eat('>', true); parser.eat('>', true);
@ -137,7 +134,7 @@ export default function element(parser) {
return; return;
} }
const tag = read_tag(parser, regex_whitespace_or_slash_or_closing_tag); const tag = read_tag(parser);
if (tag.name.startsWith('svelte:') && !meta_tags.has(tag.name)) { if (tag.name.startsWith('svelte:') && !meta_tags.has(tag.name)) {
const bounds = { start: start + 1, end: start + 1 + tag.name.length }; const bounds = { start: start + 1, end: start + 1 + tag.name.length };
@ -475,7 +472,7 @@ function parent_is_shadowroot_template(stack) {
function read_static_attribute(parser) { function read_static_attribute(parser) {
const start = parser.index; const start = parser.index;
const tag = read_tag(parser, regex_token_ending_character); const tag = read_tag(parser, true);
if (!tag.name) return null; if (!tag.name) return null;
/** @type {true | Array<AST.Text | AST.ExpressionTag>} */ /** @type {true | Array<AST.Text | AST.ExpressionTag>} */
@ -607,7 +604,7 @@ function read_attribute(parser) {
} }
} }
const tag = read_tag(parser, regex_token_ending_character); const tag = read_tag(parser, true);
if (!tag.name) return null; if (!tag.name) return null;
@ -731,7 +728,7 @@ function read_comment(parser) {
const start = parser.index; const start = parser.index;
if (parser.eat('//')) { if (parser.eat('//')) {
const value = parser.read_until(/\n/); const value = parser.read_until('\n');
const end = parser.index; const end = parser.index;
return { return {
@ -747,7 +744,7 @@ function read_comment(parser) {
} }
if (parser.eat('/*')) { if (parser.eat('/*')) {
const value = parser.read_until(/\*\//); const value = parser.read_until('*/');
parser.eat('*/'); parser.eat('*/');
const end = parser.index; const end = parser.index;
@ -847,25 +844,21 @@ function read_attribute_value(parser) {
* @returns {any[]} * @returns {any[]}
*/ */
function read_sequence(parser, done, location) { function read_sequence(parser, done, location) {
/** @type {AST.Text} */
let current_chunk = {
start: parser.index,
end: -1,
type: 'Text',
raw: '',
data: ''
};
/** @type {Array<AST.Text | AST.ExpressionTag>} */ /** @type {Array<AST.Text | AST.ExpressionTag>} */
const chunks = []; const chunks = [];
let chunk_start = parser.index;
/** @param {number} end */ /** @param {number} end */
function flush(end) { function flush(end) {
if (end > current_chunk.start) { if (end > chunk_start) {
current_chunk.raw = parser.template.slice(current_chunk.start, end); const raw = parser.template.slice(chunk_start, end);
current_chunk.data = decode_character_references(current_chunk.raw, true); chunks.push({
current_chunk.end = end; start: chunk_start,
chunks.push(current_chunk); end,
type: 'Text',
raw,
data: decode_character_references(raw, true)
});
} }
} }
@ -879,12 +872,14 @@ function read_sequence(parser, done, location) {
if (parser.match('#')) { if (parser.match('#')) {
const index = parser.index - 1; const index = parser.index - 1;
parser.eat('#'); parser.eat('#');
const name = parser.read_until(/[^a-z]/); // const name = parser.read_until_regex(/[^a-z]/);
const name = read_lowercase_name(parser);
e.block_invalid_placement(index, name, location); e.block_invalid_placement(index, name, location);
} else if (parser.match('@')) { } else if (parser.match('@')) {
const index = parser.index - 1; const index = parser.index - 1;
parser.eat('@'); parser.eat('@');
const name = parser.read_until(/[^a-z]/); // const name = parser.read_until_regex(/[^a-z]/);
const name = read_lowercase_name(parser);
e.tag_invalid_placement(index, name, location); e.tag_invalid_placement(index, name, location);
} }
@ -907,14 +902,7 @@ function read_sequence(parser, done, location) {
}; };
chunks.push(chunk); chunks.push(chunk);
chunk_start = parser.index;
current_chunk = {
start: parser.index,
end: -1,
type: 'Text',
raw: '',
data: ''
};
} else { } else {
parser.index++; parser.index++;
} }
@ -929,12 +917,36 @@ function read_sequence(parser, done, location) {
/** /**
* @param {Parser} parser * @param {Parser} parser
* @param {RegExp} regex * @param {boolean} [attribute]
*/
function read_tag_name(parser, attribute = false) {
const start = parser.index;
if (start >= parser.template.length && !parser.loose) e.unexpected_eof(parser.template.length);
while (parser.index < parser.template.length) {
const cc = parser.template.charCodeAt(parser.index);
if (
is_whitespace(cc) ||
cc === 47 || // /
cc === 62 || // >
(attribute && (cc === 34 || cc === 39 || cc === 61)) // " ' =
) {
break;
}
parser.index += 1;
}
return parser.template.slice(start, parser.index);
}
/**
* @param {Parser} parser
* @param {boolean} [attribute]
* @returns {Identifier & { start: number, end: number, loc: SourceLocation }} * @returns {Identifier & { start: number, end: number, loc: SourceLocation }}
*/ */
function read_tag(parser, regex) { function read_tag(parser, attribute = false) {
const start = parser.index; const start = parser.index;
const name = parser.read_until(regex); const name = read_tag_name(parser, attribute);
const end = parser.index; const end = parser.index;
return { return {
@ -948,3 +960,15 @@ function read_tag(parser, regex) {
} }
}; };
} }
/** @param {Parser} parser */
function read_lowercase_name(parser) {
const start = parser.index;
while (parser.index < parser.template.length) {
const cc = parser.template.charCodeAt(parser.index);
// a-z
if (cc < 97 || cc > 122) break;
parser.index += 1;
}
return parser.template.slice(start, parser.index);
}

@ -1,16 +1,20 @@
/** @import { ArrowFunctionExpression, Expression, Identifier, Pattern } from 'estree' */ /** @import { ArrowFunctionExpression, Expression, Identifier, Pattern, VariableDeclaration } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */ /** @import { Parser } from '../index.js' */
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
import { ExpressionMetadata } from '../../nodes.js'; import { ExpressionMetadata } from '../../nodes.js';
import { parse_expression_at } from '../acorn.js'; import { parse_expression_at, parse_statement_at } from '../acorn.js';
import read_pattern from '../read/context.js'; import read_pattern from '../read/context.js';
import read_expression, { get_loose_identifier } from '../read/expression.js'; import read_expression, { get_loose_identifier } from '../read/expression.js';
import { create_fragment } from '../utils/create.js'; import { create_fragment } from '../utils/create.js';
import { match_bracket } from '../utils/bracket.js'; import { find_matching_bracket, match_bracket } from '../utils/bracket.js';
const regex_whitespace_with_closing_curly_brace = /\s*}/y; const regex_whitespace_with_closing_curly_brace = /\s*}/y;
const regex_supported_declaration = /(?:let|const)\b/y;
const regex_unsupported_declaration = /(?:var|interface|enum)\b/y;
// `type` is a contextual keyword; this is just a shape hint, confirmed by parsing.
const regex_maybe_type_declaration = /type\b/y;
const pointy_bois = { '<': '>' }; const pointy_bois = { '<': '>' };
@ -31,6 +35,20 @@ export default function tag(parser) {
} }
} }
const declaration = read_declaration(parser);
if (declaration) {
parser.append({
type: 'DeclarationTag',
start,
end: parser.index,
declaration: /** @type {VariableDeclaration} */ (declaration),
metadata: {
expression: new ExpressionMetadata()
}
});
return;
}
const expression = read_expression(parser); const expression = read_expression(parser);
parser.allow_whitespace(); parser.allow_whitespace();
@ -47,6 +65,89 @@ export default function tag(parser) {
}); });
} }
/**
* @param {Parser} parser
* @returns {null | import('estree').VariableDeclaration}
*/
function read_declaration(parser) {
const start = parser.index;
const unsupported = parser.match_regex(regex_unsupported_declaration);
if (unsupported) {
e.declaration_tag_invalid_type({ start, end: start + unsupported.length });
}
if (
!parser.match_regex(regex_supported_declaration) &&
// `type` is special, since it is not a reserved keyword and can be used
// as part of a valid expression. We gotta parse first and then see what it is.
!parser.match_regex(regex_maybe_type_declaration)
) {
return null;
}
const initial_comment_count = parser.root.comments.length;
/** @type {import('estree').Statement | import('estree').VariableDeclaration} */
let declaration;
try {
declaration = parse_statement_at(parser, parser.template, start);
} catch (error) {
if (!parser.loose) throw error;
const end = find_matching_bracket(parser.template, start, '{');
if (end === undefined) throw error;
parser.index = end;
const kind = parser.template.startsWith('const', start) ? 'const' : 'let';
declaration = {
type: 'VariableDeclaration',
kind,
declarations: [
{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name: '',
start: parser.index,
end: parser.index
},
init: null,
start: parser.index,
end: parser.index
}
],
start,
end
};
}
if (declaration.type !== 'VariableDeclaration') {
if (declaration.type === 'ExpressionStatement') {
parser.root.comments.length = initial_comment_count; // Else they show up duplicated
return null;
} else {
// This is a TSTypeAliasDeclaration
e.declaration_tag_invalid_type({
start: declaration.start ?? start,
end: declaration.end ?? parser.index
});
}
}
// TODO support using
if (declaration.kind !== 'let' && declaration.kind !== 'const') {
e.declaration_tag_invalid_type(declaration);
}
parser.index = /** @type {number} */ (declaration.end);
parser.allow_whitespace();
parser.eat('}', true);
return declaration;
}
/** @param {Parser} parser */ /** @param {Parser} parser */
function open(parser) { function open(parser) {
let start = parser.index - 2; let start = parser.index - 2;
@ -387,12 +488,13 @@ function open(parser) {
parser.eat(')', true); parser.eat(')', true);
} }
const prelude = parser.template.slice(0, params_start).replace(/\S/g, ' ');
const params = parser.template.slice(params_start, parser.index);
let function_expression = matched let function_expression = matched
? /** @type {ArrowFunctionExpression} */ ( ? /** @type {ArrowFunctionExpression} */ (
parse_expression_at(parser, prelude + `${params} => {}`, params_start) parse_expression_at(
parser,
parser.template.slice(0, parser.index) + ' => {}',
params_start
)
) )
: { params: [] }; : { params: [] };
@ -682,6 +784,8 @@ function special(parser) {
const expression_start = parser.index; const expression_start = parser.index;
const init = read_expression(parser); const init = read_expression(parser);
// parser is past wrapping parens, but `init.end` is not — use the parser position
const declarator_end = parser.index;
if ( if (
init.type === 'SequenceExpression' && init.type === 'SequenceExpression' &&
!parser.template.substring(expression_start, init.start).includes('(') !parser.template.substring(expression_start, init.start).includes('(')
@ -700,7 +804,9 @@ function special(parser) {
declaration: { declaration: {
type: 'VariableDeclaration', type: 'VariableDeclaration',
kind: 'const', kind: 'const',
declarations: [{ type: 'VariableDeclarator', id, init, start: id.start, end: init.end }], declarations: [
{ type: 'VariableDeclarator', id, init, start: id.start, end: declarator_end }
],
start: start + 2, // start at const, not at @const start: start + 2, // start at const, not at @const
end: parser.index - 1 end: parser.index - 1
}, },

@ -39,7 +39,9 @@ function find_string_end(string, search_start_index, string_start_char) {
* @returns {number} The index of the end of this regex expression, or `Infinity` if not found. * @returns {number} The index of the end of this regex expression, or `Infinity` if not found.
*/ */
function find_regex_end(string, search_start_index) { function find_regex_end(string, search_start_index) {
return find_unescaped_char(string, search_start_index, '/'); const slash = find_unescaped_char(string, search_start_index, '/');
const eol = find_unescaped_char(string, search_start_index, '\n');
return slash < eol ? slash : Infinity;
} }
/** /**
@ -105,7 +107,11 @@ export function find_matching_bracket(template, index, open) {
continue; continue;
case '/': { case '/': {
const next_char = template[i + 1]; const next_char = template[i + 1];
if (!next_char) continue; if (!next_char) {
// `/` is the last character; advance past it so we don't loop forever
i++;
continue;
}
if (next_char === '/') { if (next_char === '/') {
i = infinity_if_negative(template.indexOf('\n', i + 1)) + '\n'.length; i = infinity_if_negative(template.indexOf('\n', i + 1)) + '\n'.length;
continue; continue;
@ -114,7 +120,12 @@ export function find_matching_bracket(template, index, open) {
i = infinity_if_negative(template.indexOf('*/', i + 1)) + '*/'.length; i = infinity_if_negative(template.indexOf('*/', i + 1)) + '*/'.length;
continue; continue;
} }
i = find_regex_end(template, i + 1) + '/'.length; const end = find_regex_end(template, i + 1) + '/'.length;
if (end === Infinity) {
i++;
} else {
i = end;
}
continue; continue;
} }
default: { default: {

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

Loading…
Cancel
Save