From c0b1b0873eaa654867b69f82c5a450669cc29dbd Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 22 Jul 2026 00:13:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(query-draft):=20?= =?UTF-8?q?=E5=B0=86=E5=A4=A7=E8=8D=89=E7=A8=BF=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E7=A7=BB=E5=87=BA=E8=BE=93=E5=85=A5=E7=83=AD=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将防抖后的序列化与 localStorage 写入转移到空闲任务,并合并重复调度。 保留 pagehide、beforeunload 与显式同步刷新,补充大草稿和调度边界回归测试。 --- frontend/src/utils/sqlFileTabDrafts.test.ts | 256 ++++++++++++++++++++ frontend/src/utils/sqlFileTabDrafts.ts | 85 ++++++- 2 files changed, 331 insertions(+), 10 deletions(-) diff --git a/frontend/src/utils/sqlFileTabDrafts.test.ts b/frontend/src/utils/sqlFileTabDrafts.test.ts index e609ab41..e308dd23 100644 --- a/frontend/src/utils/sqlFileTabDrafts.test.ts +++ b/frontend/src/utils/sqlFileTabDrafts.test.ts @@ -39,6 +39,55 @@ class MemoryStorage implements Storage { } } +const createBrowserSchedulingHarness = () => { + let nextIdleId = 1; + const idleCallbacks = new Map(); + const eventListeners = new Map(); + const windowStub = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + eventListeners.set(type, [...(eventListeners.get(type) || []), listener]); + }), + requestIdleCallback: vi.fn((callback: IdleRequestCallback) => { + const id = nextIdleId; + nextIdleId += 1; + idleCallbacks.set(id, callback); + return id; + }), + cancelIdleCallback: vi.fn((id: number) => { + idleCallbacks.delete(id); + }), + }; + const dispatch = (type: string) => { + const event = { type } as Event; + (eventListeners.get(type) || []).forEach((listener) => { + if (typeof listener === 'function') { + listener(event); + } else { + listener.handleEvent(event); + } + }); + }; + const runNextIdleCallback = () => { + const next = idleCallbacks.entries().next().value as [number, IdleRequestCallback] | undefined; + if (!next) { + throw new Error('No idle callback is pending'); + } + idleCallbacks.delete(next[0]); + next[1]({ + didTimeout: false, + timeRemaining: () => 50, + }); + }; + return { + dispatch, + idleCallbacks, + runNextIdleCallback, + windowStub, + }; +}; + describe('sqlFileTabDrafts', () => { beforeEach(() => { vi.stubGlobal('localStorage', new MemoryStorage()); @@ -49,9 +98,216 @@ describe('sqlFileTabDrafts', () => { }); afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + it('keeps the 160ms editor flush path free of large serialization and synchronous storage writes', async () => { + vi.useFakeTimers(); + vi.resetModules(); + + const scheduling = createBrowserSchedulingHarness(); + const storage = new MemoryStorage(); + const setItemSpy = vi.spyOn(storage, 'setItem'); + const stringifySpy = vi.spyOn(JSON, 'stringify'); + vi.stubGlobal('window', scheduling.windowStub); + vi.stubGlobal('localStorage', storage); + + const { persistQueryTabDraftSnapshot } = await import('./sqlFileTabDrafts'); + const oneMiBDraft = 'x'.repeat(1024 * 1024); + for (let index = 0; index < 30; index += 1) { + persistQueryTabDraftSnapshot({ + id: `large-query-${index}`, + title: `Large query ${index}`, + connectionId: 'conn-1', + dbName: 'main', + }, oneMiBDraft); + } + + expect(stringifySpy).not.toHaveBeenCalled(); + expect(setItemSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(160); + + expect(stringifySpy).not.toHaveBeenCalled(); + expect(setItemSpy).not.toHaveBeenCalled(); + expect(scheduling.windowStub.requestIdleCallback).toHaveBeenCalledTimes(1); + expect(scheduling.idleCallbacks.size).toBe(1); + + scheduling.runNextIdleCallback(); + + expect(stringifySpy).toHaveBeenCalledTimes(1); + expect(setItemSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse(storage.getItem('gonavi-query-tab-drafts-v1') || '[]')).toHaveLength(30); + }); + + it('keeps at most one idle write pending while edits continue', async () => { + vi.useFakeTimers(); + vi.resetModules(); + const scheduling = createBrowserSchedulingHarness(); + const storage = new MemoryStorage(); + vi.stubGlobal('window', scheduling.windowStub); + vi.stubGlobal('localStorage', storage); + const { persistQueryTabDraftSnapshot } = await import('./sqlFileTabDrafts'); + const tab = { + id: 'query-bounded', + title: 'Bounded queue', + connectionId: 'conn-1', + dbName: 'main', + }; + + persistQueryTabDraftSnapshot(tab, 'select 1;'); + await vi.advanceTimersByTimeAsync(160); + expect(scheduling.idleCallbacks.size).toBe(1); + + for (let index = 0; index < 100; index += 1) { + persistQueryTabDraftSnapshot(tab, `select ${index};`); + } + + expect(scheduling.idleCallbacks.size).toBe(0); + expect(scheduling.windowStub.cancelIdleCallback).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(160); + expect(scheduling.idleCallbacks.size).toBe(1); + expect(scheduling.windowStub.requestIdleCallback).toHaveBeenCalledTimes(2); + }); + + it('reuses the pending idle callback without losing the latest edit when cancellation is unavailable', async () => { + vi.useFakeTimers(); + vi.resetModules(); + const scheduling = createBrowserSchedulingHarness(); + const storage = new MemoryStorage(); + vi.stubGlobal('window', { + ...scheduling.windowStub, + cancelIdleCallback: undefined, + }); + vi.stubGlobal('localStorage', storage); + const { persistQueryTabDraftSnapshot } = await import('./sqlFileTabDrafts'); + const tab = { + id: 'query-no-idle-cancel', + title: 'No idle cancellation', + connectionId: 'conn-1', + dbName: 'main', + }; + + persistQueryTabDraftSnapshot(tab, 'select 1;'); + await vi.advanceTimersByTimeAsync(160); + persistQueryTabDraftSnapshot(tab, 'select 2;'); + await vi.advanceTimersByTimeAsync(160); + + expect(scheduling.windowStub.requestIdleCallback).toHaveBeenCalledTimes(1); + expect(scheduling.idleCallbacks.size).toBe(1); + scheduling.runNextIdleCallback(); + expect(JSON.parse(storage.getItem('gonavi-query-tab-drafts-v1') || '[]')[0].query).toBe('select 2;'); + }); + + it('defers the synchronous fallback write beyond the editor debounce when idle callbacks are unavailable', async () => { + vi.useFakeTimers(); + vi.resetModules(); + const scheduling = createBrowserSchedulingHarness(); + const storage = new MemoryStorage(); + const setItemSpy = vi.spyOn(storage, 'setItem'); + vi.stubGlobal('window', { + ...scheduling.windowStub, + requestIdleCallback: undefined, + cancelIdleCallback: undefined, + }); + vi.stubGlobal('localStorage', storage); + const { persistQueryTabDraftSnapshot } = await import('./sqlFileTabDrafts'); + + persistQueryTabDraftSnapshot({ + id: 'query-fallback', + title: 'Fallback queue', + connectionId: 'conn-1', + dbName: 'main', + }, 'select 1;'); + + await vi.advanceTimersByTimeAsync(160); + expect(setItemSpy).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(499); + expect(setItemSpy).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(setItemSpy).toHaveBeenCalledTimes(1); + }); + + it('flushes synchronously for pagehide, beforeunload, and explicit recovery requests', async () => { + vi.useFakeTimers(); + vi.resetModules(); + const scheduling = createBrowserSchedulingHarness(); + const storage = new MemoryStorage(); + const setItemSpy = vi.spyOn(storage, 'setItem'); + vi.stubGlobal('window', scheduling.windowStub); + vi.stubGlobal('localStorage', storage); + const drafts = await import('./sqlFileTabDrafts'); + const tab = { + id: 'query-recovery-events', + title: 'Recovery events', + connectionId: 'conn-1', + dbName: 'main', + }; + + drafts.persistQueryTabDraftSnapshot(tab, 'select 1;'); + scheduling.dispatch('pagehide'); + expect(setItemSpy).toHaveBeenCalledTimes(1); + + drafts.persistQueryTabDraftSnapshot(tab, 'select 2;'); + scheduling.dispatch('beforeunload'); + expect(setItemSpy).toHaveBeenCalledTimes(2); + + drafts.persistQueryTabDraftSnapshot(tab, 'select 3;'); + await vi.advanceTimersByTimeAsync(160); + expect(scheduling.idleCallbacks.size).toBe(1); + drafts.flushQueryTabDraftSnapshots(); + expect(setItemSpy).toHaveBeenCalledTimes(3); + expect(scheduling.idleCallbacks.size).toBe(0); + expect(scheduling.windowStub.cancelIdleCallback).toHaveBeenCalledTimes(1); + expect(JSON.parse(storage.getItem('gonavi-query-tab-drafts-v1') || '[]')[0].query).toBe('select 3;'); + + vi.resetModules(); + const reloaded = await import('./sqlFileTabDrafts'); + expect(reloaded.getQueryTabDraft(tab.id)).toBe('select 3;'); + }); + + it('preserves v1 ordering, count, and text-size limits when an idle batch is flushed', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-21T00:00:00Z')); + vi.resetModules(); + const scheduling = createBrowserSchedulingHarness(); + const storage = new MemoryStorage(); + const setItemSpy = vi.spyOn(storage, 'setItem'); + vi.stubGlobal('window', scheduling.windowStub); + vi.stubGlobal('localStorage', storage); + const drafts = await import('./sqlFileTabDrafts'); + + for (let index = 0; index < 31; index += 1) { + vi.setSystemTime(new Date(`2026-07-21T00:00:${String(index).padStart(2, '0')}Z`)); + drafts.persistQueryTabDraftSnapshot({ + id: `ordered-query-${index}`, + title: `Ordered query ${index}`, + connectionId: 'conn-1', + dbName: 'main', + }, `select ${index};`); + } + vi.setSystemTime(new Date('2026-07-21T00:01:00Z')); + drafts.persistQueryTabDraftSnapshot({ + id: 'ordered-query-1', + title: 'Ordered query 1', + connectionId: 'conn-1', + dbName: 'main', + }, 'x'.repeat(1024 * 1024 + 100)); + + await vi.advanceTimersByTimeAsync(160); + scheduling.runNextIdleCallback(); + + expect(setItemSpy).toHaveBeenCalledTimes(1); + expect(storage.length).toBe(1); + const payload = JSON.parse(storage.getItem('gonavi-query-tab-drafts-v1') || '[]'); + expect(payload).toHaveLength(30); + expect(payload[0].tabId).toBe('ordered-query-1'); + expect(payload[0].query).toHaveLength(1024 * 1024); + expect(payload.some((entry: { tabId: string }) => entry.tabId === 'ordered-query-0')).toBe(false); + }); + it('stores query editor drafts outside the persisted tab state', () => { clearQueryTabDraft('query-tab-1'); diff --git a/frontend/src/utils/sqlFileTabDrafts.ts b/frontend/src/utils/sqlFileTabDrafts.ts index 762161d3..fc553f3d 100644 --- a/frontend/src/utils/sqlFileTabDrafts.ts +++ b/frontend/src/utils/sqlFileTabDrafts.ts @@ -6,6 +6,8 @@ const QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY = 'gonavi-query-tab-drafts-v1'; const QUERY_TAB_DRAFT_SNAPSHOT_MAX_COUNT = 30; const QUERY_TAB_DRAFT_SNAPSHOT_MAX_TEXT_LENGTH = 1024 * 1024; const QUERY_TAB_DRAFT_SNAPSHOT_DEBOUNCE_MS = 160; +const QUERY_TAB_DRAFT_SNAPSHOT_IDLE_TIMEOUT_MS = 1500; +const QUERY_TAB_DRAFT_SNAPSHOT_FALLBACK_DELAY_MS = 500; type PersistedQueryTabDraftEntry = { tabId: string; @@ -28,11 +30,17 @@ const persistedDrafts = new Map(); let persistedDraftsHydrated = false; let persistTimer: ReturnType | null = null; +let persistIdleCallback: number | null = null; +let persistFallbackTimer: ReturnType | null = null; +let persistedDraftRevision = 0; +let flushedPersistedDraftRevision = 0; let flushListenersBound = false; -const getWindowTimerApi = (): { +const getWindowSchedulingApi = (): { setTimeout: typeof globalThis.setTimeout; clearTimeout: typeof globalThis.clearTimeout; + requestIdleCallback: typeof window.requestIdleCallback | null; + cancelIdleCallback: typeof window.cancelIdleCallback | null; } | null => { if (typeof window === 'undefined') { return null; @@ -45,6 +53,12 @@ const getWindowTimerApi = (): { return { setTimeout: setTimeoutImpl, clearTimeout: clearTimeoutImpl, + requestIdleCallback: typeof window.requestIdleCallback === 'function' + ? window.requestIdleCallback.bind(window) + : null, + cancelIdleCallback: typeof window.cancelIdleCallback === 'function' + ? window.cancelIdleCallback.bind(window) + : null, }; }; @@ -125,19 +139,37 @@ const ensurePersistedDraftsHydrated = (): void => { } }; -const flushPersistedDrafts = (): void => { - const timerApi = getWindowTimerApi(); - if (persistTimer !== null && timerApi) { - timerApi.clearTimeout(persistTimer); +const cancelScheduledPersistedDraftFlush = (): void => { + const schedulingApi = getWindowSchedulingApi(); + if (persistTimer !== null && schedulingApi) { + schedulingApi.clearTimeout(persistTimer); persistTimer = null; } + if (persistFallbackTimer !== null && schedulingApi) { + schedulingApi.clearTimeout(persistFallbackTimer); + persistFallbackTimer = null; + } + if (persistIdleCallback !== null && schedulingApi?.cancelIdleCallback) { + schedulingApi.cancelIdleCallback(persistIdleCallback); + persistIdleCallback = null; + } +}; + +const flushPersistedDrafts = (): void => { + cancelScheduledPersistedDraftFlush(); + if (flushedPersistedDraftRevision === persistedDraftRevision) { + return; + } + const revision = persistedDraftRevision; const storage = getDraftSnapshotStorage(); if (!storage) { + flushedPersistedDraftRevision = revision; return; } try { if (persistedDrafts.size === 0) { storage.removeItem(QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY); + flushedPersistedDraftRevision = revision; return; } const payload = Array.from(persistedDrafts.values()) @@ -147,6 +179,7 @@ const flushPersistedDrafts = (): void => { QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY, JSON.stringify(payload), ); + flushedPersistedDraftRevision = revision; } catch { // ignore storage quota or serialization failures } @@ -174,16 +207,44 @@ const bindFlushListeners = (): void => { const schedulePersistedDraftFlush = (): void => { bindFlushListeners(); - const timerApi = getWindowTimerApi(); - if (!timerApi) { + persistedDraftRevision += 1; + const schedulingApi = getWindowSchedulingApi(); + if (!schedulingApi) { flushPersistedDrafts(); return; } if (persistTimer !== null) { - timerApi.clearTimeout(persistTimer); + schedulingApi.clearTimeout(persistTimer); } - persistTimer = timerApi.setTimeout(() => { - flushPersistedDrafts(); + if (persistFallbackTimer !== null) { + schedulingApi.clearTimeout(persistFallbackTimer); + persistFallbackTimer = null; + } + if (persistIdleCallback !== null && schedulingApi.cancelIdleCallback) { + schedulingApi.cancelIdleCallback(persistIdleCallback); + persistIdleCallback = null; + } + persistTimer = schedulingApi.setTimeout(() => { + persistTimer = null; + // The snapshot can approach 30 MiB. Keep its JSON serialization and the + // synchronous localStorage write out of the editor's debounce callback. + if (schedulingApi.requestIdleCallback) { + if (persistIdleCallback !== null) { + return; + } + persistIdleCallback = schedulingApi.requestIdleCallback(() => { + persistIdleCallback = null; + if (persistTimer !== null) { + return; + } + flushPersistedDrafts(); + }, { timeout: QUERY_TAB_DRAFT_SNAPSHOT_IDLE_TIMEOUT_MS }); + return; + } + persistFallbackTimer = schedulingApi.setTimeout(() => { + persistFallbackTimer = null; + flushPersistedDrafts(); + }, QUERY_TAB_DRAFT_SNAPSHOT_FALLBACK_DELAY_MS); }, QUERY_TAB_DRAFT_SNAPSHOT_DEBOUNCE_MS); }; @@ -281,6 +342,10 @@ export const listPersistedQueryTabDraftEntries = (): PersistedQueryTabDraftEntry return Array.from(persistedDrafts.values()).sort((a, b) => b.updatedAt - a.updatedAt); }; +export const flushQueryTabDraftSnapshots = (): void => { + flushPersistedDrafts(); +}; + export const setSQLFileTabDraft = (tabId: string, content: string): void => { setQueryTabDraft(tabId, content); };