Merge branch 'main' into entangle-batches

entangle-batches-2
Simon Holthausen 2 months ago
commit a360023551
No known key found for this signature in database

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: don't (re)connect deriveds when read inside branch/root effects

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: skip unnecessary derived effect in earlier batch

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: avoid declaration tag warning in event handlers

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: transform computed keys in keyed `{#each}` destructuring patterns

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: chain preprocessor sourcemaps with an empty `sources[0]` instead of dropping them

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: don't treat declaration tags as parts inside each blocks

1
.gitignore vendored

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

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

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

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

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

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

@ -1,18 +1,67 @@
import { fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js'; import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js';
import { with_cpu_profile } from '../utils.js'; import { with_cpu_profile } from '../utils.js';
const results = []; const PROFILE_DIR = process.env.BENCH_PROFILE_DIR ?? null;
const PROFILE_DIR = process.env.BENCH_PROFILE_DIR; const single = process.env.BENCH_SINGLE;
for (let i = 0; i < reactivity_benchmarks.length; i += 1) { if (single) {
const benchmark = reactivity_benchmarks[i]; // 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} `); if (!benchmark) {
results.push({ throw new Error(`Unknown benchmark ${single}`);
benchmark: benchmark.label, }
...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()))
}); const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
process.stderr.write('\x1b[2K\r');
} // 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);
}

@ -1,94 +1,149 @@
import { fork } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as $ from '../packages/svelte/src/internal/client/index.js'; import * as $ from '../packages/svelte/src/internal/client/index.js';
import { reactivity_benchmarks } from './benchmarks/reactivity/index.js'; import { reactivity_benchmarks } from './benchmarks/reactivity/index.js';
import { ssr_benchmarks } from './benchmarks/ssr/index.js'; import { ssr_benchmarks } from './benchmarks/ssr/index.js';
import { with_cpu_profile } from './utils.js'; import { with_cpu_profile } from './utils.js';
// e.g. `pnpm bench kairo` to only run the kairo benchmarks
const filters = process.argv.slice(2);
const PROFILE_DIR = './benchmarking/.profiles'; const PROFILE_DIR = './benchmarking/.profiles';
const suites = [ const single = process.env.BENCH_SINGLE;
{
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;
let total_time = 0; if (single) {
let total_gc_time = 0; // child mode — run a single benchmark and report the result to the parent
const benchmark = [...reactivity_benchmarks, ...ssr_benchmarks].find((b) => b.label === single);
$.push({}, true); if (!benchmark) {
throw new Error(`Unknown benchmark ${single}`);
}
try { $.push({}, true);
for (const { benchmarks, name } of suites) {
let suite_time = 0; const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
let suite_gc_time = 0;
$.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 = [
{
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`); if (suites.length === 0) {
console.log( console.log('No benchmarks matched provided filters');
pad_right('Benchmark', COLUMN_WIDTHS[0]) + process.exit(1);
pad_left('Time', COLUMN_WIDTHS[1]) + }
pad_left('GC time', COLUMN_WIDTHS[2])
);
console.log('='.repeat(TOTAL_WIDTH));
for (const benchmark of benchmarks) { const filename = fileURLToPath(import.meta.url);
const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn());
/**
* @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( console.log(
pad_right(benchmark.label, COLUMN_WIDTHS[0]) + pad_right('suite', COLUMN_WIDTHS[0]) +
pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) + pad_left(suite_time.toFixed(2), COLUMN_WIDTHS[1]) +
pad_left(results.gc_time.toFixed(2), COLUMN_WIDTHS[2]) pad_left(suite_gc_time.toFixed(2), COLUMN_WIDTHS[2])
); );
total_time += results.time; console.log('='.repeat(TOTAL_WIDTH));
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('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}`); 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]) +
console.log( pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) +
pad_right('total', COLUMN_WIDTHS[0]) + pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2])
pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) + );
pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2]) }
);

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

