Merge branch 'main' into fix/a11y-click-events-table-row

pull/17990/head
Razin Shafayet 1 month ago committed by GitHub
commit 4e76efacb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,70 @@
---
name: performance-investigation
description: Investigate performance regressions and find opportunities for optimization
---
## Quick start
1. Start from a branch you want to measure (for example `foo`).
2. Run:
```sh
pnpm bench:compare main foo
```
If you pass one branch, `bench:compare` automatically compares it to `main`.
## Where outputs go
- Summary report: `benchmarking/compare/.results/report.txt`
- Raw benchmark numbers:
- `benchmarking/compare/.results/main.json`
- `benchmarking/compare/.results/<your-branch>.json`
- CPU profiles (per benchmark, per branch):
- `benchmarking/compare/.profiles/main/*.cpuprofile`
- `benchmarking/compare/.profiles/main/*.md`
- `benchmarking/compare/.profiles/<your-branch>/*.cpuprofile`
- `benchmarking/compare/.profiles/<your-branch>/*.md`
The `.md` files are generated summaries of the CPU profile and are usually the fastest way to inspect hotspots.
## Suggested investigation flow
1. Open `benchmarking/compare/.results/report.txt` and identify largest regressions first.
2. For each high-delta benchmark, compare:
- `benchmarking/compare/.profiles/main/<benchmark>.md`
- `benchmarking/compare/.profiles/<branch>/<benchmark>.md`
3. Look for changes in self/inclusive hotspot share in runtime internals (`runtime.js`, `reactivity/batch.js`, `reactivity/deriveds.js`, `reactivity/sources.js`).
4. Make one optimization change at a time, then re-run targeted benches before re-running full compare.
## Fast benchmark loops
Run only selected reactivity benchmarks by substring:
```sh
pnpm bench kairo_mux kairo_deep kairo_broad kairo_triangle
pnpm bench repeated_deps sbench_create_signals mol_owned
```
## Tests to run after perf changes
Runtime reactivity regressions are most likely in runes runtime tests:
```sh
pnpm test runtime-runes
```
## Helpful script
For quick cpuprofile hotspot deltas between two branches:
```sh
node benchmarking/compare/profile-diff.mjs kairo_mux_owned main foo
```
This prints top function sample-share deltas for the selected benchmark.
## Practical gotchas
- `bench:compare` checks out branches while running. Avoid uncommitted changes (or stash them) so branch switching is safe.
- Each `bench:compare` run rewrites `benchmarking/compare/.results` and `benchmarking/compare/.profiles`.

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: prevent hydration error on async `{@html ...}`

@ -4,7 +4,6 @@ root = true
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true

@ -50,7 +50,7 @@ jobs:
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@v4.3.0
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- uses: actions/setup-node@v6
with:
node-version: 24

@ -33,7 +33,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
@ -49,7 +49,7 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
with:
node-version: 22
@ -66,7 +66,7 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
with:
node-version: 24
@ -83,7 +83,7 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
with:
node-version: 24
@ -104,10 +104,10 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
with:
node-version: 18
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm bench

@ -35,7 +35,7 @@ jobs:
# For push, fall back to the push SHA.
ref: ${{ github.event.pull_request.head.sha || inputs.sha || github.sha }}
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- uses: actions/setup-node@v6
with:
node-version: 22.x

@ -27,7 +27,7 @@ jobs:
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@v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
@ -42,7 +42,7 @@ jobs:
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1
with:
version: pnpm changeset:version
publish: pnpm changeset:publish

2
.gitignore vendored

@ -23,4 +23,6 @@ coverage
tmp
benchmarking/.profiles
benchmarking/compare/.results
benchmarking/compare/.profiles

@ -0,0 +1,9 @@
# Svelte Coding Agent Guide
This guide is for AI coding agents working in the Svelte monorepo.
**Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here.
## Quick Reference
If asked to do a performance investigation, use the `performance-investigation` skill.

