#18811·svelte

`forkPreloads` crashing: a speculative render writes the `UNINITIALIZED` sentinel to the DOM

Author: TristanCopleyCreated Sep 11, 2026Updated Sep 11, 2026

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 has UNINITIALIZED as 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 of class={klass(props.label)}. A call compiles to a derived; a bare reference is inlined.
  • await new Promise(...) instead of await 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.