Files
writer-work-flow/apps/web/components/ThemeToggle.tsx
Yaojia Wang 9bc0e1f2e6 fix(ui): 修复主题切换按钮的水合不一致(夜读模式刷新报错)
- 首帧 mode 用默认值(与服务端一致)→ 消除 SSR/客户端 aria-label/图标不一致
- 图标改由 CSS 按 <html data-theme> 显隐(两个图标始终在 DOM)→ 无首帧闪烁、无结构差异
- 挂载后 useEffect 以 localStorage 校正 mode → label 恢复正确文案
- 去掉会锁死服务端陈旧 label 的 suppressHydrationWarning
2026-07-12 19:02:27 +02:00

75 lines
2.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/Button";
import {
DEFAULT_THEME_MODE,
THEME_STORAGE_KEY,
nextThemeMode,
normalizeThemeMode,
themeModeLabel,
themeToggleLabel,
type ThemeMode,
} from "@/lib/ui/theme";
function applyThemeMode(mode: ThemeMode) {
document.documentElement.dataset.theme = mode;
}
function readStoredThemeMode(): ThemeMode {
try {
return normalizeThemeMode(window.localStorage.getItem(THEME_STORAGE_KEY));
} catch {
return DEFAULT_THEME_MODE;
}
}
function writeStoredThemeMode(mode: ThemeMode) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// 存储不可用时仍允许本次页面会话切换主题。
}
}
export function ThemeToggle() {
// 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
// 可见的图标由 CSS 按 <html data-theme> 决定ThemeScript 已在水合前写入),故无首帧闪烁。
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
useEffect(() => {
// 水合后以 localStorage 为权威源校正dataset 已由 ThemeScript 同样依据写入,通常一致)。
const initial = readStoredThemeMode();
setMode(initial);
applyThemeMode(initial);
}, []);
const toggle = () => {
setMode((current) => {
const next = nextThemeMode(current);
writeStoredThemeMode(next);
applyThemeMode(next);
return next;
});
};
// 图标显隐交给 CSS按 data-theme两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
return (
<Button
onClick={toggle}
variant="ghost"
size="icon"
aria-label={themeToggleLabel(mode)}
title={themeToggleLabel(mode)}
className="border-transparent"
>
<Moon className="theme-icon-moon h-4 w-4" aria-hidden="true" />
<Sun className="theme-icon-sun h-4 w-4" aria-hidden="true" />
<span className="sr-only">{themeModeLabel(mode)}</span>
</Button>
);
}