@ -294,7 +294,10 @@ export function EachBlock(node, context) {
let key_function = b.id('$.index'); let key_function = b.id('$.index');
if (node.metadata.keyed) { 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} */ ( const expression = /** @type {Expression} */ (
context.visit(/** @type {Expression} */ (node.key), key_state) context.visit(/** @type {Expression} */ (node.key), key_state)
); );

@ -8,7 +8,7 @@ import { sanitize_template_string } from '../../../../../utils/sanitize_template
import { regex_is_valid_identifier } from '../../../../patterns.js'; import { regex_is_valid_identifier } from '../../../../patterns.js';
import is_reference from 'is-reference'; import is_reference from 'is-reference';
import { dev, is_ignored, locator, component_name } from '../../../../../state.js'; import { dev, is_ignored, locator, component_name } from '../../../../../state.js';
import { build_getter } from '../../utils.js'; import { build_getter, is_state_source } from '../../utils.js';
import { ExpressionMetadata } from '../../../../nodes.js'; import { ExpressionMetadata } from '../../../../nodes.js';
/** /**
@ -272,6 +272,10 @@ export function build_bind_this(expression, value, { state, visit }) {
const binding = state.scope.get(node.name); const binding = state.scope.get(node.name);
if (!binding) return; if (!binding) return;
// if it is a state or a derived it means is a declaration tag...in that case we don't want to pass the
// value but the signal itself or assignment will break
if (is_state_source(binding, state.analysis) || binding.kind === 'derived') return;
for (const [owner, scope] of state.scopes) { for (const [owner, scope] of state.scopes) {
if (owner.type === 'EachBlock' && scope === binding.scope) { if (owner.type === 'EachBlock' && scope === binding.scope) {
ids.push(node); ids.push(node);

@ -311,6 +311,13 @@ function apply_preprocessor_sourcemap(filename, svelte_map, preprocessor_map_inp
typeof preprocessor_map_input === 'string' typeof preprocessor_map_input === 'string'
? JSON.parse(preprocessor_map_input) ? JSON.parse(preprocessor_map_input)
: preprocessor_map_input; : preprocessor_map_input;
// A preprocessor map with a missing/empty `sources[0]` (e.g. from a MagicString transform
// created without a `source` option) can't be matched against `filename` during combination,
// which silently drops the chain instead of erroring. Normalize it to `filename` first, the
// same way Vite treats an empty `sources[0]` as referring to the file being transformed.
if (preprocessor_map.sources?.length === 1 && !preprocessor_map.sources[0]) {
preprocessor_map.sources = [filename];
}
const result_map = combine_sourcemaps(filename, [svelte_map, preprocessor_map]); const result_map = combine_sourcemaps(filename, [svelte_map, preprocessor_map]);
// Svelte expects a SourceMap which includes toUrl and toString. Instead of wrapping our output in a class, // Svelte expects a SourceMap which includes toUrl and toString. Instead of wrapping our output in a class,
// we just tack on the extra properties. // we just tack on the extra properties.

@ -63,6 +63,9 @@ import { without_reactive_context } from './dom/elements/bindings/shared.js';
import { set_signal_status, update_derived_status } from './reactivity/status.js'; import { set_signal_status, update_derived_status } from './reactivity/status.js';
import * as w from './warnings.js'; import * as w from './warnings.js';
/**
* True if updating in an effect context that is reactive (i.e. not branch/root effects)
*/
let is_updating_effect = false; let is_updating_effect = false;
export let is_destroying_effect = false; export let is_destroying_effect = false;
@ -462,7 +465,7 @@ export function update_effect(effect) {
var was_updating_effect = is_updating_effect; var was_updating_effect = is_updating_effect;
active_effect = effect; active_effect = effect;
is_updating_effect = true; is_updating_effect = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0; // Branch/root effects are not reactive contexts
if (DEV) { if (DEV) {
var previous_component_fn = dev_current_component_function; var previous_component_fn = dev_current_component_function;

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

@ -0,0 +1,29 @@
<script>
let count = $state(0);
let other = $state(0);
const queue = [];
let pending = $derived(defaultPending);
function push(v) {
return new Promise((resolve) => queue.push(() => resolve(v)));
}
</script>
<button onclick={() => {
if (count === 0) {
other++;
count++;
} else {
count++
}
}}>increment</button>
<button onclick={() => queue.pop()?.()}>pop</button>
{#snippet defaultPending()}
<p>Loading...</p>
{/snippet}
{#if count > 0}
<svelte:boundary {pending}>
{await push(count)} {count} {other}
</svelte:boundary>
{/if}

@ -0,0 +1,10 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
async test({ target, assert }) {
const [btn1, btn2] = target.querySelectorAll('button');
flushSync(() => btn2.click());
assert.equal(btn1.textContent, '1');
}
});

@ -0,0 +1,6 @@
{#each Array(1)}
{let ref = $state()}
{let count = $state(0)}
<button onclick={()=>count++} bind:this={ref}>{count}</button>
<button onclick={()=>ref.click()}></button>
{/each}

@ -0,0 +1,7 @@
<script>
let { labelKey, valueKey, options } = $props();
</script>
{#each options as { [labelKey]: label, [valueKey]: value } (value)}
<p>{label}: {value}</p>
{/each}

@ -0,0 +1,29 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../test';
// https://github.com/sveltejs/svelte/issues/18519
export default test({
html: `
<button>reverse</button>
<p>1: a1</p>
<p>2: a2</p>
<p>3: a3</p>
`,
test({ assert, target }) {
const btn = target.querySelector('button');
ok(btn);
flushSync(() => btn.click());
assert.htmlEqual(
target.innerHTML,
`
<button>reverse</button>
<p>3: a3</p>
<p>2: a2</p>
<p>1: a1</p>
`
);
}
});

@ -0,0 +1,12 @@
<script>
import Child from './Child.svelte';
let options = $state([
{ a: 1, v: 'a1' },
{ a: 2, v: 'a2' },
{ a: 3, v: 'a3' }
]);
</script>
<button onclick={() => options.reverse()}>reverse</button>
<Child {options} labelKey="a" valueKey="v" />

@ -1503,4 +1503,22 @@ describe('signals', () => {
assert.deepEqual(log, ['inner destroyed', 'inner destroyed']); assert.deepEqual(log, ['inner destroyed', 'inner destroyed']);
}; };
}); });
test('derived read in an untracked context should not leak in deps reactions', () => {
return () => {
let s = state('hello');
let a = derived(() => $.get(s));
let b = derived(() => $.get(a));
let destroy = effect_root(() => {
$.get(b);
});
destroy();
// a was spuriously added to s.reactions via is_updating_effect
// even though the entire derived chain was read in an untracked context
assert.equal(s.reactions, null);
};
});
}); });

