#18802·svelte

Hydration mismatch in <svelte:head> can remove unrelated stylesheet via skip_nodes()

Author: toon-van-berkelCreated Sep 9, 2026Updated Sep 10, 2026
Labelsawaiting submitter

Describe the bug

A hydration mismatch inside a conditional <svelte:head> fragment can cause Svelte's hydration recovery logic to remove an unrelated stylesheet <link> from <head>.

In my production application, this caused the entire site to become unstyled shortly after the initial page render.

The application is a prerendered SvelteKit site using @sveltejs/adapter-static.

The global stylesheet is imported normally from the root layout:

typescript
import '$lib/scss/style.scss';

The initial prerendered HTML is correct and the stylesheet loads successfully. The page initially renders with all expected styling.

On one affected mobile device, shortly after JavaScript starts and client hydration runs, the generated global stylesheet link is removed from <head>.

The page then immediately falls back to completely unstyled/default browser HTML.

This included:

  • default body margins
  • serif font
  • default blue links
  • unstyled buttons and inputs
  • navigation/sidebar content appearing in normal document flow

The same deployed site works correctly on another phone with the exact same model and the same browser/software versions.

I also tested multiple browsers on the affected phone:

  • Chrome
  • Brave
  • Opera
  • Yahoo browser

All showed the same behavior.

Isolation

JavaScript disabled

With JavaScript disabled on the affected device:

  • prerendered HTML renders correctly
  • global CSS loads correctly
  • styling remains intact
  • the issue does not occur

CSR disabled

I then temporarily added:

typescript
export const csr = false;

to the root SvelteKit layout configuration.

With CSR disabled:

  • JavaScript was still enabled in the browser
  • prerendered HTML remained correct
  • global CSS remained loaded
  • the site stayed fully styled

This isolated the failure to client-side hydration rather than the static HTML, CSS asset, deployment, or browser cache.

JSON-LD diagnostic

Before isolating CSR, I also temporarily removed a JSON-LD {@html} block from <svelte:head>.

That did not resolve the issue.

JSON-LD itself was therefore ruled out as the original cause.

Runtime instrumentation

Because remote mobile DevTools was not practical on the affected device, I added temporary instrumentation that executed before the Svelte client runtime.

The diagnostic intercepted DOM removal operations, including Element.prototype.remove, and also observed <head> using a MutationObserver.

When the failure occurred, it captured removal of this node:

xml
<link href="./_app/immutable/assets/0.vs2PQPtR.css" rel="stylesheet">

The captured stack was:

