Merge branch 'main' into effect-tree-crosstalk-fix

pull/18508/head
Simon H 22 hours ago committed by GitHub
commit 0ae5d7a8fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,5 +0,0 @@
---
'svelte': patch
---
chore: drop dead code that make TSGO fail

@ -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",
"changelog": ["@svitejs/changesets-changelog-github-compact", { "repo": "sveltejs/svelte" }],
"changelog": ["@changesets/changelog-github", { "repo": "sveltejs/svelte", "template": "\n- {summary} {ref}" }],
"commit": false,
"fixed": [],
"linked": [],

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

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

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: remove `WAS_MARKED` flag in favor of `Set`

@ -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
---
fix: correctly transform declaration tags during SSR

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

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

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

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

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

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

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

1
.gitignore vendored

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

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

@ -13,7 +13,7 @@ import {
sbench_create_4to1,
sbench_create_signals
} from './sbench.js';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { create_test } from './util.js';
// This benchmark has been adapted from the js-reactivity-benchmark (https://github.com/milomg/js-reactivity-benchmark)
@ -39,7 +39,8 @@ for (const file of fs.readdirSync(`${dirname}/tests`)) {
const name = file.replace('.bench.js', '');
const module = await import(`${dirname}/tests/${file}`);
const module_url = pathToFileURL(path.join(dirname, 'tests', file));
const module = await import(module_url.href);
const { owned, unowned } = create_test(name, module.default);
reactivity_benchmarks.push(owned, unowned);

@ -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 { 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
.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));
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) =>
JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8'))
JSON.parse(fs.readFileSync(path.join(outdir, file), 'utf-8'))
);
if (results.length === 0) {
@ -33,11 +45,38 @@ export function generate_report(outdir) {
write('');
for (let i = 0; i < results[0].length; i += 1) {
write(`${results[0][i].benchmark}`);
// match results by benchmark name — branches may have different 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']) {
const times = results.map((result) => +result[i][metric]);
const times = entries.map((entry) => +entry[metric]);
let min = Infinity;
let max = -Infinity;
let min_index = -1;
@ -68,6 +107,32 @@ export function generate_report(outdir) {
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) {

@ -85,4 +85,4 @@ if (PROFILE_DIR !== null) {
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 { with_cpu_profile } from '../utils.js';
const results = [];
const PROFILE_DIR = process.env.BENCH_PROFILE_DIR;
const PROFILE_DIR = process.env.BENCH_PROFILE_DIR ?? null;
const single = process.env.BENCH_SINGLE;
for (let i = 0; i < reactivity_benchmarks.length; i += 1) {
const benchmark = reactivity_benchmarks[i];
if (single) {
// child mode — run a single benchmark and report the result to the parent
const benchmark = reactivity_benchmarks.find((b) => b.label === single);
process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `);
results.push({
benchmark: benchmark.label,
...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()))
});
process.stderr.write('\x1b[2K\r');
}
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];
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;
process.send(results);
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({
benchmark: benchmark.label,
.../** @type {object} */ (result)
});
process.stderr.write('\x1b[2K\r');
}
/** @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,94 +1,159 @@
import { fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
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 { ssr_benchmarks } from './benchmarks/ssr/index.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 suites = [
{
benchmarks: reactivity_benchmarks.filter(
(b) => filters.length === 0 || filters.some((f) => b.label.includes(f))
),
name: 'reactivity benchmarks'
},
{
benchmarks: ssr_benchmarks.filter(
(b) => filters.length === 0 || filters.some((f) => b.label.includes(f))
),
name: 'server-side rendering benchmarks'
}
].filter((suite) => suite.benchmarks.length > 0);
if (suites.length === 0) {
console.log('No benchmarks matched provided filters');
process.exit(1);
}
const COLUMN_WIDTHS = [25, 9, 9];
const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b);
const pad_right = (str, n) => str + ' '.repeat(n - str.length);
const pad_left = (str, n) => ' '.repeat(n - str.length) + str;
const single = process.env.BENCH_SINGLE;
let total_time = 0;
let total_gc_time = 0;
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
);
$.push({}, true);
if (!benchmark) {
throw new Error(`Unknown benchmark ${single}`);
}
try {
for (const { benchmarks, name } of suites) {
let suite_time = 0;
let suite_gc_time = 0;
$.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(
(b) => filters.length === 0 || filters.some((f) => b.label.includes(f))
),
name: 'reactivity benchmarks'
},
{
benchmarks: ssr_benchmarks.filter(
(b) => filters.length === 0 || filters.some((f) => b.label.includes(f))
),
name: 'server-side rendering benchmarks'
}
].filter((suite) => suite.benchmarks.length > 0);
console.log(`\nRunning ${name}...\n`);
console.log(
pad_right('Benchmark', COLUMN_WIDTHS[0]) +
pad_left('Time', COLUMN_WIDTHS[1]) +
pad_left('GC time', COLUMN_WIDTHS[2])
);
console.log('='.repeat(TOTAL_WIDTH));
if (suites.length === 0) {
console.log('No benchmarks matched provided filters');
process.exit(1);
}
for (const benchmark of benchmarks) {
const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
const filename = fileURLToPath(import.meta.url);
/**
* @param {string} label
* @returns {Promise<{ time: number, gc_time: number }>}
*/
const run_benchmark = (label) => {
return new Promise((fulfil, reject) => {
const child = fork(filename, [], {
env: {
...process.env,
BENCH_SINGLE: label
}
});
/** @type {{ time: number, gc_time: number } | null} */
let result = null;
child.on('message', (message) => {
result = /** @type {{ time: number, gc_time: number }} */ (message);
});
child.on('error', reject);
child.on('exit', (code) => {
if (result === null) {
reject(new Error(`benchmark ${label} exited with code ${code}`));
} else {
fulfil(result);
}
});
});
};
const COLUMN_WIDTHS = [25, 9, 9];
const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b);
/** @type {(str: string, n: number) => string} */
const pad_right = (str, n) => str + ' '.repeat(n - str.length);
/** @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) {
let suite_time = 0;
let suite_gc_time = 0;
console.log(`\nRunning ${name}...\n`);
console.log(
pad_right('Benchmark', COLUMN_WIDTHS[0]) +
pad_left('Time', COLUMN_WIDTHS[1]) +
pad_left('GC time', COLUMN_WIDTHS[2])
);
console.log('='.repeat(TOTAL_WIDTH));
for (const benchmark of benchmarks) {
const results = await run_benchmark(benchmark.label);
console.log(
pad_right(benchmark.label, COLUMN_WIDTHS[0]) +
pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) +
pad_left(results.gc_time.toFixed(2), COLUMN_WIDTHS[2])
);
total_time += results.time;
total_gc_time += results.gc_time;
suite_time += results.time;
suite_gc_time += results.gc_time;
}
console.log('='.repeat(TOTAL_WIDTH));
console.log(
pad_right(benchmark.label, COLUMN_WIDTHS[0]) +
pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) +
pad_left(results.gc_time.toFixed(2), COLUMN_WIDTHS[2])
pad_right('suite', COLUMN_WIDTHS[0]) +
pad_left(suite_time.toFixed(2), COLUMN_WIDTHS[1]) +
pad_left(suite_gc_time.toFixed(2), COLUMN_WIDTHS[2])
);
total_time += results.time;
total_gc_time += results.gc_time;
suite_time += results.time;
suite_gc_time += results.gc_time;
console.log('='.repeat(TOTAL_WIDTH));
}
console.log('='.repeat(TOTAL_WIDTH));
console.log(
pad_right('suite', COLUMN_WIDTHS[0]) +
pad_left(suite_time.toFixed(2), COLUMN_WIDTHS[1]) +
pad_left(suite_gc_time.toFixed(2), COLUMN_WIDTHS[2])
);
console.log('='.repeat(TOTAL_WIDTH));
}
if (PROFILE_DIR !== null) {
console.log(`\nCPU profiles written to ${PROFILE_DIR}`);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
process.exit(1);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
process.exit(1);
}
$.pop();
console.log('');
console.log('');
console.log(
pad_right('total', COLUMN_WIDTHS[0]) +
pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) +
pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2])
);
console.log(
pad_right('total', COLUMN_WIDTHS[0]) +
pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) +
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>
```
`$effect.pre` runs before DOM updates that are scheduled after it, not before every DOM mutation in the flush - DOM of parent components may already be updated. When using [await expressions](await-expressions), block updates like `{#if ...}` and `{#each ...}` in the same component also run before `$effect.pre`.
Apart from the timing, `$effect.pre` works exactly like `$effect`.
## `$effect.tracking`

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

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

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

@ -251,6 +251,19 @@ You can give the `<select>` a default value by adding a `selected` attribute to
</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>` elements have their own set of bindings — five two-way ones...

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

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

@ -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').
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"} -->
```svelte
@ -165,9 +165,9 @@ Svelte will warn you if you get it wrong.
Similarly, to pass primitive values through context, use functions as described in [Passing state into functions]($state#Passing-state-into-functions).
## Component testing
## Mounting components with context
When writing [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), it can be useful to create a wrapper component that sets the context in order to check the behaviour of a component that uses it. As of version 5.49, you can do this sort of thing:
To mount a component with specific context, create a wrapper component that sets the context before rendering the component. This is useful for [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), or any other scenario that needs to provide context through `mount`. As of version 5.49, you can do this sort of thing:
```js
import { mount, unmount } from 'svelte';
@ -193,6 +193,8 @@ test('MyComponent', () => {
This approach also works with [`hydrate`](imperative-component-api#hydrate) and [`render`](imperative-component-api#render).
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
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.
With runes, we can use `$effect.pre`, which behaves the same as `$effect` but runs before the DOM is updated. As long as we explicitly reference `messages` inside the effect body, it will run whenever `messages` changes, but _not_ when `theme` changes.
With runes, we can use `$effect.pre`, which behaves the same as `$effect` but runs before DOM updates scheduled after it (see [$effect.pre]($effect#$effect.pre) for the exact ordering). As long as we explicitly reference `messages` inside the effect body, it will run whenever `messages` changes, but _not_ when `theme` changes.
`beforeUpdate`, and its equally troublesome counterpart `afterUpdate`, are therefore deprecated in Svelte 5.

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

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

@ -225,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
```
### 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
```

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

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

@ -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
```
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
<!-- A11y: onmouseover must be accompanied by onfocus -->

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

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

@ -5,7 +5,7 @@
"private": true,
"type": "module",
"license": "MIT",
"packageManager": "pnpm@10.4.0",
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800",
"engines": {
"pnpm": ">=9.0.0"
},
@ -19,6 +19,7 @@
"lint": "eslint && prettier --check .",
"format": "prettier --write .",
"test": "vitest run",
"profile:compiler": "node ./benchmarking/profile-compiler.js",
"changeset:version": "changeset version && pnpm -r generate:version && git add --all",
"changeset:publish": "changeset publish",
"bench": "NODE_ENV=production node --allow-natives-syntax ./benchmarking/run.js",
@ -26,10 +27,10 @@
"bench:debug": "NODE_ENV=production node --allow-natives-syntax --inspect-brk ./benchmarking/run.js"
},
"devDependencies": {
"@changesets/changelog-github": "1.0.0-next.6",
"@changesets/cli": "^2.29.8",
"@eslint/js": "^10.0.0",
"@sveltejs/eslint-config": "^9.0.0",
"@svitejs/changesets-changelog-github-compact": "^1.1.0",
"@types/node": "^20.11.5",
"@types/picomatch": "^4.0.2",
"@vitest/coverage-v8": "^4.1.7",
@ -37,7 +38,7 @@
"eslint-plugin-lube": "^0.5.1",
"eslint-plugin-svelte": "^3.15.0",
"jsdom": "25.0.1",
"playwright": "^1.60.0",
"playwright": "^1.62.0",
"prettier": "^3.2.4",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "workspace:^",

@ -1,5 +1,213 @@
# 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

@ -1352,6 +1352,9 @@ export interface HTMLSelectAttributes extends HTMLAttributes<HTMLSelectElement>
required?: boolean | undefined | null;
size?: number | undefined | null;
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;
onchange?: ChangeEventHandler<HTMLSelectElement> | undefined | null;

@ -171,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
## 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
> 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.
## state_proxy_unmount
> Tried to unmount a state proxy, rather than a component
`unmount` was called with a state proxy:
```js
import { mount, unmount } from 'svelte';
import Component from './Component.svelte';
let target = document.body;
// ---cut---
let component = $state(mount(Component, { target }));
// later...
unmount(component);
```
Avoid using `$state` here. If `component` _does_ need to be reactive for some reason, use `$state.raw` instead.
## svelte_boundary_reset_noop
> A `<svelte:boundary>` `reset` function only resets the boundary the first time it is called

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

@ -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
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
<!-- A11y: onmouseover must be accompanied by onfocus -->

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

@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
"version": "5.56.4",
"version": "5.57.0",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
@ -152,13 +152,14 @@
},
"devDependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.62.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-virtual": "^3.0.2",
"@types/aria-query": "^5.0.4",
"@types/node": "^20.11.5",
"@types/trusted-types": "^2.0.7",
"baseline-browser-mapping": "^2.10.32",
"dts-buddy": "^0.5.5",
"esbuild": "^0.28.1",
@ -170,18 +171,17 @@
"web-features": "^3.29.0"
},
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"@jridgewell/remapping": "^2.3.5",
"@jridgewell/sourcemap-codec": "^1.6.0",
"@sveltejs/acorn-typescript": "^1.0.13",
"@types/estree": "^1.0.9",
"acorn": "^8.18.0",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.8.1",
"devalue": "^5.9.2",
"esm-env": "^1.2.1",
"esrap": "^2.2.12",
"esrap": "^2.3.6",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",

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

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

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

@ -5,6 +5,7 @@ import * as acorn from 'acorn';
import { walk } from 'zimmerframe';
import { tsPlugin } from '@sveltejs/acorn-typescript';
import * as e from '../../errors.js';
import { locator } from '../../state.js';
const JSParser = acorn.Parser;
const TSParser = JSParser.extend(tsPlugin());
@ -59,7 +60,7 @@ export function parse(source, comments, typescript, is_script) {
return /** @type {Program} */ (ast);
} catch (err) {
// TODO the `return` in necessary for TS<7 due to a bug; otherwise
// TODO the `return` is necessary for TS<7 due to a bug; otherwise
// the `finally` block is regarded as unreachable
return handle_parse_error(err);
} finally {
@ -87,7 +88,8 @@ export function parse_expression_at(parser, source, index) {
sourceType: 'module',
ecmaVersion: 16,
locations: true,
preserveParens: true
preserveParens: true,
startLocation: start_location(parser, index)
});
add_comments(ast);
@ -112,7 +114,13 @@ export function parse_statement_at(parser, source, index) {
try {
// This is like parseExpressionAt but for statements
const p = new acorn(
{ onComment, sourceType: 'module', ecmaVersion: 16, locations: true },
{
onComment,
sourceType: 'module',
ecmaVersion: 16,
locations: true,
startLocation: start_location(parser, index)
},
source,
index
);
@ -128,6 +136,32 @@ export function parse_statement_at(parser, source, index) {
}
}
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+\)$/;
/**

@ -10,25 +10,7 @@ import read_options from './read/options.js';
import { is_reserved } from '../../../utils.js';
import { disallow_children } from '../2-analyze/visitors/shared/special-element.js';
import * as state from '../../state.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
);
}
import { is_whitespace } from './utils/whitespace.js';
const regex_lang_attribute =
/<!--[^]*?-->|<script\s+(?:[^>]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s]+)\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/g;
@ -50,6 +32,9 @@ export class Parser {
/** */
index = 0;
/** @type {AST.CSS.CSSComment[]} */
css_comments = [];
/**
* Creates a minimal parser instance for CSS-only parsing.
* Skips Svelte component parsing setup.
@ -61,6 +46,7 @@ export class Parser {
parser.template = source;
parser.index = 0;
parser.loose = false;
parser.css_comments = [];
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 */
read_until(pattern) {
read_until_regex(pattern) {
if (this.index >= this.template.length) {
if (this.loose) return '';
e.unexpected_eof(this.template.length);
@ -298,6 +303,7 @@ export class Parser {
e.expected_whitespace(this.index);
}
this.index++;
this.allow_whitespace();
}

@ -2,7 +2,6 @@
/** @import { Parser } from '../index.js' */
import { match_bracket } from '../utils/bracket.js';
import { parse_expression_at, remove_parens } from '../acorn.js';
import { regex_not_newline_characters } from '../../patterns.js';
import * as e from '../../../errors.js';
/**
@ -33,24 +32,10 @@ export default function read_pattern(parser) {
i = match_bracket(parser, start);
parser.index = i;
const pattern_string = parser.template.slice(start, i);
// 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);
// acorn never reads before `start`, so the template itself can serve as the prefix
/** @type {any} */
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;
@ -80,7 +65,7 @@ function read_type_annotation(parser) {
const insert = '_ as ';
let a = parser.index - insert.length;
const template =
parser.template.slice(0, a).replace(/[^\n]/g, ' ') +
parser.template.slice(0, a) +
insert +
// 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

@ -1,9 +1,13 @@
/** @import { Expression } from 'estree' */
/** @import { Expression, Identifier } from 'estree' */
/** @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 * as e from '../../../errors.js';
import { find_matching_bracket } from '../utils/bracket.js';
import { is_reserved } from '../../../../utils.js';
import { locator } from '../../../state.js';
/**
* @param {Parser} parser
@ -33,6 +37,9 @@ export function get_loose_identifier(parser, opening_token) {
* @returns {Expression}
*/
export default function read_expression(parser, opening_token, disallow_loose) {
const simple = read_simple_expression(parser);
if (simple) return simple;
try {
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;
}
}
/**
* 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) {
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) {
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_COMBINATOR = /(\+|~|>|\|\|)/y;
const REGEX_PERCENTAGE = /\d+(\.\d+)?%/y;
// `of` must be preceded by whitespace, otherwise it would be part of the `<an+b>` token
// (`2nof` is a single dimension token). It does not need to be followed by whitespace,
// because a `.`, `#`, `[`, `*`, `:` or `&` already ends the `of` identifier — minifiers rely on that
const REGEX_NTH_OF =
/(even|odd|\+?(\d+|\d*n(\s*[+-]\s*\d+)?)|-\d*n(\s*\+\s*\d+))((?=\s*[,)])|\s+of\s+)/y;
/(even|odd|\+?(\d+|\d*n(\s*[+-]\s*\d+)?)|-\d*n(\s*\+\s*\d+))((?=\s*[,)])|\s+of(\s+|(?=[.#[*:&])))/y;
const REGEX_WHITESPACE_OR_COLON = /[\s:]/;
const REGEX_LEADING_HYPHEN_OR_DIGIT = /-?\d/y;
const REGEX_VALID_IDENTIFIER_CHAR = /[a-zA-Z0-9_-]/;
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
@ -24,6 +25,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/;
*/
export default function read_style(parser, start, attributes) {
const content_start = parser.index;
parser.css_comments = [];
const children = read_body(parser, (p) => p.match('</style') || p.index >= p.template.length);
const content_end = parser.index;
@ -36,6 +38,7 @@ export default function read_style(parser, start, attributes) {
end: parser.index,
attributes,
children,
comments: parser.css_comments,
content: {
start: content_start,
end: content_end,
@ -202,15 +205,18 @@ function read_selector(parser, inside_pseudo_class = false) {
});
} else if (parser.eat('*')) {
let name = '*';
/** @type {string | undefined} */
let namespace;
if (parser.eat('|')) {
// * is the namespace (which we ignore)
name = read_identifier(parser);
namespace = name;
name = parser.eat('*') ? '*' : read_identifier(parser);
}
relative_selector.selectors.push({
type: 'TypeSelector',
name,
...(namespace !== undefined && { namespace }),
start,
end: parser.index
});
@ -229,18 +235,22 @@ function read_selector(parser, inside_pseudo_class = false) {
end: parser.index
});
} else if (parser.eat('::')) {
const name = read_identifier(parser);
/** @type {AST.CSS.SelectorList | null} */
let args = null;
if (parser.eat('(')) {
args = read_selector_list(parser, true);
parser.eat(')', true);
}
relative_selector.selectors.push({
type: 'PseudoElementSelector',
name: read_identifier(parser),
name,
start,
end: parser.index
end: parser.index,
...(args && { args })
});
// We read the inner selectors of a pseudo element to ensure it parses correctly,
// but we don't do anything with the result.
if (parser.eat('(')) {
read_selector_list(parser, true);
parser.eat(')', true);
}
} else if (parser.eat(':')) {
const name = read_identifier(parser);
@ -308,22 +318,25 @@ function read_selector(parser, inside_pseudo_class = false) {
});
} else if (!parser.match_regex(REGEX_COMBINATOR)) {
let name = read_identifier(parser);
/** @type {string | undefined} */
let namespace;
if (parser.eat('|')) {
// we ignore the namespace when trying to find matching element classes
name = read_identifier(parser);
namespace = name;
name = parser.eat('*') ? '*' : read_identifier(parser);
}
relative_selector.selectors.push({
type: 'TypeSelector',
name,
...(namespace !== undefined && { namespace }),
start,
end: parser.index
});
}
const index = parser.index;
allow_comment_or_whitespace(parser);
allow_comment_or_whitespace(parser, false);
if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) {
// rewind, so we know whether to continue building the selector list
@ -449,7 +462,7 @@ function read_block_item(parser) {
// read ahead to understand whether we're dealing with a declaration or a nested rule.
// this involves some duplicated work, but avoids a try-catch that would disguise errors
const start = parser.index;
read_value(parser);
read_value(parser, false);
const char = parser.template[parser.index];
parser.index = start;
@ -463,7 +476,7 @@ function read_block_item(parser) {
function read_declaration(parser) {
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.eat(':');
let index = parser.index;
@ -492,10 +505,13 @@ function read_declaration(parser) {
/**
* @param {Parser} parser
* @param {boolean} [capture_comments]
* @returns {string}
*/
function read_value(parser) {
function read_value(parser, capture_comments = true) {
let value = '';
/** @type {AST.CSS.CSSComment[]} */
const value_comments = [];
let escaped = false;
let in_url = false;
@ -523,6 +539,13 @@ function read_value(parser) {
} else if (char === '(' && value.slice(-3) === 'url') {
in_url = true;
} else if ((char === ';' || char === '{' || char === '}') && !in_url && !quote_mark) {
const leading_whitespace = value.length - value.trimStart().length;
for (const comment of value_comments) {
comment.position = Math.max(
0,
/** @type {number} */ (comment.position) - leading_whitespace
);
}
return value.trim();
} else if (
char === '/' &&
@ -530,13 +553,11 @@ function read_value(parser) {
!quote_mark &&
parser.template[parser.index + 1] === '*'
) {
parser.index += 2;
while (parser.index < parser.template.length) {
if (parser.template[parser.index] === '*' && parser.template[parser.index + 1] === '/') {
parser.index += 2;
break;
}
parser.index++;
const comment = read_comment(parser);
if (capture_comments) {
comment.position = value.length;
parser.css_comments.push(comment);
value_comments.push(comment);
}
continue;
}
@ -600,7 +621,8 @@ function read_identifier(parser) {
if (char === '\\') {
const sequence = parser.match_regex(REGEX_UNICODE_SEQUENCE);
if (sequence) {
identifier += String.fromCodePoint(parseInt(sequence.slice(1), 16));
const character = String.fromCodePoint(parseInt(sequence.slice(1), 16));
identifier += character === '\\' ? '\\\\' : character;
parser.index += sequence.length;
} else {
identifier += '\\' + parser.template[parser.index + 1];
@ -624,17 +646,20 @@ function read_identifier(parser) {
return identifier;
}
/** @param {Parser} parser */
function allow_comment_or_whitespace(parser) {
/**
* @param {Parser} parser
* @param {boolean} [capture_comments]
*/
function allow_comment_or_whitespace(parser, capture_comments = true) {
parser.allow_whitespace();
while (parser.match('/*') || parser.match('<!--')) {
if (parser.eat('/*')) {
parser.read_until(REGEX_COMMENT_CLOSE);
parser.eat('*/', true);
if (parser.match('/*')) {
const comment = read_comment(parser);
if (capture_comments) parser.css_comments.push(comment);
}
if (parser.eat('<!--')) {
parser.read_until(REGEX_HTML_COMMENT_CLOSE);
parser.read_until('-->');
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>`).
* @param {Parser} parser

@ -15,13 +15,10 @@ import { get_attribute_expression, is_expression_attribute } from '../../../util
import { closing_tag_omitted } from '../../../../html-tree-validation.js';
import { list } from '../../../utils/string.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_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_attribute_value = /(?:"([^"]*)"|'([^'])*'|([^>\s]+))/y;
const regex_doctype_name = /^![a-zA-Z]+$/;
@ -67,7 +64,7 @@ export default function element(parser) {
let parent = parser.current();
if (parser.eat('!--')) {
const data = parser.read_until(regex_closing_comment);
const data = parser.read_until('-->');
parser.eat('-->', true);
parser.append({
@ -81,7 +78,7 @@ export default function element(parser) {
}
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.eat('>', true);
@ -137,7 +134,7 @@ export default function element(parser) {
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)) {
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) {
const start = parser.index;
const tag = read_tag(parser, regex_token_ending_character);
const tag = read_tag(parser, true);
if (!tag.name) return null;
/** @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;
@ -731,7 +728,7 @@ function read_comment(parser) {
const start = parser.index;
if (parser.eat('//')) {
const value = parser.read_until(/\n/);
const value = parser.read_until('\n');
const end = parser.index;
return {
@ -747,7 +744,7 @@ function read_comment(parser) {
}
if (parser.eat('/*')) {
const value = parser.read_until(/\*\//);
const value = parser.read_until('*/');
parser.eat('*/');
const end = parser.index;
@ -847,25 +844,21 @@ function read_attribute_value(parser) {
* @returns {any[]}
*/
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>} */
const chunks = [];
let chunk_start = parser.index;
/** @param {number} end */
function flush(end) {
if (end > current_chunk.start) {
current_chunk.raw = parser.template.slice(current_chunk.start, end);
current_chunk.data = decode_character_references(current_chunk.raw, true);
current_chunk.end = end;
chunks.push(current_chunk);
if (end > chunk_start) {
const raw = parser.template.slice(chunk_start, end);
chunks.push({
start: chunk_start,
end,
type: 'Text',
raw,
data: decode_character_references(raw, true)
});
}
}
@ -879,12 +872,14 @@ function read_sequence(parser, done, location) {
if (parser.match('#')) {
const index = parser.index - 1;
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);
} else if (parser.match('@')) {
const index = parser.index - 1;
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);
}
@ -907,14 +902,7 @@ function read_sequence(parser, done, location) {
};
chunks.push(chunk);
current_chunk = {
start: parser.index,
end: -1,
type: 'Text',
raw: '',
data: ''
};
chunk_start = parser.index;
} else {
parser.index++;
}
@ -929,12 +917,36 @@ function read_sequence(parser, done, location) {
/**
* @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 }}
*/
function read_tag(parser, regex) {
function read_tag(parser, attribute = false) {
const start = parser.index;
const name = parser.read_until(regex);
const name = read_tag_name(parser, attribute);
const end = parser.index;
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);
}

@ -488,12 +488,13 @@ function open(parser) {
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
? /** @type {ArrowFunctionExpression} */ (
parse_expression_at(parser, prelude + `${params} => {}`, params_start)
parse_expression_at(
parser,
parser.template.slice(0, parser.index) + ' => {}',
params_start
)
)
: { params: [] };

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

@ -0,0 +1,18 @@
/** @param {number} cc */
export 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
);
}

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

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

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

@ -285,7 +285,7 @@ export function analyze_module(source, options) {
runes: true,
immutable: true,
tracing: false,
async_deriveds: new Set(),
async_deriveds: new Map(),
comments,
classes: new Map(),
pickled_awaits: new Set()
@ -557,7 +557,7 @@ export function analyze_component(root, source, options) {
source,
snippet_renderers: new Map(),
snippets: new Set(),
async_deriveds: new Set(),
async_deriveds: new Map(),
pickled_awaits: new Set(),
instance_body: {
sync: [],
@ -832,6 +832,11 @@ export function analyze_component(root, source, options) {
} else {
e.export_undefined(specifier, name);
}
} else if (binding.initial?.type === 'SnippetBlock') {
// If a snippet is exported, a consumer could only import this named export and not the default export (the component).
// In this case we need to set hasGlobal of our output to true so that e.g. vite-plugin-svelte does not tell Vite to
// tree-shake the CSS if the default export is not used.
analysis.css.has_global = true;
}
}
}
@ -958,7 +963,7 @@ function calculate_blockers(instance, analysis) {
* @param {Set<Binding>} touched
* @param {Set<ESTree.Node>} seen
*/
const touch = (expression, scope, touched, seen = new Set()) => {
const touch = (expression, scope, touched, seen) => {
if (seen.has(expression)) return;
seen.add(expression);
@ -1015,6 +1020,13 @@ function calculate_blockers(instance, analysis) {
}
}
// Share seen nodes across calls so transitive assignments are only visited once.
// Keep separate read/write state because the target sets can differ.
/** @type {Set<ESTree.Node>} */
const writes_seen = new Set();
/** @type {Set<ESTree.Node>} */
const reads_seen = new Set();
walk(
node,
{ scope },
@ -1044,13 +1056,7 @@ function calculate_blockers(instance, analysis) {
const rune = get_rune(node, context.state.scope);
if (rune === '$effect') return;
/** @type {Set<Binding>} */
const touched = new Set();
touch(node, context.state.scope, touched);
for (const b of touched) {
writes.add(b);
}
touch(node, context.state.scope, writes, writes_seen);
},
Identifier(node, context) {
const parent = /** @type {ESTree.Node} */ (context.path.at(-1));
@ -1066,7 +1072,7 @@ function calculate_blockers(instance, analysis) {
// might be called immediately, so we have to touch all references within it. Example:
// function foo() { return () => blocker; } foo(); // blocker is touched
if (node.argument) {
touch(node.argument, context.state.scope, reads);
touch(node.argument, context.state.scope, reads, reads_seen);
}
},
// don't look inside functions until they are called
@ -1221,8 +1227,29 @@ function calculate_blockers(instance, analysis) {
}
}
// With no top-level await, no binding can have a blocker and function tracing
// cannot affect the output.
if (!awaited) return;
flush_sync_group();
// a store subscription must wait on whatever blocks the store itself; this must happen
// before function tracing so that functions reading `$store` inherit the blocker
for (const [name, binding] of instance.scope.declarations) {
if (binding.kind !== 'store_sub') continue;
const store_blocker = instance.scope.get(name.slice(1))?.blocker;
if (!store_blocker) continue;
if (
!binding.blocker ||
/** @type {ESTree.SimpleLiteral & { value: number }} */ (binding.blocker.property).value <
/** @type {ESTree.SimpleLiteral & { value: number }} */ (store_blocker.property).value
) {
binding.blocker = store_blocker;
}
}
for (const fn of functions) {
/** @type {Set<Binding>} */
const reads_writes = new Set();
@ -1231,12 +1258,15 @@ function calculate_blockers(instance, analysis) {
? /** @type {ESTree.FunctionExpression | ESTree.ArrowFunctionExpression} */ (fn.init)
: fn;
trace_references(
init.body,
reads_writes,
reads_writes,
/** @type {Scope} */ (instance.scopes.get(init))
);
const fn_scope = /** @type {Scope} */ (instance.scopes.get(init));
if (init.body.type === 'BlockStatement') {
trace_references(init.body, reads_writes, reads_writes, fn_scope);
} else {
// A concise arrow body is an implicit return, so treat it like the
// `ReturnStatement` visitor in `trace_references` would.
touch(init.body, fn_scope, reads_writes, new Set());
}
const max = [...reads_writes].reduce((max, binding) => {
if (binding.blocker) {
@ -1310,7 +1340,8 @@ function order_reactive_statements(unsorted_reactive_declarations) {
* @returns
*/
const add_declaration = (node, declaration) => {
if ([...reactive_declarations.values()].includes(declaration)) return;
// Visited set: each ReactiveStatement is stored under exactly one LabeledStatement node
if (reactive_declarations.has(node)) return;
for (const binding of declaration.dependencies) {
if (declaration.assignments.has(binding)) continue;

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

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

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

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

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

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

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

@ -4,7 +4,7 @@ import { roles as roles_map, elementRoles } from 'aria-query';
import { AXObjects, elementAXObjects } from 'axobject-query';
export const aria_attributes =
'activedescendant atomic autocomplete busy checked colcount colindex colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(
'activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount colindex colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(
' '
);

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

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

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

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

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

@ -39,6 +39,12 @@ export function transform_template(state, name, flags = 0) {
const namespace = state.metadata.namespace;
const tree = state.options.fragments === 'tree';
const { nodes } = state.template;
const is_lone_anchor = nodes.length === 1 && nodes[0].type === 'comment';
// special case - `$.comment` creates the anchor more cheaply than cloning a template
if (is_lone_anchor) return b.id('$.comment');
const expression = tree ? state.template.as_tree() : state.template.as_html();
const key =

@ -2,6 +2,7 @@
/** @import { Binding } from '#compiler' */
/** @import { ClientTransformState, ComponentClientTransformState } from './types.js' */
/** @import { Analysis } from '../../types.js' */
/** @import { ExpressionMetadata } from '../../nodes.js' */
/** @import { Scope } from '../../scope.js' */
import * as b from '#compiler/builders';
import { is_simple_expression, save } from '../../../utils/ast.js';
@ -164,20 +165,46 @@ export function should_proxy(node, scope) {
return true;
}
/**
* An async thunk. If an `await` inside restores the reaction context via `$.save`,
* the body exits through `$.unsave` so the context cannot leak into foreign microtasks
* that run before the returned promise settles
* @param {Expression | BlockStatement} body
* @param {ExpressionMetadata} metadata
*/
export function async_thunk(body, metadata) {
if (!metadata.has_pickled_await) {
return b.arrow([], body, true);
}
const block = body.type === 'BlockStatement' ? body : b.block([b.return(body)]);
return b.arrow(
[],
b.block([
{
type: 'TryStatement',
block,
handler: null,
finalizer: b.block([b.stmt(b.call('$.unsave'))])
}
]),
true
);
}
/**
* Svelte legacy mode should use safe equals in most places, runes mode shouldn't
* @param {ComponentClientTransformState} state
* @param {Expression | BlockStatement} expression
* @param {boolean} [async]
* @param {ExpressionMetadata} [metadata]
*/
export function create_derived(state, expression, async = false) {
const thunk = b.thunk(expression, async);
if (async) {
return save(b.call('$.async_derived', thunk));
} else {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk);
export function create_derived(state, expression, metadata) {
if (metadata?.has_await) {
return save(b.call('$.async_derived', async_thunk(expression, metadata)));
}
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', b.thunk(expression));
}
/**

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

@ -1,9 +1,9 @@
/** @import { BlockStatement, Pattern, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */
import { extract_identifiers, is_expression_async } from '../../../../utils/ast.js';
import { extract_identifiers } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { create_derived } from '../utils.js';
import { async_thunk, create_derived } from '../utils.js';
import { get_value } from './shared/declarations.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
@ -15,10 +15,10 @@ export function AwaitBlock(node, context) {
context.state.template.push_comment();
// Visit {#await <expression>} first to ensure that scopes are in the correct order
const expression = b.thunk(
build_expression(context, node.expression, node.metadata.expression),
node.metadata.expression.has_await
);
const input = build_expression(context, node.expression, node.metadata.expression);
const expression = node.metadata.expression.has_await
? async_thunk(input, node.metadata.expression)
: b.thunk(input);
let then_block;
let catch_block;

@ -1,7 +1,7 @@
/** @import { CallExpression, Expression, Pattern } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { dev, is_ignored } from '../../../../state.js';
import { dev, ignore_map, is_ignored } from '../../../../state.js';
import { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { binding_properties } from '../../../bindings.js';
@ -40,9 +40,15 @@ export function BindDirective(node, context) {
validate_binding(context.state, node, expression);
}
const assignment = /** @type {Expression} */ (
context.visit(b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value')))
const raw_assignment = b.assignment(
'=',
/** @type {Pattern} */ (node.expression),
b.id('$$value')
);
// The assignment is generated, so inherit any ignores attached to the binding
ignore_map.set(raw_assignment, ignore_map.get(node) ?? []);
const assignment = /** @type {Expression} */ (context.visit(raw_assignment));
if (dev) {
// in dev, create named functions, so that `$inspect(...)` delivers
@ -58,16 +64,7 @@ export function BindDirective(node, context) {
get = b.thunk(expression);
/** @type {Expression | undefined} */
set = b.unthunk(
b.arrow(
[b.id('$$value')],
/** @type {Expression} */ (
context.visit(
b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value'))
)
)
)
);
set = b.unthunk(b.arrow([b.id('$$value')], assignment));
if (get === set) {
set = undefined;

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

@ -3,6 +3,7 @@
/** @import { ComponentContext } from '../types' */
import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { add_state_transformers } from './shared/declarations.js';
/**
@ -70,7 +71,7 @@ export function add_async_declaration(context, metadata, ids, assignments, kind
context.state.consts.push(kind === 'var' ? b.var(id.name) : b.let(id.name));
}
const blockers = [...metadata.expression.dependencies]
const blockers = [...metadata.expression.references]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
@ -85,5 +86,5 @@ export function add_async_declaration(context, metadata, ids, assignments, kind
metadata.expression.has_await ||
assignments.some((assignment) => has_await_expression(assignment));
const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments);
run.thunks.push(b.thunk(body, has_await));
run.thunks.push(has_await ? async_thunk(body, metadata.expression) : b.thunk(body));
}

@ -12,6 +12,7 @@ import {
import { dev } from '../../../../state.js';
import { extract_paths, object } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { get_value } from './shared/declarations.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
@ -294,7 +295,10 @@ export function EachBlock(node, context) {
let key_function = b.id('$.index');
if (node.metadata.keyed) {
const pattern = /** @type {Pattern} */ (node.context); // can only be keyed when a context is provided
// can only be keyed when a context is provided
const pattern = /** @type {Pattern} */ (
context.visit(/** @type {Pattern} */ (node.context), key_state)
);
const expression = /** @type {Expression} */ (
context.visit(/** @type {Expression} */ (node.key), key_state)
);
@ -310,7 +314,9 @@ export function EachBlock(node, context) {
const has_await = node.metadata.expression.has_await;
const get_collection = b.thunk(collection, has_await);
const get_collection = has_await
? async_thunk(collection, node.metadata.expression)
: b.thunk(collection);
const thunk = has_await ? b.thunk(b.call('$.get', b.id('$$collection'))) : get_collection;
const render_args = [b.id('$$anchor'), item];

@ -141,14 +141,9 @@ export function Fragment(node, context) {
flags |= TEMPLATE_USE_IMPORT_NODE;
}
if (state.template.nodes.length === 1 && state.template.nodes[0].type === 'comment') {
// special case — we can use `$.comment` instead of creating a unique template
state.init.unshift(b.var(id, b.call('$.comment')));
} else {
const template_name = transform_template(state, 'root', flags);
const template_name = transform_template(state, 'root', flags);
state.init.unshift(b.var(id, b.call(template_name)));
}
state.init.unshift(b.var(id, b.call(template_name)));
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
}

@ -2,6 +2,7 @@
/** @import { ComponentContext } from '../types' */
import { is_ignored } from '../../../../state.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression } from './shared/utils.js';
/**
@ -46,7 +47,7 @@ export function HtmlTag(node, context) {
'$.async',
context.state.node,
node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0,
has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow(
has_await ? [context.state.node, b.id('$$html')] : [context.state.node],
b.block([statement])

@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
/**
@ -117,7 +118,7 @@ export function IfBlock(node, context) {
'$.async',
context.state.node,
node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0,
has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow(
has_await ? [context.state.node, b.id('$$condition')] : [context.state.node],
b.block(statements)

@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
/**
@ -31,7 +32,7 @@ export function KeyBlock(node, context) {
'$.async',
context.state.node,
node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0,
has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow(
has_await ? [context.state.node, b.id('$$key')] : [context.state.node],
b.block([statement])

@ -1,10 +1,9 @@
/** @import { ArrayExpression, Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression } from 'estree' */
/** @import { ArrayExpression, Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */
/** @import { Scope } from '../../../scope' */
import {
cannot_be_set_statically,
is_boolean_attribute,
is_dom_property,
is_load_error_element
} from '../../../../../utils.js';
@ -13,7 +12,6 @@ import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js'
import * as b from '#compiler/builders';
import {
create_attribute,
ExpressionMetadata,
is_custom_element_node,
is_customizable_select_element
} from '../../../nodes.js';
@ -231,11 +229,18 @@ export function RegularElement(node, context) {
continue;
}
// `<select defaultValue>` needs the options to exist before it can mark one
// as selected, so it is handled after the children, alongside `value`
if (node.name === 'select' && get_attribute_name(node, attribute) === 'defaultValue') {
continue;
}
const name = get_attribute_name(node, attribute);
if (
!is_custom_element &&
!cannot_be_set_statically(attribute.name) &&
(name !== 'value' || node.name !== 'textarea') &&
(attribute.value === true || is_text_attribute(attribute)) &&
(name !== 'class' || class_directives.length === 0) &&
(name !== 'style' || style_directives.length === 0)
@ -434,7 +439,7 @@ export function RegularElement(node, context) {
state: child_state
});
if (needs_reset) {
if (needs_reset && !fold_reset_into_child(child_state.init, context.state.node)) {
child_state.init.push(b.stmt(b.call('$.reset', context.state.node)));
}
}
@ -513,6 +518,34 @@ export function RegularElement(node, context) {
}
}
// deferred from the attribute loop above, so that the options it selects from
// have been created and had their values assigned
if (!has_spread && name === 'select') {
const default_value = /** @type {AST.Attribute[]} */ (attributes).find(
(attribute) => get_attribute_name(node, attribute) === 'defaultValue'
);
if (default_value) {
const { value, has_state } = build_attribute_value(default_value.value, context, (v, m) =>
context.state.memoizer.add(v, m)
);
(has_state ? context.state.update : context.state.init).push(
b.stmt(b.call('$.set_default_select_value', node_id, value))
);
}
const value_attribute = lookup.get('value');
const dynamic_value =
value_attribute !== undefined &&
value_attribute.value !== true &&
!is_text_attribute(value_attribute);
if (default_value || dynamic_value || bindings.has('value')) {
context.state.init.push(b.stmt(b.call('$.init_select', node_id)));
}
}
context.state.template.pop_element();
}
@ -528,18 +561,12 @@ export function build_class_directives_object(
) {
let properties = [];
const metadata = new ExpressionMetadata();
for (const d of class_directives) {
metadata.merge(d.metadata.expression);
const expression = /** @type Expression */ (context.visit(d.expression));
properties.push(b.init(d.name, expression));
properties.push(b.init(d.name, memoizer.add(expression, d.metadata.expression)));
}
const directives = b.object(properties);
return memoizer.add(directives, metadata);
return b.object(properties);
}
/**
@ -555,23 +582,17 @@ export function build_style_directives_object(
const normal = b.object([]);
const important = b.object([]);
const metadata = new ExpressionMetadata();
for (const d of style_directives) {
metadata.merge(d.metadata.expression);
const expression =
d.value === true
? build_getter(b.id(d.name), context.state)
: build_attribute_value(d.value, context).value;
const object = d.modifiers.includes('important') ? important : normal;
object.properties.push(b.init(d.name, expression));
object.properties.push(b.init(d.name, memoizer.add(expression, d.metadata.expression)));
}
const directives = important.properties.length ? b.array([normal, important]) : normal;
return memoizer.add(directives, metadata);
return important.properties.length ? b.array([normal, important]) : normal;
}
/**
@ -664,14 +685,25 @@ function build_element_attribute_update(element, node_id, name, value, attribute
* @param {ComponentContext} context
*/
function build_custom_element_attribute_update_assignment(node_id, attribute, context) {
const { value, has_state } = build_attribute_value(attribute.value, context);
const memoizer = new Memoizer();
const { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) =>
memoizer.add(value, metadata)
);
// don't lowercase name, as we set the element's property, which might be case sensitive
const call = b.call('$.set_custom_element_data', node_id, b.literal(attribute.name), value);
// this is different from other updates — it doesn't get grouped,
// because set_custom_element_data may not be idempotent
const update = has_state ? b.call('$.template_effect', b.thunk(call)) : call;
const update = has_state
? b.call(
'$.template_effect',
b.arrow(memoizer.apply(), call),
memoizer.sync_values(),
memoizer.async_values(),
memoizer.blockers()
)
: call;
context.state.init.push(b.stmt(update));
}
@ -704,28 +736,32 @@ function build_element_special_value_attribute(
);
const evaluated = context.state.scope.evaluate(value);
const assignment = b.assignment('=', b.member(node_id, '__value'), value);
const set_value_assignment = b.assignment(
'=',
b.member(node_id, 'value'),
evaluated.is_defined ? assignment : b.logical('??', assignment, b.literal(''))
);
/** @param {Expression} value */
const build_update = (value) => {
const assignment = b.assignment('=', b.member(node_id, '__value'), value);
const update = b.stmt(
is_select_with_value
? b.sequence([
set_value_assignment,
// This ensures a one-way street to the DOM in case it's <select {value}>
// and not <select bind:value>. We need it in addition to $.init_select
// because the select value is not reflected as an attribute, so the
// mutation observer wouldn't notice.
b.call('$.select_option', node_id, value)
])
: synthetic
? assignment
: set_value_assignment
);
const set_value_assignment = b.assignment(
'=',
b.member(node_id, 'value'),
evaluated.is_defined ? assignment : b.logical('??', assignment, b.literal(''))
);
return b.stmt(
is_select_with_value
? b.sequence([
set_value_assignment,
// This ensures a one-way street to the DOM in case it's <select {value}>
// and not <select bind:value>. We need it in addition to $.init_select
// because the select value is not reflected as an attribute, so the
// mutation observer wouldn't notice.
b.call('$.select_option', node_id, value)
])
: synthetic
? assignment
: set_value_assignment
);
};
if (has_state) {
const id = b.id(state.scope.generate(`${node_id.name}_value`));
@ -736,12 +772,49 @@ function build_element_special_value_attribute(
const init = element === 'option' ? b.object([]) : undefined;
state.init.push(b.var(id, init));
state.update.push(b.if(b.binary('!==', id, b.assignment('=', id, value)), b.block([update])));
// the guard already evaluated `value` into `id`, so read that back rather than
// evaluating the same expression (and its signal reads) a second time
state.update.push(
b.if(b.binary('!==', id, b.assignment('=', id, value)), b.block([build_update(id)]))
);
} else {
state.init.push(update);
state.init.push(build_update(value));
}
}
/**
* `<p>{text}</p>` and friends produce `var x = $.child(p, true); $.reset(p);`. That pair is
* by far the most common shape in compiled output, and `$.only_child` does both, so fold the
* two together when the `$.child(...)` is the last thing we emitted for this element.
* @param {Statement[]} init
* @param {Expression} node_id
* @returns {boolean} whether the reset was folded in
*/
function fold_reset_into_child(init, node_id) {
const last = init.at(-1);
if (
node_id?.type !== 'Identifier' ||
last?.type !== 'VariableDeclaration' ||
last.declarations.length !== 1
) {
return false;
}
if (is_select_with_value) {
state.init.push(b.stmt(b.call('$.init_select', node_id)));
const call = last.declarations[0].init;
if (
call?.type !== 'CallExpression' ||
call.callee.type !== 'Identifier' ||
call.callee.name !== '$.child' ||
call.arguments[0]?.type !== 'Identifier' ||
call.arguments[0].name !== node_id.name
) {
return false;
}
call.callee = b.id('$.only_child');
return true;
}

@ -4,6 +4,7 @@
import { dev, locator } from '../../../../state.js';
import { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { determine_namespace_for_children } from '../../utils.js';
import {
build_attribute_value,
@ -147,7 +148,7 @@ export function SvelteElement(node, context) {
'$.async',
context.state.node,
node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0,
has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow(
has_await ? [context.state.node, b.id('$$tag')] : [context.state.node],
b.block(statements)

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

Loading…
Cancel
Save