fix(ui): 修复主题切换按钮的水合不一致(夜读模式刷新报错)

- 首帧 mode 用默认值(与服务端一致)→ 消除 SSR/客户端 aria-label/图标不一致
- 图标改由 CSS 按 <html data-theme> 显隐(两个图标始终在 DOM)→ 无首帧闪烁、无结构差异
- 挂载后 useEffect 以 localStorage 校正 mode → label 恢复正确文案
- 去掉会锁死服务端陈旧 label 的 suppressHydrationWarning
This commit is contained in:
Yaojia Wang
2026-07-12 19:02:27 +02:00
parent 40cc3fc9e3
commit 9bc0e1f2e6
2 changed files with 22 additions and 11 deletions

View File

@@ -134,6 +134,21 @@ body {
}
}
/* 主题切换图标:显隐由 data-theme 决定(而非 JS 状态),两个图标始终在 DOM 里,
服务端/客户端首帧结构一致——既避免水合不一致,又无首帧闪烁。 */
.theme-icon-moon {
display: inline-block;
}
.theme-icon-sun {
display: none;
}
[data-theme="night"] .theme-icon-moon {
display: none;
}
[data-theme="night"] .theme-icon-sun {
display: inline-block;
}
/* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion见下。 */
.toast-enter {
animation: toast-in var(--dur-base) var(--ease-standard);

View File

@@ -26,13 +26,6 @@ function readStoredThemeMode(): ThemeMode {
}
}
// 初始模式直接读 ThemeScript 在水合前写入的 <html data-theme>,避免夜读首帧渲染错误图标。
// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。
function initialThemeMode(): ThemeMode {
if (typeof document === "undefined") return DEFAULT_THEME_MODE;
return normalizeThemeMode(document.documentElement.dataset.theme);
}
function writeStoredThemeMode(mode: ThemeMode) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
@@ -42,7 +35,9 @@ function writeStoredThemeMode(mode: ThemeMode) {
}
export function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>(initialThemeMode);
// 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
// 可见的图标由 CSS 按 <html data-theme> 决定ThemeScript 已在水合前写入),故无首帧闪烁。
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
useEffect(() => {
// 水合后以 localStorage 为权威源校正dataset 已由 ThemeScript 同样依据写入,通常一致)。
@@ -60,8 +55,8 @@ export function ThemeToggle() {
});
};
const Icon = mode === "night" ? Sun : Moon;
// 图标显隐交给 CSS按 data-theme两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
return (
<Button
onClick={toggle}
@@ -71,7 +66,8 @@ export function ThemeToggle() {
title={themeToggleLabel(mode)}
className="border-transparent"
>
<Icon className="h-4 w-4" aria-hidden="true" />
<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>
);