Skip to content

mode-watcher 1.1 userPrefersMode label shows 'system' after SSR reload in SvelteKit/Svelte 5

SvelteKit 2 + Svelte 5 app with mode-watcher 1.1.0 and a shadcn-svelte (bits-ui) Select for a System/Light/Dark theme picker: <Select.Root type="single" bind:value={() => userPrefersMode.current, (v) => setMode(v)}> with the trigger text {label(userPrefersMode.current)}. Picking Dark works: the html gets the dark class, localStorage mode-watcher-mode is 'dark', the page stays dark across reloads. But after a full reload (SSR) the trigger label reads 'System' while the page is dark, and it stays wrong until the value changes again. No hydration_mismatch warning appears in the console. Client-side navigation to the page shows the correct label. userPrefersMode.current is $state-backed, so I expected reactivity to fix the label.

1 solution
ranked by outcome — not votes
Accepted

Root cause: UserPrefersMode in mode-watcher 1.1.0 (dist/mode-states.svelte.js) reads localStorage.getItem('mode-watcher-mode') in a class field initializer, i.e. at module import time on the client. The server has no localStorage, so SSR renders 'system'; the first client render already has 'dark'. Svelte 5 hydration does not repair text-node mismatches (it adopts the server DOM and only updates on later changes), so the server's 'System' text persists, and since the state never changes after hydration, no effect re-renders it. No warning is logged for text mismatches.

Fix: make the first client render match the server, then switch after mount:

<script lang="ts">
  import { onMount } from 'svelte';
  import { setMode, userPrefersMode } from 'mode-watcher';
  type Theme = 'system' | 'light' | 'dark';
  let mounted = $state(false);
  onMount(() => (mounted = true));
  const theme = $derived<Theme>(mounted ? (userPrefersMode.current as Theme) : 'system');
</script>
<Select.Root type="single" bind:value={() => theme, (v) => setMode(v as Theme)}>
  <Select.Trigger>{labelOf(theme)}</Select.Trigger>
  ...
</Select.Root>

The same applies to any of your own localStorage-backed preferences: keep SSR-identical defaults in $state and load the stored value in onMount (e.g. a store with a load() called from the root layout's onMount), not at module init. Note the actual dark class is unaffected because mode-watcher applies it via its own head script and effects; only template text derived from userPrefersMode is stale.