From 0545910b1601677dafc9ac8c00c3e38075ac4bfd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 09:12:58 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E7=AB=9E=E6=80=81=E3=80=81=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E7=8A=B6=E6=80=81=E9=9D=99=E9=BB=98=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E4=B8=8E=E5=AD=98=E5=82=A8=E6=B8=85=E7=90=86=E8=AF=AF=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StorageCleaner/__tests__/index.test.tsx | 27 +++++++++++ src/pages/StorageCleaner/index.tsx | 3 +- src/pages/StorageCleaner/useStorageCleaner.ts | 14 ++++++ src/providers/RouterProvider.tsx | 20 ++++++--- .../__tests__/RouterProvider.test.tsx | 45 +++++++++++++++++++ src/utils/__tests__/useStorageState.test.ts | 14 ++++++ src/utils/useStorageState.ts | 25 +++++++++-- 7 files changed, 137 insertions(+), 11 deletions(-) diff --git a/src/pages/StorageCleaner/__tests__/index.test.tsx b/src/pages/StorageCleaner/__tests__/index.test.tsx index 373bb15..e73564d 100644 --- a/src/pages/StorageCleaner/__tests__/index.test.tsx +++ b/src/pages/StorageCleaner/__tests__/index.test.tsx @@ -103,4 +103,31 @@ describe('StorageCleaner 页面', () => { expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled(); }); }); + + it('展示的数据与当前标签页不一致时不应执行清理', async () => { + let currentTabId = 1; + vi.mocked(getCurrentTab).mockImplementation( + async () => + ({ + id: currentTabId, + url: currentTabId === 1 ? 'https://a.example.com' : 'https://b.example.com', + }) as any, + ); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled(); + }); + + currentTabId = 2; + + fireEvent.click(screen.getByRole('button', { name: /立即清理/ })); + fireEvent.click(screen.getByRole('button', { name: /确认清理/ })); + + await waitFor(() => { + expect(clearStorage).not.toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledWith('当前标签页已切换,请等待数据刷新后再清理'); + }); + }); }); diff --git a/src/pages/StorageCleaner/index.tsx b/src/pages/StorageCleaner/index.tsx index 4557f2d..1d31f87 100644 --- a/src/pages/StorageCleaner/index.tsx +++ b/src/pages/StorageCleaner/index.tsx @@ -15,6 +15,7 @@ export default function Index() { sizes, reloadAfterClean, loading, + isRefreshingSizes, result, showConfirm, setShowConfirm, @@ -26,7 +27,7 @@ export default function Index() { handleClean, } = useStorageCleaner(); - const isButtonDisabled = !(someSelected || allSelected) || loading; + const isButtonDisabled = !(someSelected || allSelected) || loading || isRefreshingSizes; if (isInitializing) { return ( diff --git a/src/pages/StorageCleaner/useStorageCleaner.ts b/src/pages/StorageCleaner/useStorageCleaner.ts index 437d1df..a3588a3 100644 --- a/src/pages/StorageCleaner/useStorageCleaner.ts +++ b/src/pages/StorageCleaner/useStorageCleaner.ts @@ -75,6 +75,7 @@ export interface UseStorageCleanerReturn { sizes: Record; reloadAfterClean: boolean; loading: boolean; + isRefreshingSizes: boolean; result: CleaningResult | null; showConfirm: boolean; setShowConfirm: (show: boolean) => void; @@ -95,10 +96,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn { const [sizes, setSizes] = useState>({}); const [reloadAfterClean, setReloadAfterClean] = useState(true); const [loading, setLoading] = useState(false); + const [isRefreshingSizes, setIsRefreshingSizes] = useState(false); const [result, setResult] = useState(null); const [showConfirm, setShowConfirm] = useState(false); const requestIdRef = useRef(0); + const boundTabRef = useRef<{ id: number; url: string } | null>(null); const debounceTimerRef = useRef(null); const storageTimerRef = useRef(null); const loadingRef = useRef(loading); @@ -117,6 +120,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn { // 核心数据拉取链条 const loadInfo = useCallback(async () => { const currentRequestId = ++requestIdRef.current; + setIsRefreshingSizes(true); try { const tab = await getCurrentTab(); if (currentRequestId !== requestIdRef.current) return; @@ -159,6 +163,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn { cacheStorage: { value: cacheCount, displayType: 'count' }, serviceWorkers: { value: swCount, displayType: 'count' }, }); + boundTabRef.current = { id: tabId, url }; } catch (err) { console.error('Failed to load storage cleaner info:', err); if (currentRequestId === requestIdRef.current) { @@ -167,6 +172,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn { } finally { if (currentRequestId === requestIdRef.current) { setIsInitializing(false); + setIsRefreshingSizes(false); } } }, []); @@ -256,6 +262,13 @@ export function useStorageCleaner(): UseStorageCleanerReturn { return; } + const boundTab = boundTabRef.current; + if (!boundTab || boundTab.id !== tab.id) { + toast.warning('当前标签页已切换,请等待数据刷新后再清理'); + setShowConfirm(false); + return; + } + setLoading(true); setShowConfirm(false); try { @@ -297,6 +310,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn { sizes, reloadAfterClean, loading, + isRefreshingSizes, result, showConfirm, setShowConfirm, diff --git a/src/providers/RouterProvider.tsx b/src/providers/RouterProvider.tsx index 81263b5..d72bf84 100644 --- a/src/providers/RouterProvider.tsx +++ b/src/providers/RouterProvider.tsx @@ -105,6 +105,8 @@ export function RouterProvider({ ); const [isLoaded, setIsLoaded] = useState(false); + const hasUserNavigatedRef = useRef(false); + const canPersistRef = useRef(false); /** * 从异步存储中安全溯源初始数据 @@ -119,7 +121,7 @@ export function RouterProvider({ const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder()); const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []); - if (isValidPage(savedRoute) && syncRoute) { + if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) { setCurrentPage(savedRoute); } if (isValidPageList(savedVisiblePages)) { @@ -131,9 +133,11 @@ export function RouterProvider({ if (isValidPageList(savedRecentTools)) { setRecentlyUsedTools(savedRecentTools); } - setIsLoaded(true); + canPersistRef.current = true; } catch (error) { console.error('[Router Init Error] Core data fetch failed:', error); + } finally { + setIsLoaded(true); } }, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]); @@ -187,7 +191,7 @@ export function RouterProvider({ }, [loadInitialData]); useEffect(() => { - if (isLoaded && syncRoute) { + if (isLoaded && canPersistRef.current && syncRoute) { void storageUtil.set(syncKey, currentPage as PageType).catch(console.error); try { localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage)); @@ -198,7 +202,7 @@ export function RouterProvider({ }, [currentPage, isLoaded, syncRoute, syncKey]); useEffect(() => { - if (isLoaded) { + if (isLoaded && canPersistRef.current) { void storageUtil.set(visiblePagesKey, visiblePages).catch(console.error); try { localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages)); @@ -209,7 +213,7 @@ export function RouterProvider({ }, [visiblePages, isLoaded, visiblePagesKey]); useEffect(() => { - if (isLoaded) { + if (isLoaded && canPersistRef.current) { void storageUtil.set(pageOrderKey, pageOrder).catch(console.error); try { localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder)); @@ -220,7 +224,7 @@ export function RouterProvider({ }, [pageOrder, isLoaded, pageOrderKey]); useEffect(() => { - if (isLoaded) { + if (isLoaded && canPersistRef.current) { void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error); try { localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools)); @@ -283,6 +287,8 @@ export function RouterProvider({ }, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]); const navigateTo = (page: PageType) => { + hasUserNavigatedRef.current = true; + canPersistRef.current = true; setCurrentPage(page); setRecentlyUsedTools((prev) => { const filtered = prev.filter((p) => p !== page); @@ -291,6 +297,8 @@ export function RouterProvider({ }; const goHome = () => { + hasUserNavigatedRef.current = true; + canPersistRef.current = true; setCurrentPage('dashboard'); }; diff --git a/src/providers/__tests__/RouterProvider.test.tsx b/src/providers/__tests__/RouterProvider.test.tsx index b71640f..f9830bb 100644 --- a/src/providers/__tests__/RouterProvider.test.tsx +++ b/src/providers/__tests__/RouterProvider.test.tsx @@ -323,6 +323,51 @@ describe('RouterProvider', () => { }); }); + it('初始化失败时仍应解除加载状态以便渲染页面', async () => { + (storageUtil.get as any).mockRejectedValue(new Error('Storage unavailable')); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard'); + }); + expect(storageUtil.set).not.toHaveBeenCalled(); + }); + + it('用户在 loadInitialData 完成前导航时不应被存储路由覆盖', async () => { + let resolveGet: () => void; + const getBlocked = new Promise((resolve) => { + resolveGet = resolve; + }); + + (storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => { + await getBlocked; + if (key === 'app/currentRoute') return 'dashboard'; + return defaultValue; + }); + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByTestId('navigate-btn')); + }); + expect(screen.getByTestId('current-page')).toHaveTextContent('timestamp'); + + resolveGet!(); + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('timestamp'); + expect(storageUtil.set).toHaveBeenCalledWith('app/currentRoute', 'timestamp'); + }); + }); + it('组件卸载时不应设置 isLoaded 状态(竞态条件防护)', async () => { let resolveStorage: (value: unknown) => void; const storagePromise = new Promise((resolve) => { diff --git a/src/utils/__tests__/useStorageState.test.ts b/src/utils/__tests__/useStorageState.test.ts index 642bddf..3f56489 100644 --- a/src/utils/__tests__/useStorageState.test.ts +++ b/src/utils/__tests__/useStorageState.test.ts @@ -166,4 +166,18 @@ describe('useStorageState', () => { expect(result.current[2]).toBe(false); }); + + it('加载失败时不应把快照默认值写回 Chrome Storage', async () => { + localStorage.setItem('snapshot/app/searchHistory', JSON.stringify([])); + + (storageUtil.get as any).mockRejectedValue(new Error('Storage read failed')); + + renderHook(() => + useStorageState('app/searchHistory', [], (val): val is string[] => Array.isArray(val)), + ); + + await waitFor(() => { + expect(storageUtil.set).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/utils/useStorageState.ts b/src/utils/useStorageState.ts index 71d0241..94782a4 100644 --- a/src/utils/useStorageState.ts +++ b/src/utils/useStorageState.ts @@ -1,4 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; import { storageUtil } from '@/utils/chromeStorage'; import { getSyncSnapshot } from '@/utils/syncSnapshot'; import type { StorageSchema } from '@/types/storage'; @@ -8,11 +15,18 @@ export const useStorageState = ( defaultValue: StorageSchema[K], validator?: (val: unknown) => val is StorageSchema[K], ) => { - const [value, setValue] = useState(() => + const [value, setValueInternal] = useState(() => getSyncSnapshot(key as string, defaultValue, validator), ); const [isInitialized, setIsInitialized] = useState(false); const hasLoadedFromStorage = useRef(false); + const loadSucceededRef = useRef(false); + const userModifiedRef = useRef(false); + + const setValue = useCallback>>((next) => { + userModifiedRef.current = true; + setValueInternal(next); + }, []); // Only load from storage once on mount useEffect(() => { @@ -24,15 +38,17 @@ export const useStorageState = ( try { const savedValue = await storageUtil.get(key, defaultValue); if (cancelled) return; + loadSucceededRef.current = true; if (savedValue !== undefined) { if (validator) { - setValue(validator(savedValue) ? savedValue : defaultValue); + setValueInternal(validator(savedValue) ? savedValue : defaultValue); } else { - setValue(savedValue); + setValueInternal(savedValue); } } } catch (error) { console.error(`加载状态失败 (${key}):`, error); + loadSucceededRef.current = false; } finally { if (!cancelled) { setIsInitialized(true); @@ -51,6 +67,7 @@ export const useStorageState = ( // Save to storage and localStorage snapshot when value changes (after initial load) useEffect(() => { if (!isInitialized) return; + if (!loadSucceededRef.current && !userModifiedRef.current) return; const saveState = async () => { try {