`forkPreloads` crashing: a speculative render writes the `UNINITIALIZED` sentinel to the DOM
Describe the bug
With async mode on and SvelteKit's forkPreloads enabled, hovering a link — not clicking it — throws and replaces the current page with Kit's 500 error page:
TypeError: Cannot convert a Symbol value to a string
at to_class
at Module.set_class
at .../child.svelte:22:6
at update_reaction
at update_effect
at flush_queued_effects
at Batch.flush
The value is Symbol(uninitialized) — Svelte's own UNINITIALIZED. The speculative forked render reads a derived belonging to the other batch before it has been evaluated, gets the sentinel back, and hands it to the DOM writers; to_class() then does '' + value.
runtime.js already documents the precondition:
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 hasUNINITIALIZEDas its value […]
That handling keeps the derived DIRTY so it re-executes on read, but the attribute writers read the raw .v first.
class is where it fails loudly. Other attributes take the same sentinel through set_attribute() and would stringify it just as readily, or render something wrong without throwing.
Reproduction
No REPL: forkPreloads is a SvelteKit option and the fork is driven by the router's hover-preload, so this needs an app. Stock SvelteKit, no other dependencies. Verified on a cold dev server, 5/5.
// vite.config.js
import { sveltekit } from "@sveltejs/kit/vite";
export default {
plugins: [
sveltekit({
compilerOptions: { experimental: { async: true } },
experimental: { forkPreloads: true, remoteFunctions: true },
}),
],
};
<!-- src/app.html — the preload attribute is what arms the fork -->
<body data-sveltekit-preload-data="hover">
<div>%sveltekit.body%</div>
</body>
<!-- src/routes/repro/+page.svelte -->
<a href="/repro/target">hover me</a>
// src/routes/repro/thing.remote.js
import { query } from "$app/server";
export const getThing = query(async () => {
await new Promise((resolve) => setTimeout(resolve, 400));
return { id: "x" };
});
<!-- src/routes/repro/target/child.svelte -->
<script>
const props = $props();
const klass = (value) => `box ${value}`;
</script>
<button class={klass(props.label)} disabled={props.disabled}>x</button>
<!-- src/routes/repro/target/+page.svelte -->
<script>
import { getThing } from "../thing.remote.js";
import Child from "./child.svelte";
const thing = $derived(await getThing());
let accepted = $state();
</script>
<Child label="a" disabled={accepted === thing.id} />
Load /repro, hover the link, don't click.
Three variants, each as its own route with everything on disk before a cold start, 3 runs each — all clean, so each of these looks load-bearing:
class={props.label}instead ofclass={klass(props.label)}. A call compiles to a derived; a bare reference is inlined.await new Promise(...)instead ofawait getThing().- dropping
disabled={accepted === thing.id}, so no second prop reads the awaited value.
Logs
System Info
### System Info
`[email protected]`, `@sveltejs/[email protected]`, `[email protected]`, Chromium, Linux, dev mode.
Severity
Blocks using forkPreloads. Anything computing a class through a helper is exposed, which is every shadcn-svelte component via cn().
Returning early from set_class, set_attribute and set_attributes when the value is UNINITIALIZED stops the throw and makes hover-preloading work, but it only suppresses the symptom — it doesn't stop the fork reading a derived before it's evaluated.
Source: sveltejs/svelte