From 1dcf5a15a524eb8c66fc50b1607a06c79dc8179e Mon Sep 17 00:00:00 2001 From: Oleg Date: Fri, 7 Aug 2026 23:34:01 +0000 Subject: [PATCH] Fix light theme reverting to dark on full page reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced with a headless browser: the no-flash script correctly sets data-theme from localStorage before first paint (confirmed present immediately at navigation commit), but it was gone by ~50ms later and never came back — something in this app's streaming SSR/hydration does a second commit shortly after first paint that strips an attribute React doesn't know about, and suppressHydrationWarning on only silences the mismatch warning for the *first* hydration diff, not a later one. Client-side navigation was unaffected (the attribute survives once already set post-mount), only a fresh full load hit this. Fix: ThemeToggle now re-applies the stored theme from localStorage in a useLayoutEffect on mount, which runs after the attribute-stripping commit has already happened and reliably makes it stick. Verified with the same repro: data-theme now stays "light" at commit, +50ms, +550ms, and +2550ms after reload, both directly and through the original toggle -> navigate -> reload sequence. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY --- src/app/(admin)/theme-toggle.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/app/(admin)/theme-toggle.tsx b/src/app/(admin)/theme-toggle.tsx index 3cc7845..b2cd155 100644 --- a/src/app/(admin)/theme-toggle.tsx +++ b/src/app/(admin)/theme-toggle.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLayoutEffect } from "react"; import { Sun, Moon } from "lucide-react"; function getEffectiveTheme(): "light" | "dark" { @@ -12,6 +13,22 @@ function getEffectiveTheme(): "light" | "dark" { // palette itself uses) decides which one shows — avoids a useState/useEffect // pair just to reflect the DOM attribute the no-flash script already set. export function ThemeToggle() { + // The no-flash script sets data-theme before hydration, but + // something in this app's streaming SSR/hydration commits again shortly + // after first paint and strips it back off (confirmed: present at ~0ms, + // gone by ~50ms, and it doesn't come back on its own) — suppressHydration + // Warning on only silences the mismatch warning for the *first* + // hydration diff, it doesn't stop a later commit from resetting an + // attribute React doesn't know about. Re-applying here, once this client + // component has mounted (i.e. after that reset has already happened), + // wins the race and makes it stick. + useLayoutEffect(() => { + const stored = localStorage.getItem("theme"); + if (stored === "light" || stored === "dark") { + document.documentElement.dataset.theme = stored; + } + }, []); + function toggle() { const next = getEffectiveTheme() === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = next;