sveltejs / svelte · Issue No. 18845
A component that uses bind: on a child renders in a retry loop: the child can write back
to the bound prop, so the parent body runs again. Every iteration starts fromrenderer.copy(), and copy() duplicates everything the page has rendered so far. A page
with N such components copies O(N²).
This is not a legacy-mode corner. uses_component_bindings is set by any bind: other
than bind:this, so plain runes components take the same path, and they pay more than
legacy ones do.
A form of N rows, each row a runes component with bind:value on a child, Node 26.7.0,NODE_ENV=production, ms per render, best of 15:
| rows | 5.38.10 | main | with the fix |
|---|---|---|---|
| 100 | 0.04 | 0.35 | 0.04 |
| 400 | 0.36 | 7.66 | 0.14 |
| 800 | 1.25 | 32.71 | 0.28 |
Doubling 400 to 800 multiplies main by 4.3 and the fix by 2.0.
The same method has a second bug, and that one changes output. copy() builds the new
renderer from this.#parent, so the constructor fills local from the parent'slocal instead of from the renderer being copied. local.select_value is how an<option> learns whether it is selected:
<!-- App.svelte, with `selected = 'b'` -->
<select bind:value={selected}>
<option value="a">a</option>
<OptRow />
<option value="b">b</option>
</select>
<!-- OptRow.svelte: any component with a `bind:` on a child -->
<script>
import Opt from './Opt.svelte';
let value = $state();
</script>
<Opt bind:value />
<!-- Opt.svelte -->
<script>
let { value = $bindable() } = $props();
</script>
<option value={value}>in</option>
Rendered, comments stripped:
<option> inside OptRow |
<option value="b"> |
|
|---|---|---|
| 5.38.10 | selected |
selected |
| main | selected="" |
none |
| with the fix | none | selected="" |
Two things are wrong on main, and one of them is a regression. The matching option loses
its selected, which 5.38.10 got right. And the <option> inside OptRow, whose own value
is undefined, gets a selected it should not have, because the child never sees the realselect_value and undefined === undefined holds. That one is wrong in 5.38.10 too, and
the fix gets both right.
// packages/svelte/src/internal/server/renderer.js
copy() {
const copy = new Renderer(this.global, this.#parent);
copy.type = this.type;
copy.#out = this.#out.map((item) => (item instanceof Renderer ? item.copy() : item));
copy.promise = this.promise;
return copy;
}
#out is everything rendered into this renderer so far, and item.copy() recurses into
every child, so one retry iteration duplicates the whole subtree. The loop runs at least
twice per bound component, and the renderer grows as the page renders. That is the N².
The copy exists because the loop discards an attempt when a binding changed. But in sync
mode the loop is the only writer, so what was rendered before it can stay where it is, and
only what the loop produces has to be discardable.
For local: new Renderer(global, parent) does { ...parent.local }, and copy() passesthis.#parent, so the copy gets the grandparent's select_value. Inside a <select> that
is undefined.
Remember which renderer a copy continues, start the copy empty, and append on subsume:
copy() {
const copy = new Renderer(this.global, this.#parent);
copy.type = this.type;
+ copy.local = { select_value: this.local.select_value, multiple: this.local.multiple };
+
+ if (this.global.mode === 'sync') {
+ // the retry loop is the only writer here, so what was rendered before it can stay
+ // in place and the copy starts empty. Copying it would be O(page) per retry
+ copy.#appends_to = this;
+ copy.#offset = this.#offset + this.#out.length;
+ return copy;
+ }
+
copy.#out = this.#out.map((item) => (item instanceof Renderer ? item.copy() : item));
copy.promise = this.promise;
return copy;
}
this.local = other.local;
- 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;
- });
+
+ if (other.#appends_to === this) {
+ for (const item of other.#out) this.#out.push(item);
+ } else {
+ 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;
#appends_to and #offset are two new private fields, both defaulting to null and 0.
get_path() reports where a renderer sits in the tree, and <svelte:head><title> uses it
to decide which title wins. An empty copy is not in its parent's #out at all, and
anything created inside it would be indexed from the copy rather than from the renderer it
will be appended into, so the path comes out too small and a later title can lose:
get_path() {
- return this.#parent ? [...this.#parent.get_path(), this.#parent.#out.indexOf(this)] : [];
+ if (this.#appends_to !== null) return this.#appends_to.get_path();
+
+ const parent = this.#parent;
+ return parent ? [...parent.get_path(), parent.#offset + parent.#out.indexOf(this)] : [];
}
The async path is unchanged: there a suspended child can still be writing, so the copy has
to stay a real copy.
Two SSR samples cover both bugs, and both fail on main:select-value-after-legacy-bind pins the two <select> symptoms in sync and async mode,
and head-title-order-with-binding pins the <svelte:head> ordering that the offset
keeps. Happy to open the PR.
This repo, no extra dependencies. Save this as repro.mjs in the root:
// Run from the root of this repo: `node repro.mjs`
// Prints the N-row ladder and the <select> output for the checked-out version.
import fs from 'node:fs';
const SRC = new URL('./packages/svelte/src/', import.meta.url).pathname;
const { render } = await import(`${SRC}server/index.js`);
const { compile } = await import(`${SRC}compiler/index.js`);
const sources = {
Input: `<script>
let { value = $bindable(), label } = $props();
</script>
<label>{label}<input {value} /></label>`,
Row: `<script>
import Input from './Input.svelte';
let { index } = $props();
let value = $state();
</script>
<div class="row"><Input bind:value label={'Row ' + index} /><span>{value}</span><p>filler text so the page has a realistic size</p></div>`,
Form: `<script>
import Row from './Row.svelte';
let { rows } = $props();
</script>
<form>{#each rows as index}<Row {index} />{/each}</form>`,
Opt: `<script>
let { value = $bindable() } = $props();
</script>
<option value={value}>in</option>`,
OptRow: `<script>
import Opt from './Opt.svelte';
let value = $state();
</script>
<Opt bind:value />`,
App: `<script>
import OptRow from './OptRow.svelte';
let selected = 'b';
</script>
<select bind:value={selected}><option value="a">a</option><OptRow /><option value="b">b</option></select>`
};
const out = new URL('./.repro-out/', import.meta.url).pathname;
fs.rmSync(out, { recursive: true, force: true });
fs.mkdirSync(out, { recursive: true });
for (const [name, source] of Object.entries(sources)) {
const code = compile(source, { generate: 'server', filename: `${name}.svelte` })
.js.code.replace(/from '\.\/([^']+)\.svelte'/g, "from './$1.js'")
.replace(/from 'svelte\/internal\/server'/g, `from '${SRC}internal/server/index.js'`)
.replace(/from 'svelte'/g, `from '${SRC}index-server.js'`);
fs.writeFileSync(`${out}${name}.js`, code);
}
const Form = (await import(`${out}Form.js`)).default;
const App = (await import(`${out}App.js`)).default;
console.log('rows ms/render');
for (const rows_count of [100, 400, 800]) {
const rows = Array.from({ length: rows_count }, (_, i) => i);
for (let i = 0; i < 20; i++) render(Form, { props: { rows } }).body;
let best = Infinity;
for (let r = 0; r < 15; r++) {
const t = process.hrtime.bigint();
for (let i = 0; i < 10; i++) render(Form, { props: { rows } }).body;
const ms = Number(process.hrtime.bigint() - t) / 10 / 1e6;
if (ms < best) best = ms;
}
console.log(String(rows_count).padEnd(7) + best.toFixed(2));
}
console.log('\n<select>:');
console.log(
render(App, {})
.body.replace(/<!--[^>]*-->/g, '')
.replace(/<!>/g, '')
);
Then, with NODE_ENV=production node repro.mjs on each:
| 800 rows | <option value="b"> |
|
|---|---|---|
git checkout svelte@5.38.10 && pnpm i |
1.25 ms | selected |
git checkout main && pnpm i |
32.71 ms | none |
main plus the patch above |
0.28 ms | selected="" |
Full output on main:
rows ms/render
100 0.35
400 7.66
800 32.71
<select>:
<select><option value="a">a</option><option selected="">in</option><option value="b">b</option></select>
System:
OS: macOS 26.0.1
CPU: (16) arm64 Apple M4 Max
Memory: 48.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 26.7.0
npm: 11.19.0
pnpm: 10.33.4
npmPackages:
svelte: workspace, measured on main (636eaaa) and on svelte@5.38.10
blocking an upgrade
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.