@ -0,0 +1,29 @@
import * as fs from 'node:fs';
import MagicString from 'magic-string';
import { test } from '../../test';
// Simulates a bundler plugin (e.g. a Vite plugin using `magic-string`) that transforms
// the Svelte source *before* it reaches `compile()`, and hands its own sourcemap to
// `compileOptions.sourcemap` — the documented way to let svelte chain an upstream map
// into its own output map. Crucially, the upstream map is generated *without* a `source`
// option, exactly like `new MagicString(code).generateMap()` — this yields a sourcemap
// whose `sources` is `['']`, which previously broke the chain entirely (see #18491)
// instead of being treated as "this file", causing every mapping through it to resolve
// to `{ source: null, line: null, column: null }`.
const input = fs.readFileSync(new URL('./input.svelte', import.meta.url), 'utf-8');
const src = new MagicString(input);
src.overwrite(
src.original.indexOf('count * 2'),
src.original.indexOf('count * 2') + 'count * 2'.length,
'count * 2',
{
storeName: false
}
);
export default test({
compileOptions: {
sourcemap: src.generateMap({ hires: true })
},
client: [{ str: 'let doubled' }]
});

@ -0,0 +1,6 @@
<script>
let count = 0;
let doubled = count * 2;
</script>
<button>clicks: {count}</button>

@ -7,3 +7,7 @@
{let e = $state(0), f = e} {let e = $state(0), f = e}
{a}{b}{c}{d}{e}{f} {a}{b}{c}{d}{e}{f}
<button onclick={() => {
console.log(a);
}}>a</button>
Loading…
Cancel
Save