Error
    at removed (https://dnd-portal.com/?hydration-debug=1:38:14)
    at prototype.<computed> [as remove] (https://dnd-portal.com/?hydration-debug=1:46:12)
    at $e (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:3653)
    at o (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2662)
    at https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2765
    at https://dnd-portal.com/_app/immutable/nodes/0.CMRnMLq6.js:8:13973
    at https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2748
    at Tr (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:25806)
    at Or (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:26924)
    at z (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:22309)

Inspection of the generated client bundle mapped the relevant application frame to a conditional block in the root <svelte:head>.

The application contained:

svelte
{#if seo.imageWidth}
    <meta
        property="og:image:width"
        content={String(seo.imageWidth)}
    />
{/if}

{#if seo.imageHeight}
    <meta
        property="og:image:height"
        content={String(seo.imageHeight)}
    />
{/if}

The Svelte runtime stack mapped to hydration handling for an if_block.

The observed recovery path was:

if_block hydration branch mismatch
→ skip_nodes()
→ node.remove()

During this production failure, that recovery traversal reached the generated global stylesheet and removed it.

Important limitation

I have not proven why the SSR and client branch states differed on the affected device.

I am specifically not claiming that seo.imageWidth definitely had one specific value during SSR and another specific value on the client.

What is confirmed is:

  1. Svelte entered hydration mismatch recovery for this conditional <svelte:head> structure.
  2. That recovery executed node removal logic.
  3. The generated global stylesheet link was removed.
  4. The page became completely unstyled immediately afterward.

Workaround / production fix

I changed the head elements to have a stable structure instead of conditionally rendering the elements:

svelte
<meta
    property="og:image:width"
    content={seo.imageWidth == null ? undefined : String(seo.imageWidth)}
/>

<meta
    property="og:image:height"
    content={seo.imageHeight == null ? undefined : String(seo.imageHeight)}
/>

After this change:

  • CSR remained enabled
  • hydration remained enabled
  • the stylesheet remained present
  • the affected device stayed fully styled
  • the generated root client module no longer used if_block for these two head nodes

The production issue disappeared completely.

I then removed the temporary debugging instrumentation and restored the rest of the SEO head content. The cleaned-up production build continues to work on the previously affected device.

Expected behavior

If a hydration mismatch occurs inside a conditional <svelte:head> fragment, recovery should be limited to the mismatching fragment or otherwise recover without deleting unrelated head resources.

In particular, a mismatch involving an Open Graph <meta> element should not remove the application's generated global stylesheet.

Actual behavior

Svelte entered hydration mismatch recovery, called skip_nodes(), and removed an unrelated generated stylesheet <link> from <head>.

This caused the entire application to lose all styling after hydration.

Reproduction

A minimal SvelteKit reproduction project is available here:

https://github.com/toon-van-berkel/dnd-portal/tree/repro/svelte-head-hydration-stylesheet/reproductions/svelte-head-hydration

The reproduction uses the same Svelte/SvelteKit versions as the production application and intentionally creates an SSR/client branch difference inside <svelte:head>.

The relevant code is:

svelte
<script>
    import { browser } from '$app/environment';
</script>

<svelte:head>
    {#if browser}
        <meta
            name="head-hydration-repro"
            content="client-only"
        />
    {/if}
</svelte:head>

The reproduction also:

  • imports a global stylesheet
  • uses SvelteKit
  • uses @sveltejs/adapter-static
  • prerenders successfully
  • includes the generated stylesheet in the prerendered HTML
  • includes the normal SvelteKit client runtime/modulepreloads
  • contains reproduction-only MutationObserver instrumentation to detect stylesheet removal

pnpm check and pnpm build both succeed.

The reproduction branch was created to isolate the conditional <svelte:head> hydration structure from the much larger production application.

However, I was not able to establish deterministic stylesheet removal in this smaller reproduction in my local environment.

The original production failure was device-specific. The production runtime instrumentation did capture the actual stylesheet removal and Svelte hydration stack on the affected device.

So the current reproduction demonstrates the relevant conditional <svelte:head> hydration structure, but does not guarantee that the unrelated stylesheet deletion will happen on every machine/browser.

Production behavior was:

  1. prerendered HTML loads correctly
  2. stylesheet is present and applied
  3. Svelte client hydration begins
  4. hydration mismatch recovery runs
  5. the global stylesheet <link> is removed
  6. the page becomes completely unstyled

The production workaround was to replace the structural {#if} blocks with unconditional <meta> elements.

That prevents the issue while keeping normal CSR and hydration enabled.

Logs

bash
Removed node:

<link href="./_app/immutable/assets/0.vs2PQPtR.css" rel="stylesheet">

Captured Element.remove stack:

Error
    at removed (https://dnd-portal.com/?hydration-debug=1:38:14)
    at prototype.<computed> [as remove] (https://dnd-portal.com/?hydration-debug=1:46:12)
    at $e (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:3653)
    at o (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2662)
    at https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2765
    at https://dnd-portal.com/_app/immutable/nodes/0.CMRnMLq6.js:8:13973
    at https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2748
    at Tr (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:25806)
    at Or (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:26924)
    at z (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:22309)

A MutationObserver independently observed the same stylesheet node being removed immediately afterward.

The relevant generated application frame was:

https://dnd-portal.com/_app/immutable/nodes/0.CMRnMLq6.js:8:13973

Inspection of the generated bundle mapped this to the conditional Open Graph image dimension block inside the root <svelte:head>.

Observed runtime sequence:
if_block hydration branch mismatch
→ skip_nodes()
→ node.remove()
→ generated global stylesheet removed
→ complete loss of page styling

System Info

bash
System:
  OS: Windows 11 10.0.26200
  CPU: (16) x64 AMD Ryzen 9 8945H w/ Radeon 780M Graphics
  Memory: 10.65 GB / 31.29 GB

Binaries:
  Node: 25.9.0
  npm: 11.12.1
  pnpm: 11.10.0

Browsers:
  Chrome: 152.0.7977.83
  Edge: Chromium (146.0.3856.59)

npmPackages:
  @sveltejs/adapter-static: ^3.0.10 => 3.0.10
  @sveltejs/kit: ^2.63.0 => 2.70.1
  @sveltejs/vite-plugin-svelte: ^7.1.2 => 7.2.0
  svelte: ^5.56.1 => 5.56.7
  vite: ^8.0.16 => 8.1.5

Production framework versions involved in the failure:
Svelte: 5.56.7
SvelteKit: 2.70.1
@sveltejs/vite-plugin-svelte: 7.2.0
Vite: 8.1.5
@sveltejs/adapter-static: 3.0.10

Severity

annoyance