From 9a2bb7faedad21589a86b3b3e4ee28e1f8f342a7 Mon Sep 17 00:00:00 2001 From: tuha Date: Fri, 3 Jul 2026 21:19:39 +0700 Subject: [PATCH] fix: hydration mismatch (React #418) in root HydrateFallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPA build prerenders HydrateFallback where `typeof window` is undefined, emitting an empty
. On the first client render next-themes resolves the theme synchronously from localStorage, so the existing guard falls through and renders the spinner subtree — the hydration tree no longer matches the prerendered HTML, and React logs error #418 (hydration mismatch) on every page load, discarding the prerendered shell and re-rendering it client-side. Gate the spinner behind a mounted flag so the hydration pass renders the same empty
as the prerender. The spinner still appears immediately after mount; behaviour is otherwise unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LDA1JjtePo7qPyE3CiQcVv --- apps/web/app/root.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index e9f46d014c1..8f4cbefef22 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -5,6 +5,7 @@ */ import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; import Script from "next/script"; import { Links, Meta, Outlet, Scripts } from "react-router"; import type { LinksFunction } from "react-router"; @@ -135,8 +136,18 @@ export default function Root() { export function HydrateFallback() { const { resolvedTheme } = useTheme(); - // if we are on the server or the theme is not resolved, return an empty div - if (typeof window === "undefined" || resolvedTheme === undefined) return
; + // The SPA build (ssr: false) prerenders this component on the server, where + // `typeof window === "undefined"` yields an empty
. On the first + // client (hydration) render, next-themes has already resolved the theme + // synchronously from localStorage, so rendering the spinner subtree here + // mismatches the prerendered HTML — React throws error #418 on every page + // load and re-renders the shell client-side. Gate on `mounted` so the + // hydration pass renders the same empty
as the prerender; the + // spinner appears immediately after mount. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!mounted || resolvedTheme === undefined) return
; return (