diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e4ffa4e8..1791bbd3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,20 @@ import "@/utils/earlyErrorSuppression"; import { ClientProviders } from "@/components/ClientProviders"; import { headers } from "next/headers"; +/** + * Synchronous blocking script that runs before React hydrates. + * It reads the persisted theme from localStorage (matching the + * `storageKey="theme"` configured on next-themes) and applies (or + * removes) the `dark` class on the element so the first paint + * already matches the user's preferred theme. Without this script, + * dark-mode users see a flash of light content (FOUC) on hard reload + * because next-themes' class attribute is only applied after hydration. + * + * CLS = 0 because the classList mutation is non-geometric and React + * reconciles the className diff silently thanks to `suppressHydrationWarning`. + */ +const themeBootstrapScript = `(function(){try{var t=localStorage.getItem('theme');var d=t==='dark'||((t===null||t==='system')&&window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches);var c=document.documentElement.classList;c.remove('light','dark');if(d){c.add('dark');}else if(t==='light'){c.add('light');}document.documentElement.style.colorScheme=d?'dark':'light';}catch(e){}})();`; + const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -39,6 +53,18 @@ export default async function RootLayout({ dir={isRTL ? "rtl" : "ltr"} suppressHydrationWarning > + + {/* + dangerouslySetInnerHTML is used because this script must run + synchronously before paint. It is a static string we control, + so there is no XSS risk. `suppressHydrationWarning` on + absorbs the className diff that next-themes writes after hydration. + */} +