Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6776a89003 |
@@ -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('当前标签页已切换,请等待数据刷新后再清理');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user