SvelteKit SSR hydration bug: Card component renders wrong markdown body on production
Svelte 4.2.20 / SvelteKit 2.69.1, SSR + hydration. A feed page renders a list of card components; a summary card is placed either at the top or further down depending on a time check, expressed as two sibling blocks gated on the same boolean:
$: on_top = !!summary && is_past_friday_11am(week_start);
{#if summary && on_top}<Card item={summary} />{/if}
{#if today}<Card item={today} />{/if}
{#each earlier as e (e.date)}<Card item={e} />{/each}
{#if summary && !on_top}<Card item={summary} />{/if}Each Card renders reactive text (a headline, a date label, a list of links) plus a body paragraph via a child component that does {@html process_markdown(md)}.
In production every card displayed the headline/date/links of item N with the BODY PARAGRAPH of item N-1 — an off-by-one that cascaded down the whole list. curl of the SSR HTML paired every headline with its own paragraph correctly, so the server output was fine. No hydration warning appeared in the console; nothing was logged at all. svelte-check, the unit tests and the API response were all clean, and the stored data was verified correct field by field.
Dead ends: suspected the markdown component was memoizing, but its $: block does recompute on prop change; suspected a CDN serving a stale document, but cache-busting query params changed nothing; suspected the API, but the JSON was correct; suspected the {#each} needed a key, but it already had one. The bug also did not reproduce in a headless browser run from CI — only from a developer laptop.
Root cause: the boolean that decides DOM ORDER is evaluated once on the server and again in the browser, in different timezones — and Svelte's hydration does not re-set {@html} content.
is_past_friday_11am did new Date() >= friday.setHours(11, 0, 0, 0). Date.prototype.setHours uses the local timezone of whoever runs it. The Node server runs in UTC; the browser runs in the viewer's zone. For every viewer west of UTC there is a window (here, the hours between 11:00 server-time and 11:00 local-time on the relevant day) in which the server says true and the client says false. The two {#if} branches then place the summary card at a different index, so the whole client-side component list is offset by one relative to the server markup.
Two Svelte behaviours combine to make this silent and specifically corrupting:
- Hydration in Svelte 4 claims existing DOM nodes positionally. Two sibling
{#if}blocks gated on the same boolean are separate blocks; flipping the boolean between SSR and hydration shifts what each subsequent component claims. Keying the{#each}does not help — the reordering happens across the{#if}boundaries, outside the keyed block. - During hydration
{@html expr}claims the existing innerHTML instead of writing it. Reactive text nodes and attributes ARE patched to the new props, so headline, date and links all correct themselves — which is exactly why the mismatch looks like data corruption rather than a hydration bug. Only the{@html}body stays as the server wrote it, i.e. belonging to whichever component originally occupied that DOM position.
No hydration warning fires because Svelte 4 does not diff claimed content against what it would have rendered.
Reproduce deterministically with Puppeteer/Playwright timezone emulation — this is the step that turns "looks odd" into proof:
await page.emulateTimezone('UTC');
await page.goto(url); // pairing correct
await page.emulateTimezone('America/Los_Angeles');
await page.goto(url); // pairing shifted by oneA headless browser inheriting the server's TZ hides the bug completely, which is why CI stayed green.
Fix: never let a viewer-local time comparison decide DOM order. Compute the flag on the server (in the loader / +page.ts, alongside any other server-derived "today" value you already pass down) and pass it as data, so SSR and hydration agree by construction. If the value genuinely must be client-local, render the server's ordering first and only reorder in onMount, after hydration has finished.
Defence in depth, in rough order of value:
- Prefer
Intl.DateTimeFormat(..., { timeZone: 'UTC' })or explicit-offset arithmetic oversetHours/getHours, which silently mean "local". - Wrap the alternatives in ONE keyed block (
{#each ordered as item (item.id)}) instead of two sibling{#if}s, so identity, not position, decides node reuse. - Have the markdown/
{@html}wrapper re-assert its content inonMount(el.innerHTML = html), which converts this whole class of bug from silent corruption into a harmless flash.
Generalisation: any expression reading new Date(), Date.now(), navigator.language, window.matchMedia, or localStorage that changes the ORDER or PRESENCE of components — as opposed to only their styling — can produce this. Styling-only differences self-heal on the first reactive update; ordering differences permanently misalign every {@html} in the list.