@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import 'svelte/internal/flags/async';
import {
sbench_create_0to1,
sbench_create_1000to1,

@ -0,0 +1,32 @@
import assert from 'node:assert';
import * as $ from 'svelte/internal/client';
export default () => {
const a = $.state(1);
const b = $.state(2);
let total = 0;
const destroy = $.effect_root(() => {
for (let i = 0; i < 1000; i += 1) {
$.render_effect(() => {
total += $.get(a);
});
}
$.render_effect(() => {
total += $.get(b);
});
});
return {
destroy,
run() {
for (let i = 0; i < 5; i++) {
total = 0;
$.flush(() => $.set(b, i));
assert.equal(total, i);
}
}
};
};

@ -0,0 +1,81 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
export function generate_report(outdir) {
const result_files = fs
.readdirSync(outdir)
.filter((file) => file.endsWith('.json'))
.sort((a, b) => a.localeCompare(b));
const branches = result_files.map((file) => file.slice(0, -5));
const results = result_files.map((file) =>
JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8'))
);
if (results.length === 0) {
console.error(`No result files found in ${outdir}`);
process.exit(1);
}
const report_file = path.join(outdir, 'report.txt');
fs.writeFileSync(report_file, '');
const write = (str) => {
fs.appendFileSync(report_file, str + '\n');
console.log(str);
};
for (let i = 0; i < branches.length; i += 1) {
write(`${char(i)}: ${branches[i]}`);
}
write('');
for (let i = 0; i < results[0].length; i += 1) {
write(`${results[0][i].benchmark}`);
for (const metric of ['time', 'gc_time']) {
const times = results.map((result) => +result[i][metric]);
let min = Infinity;
let max = -Infinity;
let min_index = -1;
for (let b = 0; b < times.length; b += 1) {
const time = times[b];
if (time < min) {
min = time;
min_index = b;
}
if (time > max) {
max = time;
}
}
if (min !== 0) {
write(` ${metric}: fastest is ${char(min_index)} (${branches[min_index]})`);
times.forEach((time, b) => {
const SIZE = 20;
const n = Math.round(SIZE * (time / max));
write(` ${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`);
});
}
}
write('');
}
}
function char(i) {
return String.fromCharCode(97 + i);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const outdir = path.resolve(process.argv[1], '../.results');
generate_report(outdir);
}

@ -2,6 +2,8 @@ import fs from 'node:fs';
import path from 'node:path';
import { execSync, fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { safe } from '../utils.js';
import { generate_report } from './generate-report.js';
// if (execSync('git status --porcelain').toString().trim()) {
// console.error('Working directory is not clean');
@ -12,39 +14,61 @@ const filename = fileURLToPath(import.meta.url);
const runner = path.resolve(filename, '../runner.js');
const outdir = path.resolve(filename, '../.results');
if (fs.existsSync(outdir)) fs.rmSync(outdir, { recursive: true });
fs.mkdirSync(outdir);
fs.mkdirSync(outdir, { recursive: true });
const branches = [];
const requested_branches = [];
let PROFILE_DIR = path.resolve(filename, '../.profiles');
fs.mkdirSync(PROFILE_DIR, { recursive: true });
for (const arg of process.argv.slice(2)) {
if (arg.startsWith('--')) continue;
if (arg === filename) continue;
branches.push(arg);
requested_branches.push(arg);
}
if (branches.length === 0) {
branches.push(
if (requested_branches.length === 0) {
requested_branches.push(
execSync('git symbolic-ref --short -q HEAD || git rev-parse --short HEAD').toString().trim()
);
}
if (branches.length === 1) {
branches.push('main');
const original_ref = execSync('git symbolic-ref --short -q HEAD || git rev-parse --short HEAD')
.toString()
.trim();
if (
requested_branches.length === 1 &&
!requested_branches.includes('main') &&
!fs.existsSync(`${outdir}/main.json`)
) {
requested_branches.push('main');
}
process.on('exit', () => {
execSync(`git checkout ${branches[0]}`);
execSync(`git checkout ${original_ref}`);
});
for (const branch of branches) {
for (const branch of requested_branches) {
console.group(`Benchmarking ${branch}`);
const branch_profile_dir = `${PROFILE_DIR}/${safe(branch)}`;
if (fs.existsSync(branch_profile_dir))
fs.rmSync(branch_profile_dir, { recursive: true, force: true });
const branch_result_file = `${outdir}/${branch}.json`;
if (fs.existsSync(branch_result_file)) fs.rmSync(branch_result_file, { force: true });
execSync(`git checkout ${branch}`);
await new Promise((fulfil, reject) => {
const child = fork(runner);
const child = fork(runner, [], {
env: {
...process.env,
BENCH_PROFILE_DIR: branch_profile_dir
}
});
child.on('message', (results) => {
fs.writeFileSync(`${outdir}/${branch}.json`, JSON.stringify(results, null, ' '));
@ -57,47 +81,8 @@ for (const branch of branches) {
console.groupEnd();
}
const results = branches.map((branch) => {
return JSON.parse(fs.readFileSync(`${outdir}/${branch}.json`, 'utf-8'));
});
for (let i = 0; i < results[0].length; i += 1) {
console.group(`${results[0][i].benchmark}`);
for (const metric of ['time', 'gc_time']) {
const times = results.map((result) => +result[i][metric]);
let min = Infinity;
let max = -Infinity;
let min_index = -1;
for (let b = 0; b < times.length; b += 1) {
const time = times[b];
if (time < min) {
min = time;
min_index = b;
}
if (time > max) {
max = time;
}
}
if (min !== 0) {
console.group(`${metric}: fastest is ${char(min_index)} (${branches[min_index]})`);
times.forEach((time, b) => {
const SIZE = 20;
const n = Math.round(SIZE * (time / max));
console.log(`${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`);
});
console.groupEnd();
}
}
console.groupEnd();
if (PROFILE_DIR !== null) {
console.log(`\nCPU profiles written to ${PROFILE_DIR}`);
}
function char(i) {
return String.fromCharCode(97 + i);
}
generate_report(outdir);

@ -0,0 +1,83 @@
import fs from 'node:fs';
import path from 'node:path';
const [benchmark, baseBranch = 'main', candidateBranch] = process.argv.slice(2);
if (!benchmark || !candidateBranch) {
console.error(
'Usage: node benchmarking/compare/profile-diff.mjs <benchmark> <base-branch> <candidate-branch>'
);
process.exit(1);
}
const root = path.resolve('benchmarking/compare/.profiles');
function safe(name) {
return name.replace(/[^a-z0-9._-]+/gi, '_');
}
function read_profile(branch, bench) {
const file = path.join(root, safe(branch), `${bench}.cpuprofile`);
const profile = JSON.parse(fs.readFileSync(file, 'utf8'));
const nodes = Array.isArray(profile.nodes) ? profile.nodes : [];
const samples = Array.isArray(profile.samples) ? profile.samples : [];
const id_to_node = new Map(nodes.map((node) => [node.id, node]));
const self_counts = new Map();
for (const sample of samples) {
if (typeof sample !== 'number') continue;
self_counts.set(sample, (self_counts.get(sample) ?? 0) + 1);
}
const total = samples.length || 1;
const by_fn = new Map();
for (const [id, count] of self_counts) {
const node = id_to_node.get(id);
if (!node || typeof node !== 'object') continue;
const frame = node.callFrame ?? {};
const function_name = frame.functionName || '(anonymous)';
const url = frame.url || '';
const line = typeof frame.lineNumber === 'number' ? frame.lineNumber + 1 : 0;
const label = url
? `${function_name} @ ${url.replace(/^.*packages\//, 'packages/')}:${line}`
: function_name;
by_fn.set(label, (by_fn.get(label) ?? 0) + count);
}
return { by_fn, total };
}
const base = read_profile(baseBranch, benchmark);
const candidate = read_profile(candidateBranch, benchmark);
const keys = new Set([...base.by_fn.keys(), ...candidate.by_fn.keys()]);
const rows = [...keys]
.map((key) => {
const base_pct = ((base.by_fn.get(key) ?? 0) * 100) / base.total;
const candidate_pct = ((candidate.by_fn.get(key) ?? 0) * 100) / candidate.total;
return {
key,
delta: candidate_pct - base_pct,
base_pct,
candidate_pct
};
})
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
.slice(0, 20);
console.log(`Benchmark: ${benchmark}`);
console.log(`Base: ${baseBranch}`);
console.log(`Candidate: ${candidateBranch}`);
console.log('');
for (const row of rows) {
const sign = row.delta >= 0 ? '+' : '';
console.log(
`${sign}${row.delta.toFixed(2).padStart(6)}pp candidate ${row.candidate_pct.toFixed(2).padStart(6)}% base ${row.base_pct.toFixed(2).padStart(6)}% ${row.key}`
);
}

@ -1,12 +1,17 @@
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;
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} `);
results.push({ benchmark: benchmark.label, ...(await benchmark.fn()) });
results.push({
benchmark: benchmark.label,
...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()))
});
process.stderr.write('\x1b[2K\r');
}

@ -1,10 +1,13 @@
import * as $ from '../packages/svelte/src/internal/client/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(
@ -50,7 +53,7 @@ try {
console.log('='.repeat(TOTAL_WIDTH));
for (const benchmark of benchmarks) {
const results = await benchmark.fn();
const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
console.log(
pad_right(benchmark.label, COLUMN_WIDTHS[0]) +
pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) +
@ -70,6 +73,10 @@ try {
);
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);

@ -1,4 +1,7 @@
import { performance, PerformanceObserver } from 'node:perf_hooks';
import fs from 'node:fs';
import path from 'node:path';
import inspector from 'node:inspector/promises';
import v8 from 'v8-natives';
// Credit to https://github.com/milomg/js-reactivity-benchmark for the logic for timing + GC tracking.
@ -41,3 +44,290 @@ export async function fastest_test(times, fn) {
return results.reduce((a, b) => (a.time < b.time ? a : b));
}
export function safe(name) {
return name.replace(/[^a-z0-9._-]+/gi, '_');
}
/**
* @param {unknown} value
*/
function format_markdown_value(value) {
if (value === null || value === undefined) return '';
if (Array.isArray(value)) return value.map((item) => String(item)).join(', ');
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
/**
* @param {string} text
*/
function escape_markdown_cell(text) {
return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
}
/**
* @param {string} value
*/
function normalize_profile_url(value) {
if (!value) return '';
if (value.startsWith('file://')) {
try {
const pathname = decodeURIComponent(new URL(value).pathname);
const relative = path.relative(process.cwd(), pathname);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative;
return pathname;
} catch {
return value;
}
}
if (path.isAbsolute(value)) {
const relative = path.relative(process.cwd(), value);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative;
}
return value;
}
/**
* @param {string} function_name
*/
function is_special_runtime_node(function_name) {
return function_name === '(idle)' || function_name === '(garbage collector)';
}
/**
* @param {string} normalized_url
*/
function is_svelte_source_url(normalized_url) {
return normalized_url.startsWith('packages/svelte/');
}
/**
* @param {Record<string, unknown>} profile
*/
function profile_to_markdown(profile) {
/** @type {string[]} */
const lines = ['# CPU profile'];
const metadata = Object.entries(profile).filter(
([key]) => key !== 'nodes' && key !== 'samples' && key !== 'timeDeltas'
);
if (metadata.length > 0) {
lines.push('', '## Metadata', '| Field | Value |', '| --- | --- |');
for (const [key, value] of metadata) {
lines.push(
`| ${escape_markdown_cell(key)} | ${escape_markdown_cell(format_markdown_value(value))} |`
);
}
}
const nodes = Array.isArray(profile.nodes) ? profile.nodes : [];
const samples = Array.isArray(profile.samples) ? profile.samples : [];
const timeDeltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : [];
/** @type {Set<number>} */
const included_node_ids = new Set();
if (nodes.length > 0) {
/** @type {Map<number, Record<string, unknown>>} */
const nodes_by_id = new Map();
/** @type {Map<number, number>} */
const parent_by_id = new Map();
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
if (typeof node.id !== 'number') continue;
nodes_by_id.set(node.id, node);
const children = Array.isArray(node.children) ? node.children : [];
for (const child of children) {
if (typeof child === 'number') {
parent_by_id.set(child, node.id);
}
}
const callFrame =
node.callFrame && typeof node.callFrame === 'object'
? /** @type {Record<string, unknown>} */ (node.callFrame)
: /** @type {Record<string, unknown>} */ ({});
const functionName =
typeof callFrame.functionName === 'string' ? callFrame.functionName : '(anonymous)';
const normalizedUrl =
typeof callFrame.url === 'string' ? normalize_profile_url(callFrame.url) : '';
if (is_special_runtime_node(functionName) || is_svelte_source_url(normalizedUrl)) {
included_node_ids.add(node.id);
}
}
/** @type {Map<number, number>} */
const self_sample_count = new Map();
for (const sample of samples) {
if (typeof sample !== 'number') continue;
if (!included_node_ids.has(sample)) continue;
self_sample_count.set(sample, (self_sample_count.get(sample) ?? 0) + 1);
}
/** @type {Map<number, number>} */
const inclusive_sample_count = new Map();
/** @type {Set<number>} */
const stack = new Set();
/** @param {number} node_id */
const get_inclusive_count = (node_id) => {
const cached = inclusive_sample_count.get(node_id);
if (cached !== undefined) return cached;
if (stack.has(node_id)) return self_sample_count.get(node_id) ?? 0;
stack.add(node_id);
const node = nodes_by_id.get(node_id);
const children = node && Array.isArray(node.children) ? node.children : [];
let total = self_sample_count.get(node_id) ?? 0;
for (const child of children) {
if (typeof child !== 'number') continue;
total += get_inclusive_count(child);
}
stack.delete(node_id);
inclusive_sample_count.set(node_id, total);
return total;
};
for (const node_id of included_node_ids) {
get_inclusive_count(node_id);
}
const total_samples = [...self_sample_count.values()].reduce((sum, count) => sum + count, 0);
if (total_samples > 0) {
const hotspot_rows = [...included_node_ids]
.map((id) => nodes_by_id.get(id))
.filter((node) => !!node)
.map((node) => {
const id = /** @type {number} */ (node.id);
const callFrame =
node.callFrame && typeof node.callFrame === 'object'
? /** @type {Record<string, unknown>} */ (node.callFrame)
: /** @type {Record<string, unknown>} */ ({});
const functionName =
typeof callFrame.functionName === 'string' && callFrame.functionName.length > 0
? callFrame.functionName
: '(anonymous)';
const selfCount = self_sample_count.get(id) ?? 0;
const inclusiveCount = inclusive_sample_count.get(id) ?? selfCount;
return { id, functionName, selfCount, inclusiveCount };
})
.filter((row) => row.selfCount > 0 || row.inclusiveCount > 0)
.sort(
(a, b) =>
b.inclusiveCount - a.inclusiveCount ||
b.selfCount - a.selfCount ||
String(a.id).localeCompare(String(b.id))
)
.slice(0, 25);
if (hotspot_rows.length > 0) {
lines.push(
'',
'## Top hotspots',
'| Rank | Node ID | Function | Self samples | Self % | Inclusive samples | Inclusive % |',
'| --- | --- | --- | --- | --- | --- | --- |'
);
for (let i = 0; i < hotspot_rows.length; i += 1) {
const row = hotspot_rows[i];
const selfPct = ((row.selfCount / total_samples) * 100).toFixed(2);
const inclusivePct = ((row.inclusiveCount / total_samples) * 100).toFixed(2);
lines.push(
`| ${i + 1} | ${row.id} | ${escape_markdown_cell(row.functionName)} | ${row.selfCount} | ${selfPct}% | ${row.inclusiveCount} | ${inclusivePct}% |`
);
}
}
}
lines.push(
'',
'## Nodes',
'| ID | Parent ID | Function | URL | Line | Column | Hit count | Children | Deopt reason |',
'| --- | --- | --- | --- | --- | --- | --- | --- | --- |'
);
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
if (typeof node.id !== 'number') continue;
if (!included_node_ids.has(node.id)) continue;
const callFrame =
node.callFrame && typeof node.callFrame === 'object'
? /** @type {Record<string, unknown>} */ (node.callFrame)
: /** @type {Record<string, unknown>} */ ({});
const id = typeof node.id === 'number' ? node.id : '';
const parentId =
typeof id === 'number' && included_node_ids.has(parent_by_id.get(id) ?? NaN)
? parent_by_id.get(id) ?? ''
: '';
const functionName =
typeof callFrame.functionName === 'string' && callFrame.functionName.length > 0
? callFrame.functionName
: '(anonymous)';
const url = typeof callFrame.url === 'string' ? normalize_profile_url(callFrame.url) : '';
const lineNumber =
typeof callFrame.lineNumber === 'number' ? String(callFrame.lineNumber + 1) : '';
const columnNumber =
typeof callFrame.columnNumber === 'number' ? String(callFrame.columnNumber + 1) : '';
const hitCount = typeof node.hitCount === 'number' ? node.hitCount : '';
const children = Array.isArray(node.children)
? node.children
.filter((child) => typeof child === 'number' && included_node_ids.has(child))
.join(', ')
: '';
const deoptReason = typeof node.deoptReason === 'string' ? node.deoptReason : '';
lines.push(
`| ${escape_markdown_cell(String(id))} | ${escape_markdown_cell(String(parentId))} | ${escape_markdown_cell(functionName)} | ${escape_markdown_cell(url)} | ${escape_markdown_cell(lineNumber)} | ${escape_markdown_cell(columnNumber)} | ${escape_markdown_cell(String(hitCount))} | ${escape_markdown_cell(children)} | ${escape_markdown_cell(deoptReason)} |`
);
}
}
return `${lines.join('\n')}\n`;
}
/**
* @template T
* @param {string | null} profile_dir
* @param {string} profile_name
* @param {() => T | Promise<T>} fn
* @returns {Promise<T>}
*/
export async function with_cpu_profile(profile_dir, profile_name, fn) {
if (profile_dir === null) {
return await fn();
}
fs.mkdirSync(profile_dir, { recursive: true });
const session = new inspector.Session();
session.connect();
await session.post('Profiler.enable');
await session.post('Profiler.start');
try {
return await fn();
} finally {
const { profile } = /** @type {{ profile: object }} */ (await session.post('Profiler.stop'));
const safe_profile_name = safe(profile_name);
const profile_file = path.join(profile_dir, `${safe_profile_name}.cpuprofile`);
const markdown_file = path.join(profile_dir, `${safe_profile_name}.md`);
fs.writeFileSync(profile_file, JSON.stringify(profile));
fs.writeFileSync(
markdown_file,
profile_to_markdown(/** @type {Record<string, unknown>} */ (profile))
);
session.disconnect();
}
}

@ -42,3 +42,9 @@ even over `!important` properties:
<div style:color="red" style="color: blue">This will be red</div>
<div style:color="red" style="color: blue !important">This will still be red</div>
```
You can set CSS custom properties:
```svelte
<div style:--columns={columns}>...</div>
```

@ -40,22 +40,9 @@ If you want to use one of these features, you need to setup up a `script` prepro
To use non-type-only TypeScript features within Svelte components, you need to add a preprocessor that will turn TypeScript into JavaScript.
```ts
/// file: svelte.config.js
// @noErrors
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
### Using Vite
const config = {
// Note the additional `{ script: true }`
preprocess: vitePreprocess({ script: true })
};
export default config;
```
### Using SvelteKit or Vite
The easiest way to get started is scaffolding a new SvelteKit project by typing `npx sv create`, following the prompts and choosing the TypeScript option.
If you're using SvelteKit, or Vite _without_ SvelteKit, you can use `vitePreprocess` from `@sveltejs/vite-plugin-svelte` in your config file:
```ts
/// file: svelte.config.js
@ -63,19 +50,16 @@ The easiest way to get started is scaffolding a new SvelteKit project by typing
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess()
// Note the additional `{ script: true }`
preprocess: vitePreprocess({ script: true })
};
export default config;
```
If you don't need or want all the features SvelteKit has to offer, you can scaffold a Svelte-flavoured Vite project instead by typing `npm create vite@latest` and selecting the `svelte-ts` option.
In both cases, a `svelte.config.js` with `vitePreprocess` will be added. Vite/SvelteKit will read from this config file.
### Other build tools
### Using other build tools
If you're using tools like Rollup or Webpack instead, install their respective Svelte plugins. For Rollup that's [rollup-plugin-svelte](https://github.com/sveltejs/rollup-plugin-svelte) and for Webpack that's [svelte-loader](https://github.com/sveltejs/svelte-loader). For both, you need to install `typescript` and `svelte-preprocess` and add the preprocessor to the plugin config (see the respective READMEs for more info).
If you're using tools like Rollup (via [rollup-plugin-svelte](https://github.com/sveltejs/rollup-plugin-svelte)) or Webpack (via [svelte-loader](https://github.com/sveltejs/svelte-loader)) instead, install `typescript` and `svelte-preprocess` and add the preprocessor to the plugin config. See the respective plugin READMEs for more info.
> [!NOTE] If you're starting a new project, we recommend using SvelteKit or Vite instead
@ -85,7 +69,7 @@ When using TypeScript, make sure your `tsconfig.json` is setup correctly.
- Use a [`target`](https://www.typescriptlang.org/tsconfig/#target) of at least `ES2015` so classes are not compiled to functions
- Set [`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax) to `true` so that imports are left as-is
- Set [`isolatedModules`](https://www.typescriptlang.org/tsconfig/#isolatedModules) to `true` so that each file is looked at in isolation. TypeScript has a few features which require cross-file analysis and compilation, which the Svelte compiler and tooling like Vite don't do.
- Set [`isolatedModules`](https://www.typescriptlang.org/tsconfig/#isolatedModules) to `true` so that each file is looked at in isolation. TypeScript has a few features which require cross-file analysis and compilation, which the Svelte compiler and tooling like Vite don't do.
## Typing `$props`

@ -396,7 +396,7 @@ Invalid selector
### declaration_duplicate_module_import
```
Cannot declare a variable with the same name as an import inside `<script module>`
Cannot declare a variable with the same name as an import from `<script module>`
```
### derived_invalid_export

@ -69,6 +69,12 @@ Cause:
`csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
```
### invalid_id_prefix
```
The `idPrefix` option cannot include `--`.
```
### lifecycle_function_unavailable
```

@ -1,5 +1,41 @@
# svelte
## 5.55.2
### Patch Changes
- fix: invalidate `@const` tags based on visible references in legacy mode ([#18041](https://github.com/sveltejs/svelte/pull/18041))
- fix: handle parens in template expressions more robustly ([#18075](https://github.com/sveltejs/svelte/pull/18075))
- fix: disallow `--` in `idPrefix` ([#18038](https://github.com/sveltejs/svelte/pull/18038))
- fix: correct types for `ontoggle` on `<details>` elements ([#18063](https://github.com/sveltejs/svelte/pull/18063))
- fix: don't override `$destroy/set/on` instance methods in dev mode ([#18034](https://github.com/sveltejs/svelte/pull/18034))
- fix: unskip branches of earlier batches after commit ([#18048](https://github.com/sveltejs/svelte/pull/18048))
- fix: never set derived.v inside fork ([#18037](https://github.com/sveltejs/svelte/pull/18037))
- fix: skip rebase logic in non-async mode ([#18040](https://github.com/sveltejs/svelte/pull/18040))
- fix: don't reset status of uninitialized deriveds ([#18054](https://github.com/sveltejs/svelte/pull/18054))
## 5.55.1
### Patch Changes
- fix: correctly handle bindings on the server ([#18009](https://github.com/sveltejs/svelte/pull/18009))
- fix: prevent hydration error on async `{@html ...}` ([#17999](https://github.com/sveltejs/svelte/pull/17999))
- fix: cleanup `superTypeParameters` in `ClassDeclarations`/`ClassExpression` ([#18015](https://github.com/sveltejs/svelte/pull/18015))
- fix: improve duplicate module import error message ([#18016](https://github.com/sveltejs/svelte/pull/18016))
- fix: reschedule new effects in prior batches ([#18021](https://github.com/sveltejs/svelte/pull/18021))
## 5.55.0
### Minor Changes

@ -952,9 +952,9 @@ export interface HTMLDetailsAttributes extends HTMLAttributes<HTMLDetailsElement
'bind:open'?: boolean | undefined | null;
'on:toggle'?: EventHandler<Event, HTMLDetailsElement> | undefined | null;
ontoggle?: EventHandler<Event, HTMLDetailsElement> | undefined | null;
ontogglecapture?: EventHandler<Event, HTMLDetailsElement> | undefined | null;
'on:toggle'?: ToggleEventHandler<HTMLDetailsElement> | undefined | null;
ontoggle?: ToggleEventHandler<HTMLDetailsElement> | undefined | null;
ontogglecapture?: ToggleEventHandler<HTMLDetailsElement> | undefined | null;
}
export interface HTMLDelAttributes extends HTMLAttributes<HTMLModElement> {

@ -16,7 +16,7 @@
## declaration_duplicate_module_import
> Cannot declare a variable with the same name as an import inside `<script module>`
> Cannot declare a variable with the same name as an import from `<script module>`
## derived_invalid_export

@ -53,6 +53,10 @@ This error occurs when using `hydratable` multiple times with the same key. To a
> `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
## invalid_id_prefix
> The `idPrefix` option cannot include `--`.
## lifecycle_function_unavailable
> `%name%(...)` is not available on the server

@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
"version": "5.55.0",
"version": "5.55.2",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
@ -178,7 +178,7 @@
"clsx": "^2.1.1",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
"esrap": "^2.2.2",
"esrap": "^2.2.4",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",

@ -117,12 +117,12 @@ export function declaration_duplicate(node, name) {
}
/**
* Cannot declare a variable with the same name as an import inside `<script module>`
* Cannot declare a variable with the same name as an import from `<script module>`
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function declaration_duplicate_module_import(node) {
e(node, 'declaration_duplicate_module_import', `Cannot declare a variable with the same name as an import inside \`<script module>\`\nhttps://svelte.dev/e/declaration_duplicate_module_import`);
e(node, 'declaration_duplicate_module_import', `Cannot declare a variable with the same name as an import from \`<script module>\`\nhttps://svelte.dev/e/declaration_duplicate_module_import`);
}
/**

@ -1,10 +1,13 @@
/** @import { Comment, Program } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Parser } from './index.js' */
import * as acorn from 'acorn';
import { walk } from 'zimmerframe';
import { tsPlugin } from '@sveltejs/acorn-typescript';
import * as e from '../../errors.js';
const ParserWithTS = acorn.Parser.extend(tsPlugin());
const JSParser = acorn.Parser;
const TSParser = JSParser.extend(tsPlugin());
/**
* @typedef {Comment & {
@ -20,15 +23,15 @@ const ParserWithTS = acorn.Parser.extend(tsPlugin());
* @param {boolean} [is_script]
*/
export function parse(source, comments, typescript, is_script) {
const parser = typescript ? ParserWithTS : acorn.Parser;
const acorn = typescript ? TSParser : JSParser;
const { onComment, add_comments } = get_comment_handlers(
source,
/** @type {CommentWithLocation[]} */ (comments)
);
// @ts-ignore
const parse_statement = parser.prototype.parseStatement;
// @ts-expect-error
const parse_statement = acorn.prototype.parseStatement;
// If we're dealing with a <script> then it might contain an export
// for something that doesn't exist directly inside but is inside the
@ -36,7 +39,7 @@ export function parse(source, comments, typescript, is_script) {
// an error in these cases
if (is_script) {
// @ts-ignore
parser.prototype.parseStatement = function (...args) {
acorn.prototype.parseStatement = function (...args) {
const v = parse_statement.call(this, ...args);
// @ts-ignore
this.undefinedExports = {};
@ -44,53 +47,77 @@ export function parse(source, comments, typescript, is_script) {
};
}
let ast;
try {
ast = parser.parse(source, {
const ast = acorn.parse(source, {
onComment,
sourceType: 'module',
ecmaVersion: 16,
locations: true
});
add_comments(ast);
return /** @type {Program} */ (ast);
} catch (err) {
// TODO the `return` in necessary for TS<7 due to a bug; otherwise
// the `finally` block is regarded as unreachable
return handle_parse_error(err);
} finally {
if (is_script) {
// @ts-ignore
parser.prototype.parseStatement = parse_statement;
// @ts-expect-error
acorn.prototype.parseStatement = parse_statement;
}
}
add_comments(ast);
return /** @type {Program} */ (ast);
}
/**
* @param {Parser} parser
* @param {string} source
* @param {Comment[]} comments
* @param {boolean} typescript
* @param {number} index
* @returns {acorn.Expression & { leadingComments?: CommentWithLocation[]; trailingComments?: CommentWithLocation[]; }}
*/
export function parse_expression_at(source, comments, typescript, index) {
const parser = typescript ? ParserWithTS : acorn.Parser;
export function parse_expression_at(parser, source, index) {
const acorn = parser.ts ? TSParser : JSParser;
const { onComment, add_comments } = get_comment_handlers(
source,
/** @type {CommentWithLocation[]} */ (comments),
index
);
const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index);
const ast = parser.parseExpressionAt(source, index, {
onComment,
sourceType: 'module',
ecmaVersion: 16,
locations: true
});
try {
const ast = acorn.parseExpressionAt(source, index, {
onComment,
sourceType: 'module',
ecmaVersion: 16,
locations: true,
preserveParens: true
});
add_comments(ast);
return ast;
} catch (e) {
handle_parse_error(e);
}
}
const regex_position_indicator = / \(\d+:\d+\)$/;
add_comments(ast);
/**
* @param {any} err
* @returns {never}
*/
function handle_parse_error(err) {
e.js_parse_error(err.pos, err.message.replace(regex_position_indicator, ''));
}
return ast;
/**
* @param {acorn.Expression} node
* @returns {acorn.Expression}
*/
export function remove_parens(node) {
return walk(node, null, {
ParenthesizedExpression(node, context) {
return context.visit(node.expression);
}
});
}
/**

@ -11,8 +11,6 @@ import { is_reserved } from '../../../utils.js';
import { disallow_children } from '../2-analyze/visitors/shared/special-element.js';
import * as state from '../../state.js';
const regex_position_indicator = / \(\d+:\d+\)$/;
/** @param {number} cc */
function is_whitespace(cc) {
// fast path for common whitespace
@ -175,14 +173,6 @@ export class Parser {
return this.stack[this.stack.length - 1];
}
/**
* @param {any} err
* @returns {never}
*/
acorn_error(err) {
e.js_parse_error(err.pos, err.message.replace(regex_position_indicator, ''));
}
/**
* @param {string} str
* @param {boolean} required

@ -1,7 +1,7 @@
/** @import { Pattern } from 'estree' */
/** @import { Parser } from '../index.js' */
import { match_bracket } from '../utils/bracket.js';
import { parse_expression_at } from '../acorn.js';
import { parse_expression_at, remove_parens } from '../acorn.js';
import { regex_not_newline_characters } from '../../patterns.js';
import * as e from '../../../errors.js';
@ -35,38 +35,32 @@ export default function read_pattern(parser) {
const pattern_string = parser.template.slice(start, i);
try {
// 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);
const expression = /** @type {any} */ (
parse_expression_at(
`${space_with_newline}(${pattern_string} = 1)`,
parser.root.comments,
parser.ts,
start - 1
)
).left;
expression.typeAnnotation = read_type_annotation(parser);
if (expression.typeAnnotation) {
expression.end = expression.typeAnnotation.end;
}
return expression;
} catch (error) {
parser.acorn_error(error);
// the length of the `space_with_newline` has to be start - 1
// because we added a `(` in front of the pattern_string,
// which shifted the entire string to right by 1
// so we offset it by removing 1 character in the `space_with_newline`
// to achieve that, we remove the 1st space encountered,
// so it will not affect the `column` of the node
let space_with_newline = parser.template
.slice(0, start)
.replace(regex_not_newline_characters, ' ');
const first_space = space_with_newline.indexOf(' ');
space_with_newline =
space_with_newline.slice(0, first_space) + space_with_newline.slice(first_space + 1);
/** @type {any} */
let expression = remove_parens(
parse_expression_at(parser, `${space_with_newline}(${pattern_string} = 1)`, start - 1)
);
expression = expression.left;
expression.typeAnnotation = read_type_annotation(parser);
if (expression.typeAnnotation) {
expression.end = expression.typeAnnotation.end;
}
return expression;
}
/**
@ -92,13 +86,13 @@ function read_type_annotation(parser) {
// parameters as part of a sequence expression instead, and will then error on optional
// parameters (`?:`). Therefore replace that sequence with something that will not error.
parser.template.slice(parser.index).replace(/\?\s*:/g, ':');
let expression = parse_expression_at(template, parser.root.comments, parser.ts, a);
let expression = remove_parens(parse_expression_at(parser, template, a));
// `foo: bar = baz` gets mangled — fix it
if (expression.type === 'AssignmentExpression') {
let b = expression.right.start;
while (template[b] !== '=') b -= 1;
expression = parse_expression_at(template.slice(0, b), parser.root.comments, parser.ts, a);
expression = remove_parens(parse_expression_at(parser, template.slice(0, b), a));
}
// `array as item: string, index` becomes `string, index`, which is mistaken as a sequence expression - fix that

@ -1,6 +1,6 @@
/** @import { Expression } from 'estree' */
/** @import { Parser } from '../index.js' */
import { parse_expression_at } from '../acorn.js';
import { 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';
@ -34,50 +34,16 @@ export function get_loose_identifier(parser, opening_token) {
*/
export default function read_expression(parser, opening_token, disallow_loose) {
try {
let comment_index = parser.root.comments.length;
const node = parse_expression_at(
parser.template,
parser.root.comments,
parser.ts,
parser.index
);
let num_parens = 0;
let i = parser.root.comments.length;
while (i-- > comment_index) {
const comment = parser.root.comments[i];
if (comment.end < node.start) {
parser.index = comment.end;
break;
}
}
for (let i = parser.index; i < /** @type {number} */ (node.start); i += 1) {
if (parser.template[i] === '(') num_parens += 1;
}
const node = parse_expression_at(parser, parser.template, parser.index);
let index = /** @type {number} */ (node.end);
const last_comment = parser.root.comments.at(-1);
if (last_comment && last_comment.end > index) index = last_comment.end;
while (num_parens > 0) {
const char = parser.template[index];
if (char === ')') {
num_parens -= 1;
} else if (!regex_whitespace.test(char)) {
e.expected_token(index, ')');
}
index += 1;
}
parser.index = index;
return /** @type {Expression} */ (node);
return /** @type {Expression} */ (remove_parens(node));
} catch (err) {
// If we are in an each loop we need the error to be thrown in cases like
// `as { y = z }` so we still throw and handle the error there
@ -88,6 +54,6 @@ export default function read_expression(parser, opening_token, disallow_loose) {
}
}
parser.acorn_error(err);
throw err;
}
}

@ -31,14 +31,7 @@ export function read_script(parser, start, attributes) {
parser.template.slice(0, script_start).replace(regex_not_newline_characters, ' ') + data;
parser.read(regex_starts_with_closing_script_tag);
/** @type {Program} */
let ast;
try {
ast = acorn.parse(source, parser.root.comments, parser.ts, true);
} catch (err) {
parser.acorn_error(err);
}
const ast = acorn.parse(source, parser.root.comments, parser.ts, true);
ast.start = script_start;

@ -138,11 +138,13 @@ const visitors = {
delete node.abstract;
delete node.implements;
delete node.superTypeArguments;
delete node.superTypeParameters;
return context.next();
},
ClassExpression(node, context) {
delete node.implements;
delete node.superTypeArguments;
delete node.superTypeParameters;
return context.next();
},
MethodDefinition(node, context) {

@ -392,12 +392,7 @@ function open(parser) {
let function_expression = matched
? /** @type {ArrowFunctionExpression} */ (
parse_expression_at(
prelude + `${params} => {}`,
parser.root.comments,
parser.ts,
params_start
)
parse_expression_at(parser, prelude + `${params} => {}`, params_start)
)
: { params: [] };

@ -10,7 +10,7 @@ export function visit_function(node, context) {
for (const [name] of context.state.scope.references) {
const binding = context.state.scope.get(name);
if (binding && binding.scope.function_depth < context.state.scope.function_depth) {
if (binding && binding.scope !== context.state.scope) {
context.state.expression.references.add(binding);
}
}

@ -161,7 +161,6 @@ export function ensure_no_module_import_conflict(node, state) {
state.scope === state.analysis.instance.scope &&
state.analysis.module.scope.get(id.name)?.declaration_kind === 'import'
) {
// TODO fix the message here
e.declaration_duplicate_module_import(node.id);
}
}

@ -352,7 +352,7 @@ export function client_component(analysis, options) {
)
);
} else if (dev) {
component_returned_object.push(b.spread(b.call(b.id('$.legacy_api'))));
component_returned_object.unshift(b.spread(b.call(b.id('$.legacy_api'))));
}
const push_args = [b.id('$$props'), b.literal(analysis.runes)];

@ -42,6 +42,7 @@ import { DEV } from 'esm-env';
import { derived_safe_equal } from '../../reactivity/deriveds.js';
import { current_batch } from '../../reactivity/batch.js';
import * as e from '../../errors.js';
import { tag } from '../../dev/tracing.js';
// When making substantive changes to this file, validate them with the each block stress test:
// https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b
@ -205,6 +206,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
return is_array(collection) ? collection : collection == null ? [] : array_from(collection);
});
if (DEV) {
tag(each_array, '{#each ...}');
}
/** @type {V[]} */
var array;

@ -145,6 +145,12 @@ export class Batch {
*/
#roots = [];
/**
* Effects created while this batch was active.
* @type {Effect[]}
*/
#new_effects = [];
/**
* Deferred effects (which run after async work has completed) that are DIRTY
* @type {Set<Effect>}
@ -166,6 +172,12 @@ export class Batch {
*/
#skipped_branches = new Map();
/**
* Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing
* @type {Set<Effect>}
*/
#unskipped_branches = new Set();
is_fork = false;
#decrement_queued = false;
@ -209,28 +221,31 @@ export class Batch {
if (!this.#skipped_branches.has(effect)) {
this.#skipped_branches.set(effect, { d: [], m: [] });
}
this.#unskipped_branches.delete(effect);
}
/**
* Remove an effect from the #skipped_branches map and reschedule
* any tracked dirty/maybe_dirty child effects
* @param {Effect} effect
* @param {(e: Effect) => void} callback
*/
unskip_effect(effect) {
unskip_effect(effect, callback = (e) => this.schedule(e)) {
var tracked = this.#skipped_branches.get(effect);
if (tracked) {
this.#skipped_branches.delete(effect);
for (var e of tracked.d) {
set_signal_status(e, DIRTY);
this.schedule(e);
callback(e);
}
for (e of tracked.m) {
set_signal_status(e, MAYBE_DIRTY);
this.schedule(e);
callback(e);
}
}
this.#unskipped_branches.add(effect);
}
#process() {
@ -343,7 +358,9 @@ export class Batch {
next_batch.#process();
}
if (!batches.has(this)) {
// In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode
// TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed
if (async_mode_flag && !batches.has(this)) {
this.#commit();
}
}
@ -413,18 +430,22 @@ export class Batch {
* Associate a change to a given source with the current
* batch, noting its previous and current values
* @param {Value} source
* @param {any} old_value
* @param {any} value
* @param {boolean} [is_derived]
*/
capture(source, old_value, is_derived = false) {
if (old_value !== UNINITIALIZED && !this.previous.has(source)) {
this.previous.set(source, old_value);
capture(source, value, is_derived = false) {
if (source.v !== UNINITIALIZED && !this.previous.has(source)) {
this.previous.set(source, source.v);
}
// Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get`
if ((source.f & ERROR_VALUE) === 0) {
this.current.set(source, [source.v, is_derived]);
batch_values?.set(source, source.v);
this.current.set(source, [value, is_derived]);
batch_values?.set(source, value);
}
if (!this.is_fork) {
source.v = value;
}
}
@ -472,6 +493,13 @@ export class Batch {
batches.delete(this);
}
/**
* @param {Effect} effect
*/
register_created_effect(effect) {
this.#new_effects.push(effect);
}
#commit() {
// If there are other pending batches, they now need to be 'rebased' —
// in other words, we re-run block/async effects with the newly
@ -513,6 +541,19 @@ export class Batch {
invariant(batch.#roots.length === 0, 'Batch has scheduled roots');
}
// A batch was unskipped in a later batch -> tell prior batches to unskip it, too
if (is_earlier) {
for (const unskipped of this.#unskipped_branches) {
batch.unskip_effect(unskipped, (e) => {
if ((e.f & (BLOCK_EFFECT | ASYNC)) !== 0) {
batch.schedule(e);
} else {
batch.#defer_effects([e]);
}
});
}
}
batch.activate();
/** @type {Set<Value>} */
@ -525,6 +566,25 @@ export class Batch {
mark_effects(source, others, marked, checked);
}
checked = new Map();
var current_unequal = [...batch.current.keys()].filter((c) =>
this.current.has(c) ? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c : true
);
for (const effect of this.#new_effects) {
if (
(effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 &&
depends_on(effect, current_unequal, checked)
) {
if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) {
set_signal_status(effect, DIRTY);
batch.schedule(effect);
} else {
batch.#dirty_effects.add(effect);
}
}
}
// Only apply and traverse when we know we triggered async work with marking the effects
if (batch.#roots.length > 0) {
batch.apply();
@ -755,6 +815,7 @@ export class Batch {
}
}
// TODO Svelte@6 think about removing the callback argument.
/**
* Synchronously flush any pending updates.
* Returns void if no callback is provided, otherwise returns the result of calling the callback.
@ -1130,11 +1191,6 @@ export function fork(fn) {
flushSync(fn);
// revert state changes
for (var [source, value] of batch.previous) {
source.v = value;
}
return {
commit: async () => {
if (committed) {

@ -384,7 +384,6 @@ export function execute_derived(derived) {
* @returns {void}
*/
export function update_derived(derived) {
var old_value = derived.v;
var value = execute_derived(derived);
if (!derived.equals(value)) {
@ -395,8 +394,11 @@ export function update_derived(derived) {
// otherwise, the next time we get here after a 'real world' state
// change, `derived.equals` may incorrectly return `true`
if (!current_batch?.is_fork || derived.deps === null) {
derived.v = value;
current_batch?.capture(derived, old_value, true);
if (current_batch !== null) {
current_batch.capture(derived, value, true);
} else {
derived.v = value;
}
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {

@ -42,7 +42,7 @@ import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js';
import { Batch, collected_effects } from './batch.js';
import { Batch, collected_effects, current_batch } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
@ -120,6 +120,8 @@ function create_effect(type, fn) {
effect.component_function = dev_current_component_function;
}
current_batch?.register_created_effect(effect);
/** @type {Effect | null} */
var e = effect;

@ -180,18 +180,10 @@ export function set(source, value, should_proxy = false) {
*/
export function internal_set(source, value, updated_during_traversal = null) {
if (!source.equals(value)) {
var old_value = source.v;
if (is_destroying_effect) {
old_values.set(source, value);
} else {
old_values.set(source, old_value);
}
source.v = value;
old_values.set(source, is_destroying_effect ? value : source.v);
var batch = Batch.ensure();
batch.capture(source, old_value);
batch.capture(source, value);
if (DEV) {
if (tracing_mode_flag || active_effect !== null) {

@ -399,7 +399,14 @@ function remove_reaction(signal, dependency) {
derived.f &= ~WAS_MARKED;
}
update_derived_status(derived);
// In a fork it's possible that a derived is executed and gets reactions, then commits, but is
// never re-executed. This is possible when the derived is only executed once in the context
// of a new branch which happens before fork.commit() runs. In this case, the derived still has
// UNINITIALIZED as its value, and then when it's loosing its reactions we need to ensure it stays
// DIRTY so it is reexecuted once someone wants its value again.
if (derived.v !== UNINITIALIZED) {
update_derived_status(derived);
}
// freeze any effects inside this derived
freeze_derived_effects(derived);

@ -105,6 +105,18 @@ export function invalid_csp() {
throw error;
}
/**
* The `idPrefix` option cannot include `--`.
* @returns {never}
*/
export function invalid_id_prefix() {
const error = new Error(`invalid_id_prefix\nThe \`idPrefix\` option cannot include \`--\`.\nhttps://svelte.dev/e/invalid_id_prefix`);
error.name = 'Svelte error';
throw error;
}
/**
* `%name%(...)` is not available on the server
* @param {string} name

@ -468,10 +468,14 @@ export class Renderer {
}
this.local = other.local;
this.#out = other.#out.map((item) => {
if (item instanceof Renderer) {
item.subsume(item);
this.#out = other.#out.map((item, i) => {
const current = this.#out[i];
if (current instanceof Renderer && item instanceof Renderer) {
current.subsume(item);
return current;
}
return item;
});
this.promise = other.promise;
@ -755,6 +759,10 @@ export class Renderer {
* @returns {Renderer}
*/
static #open_render(mode, component, options) {
if (options.idPrefix?.includes('--')) {
e.invalid_id_prefix();
}
var previous_context = ssr_context;
try {

@ -95,6 +95,10 @@ export class SvelteDate extends Date {
return date_proto[method].apply(this, args);
});
if (DEV) {
tag(d, `SvelteDate.${method}()`);
}
this.#deriveds.set(method, d);
set_active_reaction(reaction);

@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
export const VERSION = '5.55.0';
export const VERSION = '5.55.2';
export const PUBLIC_VERSION = '5';

@ -0,0 +1,61 @@
{
"css": null,
"js": [],
"start": 0,
"end": 11,
"type": "Root",
"fragment": {
"type": "Fragment",
"nodes": [
{
"type": "ExpressionTag",
"start": 0,
"end": 11,
"expression": {
"type": "Literal",
"start": 7,
"end": 9,
"loc": {
"start": {
"line": 1,
"column": 7
},
"end": {
"line": 1,
"column": 9
}
},
"value": 42,
"raw": "42",
"leadingComments": [
{
"type": "Block",
"value": "",
"start": 2,
"end": 6
}
]
}
}
]
},
"options": null,
"comments": [
{
"type": "Block",
"value": "",
"start": 2,
"end": 6,
"loc": {
"start": {
"line": 1,
"column": 2
},
"end": {
"line": 1,
"column": 6
}
}
}
]
}

@ -0,0 +1,34 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `
<input>
<p>hello</p>
<p>hello</p>
`,
ssrHtml: `
<input value="hello">
<p>hello</p>
<p>hello</p>
`,
async test({ assert, target }) {
const [input] = target.querySelectorAll('input');
flushSync(() => {
input.value = 'goodbye';
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
});
assert.htmlEqual(
target.innerHTML,
`
<input>
<p>goodbye</p>
<p>goodbye</p>
`
);
}
});

@ -0,0 +1,15 @@
<svelte:options runes={false} />
<script>
let message = 'hello';
</script>
<input bind:value={message} />
{#if true}
{@const m1 = message}
{@const m2 = (() => m1)()}
<p>{m1}</p>
<p>{m2}</p>
{/if}

@ -0,0 +1,8 @@
<svelte:options customElement={{
tag: "my-inner",
props: { value: { reflect: true }}
}} />
<script>
export let value;
</script>
{value}

@ -0,0 +1,20 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [btn] = target.querySelectorAll('button');
btn.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>inc</button>
<my-inner value="2"></my-inner>
2
`
);
}
});

@ -0,0 +1,12 @@
<script>
import "./Inner.svelte"
let count = 1;
</script>
<button on:click={() => count++}>inc</button>
<!-- updating value prop will cause a flushSync -->
<my-inner value={count}></my-inner>
<!-- updating count will cause an internal_set -->
{#each [count] as row}
{row}
{/each}

@ -0,0 +1,20 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [, toggle] = target.querySelectorAll('button');
toggle?.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>0</button> <button>toggle</button> 0`);
toggle?.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>0</button> <button>toggle</button>`);
toggle?.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>0</button> <button>toggle</button> 0`);
}
});

@ -0,0 +1,13 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
let count = $state(0);
let d_count = $derived(count);
</script>
<button onclick={() => count += 1}>{count}</button> <!-- just here so count is compiled as a source -->
<button onclick={() => fork(() => show = !show).commit()}>toggle</button>
{#if show}
{d_count}
{/if}

@ -0,0 +1,22 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [x, y, resolve, commit] = target.querySelectorAll('button');
const [p] = target.querySelectorAll('p');
y.click();
await tick();
resolve.click();
await tick();
x.click();
await tick();
assert.htmlEqual(p.innerHTML, '1 0');
await tick();
commit.click();
assert.htmlEqual(p.innerHTML, '1 1');
}
});

@ -0,0 +1,21 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let y = $state(0);
let f;
const deferred = [];
function delay(value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<p>{x} {await delay(y)}</p>
<button onclick={() => x += 1}>x</button>
<button onclick={() => f = fork(() => y += 1)}>y (fork)</button>
<button onclick={() => deferred.shift()?.()}>resolve</button>
<button onclick={() => f.commit()}>commit</button>

@ -0,0 +1,7 @@
<script>
let data = $derived(await Promise.resolve('test'));
</script>
<div data-resolved={data ? 'true' : 'false'}>
{data}
</div>

@ -0,0 +1,7 @@
<script>
import Bound from './Bound.svelte';
let open;
</script>
<Bound bind:open />

@ -0,0 +1,10 @@
import { test } from '../../test';
// Tests that renderer.subsume (which is used when bindings are present) works correctly
export default test({
mode: ['hydrate'],
html: '<div data-resolved="true">test</div>',
async test({ assert, warnings }) {
assert.deepEqual(warnings, []);
}
});

@ -0,0 +1,7 @@
<script lang="ts">
import Async from './Async.svelte';
import Binding from './Binding.svelte';
</script>
<Async />
<Binding />

@ -6,11 +6,13 @@ export default test({
ssrHtml: '<p>yep</p>',
async test({ assert, target, variant }) {
async test({ assert, target, variant, warnings }) {
if (variant === 'dom') {
await tick();
}
assert.htmlEqual(target.innerHTML, '<p>yep</p>');
assert.deepEqual(warnings, []); // TODO not quite sure why this isn't populated yet
}
});

@ -0,0 +1,30 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [load, resolve] = target.querySelectorAll('button');
load.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>load</button>
<button>resolve</button>
`
);
resolve.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
search search search search
<button>load</button>
<button>resolve</button>
`
);
}
});

@ -0,0 +1,40 @@
<script>
let query = $state('');
// changing the query results in a new promise with loading initialized to true
const promise = $derived(push(query));
const resolvers = [];
function push(value) {
if (!value) return Promise.resolve(value);
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => {
// before resolving, set loading to false - this makes it run in a different batch
loading = false;
resolve(value);
});
let loading = $state(true);
Object.defineProperty(promise, 'loading', {
get() {
return loading;
}
});
return promise
}
</script>
{query} {await promise}
{#if !promise.loading}
{query}
{/if}
{#if !promise.loading}
{await query}
{/if}
<button onclick={() => query = 'search'}>load</button>
<button onclick={() => resolvers.shift()?.()}>resolve</button>

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target, logs }) {
const [x, y, resolve] = target.querySelectorAll('button');
@ -17,12 +16,20 @@ export default test({
<button>x</button>
<button>y++</button>
<button>resolve</button>
` // if this shows world world - that would also be ok
world
` // if this does not show world - that would also be ok
);
resolve.click();
await tick();
assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
assert.deepEqual(logs, [
'universe',
'world',
'$effect: world',
'$effect: universe',
'$effect: universe'
]);
// assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); // this would also be ok
assert.htmlEqual(
target.innerHTML,
`

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, resolve] = target.querySelectorAll('button');
@ -18,7 +17,13 @@ export default test({
<button>y++</button>
<button>resolve</button>
<hr>
` // if this shows world world "world" world world world "world" - then this would also be ok
world
"world"
world
world
world
"world"
` // if this does not show world "world" world world world "world" - then this would also be ok
);
resolve.click();

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, resolve] = target.querySelectorAll('button');
@ -30,9 +29,17 @@ export default test({
<button>y++</button>
<button>resolve</button>
<hr>
` // if this shows world world "world" world world world "world" - then this would also be ok
world
"world"
world
world
world
"world"
` // if this does not show world "world" world world world "world" - then this would also be ok
);
resolve.click();
await tick();
resolve.click();
await tick();
assert.htmlEqual(

@ -31,4 +31,3 @@
{#if y > 0}
<Child x={await delay2(x)} />
{/if}

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, shift, pop, commit] = target.querySelectorAll('button');
@ -43,6 +42,8 @@ export default test({
await tick();
shift.click();
await tick();
shift.click(); // would be ok to not need this one
await tick();
assert.htmlEqual(
target.innerHTML,
`

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, resolve, commit] = target.querySelectorAll('button');

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // this fails on main, too; skip for now
async test({ assert, target, logs }) {
const [x, y, resolve] = target.querySelectorAll('button');

@ -3,5 +3,7 @@ import { test } from '../../test';
export default test({
skip_no_async: true,
mode: ['hydrate'],
async test() {}
async test({ assert, warnings }) {
assert.deepEqual(warnings, []); // TODO not quite sure why this isn't populated yet
}
});

@ -27,6 +27,11 @@
abstract x(): void;
y() {}
}
class Subclass extends Foo<string> {
constructor(value: string) {
super(value);
}
}
declare const declared_const: number;
declare function declared_fn(): void;

@ -1,7 +1,7 @@
[
{
"code": "declaration_duplicate_module_import",
"message": "Cannot declare a variable with the same name as an import inside `<script module>`",
"message": "Cannot declare a variable with the same name as an import from `<script module>`",
"start": {
"line": 12,
"column": 5

@ -21,7 +21,7 @@
"polka": "^1.0.0-next.25",
"svelte": "workspace:*",
"tinyglobby": "^0.2.12",
"vite": "^7.1.11",
"vite": "^7.3.2",
"vite-plugin-devtools-json": "^1.0.0",
"vite-plugin-inspect": "^11.3.3"
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save