fix(ThemeModeProvider): 修复主题快照同步与首屏闪烁问题

抽取 themeSnapshot 工具模块,异步恢复与跨窗同步时写回 snapshot;HTML 内联脚本与 useLayoutEffect 消除 dark 类首帧闪烁;避免 storage 恢复覆盖用户操作及 setState 嵌套更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
雨霖铃
2026-06-19 00:45:52 +08:00
parent 8f9ff27034
commit f0292acbb7
8 changed files with 302 additions and 68 deletions
@@ -0,0 +1,119 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { browser } from 'wxt/browser';
import { ThemeModeProvider, useThemeMode } from '@/providers/ThemeModeProvider';
import { storageUtil } from '@/utils/chromeStorage';
import { THEME_MODE_SNAPSHOT_KEY, THEME_MODE_STORAGE_KEY } from '@/utils/themeSnapshot';
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn(),
set: vi.fn(() => Promise.resolve()),
},
}));
const TestComponent = () => {
const { mode, resolvedMode, setMode } = useThemeMode();
return (
<div>
<div data-testid="mode">{mode}</div>
<div data-testid="resolved-mode">{resolvedMode}</div>
<button type="button" data-testid="set-dark" onClick={() => setMode('dark')}>
Set Dark
</button>
</div>
);
};
describe('ThemeModeProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
document.documentElement.classList.remove('dark');
(storageUtil.get as ReturnType<typeof vi.fn>).mockImplementation(
(_key: string, defaultValue: unknown) => Promise.resolve(defaultValue),
);
});
it('应从 localStorage 快照初始化主题', () => {
localStorage.setItem(THEME_MODE_SNAPSHOT_KEY, JSON.stringify('dark'));
render(
<ThemeModeProvider>
<TestComponent />
</ThemeModeProvider>,
);
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
expect(screen.getByTestId('resolved-mode')).toHaveTextContent('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('异步恢复 storage 后应写回 snapshot', async () => {
(storageUtil.get as ReturnType<typeof vi.fn>).mockResolvedValue('dark');
render(
<ThemeModeProvider>
<TestComponent />
</ThemeModeProvider>,
);
await waitFor(() => {
expect(localStorage.getItem(THEME_MODE_SNAPSHOT_KEY)).toBe(JSON.stringify('dark'));
});
});
it('用户已切换主题时不应被较慢的 storage 恢复覆盖', async () => {
let resolveStorage: (value: 'light' | 'dark' | 'system') => void;
const storagePromise = new Promise<'light' | 'dark' | 'system'>((resolve) => {
resolveStorage = resolve;
});
(storageUtil.get as ReturnType<typeof vi.fn>).mockImplementation(() => storagePromise);
render(
<ThemeModeProvider>
<TestComponent />
</ThemeModeProvider>,
);
await act(async () => {
screen.getByTestId('set-dark').click();
});
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
await act(async () => {
resolveStorage!('light');
await storagePromise;
});
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
});
it('storage.onChanged 同步时应更新 mode 与 snapshot', async () => {
(storageUtil.get as ReturnType<typeof vi.fn>).mockResolvedValue('system');
render(
<ThemeModeProvider>
<TestComponent />
</ThemeModeProvider>,
);
await waitFor(() => {
expect(browser.storage.onChanged.addListener).toHaveBeenCalled();
});
const storageChangeHandler = vi.mocked(browser.storage.onChanged.addListener).mock
.calls[0][0] as (changes: Record<string, { newValue?: unknown }>) => void;
await act(async () => {
storageChangeHandler({
[THEME_MODE_STORAGE_KEY]: { newValue: 'dark' },
});
});
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
expect(localStorage.getItem(THEME_MODE_SNAPSHOT_KEY)).toBe(JSON.stringify('dark'));
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});