From 44444885666a842c1f1774e31e3529874a941340 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 29 May 2026 21:02:35 -0500 Subject: [PATCH 1/4] fix(solid-router): prevent HeadContent hydration warnings and navigation FOUC Fix two related issues with HeadContent in solid-router: 1. Hydration: render bare element templates with one getNextElement per invocation (switch instead of if/return), call hydration-affecting hooks unconditionally, and capture the relocated node via the JSX return value instead of a ref attribute so SSR attributes on void meta/link elements are preserved. Eliminates "unclaimed server-rendered node" warnings. 2. FOUC: stabilize head tag object identity per-key across renders so (which keys by reference) reconciles unchanged tags in place instead of remounting every head node on navigation. Previously a single changed tag (e.g. title) invalidated the whole array, remounting the app stylesheet link and briefly detaching it, causing a flash of unstyled content in dark mode. --- packages/solid-router/src/Asset.tsx | 143 ++++++++++-------- .../solid-router/src/headContentUtils.tsx | 22 ++- 2 files changed, 99 insertions(+), 66 deletions(-) diff --git a/packages/solid-router/src/Asset.tsx b/packages/solid-router/src/Asset.tsx index 6292e48484..a3ea9b4328 100644 --- a/packages/solid-router/src/Asset.tsx +++ b/packages/solid-router/src/Asset.tsx @@ -1,5 +1,5 @@ import { isServer } from '@tanstack/router-core/isServer' -import { createEffect } from 'solid-js' +import { createEffect, onCleanup, onSettled } from 'solid-js' import { useRouter } from './useRouter' import type { RouterManagedTag } from '@tanstack/router-core' import type { JSX } from '@solidjs/web' @@ -25,89 +25,104 @@ export function Asset({ } } +// On the client, relocate a rendered head element into document.head so head +// tags end up in the right place even when is rendered in +// . The *same* node that Solid rendered/hydrated is moved — never +// recreated — so the server-rendered node stays claimed (its hydration id is +// preserved) and stylesheets/scripts are not refetched or re-executed. +// +// When is placed in (the SSR/hydration case), the node +// is already in document.head, so this is a no-op and the element is left +// exactly where Solid hydrated it. +function useRelocateToHead(getEl: () => Node | undefined) { + onSettled(() => { + const el = getEl() + if (el && el.parentNode !== document.head) { + document.head.appendChild(el) + } + }) + + onCleanup(() => { + const el = getEl() + if (el?.parentNode) { + el.parentNode.removeChild(el) + } + }) +} + function HeadElement(props: { tag: 'meta' | 'link' | 'style' attrs?: Record children?: unknown }): JSX.Element | null { - const router = useRouter() - - // Server: render the element in the tree so it's part of the SSR'd HTML. - // (Where is placed determines where it appears in the SSR - // output; the head-content design supports rendering in for full- - // document hydration.) - if (isServer ?? router.isServer) { - const { tag, attrs, children } = props - if (tag === 'style' && typeof children === 'string') { - return