mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-09 16:23:27 +08:00
⚡️ perf(query-draft): 将大草稿持久化移出输入热路径
将防抖后的序列化与 localStorage 写入转移到空闲任务,并合并重复调度。 保留 pagehide、beforeunload 与显式同步刷新,补充大草稿和调度边界回归测试。
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user