fix(StorageCleaner): 修复 IndexedDB 清理竞态、部分成功计数与刷新后状态同步

This commit is contained in:
2026-06-25 23:31:44 +08:00
parent ebf9f7a153
commit edd4cf266c
13 changed files with 1545 additions and 128 deletions
@@ -1,6 +1,9 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { browser } from 'wxt/browser';
import Index from '../index';
import { clearStorage, getCookieSize, getCurrentTab } from '@/utils/storageCleaner';
import { toast } from 'sonner';
// Mock the chrome APIs
vi.mock('@/utils/chromeStorage', () => ({
@@ -22,10 +25,82 @@ vi.mock('@/utils/storageCleaner', () => ({
formatCleaningResult: vi.fn().mockReturnValue('Cleaned successfully'),
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
},
}));
describe('StorageCleaner 页面', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getCurrentTab).mockResolvedValue({ id: 1, url: 'https://example.com' } as any);
vi.mocked(clearStorage).mockResolvedValue({ overallSuccess: true });
});
it('应该渲染初始化加载状态', () => {
// storageCleaner:initializing 的中文文案为「正在读取站点数据...」
render(<Index />);
expect(screen.getByText(/正在读取站点数据/)).toBeInTheDocument();
expect(screen.getByText(/正在读取数据/)).toBeInTheDocument();
});
it('读取当前标签页失败时应显示错误提示', async () => {
vi.mocked(getCurrentTab).mockRejectedValueOnce(new Error('Tabs unavailable'));
render(<Index />);
expect(await screen.findByText('读取数据失败')).toBeInTheDocument();
});
it('确认清理前如果当前标签页变为受限页面,不应执行清理', async () => {
render(<Index />);
const cleanButton = await screen.findByRole('button', { name: /立即清理/ });
fireEvent.click(cleanButton);
vi.mocked(getCurrentTab).mockResolvedValueOnce({
id: 1,
url: 'chrome://extensions',
} as any);
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
await waitFor(() => {
expect(clearStorage).not.toHaveBeenCalled();
expect(toast.warning).toHaveBeenCalledWith('存储清理功能不支持此页面');
});
});
it('自动刷新后应等待标签页完成加载并重新读取信息后再允许再次清理', async () => {
const tabUpdatedListeners: Array<(tabId: number, changeInfo: { status?: string }) => void> = [];
(browser.tabs.onUpdated.addListener as any).mockImplementation((listener: any) => {
tabUpdatedListeners.push(listener);
});
(browser.tabs.onUpdated.removeListener as any).mockImplementation((listener: any) => {
const index = tabUpdatedListeners.indexOf(listener);
if (index >= 0) tabUpdatedListeners.splice(index, 1);
});
render(<Index />);
const cleanButton = await screen.findByRole('button', { name: /立即清理/ });
fireEvent.click(cleanButton);
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
await waitFor(() => expect(browser.tabs.reload).toHaveBeenCalledWith(1));
const loadingButton = screen.getByRole('button', { name: /正在清理/ });
expect(loadingButton).toBeDisabled();
fireEvent.click(loadingButton);
expect(clearStorage).toHaveBeenCalledTimes(1);
const reloadListener = tabUpdatedListeners.at(-1);
expect(reloadListener).toBeDefined();
reloadListener?.(1, { status: 'complete' });
await waitFor(() => {
expect(getCookieSize).toHaveBeenCalledTimes(2);
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled();
});
});
});
@@ -5,6 +5,7 @@ import OptionItem from './OptionItem';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { CLEAN_OPTION_KEYS } from '../constants';
interface StorageOptionsGridProps extends React.HTMLAttributes<HTMLDivElement> {
options: StorageCleanerOptions;
@@ -25,15 +26,6 @@ export default function StorageOptionsGrid({
className,
...props
}: StorageOptionsGridProps) {
const optionKeys: (keyof StorageCleanerOptions)[] = [
'localStorage',
'sessionStorage',
'indexedDB',
'cookies',
'cacheStorage',
'serviceWorkers',
];
const handleToggleAll = () => {
onSelectAll(!allSelected);
};
@@ -42,7 +34,7 @@ export default function StorageOptionsGrid({
<div className={cn('w-full overflow-hidden', className)} {...props}>
<div className="px-3.5 pt-3.5 pb-2">
<div className="grid grid-cols-2 gap-2 items-stretch">
{optionKeys.map((key) => (
{CLEAN_OPTION_KEYS.map((key) => (
<OptionItem
key={key}
labelKey={key}
+1 -1
View File
@@ -12,7 +12,7 @@ export const CLEAN_OPTION_KEYS = [
export const OPTION_LABELS: Record<(typeof CLEAN_OPTION_KEYS)[number], string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
indexedDB: 'IndexedDB',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
+1 -1
View File
@@ -33,7 +33,7 @@ export default function Index() {
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<Loader2 className="h-6 w-6 text-muted-foreground/80" />
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{'正在读取站点数据...'}
{'正在读取数据...'}
</span>
</div>
);
+61 -11
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { browser } from 'wxt/browser';
import { storageUtil } from '@/utils/chromeStorage';
import type {
CleaningResult,
@@ -32,6 +33,36 @@ const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
selectedTypes: DEFAULT_OPTIONS,
};
const RELOAD_COMPLETE_TIMEOUT_MS = 10_000;
async function reloadTabAndWaitForComplete(tabId: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
function cleanup() {
clearTimeout(timeoutId);
browser.tabs.onUpdated.removeListener(handleUpdated);
}
function finish() {
cleanup();
resolve();
}
function handleUpdated(updatedTabId: number, changeInfo: { status?: string }) {
if (updatedTabId === tabId && changeInfo.status === 'complete') {
finish();
}
}
const timeoutId = setTimeout(finish, RELOAD_COMPLETE_TIMEOUT_MS);
browser.tabs.onUpdated.addListener(handleUpdated);
browser.tabs.reload(tabId).catch((err) => {
cleanup();
reject(err);
});
});
}
export interface StorageSizeInfo {
value: number;
displayType: 'bytes' | 'count';
@@ -128,6 +159,11 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
cacheStorage: { value: cacheCount, displayType: 'count' },
serviceWorkers: { value: swCount, displayType: 'count' },
});
} catch (err) {
console.error('Failed to load storage cleaner info:', err);
if (currentRequestId === requestIdRef.current) {
setError('读取数据失败');
}
} finally {
if (currentRequestId === requestIdRef.current) {
setIsInitializing(false);
@@ -158,14 +194,14 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}
};
chrome.tabs.onActivated.addListener(handleTabChange);
chrome.tabs.onUpdated.addListener(handleTabUpdated);
chrome.windows.onFocusChanged.addListener(handleTabChange);
browser.tabs.onActivated.addListener(handleTabChange);
browser.tabs.onUpdated.addListener(handleTabUpdated);
browser.windows.onFocusChanged.addListener(handleTabChange);
return () => {
chrome.tabs.onActivated.removeListener(handleTabChange);
chrome.tabs.onUpdated.removeListener(handleTabUpdated);
chrome.windows.onFocusChanged.removeListener(handleTabChange);
browser.tabs.onActivated.removeListener(handleTabChange);
browser.tabs.onUpdated.removeListener(handleTabUpdated);
browser.windows.onFocusChanged.removeListener(handleTabChange);
};
}, [debouncedLoadInfo]);
@@ -174,9 +210,15 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (storageTimerRef.current) clearTimeout(storageTimerRef.current);
storageTimerRef.current = setTimeout(async () => {
await storageUtil
.set('storageCleaner/preferences', { reloadAfterClean, selectedTypes: options })
.catch(console.error);
try {
await storageUtil.set('storageCleaner/preferences', {
reloadAfterClean,
selectedTypes: options,
});
} catch (err) {
console.error('Failed to save storage cleaner preferences:', err);
toast.warning('偏好保存失败,本次设置可能不会保留');
}
}, 500);
}, [options, reloadAfterClean, isInitializing]);
@@ -203,19 +245,27 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (loadingRef.current) return;
const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) {
if (!tab || tab.id === undefined || !tab.url) {
toast.warning('无法获取当前标签页');
return;
}
if (isRestrictedUrl(tab.url)) {
toast.warning('存储清理功能不支持此页面');
setShowConfirm(false);
return;
}
setLoading(true);
setShowConfirm(false);
try {
const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult);
if (reloadAfterClean && cleaningResult.overallSuccess) {
toast.success('清理成功,即将刷新页面');
await chrome.tabs.reload(tab.id);
await reloadTabAndWaitForComplete(tab.id);
await loadInfo();
} else {
await loadInfo();
}