sveltejs / svelte · Issue No. 18811
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.
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 of class={klass(props.label)}. A call compiles to a derived; a bare reference is inlined.await new Promise(...) instead of await getThing().disabled={accepted === thing.id}, so no second prop reads the awaited value.
### System Info
`svelte@5.57.0`, `@sveltejs/kit@3.0.0-next.27`, `vite@8.3.0`, Chromium, Linux, dev mode.
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.
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.