From 69f8abdbda843855aecbcc354acff075c1bfd4bf Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Sun, 15 Mar 2026 14:12:40 +0800 Subject: [PATCH] fix(header): sync theme icon state after mount - initialize dark mode state after mount from document to avoid mismatched first render state. - render a placeholder before mount to reduce theme icon hydration mismatch risk. --- web/src/components/layout/header.tsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/web/src/components/layout/header.tsx b/web/src/components/layout/header.tsx index dd724d198..3749743e5 100644 --- a/web/src/components/layout/header.tsx +++ b/web/src/components/layout/header.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTranslations, useLocale } from "@/lib/i18n"; import { Github, Menu, X, Sun, Moon } from "lucide-react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { cn } from "@/lib/utils"; const NAV_ITEMS = [ @@ -24,14 +24,13 @@ export function Header() { const pathname = usePathname(); const locale = useLocale(); const [mobileOpen, setMobileOpen] = useState(false); - const [dark, setDark] = useState(() => { - if (typeof window !== "undefined") { - const stored = localStorage.getItem("theme"); - if (stored) return stored === "dark"; - return window.matchMedia("(prefers-color-scheme: dark)").matches; - } - return false; - }); + const [dark, setDark] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + setDark(document.documentElement.classList.contains("dark")); + }, []); function toggleDark() { const next = !dark; @@ -91,7 +90,7 @@ export function Header() { onClick={toggleDark} className="rounded-md p-1.5 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white" > - {dark ? : } + {mounted ? (dark ? : ) : } - {dark ? : } + {mounted ? (dark ? : ) : }