fix(ThemeModeProvider): 修复主题快照同步与首屏闪烁问题
抽取 themeSnapshot 工具模块,异步恢复与跨窗同步时写回 snapshot;HTML 内联脚本与 useLayoutEffect 消除 dark 类首帧闪烁;避免 storage 恢复覆盖用户操作及 setState 嵌套更新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
applyResolvedThemeClass,
|
||||
applyThemeFromSnapshot,
|
||||
getThemeSyncSnapshot,
|
||||
persistThemeModeSnapshot,
|
||||
resolveThemeMode,
|
||||
THEME_MODE_SNAPSHOT_KEY,
|
||||
} from '@/utils/themeSnapshot';
|
||||
|
||||
describe('themeSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
it('应从未知快照值回退到 system', () => {
|
||||
localStorage.setItem(THEME_MODE_SNAPSHOT_KEY, JSON.stringify('invalid'));
|
||||
expect(getThemeSyncSnapshot()).toBe('system');
|
||||
});
|
||||
|
||||
it('resolveThemeMode 应解析 system 为当前系统主题', () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
} as MediaQueryList);
|
||||
|
||||
expect(resolveThemeMode('dark')).toBe('dark');
|
||||
expect(resolveThemeMode('system')).toBe('dark');
|
||||
});
|
||||
|
||||
it('applyThemeFromSnapshot 应根据快照同步 dark 类', () => {
|
||||
persistThemeModeSnapshot('dark');
|
||||
applyThemeFromSnapshot();
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('applyResolvedThemeClass 移除 dark 时应清理 class', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
applyResolvedThemeClass('light');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type ResolvedThemeMode = 'light' | 'dark';
|
||||
|
||||
export const THEME_MODE_STORAGE_KEY = 'app/themeMode' as const;
|
||||
export const THEME_MODE_SNAPSHOT_KEY = 'snapshot/app/themeMode';
|
||||
|
||||
const VALID_MODES: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
|
||||
export const isValidThemeMode = (value: unknown): value is ThemeMode =>
|
||||
VALID_MODES.includes(value as ThemeMode);
|
||||
|
||||
/**
|
||||
* 同步读取 localStorage 快照(首屏防闪烁)
|
||||
*/
|
||||
export const getThemeSyncSnapshot = (): ThemeMode => {
|
||||
try {
|
||||
const raw = localStorage.getItem(THEME_MODE_SNAPSHOT_KEY);
|
||||
if (!raw) return 'system';
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isValidThemeMode(parsed) ? parsed : 'system';
|
||||
} catch {
|
||||
return 'system';
|
||||
}
|
||||
};
|
||||
|
||||
export const getSystemThemeMode = (): ResolvedThemeMode => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
export const resolveThemeMode = (mode: ThemeMode): ResolvedThemeMode =>
|
||||
mode === 'system' ? getSystemThemeMode() : mode;
|
||||
|
||||
export const persistThemeModeSnapshot = (mode: ThemeMode): void => {
|
||||
try {
|
||||
localStorage.setItem(THEME_MODE_SNAPSHOT_KEY, JSON.stringify(mode));
|
||||
} catch (error) {
|
||||
console.error('[Theme Snapshot Error] LocalStorage quota exceeded:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const applyResolvedThemeClass = (resolved: ResolvedThemeMode): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
||||
};
|
||||
|
||||
/** 在 React 挂载前同步应用快照主题,避免首帧闪烁 */
|
||||
export const applyThemeFromSnapshot = (): void => {
|
||||
applyResolvedThemeClass(resolveThemeMode(getThemeSyncSnapshot()));
|
||||
};
|
||||
Reference in New Issue
Block a user