sveltejs / svelte · Issue No. 18612
When a function binding's getter is produced by a factory (bind:value={getFactory(x), setFactory(x)}) and that factory's returned closure reads an async $derived, the compiler does not mark the binding as blocked on the corresponding promise. The binding is then evaluated before the async derived has been assigned, and rendering crashes.
Writing the same getter inline (bind:value={() => value, setFactory(x)}) compiles correctly — the emitted code wraps the consumer in $$renderer.async(...) (SSR) / $.run_after_blockers(...) (client).
This looks like the same class of bug as #17667, which was fixed by #17676 ("account for return statements when determining blockers", released in 5.50.2). That fix handles a return statement inside the function whose blockers are being computed, but the reference here is one level deeper: it lives in a closure that the factory returns, and it is still missed.
Reproduced on 5.56.8 (latest at time of writing) and 5.56.4.
Self-contained SSR reproduction (no SvelteKit involved):
Factory.svelte — crashes:
<script>
let { promise } = $props();
let value = $derived(await promise);
const getValue = () => () => value;
const setValue = () => (v) => {};
</script>
<input bind:value={getValue(), setValue()} />
Inline.svelte — works, identical except the getter is written inline:
<script>
let { promise } = $props();
let value = $derived(await promise);
const setValue = () => (v) => {};
</script>
<input bind:value={() => value, setValue()} />
build.mjs:
import { compile } from 'svelte/compiler';
import { readFileSync, writeFileSync } from 'node:fs';
for (const name of ['Factory', 'Inline']) {
const out = compile(readFileSync(`${name}.svelte`, 'utf8'), {
generate: 'server',
runes: true,
experimental: { async: true },
});
writeFileSync(`${name}.js`, out.js.code);
}
run.mjs:
import { render } from 'svelte/server';
for (const name of ['Inline', 'Factory']) {
const Component = (await import(`./${name}.js`)).default;
try {
const { body } = await render(Component, { props: { promise: Promise.resolve('hello') } });
console.log(`${name}: OK -> ${body.replace(/<!--.*?-->/g, '').trim()}`);
} catch (e) {
console.log(`${name}: ${e.constructor.name}: ${e.message}`);
}
}
$ node build.mjs && node run.mjs
Inline: OK -> <input value="hello"/>
Factory: TypeError: value is not a function
The difference is that the Factory version never gets an async wrapper, so the getter runs while value is still undefined.
SSR:
// Inline — correct
var value;
var $$promises = $$renderer.run([async () => value = await $.async_derived(() => promise)]);
$$renderer.async([$$promises[0]], ($$renderer) => {
$$renderer.push(`<input${$.attr('value', (() => value())())}/>`);
});
// Factory — no `$$renderer.async(...)`, `value` is still undefined here
const getValue = () => () => value();
var value;
var $$promises = $$renderer.run([async () => value = await $.async_derived(() => promise)]);
$$renderer.push(`<input${$.attr('value', getValue()())}/>`);
Client, same shape:
// Inline — correct
$.run_after_blockers([$$promises[0]], () => {
$.bind_value(input, () => $.get(value), setValue());
});
// Factory — no `run_after_blockers`
$.bind_value(input, getValue(), setValue());
SSR:
TypeError: value is not a function
Client (from the real app where we hit this, a <Checkbox> with bind:checked={getChecked(group), setChecked(group)}):
TypeError: Cannot read properties of undefined (reading 'f')
at Module.get (.../svelte/src/internal/client/runtime.js)
at get checked (default-model-deployment-groups.svelte)
Annoyance — there is a straightforward workaround (write the getter inline), though it does cost you the ability to parameterize the getter per item inside an {#each} block.
System:
OS: Linux 6.6 Ubuntu 24.04.3 LTS
CPU: (32) x64 AMD RYZEN AI MAX+ PRO 395 w/ Radeon 8060S
Memory: 45.84 GB / 94.07 GB
Container: Yes
Shell: 5.2.21 - /bin/bash
Binaries:
Node: 24.17.0
npm: 11.13.0
pnpm: 11.18.0
Browsers:
Chrome: 149.0.7827.196
npmPackages:
svelte: 5.56.8 (also reproduced on 5.56.4)
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.