sveltejs / svelte · Issue No. 18802
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:
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:
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:
All showed the same behavior.
With JavaScript disabled on the affected device:
I then temporarily added:
export const csr = false;
to the root SvelteKit layout configuration.
With CSR disabled:
This isolated the failure to client-side hydration rather than the static HTML, CSS asset, deployment, or browser cache.
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.
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:
<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:
{#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.
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:
<svelte:head> structure.I changed the head elements to have a stable structure instead of conditionally rendering the elements:
<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:
if_block for these two head nodesThe 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.
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.
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.
A minimal SvelteKit reproduction project is available here:
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:
<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:
@sveltejs/adapter-staticMutationObserver instrumentation to detect stylesheet removalpnpm 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:
<link> is removedThe production workaround was to replace the structural {#if} blocks with unconditional <meta> elements.
That prevents the issue while keeping normal CSR and hydration enabled.
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:
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
annoyance
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.