Fix light theme reverting to dark on full page reload

Reproduced with a headless browser: the <head> 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 <html> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-07 23:34:01 +00:00
1 parent 71c0689af9
commit 1dcf5a15a5
1 file changed
+17
+17
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useLayoutEffect } from "react";
import { Sun, Moon } from "lucide-react"; import { Sun, Moon } from "lucide-react";
function getEffectiveTheme(): "light" | "dark" { function getEffectiveTheme(): "light" | "dark" {
@@ -12,6 +13,22 @@ function getEffectiveTheme(): "light" | "dark" {
// palette itself uses) decides which one shows — avoids a useState/useEffect // palette itself uses) decides which one shows — avoids a useState/useEffect
// pair just to reflect the DOM attribute the no-flash script already set. // pair just to reflect the DOM attribute the no-flash script already set.
export function ThemeToggle() { export function ThemeToggle() {
// The <head> 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 <html> 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() { function toggle() {
const next = getEffectiveTheme() === "dark" ? "light" : "dark"; const next = getEffectiveTheme() === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next; document.documentElement.dataset.theme = next;