fix: 修复路由初始化竞态、存储状态静默覆盖与存储清理误删

This commit is contained in:
Cursor Agent
2026-06-26 09:12:58 +00:00
parent 1041b3ece1
commit 6776a89003
7 changed files with 137 additions and 11 deletions
@@ -103,4 +103,31 @@ describe('StorageCleaner 页面', () => {
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled(); 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(<Index />);
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('当前标签页已切换,请等待数据刷新后再清理');
});
});
}); });
+2 -1
View File
@@ -15,6 +15,7 @@ export default function Index() {
sizes, sizes,
reloadAfterClean, reloadAfterClean,
loading, loading,
isRefreshingSizes,
result, result,
showConfirm, showConfirm,
setShowConfirm, setShowConfirm,
@@ -26,7 +27,7 @@ export default function Index() {
handleClean, handleClean,
} = useStorageCleaner(); } = useStorageCleaner();
const isButtonDisabled = !(someSelected || allSelected) || loading; const isButtonDisabled = !(someSelected || allSelected) || loading || isRefreshingSizes;
if (isInitializing) { if (isInitializing) {
return ( return (
@@ -75,6 +75,7 @@ export interface UseStorageCleanerReturn {
sizes: Record<string, StorageSizeInfo>; sizes: Record<string, StorageSizeInfo>;
reloadAfterClean: boolean; reloadAfterClean: boolean;
loading: boolean; loading: boolean;
isRefreshingSizes: boolean;
result: CleaningResult | null; result: CleaningResult | null;
showConfirm: boolean; showConfirm: boolean;
setShowConfirm: (show: boolean) => void; setShowConfirm: (show: boolean) => void;
@@ -95,10 +96,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({}); const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({});
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true); const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [isRefreshingSizes, setIsRefreshingSizes] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null); const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false); const [showConfirm, setShowConfirm] = useState<boolean>(false);
const requestIdRef = useRef<number>(0); const requestIdRef = useRef<number>(0);
const boundTabRef = useRef<{ id: number; url: string } | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null); const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const storageTimerRef = useRef<NodeJS.Timeout | null>(null); const storageTimerRef = useRef<NodeJS.Timeout | null>(null);
const loadingRef = useRef(loading); const loadingRef = useRef(loading);
@@ -117,6 +120,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
// 核心数据拉取链条 // 核心数据拉取链条
const loadInfo = useCallback(async () => { const loadInfo = useCallback(async () => {
const currentRequestId = ++requestIdRef.current; const currentRequestId = ++requestIdRef.current;
setIsRefreshingSizes(true);
try { try {
const tab = await getCurrentTab(); const tab = await getCurrentTab();
if (currentRequestId !== requestIdRef.current) return; if (currentRequestId !== requestIdRef.current) return;
@@ -159,6 +163,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
cacheStorage: { value: cacheCount, displayType: 'count' }, cacheStorage: { value: cacheCount, displayType: 'count' },
serviceWorkers: { value: swCount, displayType: 'count' }, serviceWorkers: { value: swCount, displayType: 'count' },
}); });
boundTabRef.current = { id: tabId, url };
} catch (err) { } catch (err) {
console.error('Failed to load storage cleaner info:', err); console.error('Failed to load storage cleaner info:', err);
if (currentRequestId === requestIdRef.current) { if (currentRequestId === requestIdRef.current) {
@@ -167,6 +172,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
} finally { } finally {
if (currentRequestId === requestIdRef.current) { if (currentRequestId === requestIdRef.current) {
setIsInitializing(false); setIsInitializing(false);
setIsRefreshingSizes(false);
} }
} }
}, []); }, []);
@@ -256,6 +262,13 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
return; return;
} }
const boundTab = boundTabRef.current;
if (!boundTab || boundTab.id !== tab.id) {
toast.warning('当前标签页已切换,请等待数据刷新后再清理');
setShowConfirm(false);
return;
}
setLoading(true); setLoading(true);
setShowConfirm(false); setShowConfirm(false);
try { try {
@@ -297,6 +310,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
sizes, sizes,
reloadAfterClean, reloadAfterClean,
loading, loading,
isRefreshingSizes,
result, result,
showConfirm, showConfirm,
setShowConfirm, setShowConfirm,
+14 -6
View File
@@ -105,6 +105,8 @@ export function RouterProvider({
); );
const [isLoaded, setIsLoaded] = useState(false); 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 savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []); const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []);
if (isValidPage(savedRoute) && syncRoute) { if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) {
setCurrentPage(savedRoute); setCurrentPage(savedRoute);
} }
if (isValidPageList(savedVisiblePages)) { if (isValidPageList(savedVisiblePages)) {
@@ -131,9 +133,11 @@ export function RouterProvider({
if (isValidPageList(savedRecentTools)) { if (isValidPageList(savedRecentTools)) {
setRecentlyUsedTools(savedRecentTools); setRecentlyUsedTools(savedRecentTools);
} }
setIsLoaded(true); canPersistRef.current = true;
} catch (error) { } catch (error) {
console.error('[Router Init Error] Core data fetch failed:', error); console.error('[Router Init Error] Core data fetch failed:', error);
} finally {
setIsLoaded(true);
} }
}, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]); }, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]);
@@ -187,7 +191,7 @@ export function RouterProvider({
}, [loadInitialData]); }, [loadInitialData]);
useEffect(() => { useEffect(() => {
if (isLoaded && syncRoute) { if (isLoaded && canPersistRef.current && syncRoute) {
void storageUtil.set(syncKey, currentPage as PageType).catch(console.error); void storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
try { try {
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage)); localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
@@ -198,7 +202,7 @@ export function RouterProvider({
}, [currentPage, isLoaded, syncRoute, syncKey]); }, [currentPage, isLoaded, syncRoute, syncKey]);
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded && canPersistRef.current) {
void storageUtil.set(visiblePagesKey, visiblePages).catch(console.error); void storageUtil.set(visiblePagesKey, visiblePages).catch(console.error);
try { try {
localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages)); localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages));
@@ -209,7 +213,7 @@ export function RouterProvider({
}, [visiblePages, isLoaded, visiblePagesKey]); }, [visiblePages, isLoaded, visiblePagesKey]);
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded && canPersistRef.current) {
void storageUtil.set(pageOrderKey, pageOrder).catch(console.error); void storageUtil.set(pageOrderKey, pageOrder).catch(console.error);
try { try {
localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder)); localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder));
@@ -220,7 +224,7 @@ export function RouterProvider({
}, [pageOrder, isLoaded, pageOrderKey]); }, [pageOrder, isLoaded, pageOrderKey]);
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded && canPersistRef.current) {
void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error); void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error);
try { try {
localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools)); localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools));
@@ -283,6 +287,8 @@ export function RouterProvider({
}, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]); }, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]);
const navigateTo = (page: PageType) => { const navigateTo = (page: PageType) => {
hasUserNavigatedRef.current = true;
canPersistRef.current = true;
setCurrentPage(page); setCurrentPage(page);
setRecentlyUsedTools((prev) => { setRecentlyUsedTools((prev) => {
const filtered = prev.filter((p) => p !== page); const filtered = prev.filter((p) => p !== page);
@@ -291,6 +297,8 @@ export function RouterProvider({
}; };
const goHome = () => { const goHome = () => {
hasUserNavigatedRef.current = true;
canPersistRef.current = true;
setCurrentPage('dashboard'); setCurrentPage('dashboard');
}; };
@@ -323,6 +323,51 @@ describe('RouterProvider', () => {
}); });
}); });
it('初始化失败时仍应解除加载状态以便渲染页面', async () => {
(storageUtil.get as any).mockRejectedValue(new Error('Storage unavailable'));
render(
<RouterProvider>
<TestComponent />
</RouterProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard');
});
expect(storageUtil.set).not.toHaveBeenCalled();
});
it('用户在 loadInitialData 完成前导航时不应被存储路由覆盖', async () => {
let resolveGet: () => void;
const getBlocked = new Promise<void>((resolve) => {
resolveGet = resolve;
});
(storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => {
await getBlocked;
if (key === 'app/currentRoute') return 'dashboard';
return defaultValue;
});
render(
<RouterProvider>
<TestComponent />
</RouterProvider>,
);
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 () => { it('组件卸载时不应设置 isLoaded 状态(竞态条件防护)', async () => {
let resolveStorage: (value: unknown) => void; let resolveStorage: (value: unknown) => void;
const storagePromise = new Promise((resolve) => { const storagePromise = new Promise((resolve) => {
@@ -166,4 +166,18 @@ describe('useStorageState', () => {
expect(result.current[2]).toBe(false); 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();
});
});
}); });
+21 -4
View File
@@ -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 { storageUtil } from '@/utils/chromeStorage';
import { getSyncSnapshot } from '@/utils/syncSnapshot'; import { getSyncSnapshot } from '@/utils/syncSnapshot';
import type { StorageSchema } from '@/types/storage'; import type { StorageSchema } from '@/types/storage';
@@ -8,11 +15,18 @@ export const useStorageState = <K extends keyof StorageSchema>(
defaultValue: StorageSchema[K], defaultValue: StorageSchema[K],
validator?: (val: unknown) => val is StorageSchema[K], validator?: (val: unknown) => val is StorageSchema[K],
) => { ) => {
const [value, setValue] = useState<StorageSchema[K]>(() => const [value, setValueInternal] = useState<StorageSchema[K]>(() =>
getSyncSnapshot(key as string, defaultValue, validator), getSyncSnapshot(key as string, defaultValue, validator),
); );
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const hasLoadedFromStorage = useRef(false); const hasLoadedFromStorage = useRef(false);
const loadSucceededRef = useRef(false);
const userModifiedRef = useRef(false);
const setValue = useCallback<Dispatch<SetStateAction<StorageSchema[K]>>>((next) => {
userModifiedRef.current = true;
setValueInternal(next);
}, []);
// Only load from storage once on mount // Only load from storage once on mount
useEffect(() => { useEffect(() => {
@@ -24,15 +38,17 @@ export const useStorageState = <K extends keyof StorageSchema>(
try { try {
const savedValue = await storageUtil.get(key, defaultValue); const savedValue = await storageUtil.get(key, defaultValue);
if (cancelled) return; if (cancelled) return;
loadSucceededRef.current = true;
if (savedValue !== undefined) { if (savedValue !== undefined) {
if (validator) { if (validator) {
setValue(validator(savedValue) ? savedValue : defaultValue); setValueInternal(validator(savedValue) ? savedValue : defaultValue);
} else { } else {
setValue(savedValue); setValueInternal(savedValue);
} }
} }
} catch (error) { } catch (error) {
console.error(`加载状态失败 (${key}):`, error); console.error(`加载状态失败 (${key}):`, error);
loadSucceededRef.current = false;
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setIsInitialized(true); setIsInitialized(true);
@@ -51,6 +67,7 @@ export const useStorageState = <K extends keyof StorageSchema>(
// Save to storage and localStorage snapshot when value changes (after initial load) // Save to storage and localStorage snapshot when value changes (after initial load)
useEffect(() => { useEffect(() => {
if (!isInitialized) return; if (!isInitialized) return;
if (!loadSucceededRef.current && !userModifiedRef.current) return;
const saveState = async () => { const saveState = async () => {
try { try {