diff --git a/src/providers/RouterProvider.tsx b/src/providers/RouterProvider.tsx index 8790f09..b07ea7e 100644 --- a/src/providers/RouterProvider.tsx +++ b/src/providers/RouterProvider.tsx @@ -110,13 +110,19 @@ export function RouterProvider({ const loadInitialData = useCallback(async () => { try { - const savedRoute = await storageUtil.get(syncKey, defaultRoute); - const savedVisiblePages = await storageUtil.get( + const stored = await storageUtil.getMany([ + syncKey, visiblePagesKey, - getDefaultVisibleFeatureKeys(), - ); - const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder()); - const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []); + pageOrderKey, + 'app/recentlyUsedTools', + 'contextMenu/pendingData', + ]); + + const savedRoute = (stored[syncKey] ?? defaultRoute) as PageType; + const savedVisiblePages = (stored[visiblePagesKey] ?? + getDefaultVisibleFeatureKeys()) as PageType[]; + const savedPageOrder = (stored[pageOrderKey] ?? getDefaultPageOrder()) as PageType[]; + const savedRecentTools = (stored['app/recentlyUsedTools'] ?? []) as PageType[]; if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) { setCurrentPage(savedRoute); @@ -131,8 +137,11 @@ export function RouterProvider({ setRecentlyUsedTools(savedRecentTools); } canPersistRef.current = true; + + return stored['contextMenu/pendingData']; } catch (error) { console.error('[Router Init Error] Core data fetch failed:', error); + return undefined; } finally { setIsLoaded(true); } @@ -143,7 +152,7 @@ export function RouterProvider({ // eslint-disable-next-line react-hooks/set-state-in-effect -- Valid async data loading pattern on mount loadInitialData() - .then(() => { + .then((pendingData) => { if (cancelled) return; if (typeof window !== 'undefined') { @@ -165,18 +174,13 @@ export function RouterProvider({ } } - storageUtil - .get('contextMenu/pendingData') - .then((pendingData) => { - if ( - pendingData && - isValidPage(pendingData.featureKey) && - Date.now() - pendingData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS - ) { - setCurrentPage(pendingData.featureKey as PageType); - } - }) - .catch(console.error); + if ( + pendingData && + isValidPage(pendingData.featureKey) && + Date.now() - pendingData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS + ) { + setCurrentPage(pendingData.featureKey as PageType); + } }) .catch(console.error); diff --git a/src/providers/ThemeModeProvider.tsx b/src/providers/ThemeModeProvider.tsx index 4ee6d32..449b039 100644 --- a/src/providers/ThemeModeProvider.tsx +++ b/src/providers/ThemeModeProvider.tsx @@ -13,6 +13,7 @@ import { storageUtil } from '@/utils/chromeStorage'; import { applyResolvedThemeClass, getThemeSyncSnapshot, + hasThemeSyncSnapshot, isValidThemeMode, persistThemeModeSnapshot, resolveThemeMode, @@ -68,6 +69,10 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) { ); useEffect(() => { + if (hasThemeSyncSnapshot()) { + return; + } + let cancelled = false; storageUtil diff --git a/src/providers/__tests__/RouterProvider.test.tsx b/src/providers/__tests__/RouterProvider.test.tsx index f9830bb..f22e93c 100644 --- a/src/providers/__tests__/RouterProvider.test.tsx +++ b/src/providers/__tests__/RouterProvider.test.tsx @@ -7,10 +7,24 @@ import { storageUtil } from '@/utils/chromeStorage'; vi.mock('@/utils/chromeStorage', () => ({ storageUtil: { get: vi.fn(), + getMany: vi.fn(), set: vi.fn(() => Promise.resolve()), }, })); +/** 模拟批量 storage 读取,未指定的键由 Router 侧使用默认值 */ +function mockStorageBatch(map: Record = {}) { + (storageUtil.getMany as ReturnType).mockImplementation((keys: string[]) => { + const result: Record = {}; + for (const key of keys) { + if (key in map) { + result[key] = map[key]; + } + } + return Promise.resolve(result); + }); +} + const TestComponent = () => { const { currentPage, navigateTo, visiblePages, pageOrder } = useRouter(); return ( @@ -46,9 +60,7 @@ describe('RouterProvider', () => { }); it('应该使用默认值初始化路由', async () => { - (storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => - Promise.resolve(defaultValue), - ); + mockStorageBatch(); render( @@ -59,13 +71,18 @@ describe('RouterProvider', () => { await waitFor(() => { expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard'); }); + + expect(storageUtil.getMany).toHaveBeenCalledWith([ + 'app/currentRoute', + 'app/visiblePages', + 'app/pageOrder', + 'app/recentlyUsedTools', + 'contextMenu/pendingData', + ]); }); it('应该从指定的 syncKey 加载路由', async () => { - (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { - if (key === 'app/sidepanelRoute') return Promise.resolve('timestamp'); - return Promise.resolve(defaultValue); - }); + mockStorageBatch({ 'app/sidepanelRoute': 'timestamp' }); render( @@ -79,9 +96,7 @@ describe('RouterProvider', () => { }); it('导航时应该更新指定的 syncKey', async () => { - (storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => - Promise.resolve(defaultValue), - ); + mockStorageBatch(); render( @@ -104,10 +119,7 @@ describe('RouterProvider', () => { }); it('应该支持独立的标签页路由同步', async () => { - (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { - if (key === 'app/tabRoute') return Promise.resolve('qrCode'); - return Promise.resolve(defaultValue); - }); + mockStorageBatch({ 'app/tabRoute': 'qrCode' }); render( @@ -121,11 +133,9 @@ describe('RouterProvider', () => { }); it('应该独立支持 visiblePagesKey 和 pageOrderKey,并合并缺失的新功能', async () => { - (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { - if (key === 'app/sidepanelVisiblePages') - return Promise.resolve(['timestamp', 'storageCleaner']); - if (key === 'app/sidepanelPageOrder') return Promise.resolve(['storageCleaner', 'timestamp']); - return Promise.resolve(defaultValue); + mockStorageBatch({ + 'app/sidepanelVisiblePages': ['timestamp', 'storageCleaner'], + 'app/sidepanelPageOrder': ['storageCleaner', 'timestamp'], }); render( @@ -167,10 +177,9 @@ describe('RouterProvider', () => { 'jsonTools', ]; - (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { - if (key === 'app/popupVisiblePages') return Promise.resolve(oldVisiblePages); - if (key === 'app/popupPageOrder') return Promise.resolve(oldPageOrder); - return Promise.resolve(defaultValue); + mockStorageBatch({ + 'app/popupVisiblePages': oldVisiblePages, + 'app/popupPageOrder': oldPageOrder, }); render( @@ -209,9 +218,7 @@ describe('RouterProvider', () => { localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(oldVisiblePages)); localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(oldPageOrder)); - (storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => - Promise.resolve(defaultValue), - ); + mockStorageBatch(); render( @@ -226,9 +233,7 @@ describe('RouterProvider', () => { }); it('storage.onChanged 同步 visiblePages 时应合并缺失的新功能', async () => { - (storageUtil.get as any).mockImplementation((_key: string, defaultValue: unknown) => - Promise.resolve(defaultValue), - ); + mockStorageBatch(); render( @@ -299,9 +304,9 @@ describe('RouterProvider', () => { resolveGet = resolve; }); - (storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => { + (storageUtil.getMany as ReturnType).mockImplementation(async () => { await getBlocked; - return storage.get(key) ?? defaultValue; + return Object.fromEntries(storage.entries()); }); (storageUtil.set as any).mockImplementation(async (key: string, value: unknown) => { storage.set(key, value); @@ -324,7 +329,9 @@ describe('RouterProvider', () => { }); it('初始化失败时仍应解除加载状态以便渲染页面', async () => { - (storageUtil.get as any).mockRejectedValue(new Error('Storage unavailable')); + (storageUtil.getMany as ReturnType).mockRejectedValue( + new Error('Storage unavailable'), + ); render( @@ -344,10 +351,9 @@ describe('RouterProvider', () => { resolveGet = resolve; }); - (storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => { + (storageUtil.getMany as ReturnType).mockImplementation(async () => { await getBlocked; - if (key === 'app/currentRoute') return 'dashboard'; - return defaultValue; + return { 'app/currentRoute': 'dashboard' }; }); render( @@ -374,7 +380,7 @@ describe('RouterProvider', () => { resolveStorage = resolve; }); - (storageUtil.get as any).mockImplementation(() => storagePromise); + (storageUtil.getMany as ReturnType).mockImplementation(() => storagePromise); const { unmount } = render( @@ -383,7 +389,7 @@ describe('RouterProvider', () => { ); unmount(); - resolveStorage!('dashboard'); + resolveStorage!({}); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); diff --git a/src/providers/__tests__/ThemeModeProvider.test.tsx b/src/providers/__tests__/ThemeModeProvider.test.tsx index 9793655..abc739c 100644 --- a/src/providers/__tests__/ThemeModeProvider.test.tsx +++ b/src/providers/__tests__/ThemeModeProvider.test.tsx @@ -46,6 +46,7 @@ describe('ThemeModeProvider', () => { expect(screen.getByTestId('mode')).toHaveTextContent('dark'); expect(screen.getByTestId('resolved-mode')).toHaveTextContent('dark'); expect(document.documentElement.classList.contains('dark')).toBe(true); + expect(storageUtil.get).not.toHaveBeenCalled(); }); it('异步恢复 storage 后应写回 snapshot', async () => { diff --git a/src/utils/__tests__/chromeStorage.test.ts b/src/utils/__tests__/chromeStorage.test.ts index 1ada37e..0b8d449 100644 --- a/src/utils/__tests__/chromeStorage.test.ts +++ b/src/utils/__tests__/chromeStorage.test.ts @@ -75,6 +75,27 @@ describe('chromeStorage', () => { }); }); + describe('getMany', () => { + it('应该一次读取多个键', async () => { + (chrome.storage.local.get as any).mockResolvedValue({ + 'app/theme': 'dark', + 'app/popupRoute': 'timestamp', + }); + + const result = await storageUtil.getMany(['app/theme', 'app/popupRoute']); + + expect(result).toEqual({ 'app/theme': 'dark', 'app/popupRoute': 'timestamp' }); + expect(chrome.storage.local.get).toHaveBeenCalledWith(['app/theme', 'app/popupRoute']); + }); + + it('空键数组应直接返回空对象', async () => { + const result = await storageUtil.getMany([]); + + expect(result).toEqual({}); + expect(chrome.storage.local.get).not.toHaveBeenCalled(); + }); + }); + describe('get 类型签名', () => { it('无默认值时应推断为可选返回类型', () => { const _getWithoutDefault = () => storageUtil.get('app/theme'); diff --git a/src/utils/__tests__/useStorageState.test.ts b/src/utils/__tests__/useStorageState.test.ts index 3f56489..99a6928 100644 --- a/src/utils/__tests__/useStorageState.test.ts +++ b/src/utils/__tests__/useStorageState.test.ts @@ -40,6 +40,7 @@ describe('useStorageState', () => { // 初始值应从快照恢复,而非默认值 expect(result.current[0]).toBe(false); + expect(storageUtil.get).not.toHaveBeenCalled(); }); it('应该从 Chrome Storage 异步加载并覆盖初始值', async () => { @@ -179,5 +180,6 @@ describe('useStorageState', () => { await waitFor(() => { expect(storageUtil.set).not.toHaveBeenCalled(); }); + expect(storageUtil.get).not.toHaveBeenCalled(); }); }); diff --git a/src/utils/chromeStorage.ts b/src/utils/chromeStorage.ts index 4e46085..2eccffa 100644 --- a/src/utils/chromeStorage.ts +++ b/src/utils/chromeStorage.ts @@ -18,13 +18,26 @@ class StorageUtils { key: K, defaultValue?: StorageSchema[K], ): Promise { - const result = await chrome.storage.local.get([key]); + const result = await this.getMany([key]); if (defaultValue !== undefined) { return (result[key] ?? defaultValue) as StorageSchema[K]; } return result[key] as StorageSchema[K] | undefined; } + /** + * 批量获取多个键(单次 IPC) + */ + async getMany( + keys: readonly K[], + ): Promise>> { + if (keys.length === 0) { + return {}; + } + const result = await chrome.storage.local.get([...keys]); + return result as Partial>; + } + /** * 设置值 * @param key diff --git a/src/utils/syncSnapshot.ts b/src/utils/syncSnapshot.ts index 08af5c4..ab764e5 100644 --- a/src/utils/syncSnapshot.ts +++ b/src/utils/syncSnapshot.ts @@ -1,3 +1,14 @@ +/** + * 检查 localStorage 中是否存在指定键的快照 + */ +export function hasSyncSnapshot(key: string): boolean { + try { + return localStorage.getItem(`snapshot/${key}`) !== null; + } catch { + return false; + } +} + /** * 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁) */ diff --git a/src/utils/themeSnapshot.ts b/src/utils/themeSnapshot.ts index 596b34d..da20081 100644 --- a/src/utils/themeSnapshot.ts +++ b/src/utils/themeSnapshot.ts @@ -9,6 +9,15 @@ const VALID_MODES: ThemeMode[] = ['light', 'dark', 'system']; export const isValidThemeMode = (value: unknown): value is ThemeMode => VALID_MODES.includes(value as ThemeMode); +/** 检查 localStorage 中是否存在主题快照 */ +export const hasThemeSyncSnapshot = (): boolean => { + try { + return localStorage.getItem(THEME_MODE_SNAPSHOT_KEY) !== null; + } catch { + return false; + } +}; + /** * 同步读取 localStorage 快照(首屏防闪烁) */ diff --git a/src/utils/useStorageState.ts b/src/utils/useStorageState.ts index 345f827..7277427 100644 --- a/src/utils/useStorageState.ts +++ b/src/utils/useStorageState.ts @@ -7,7 +7,7 @@ import { type SetStateAction, } from 'react'; import { storageUtil } from '@/utils/chromeStorage'; -import { getSyncSnapshot } from '@/utils/syncSnapshot'; +import { getSyncSnapshot, hasSyncSnapshot } from '@/utils/syncSnapshot'; import type { StorageSchema } from '@/types/storage'; export const useStorageState = ( @@ -34,6 +34,14 @@ export const useStorageState = ( let cancelled = false; const loadState = async () => { + if (hasSyncSnapshot(key as string)) { + if (!cancelled) { + setIsInitialized(true); + hasLoadedFromStorage.current = true; + } + return; + } + try { const savedValue = await storageUtil.get(key, defaultValue); if (cancelled) return;