️ perf(query-draft): 将大草稿持久化移出输入热路径

将防抖后的序列化与 localStorage 写入转移到空闲任务,并合并重复调度。

保留 pagehide、beforeunload 与显式同步刷新,补充大草稿和调度边界回归测试。
This commit is contained in:
Syngnat
2026-07-22 00:13:00 +08:00
parent 419480800c
commit c0b1b0873e
2 changed files with 331 additions and 10 deletions

View File

@@ -39,6 +39,55 @@ class MemoryStorage implements Storage {
}
}
const createBrowserSchedulingHarness = () => {
let nextIdleId = 1;
const idleCallbacks = new Map<number, IdleRequestCallback>();
const eventListeners = new Map<string, EventListenerOrEventListenerObject[]>();
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');

View File

@@ -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<string, PersistedQueryTabDraftEntry>();
let persistedDraftsHydrated = false;
let persistTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
let persistIdleCallback: number | null = null;
let persistFallbackTimer: ReturnType<typeof globalThis.setTimeout> | 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);
};