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 (