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 {