From cd55228e1e8c2ba0b4e2f5573d86dd1a25a6ae59 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 22 Jul 2026 01:14:26 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(store):=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E6=8C=81=E4=B9=85=E5=8C=96=E5=86=99=E5=85=A5=E5=B9=B6?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E7=8A=B6=E6=80=81=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/store.test.ts | 106 +++++++ frontend/src/store.ts | 300 +++++++++--------- .../src/utils/debouncedPersistStorage.test.ts | 112 +++++++ frontend/src/utils/debouncedPersistStorage.ts | 184 +++++++++++ 4 files changed, 549 insertions(+), 153 deletions(-) create mode 100644 frontend/src/utils/debouncedPersistStorage.test.ts create mode 100644 frontend/src/utils/debouncedPersistStorage.ts diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index f5dcad02..e6a3cd42 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -2996,3 +2996,109 @@ describe('store appearance persistence', () => { .toBe(body); }); }); + +describe('store persistence hot path', () => { + let storage: MemoryStorage; + + beforeEach(() => { + storage = new MemoryStorage(); + vi.stubGlobal('localStorage', storage); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('reuses the persisted projection across transient state updates', async () => { + const { useStore } = await importStore(); + const partialize = useStore.persist.getOptions().partialize; + if (!partialize) { + throw new Error('expected store partialize option'); + } + const state = useStore.getState(); + + const projections = Array.from({ length: 1_000 }, (_, index) => + partialize({ + ...state, + aiPanelVisible: index % 2 === 0, + jvmDiagnosticOutputs: { + [`diagnostic-${index}`]: [], + }, + }), + ); + + expect(new Set(projections).size).toBe(1); + }); + + it('invalidates the persisted projection when a persisted field changes', async () => { + const { useStore } = await importStore(); + const partialize = useStore.persist.getOptions().partialize; + if (!partialize) { + throw new Error('expected store partialize option'); + } + const state = useStore.getState(); + + const initial = partialize(state) as Partial; + const transientOnly = partialize({ + ...state, + aiPanelVisible: !state.aiPanelVisible, + }) as Partial; + const changedTheme = partialize({ + ...state, + theme: state.theme === 'light' ? 'dark' : 'light', + }) as Partial; + + expect(transientOnly).toBe(initial); + expect(changedTheme).not.toBe(initial); + expect(changedTheme.theme).not.toBe(initial.theme); + }); + + it('invalidates connection projection when legacy secrets appear or disappear', async () => { + const { useStore } = await importStore(); + const partialize = useStore.persist.getOptions().partialize; + if (!partialize) { + throw new Error('expected store partialize option'); + } + const state = useStore.getState(); + const cleanState = { ...state, connections: [] }; + + const cleanProjection = partialize(cleanState) as Partial; + expect(Object.prototype.hasOwnProperty.call(cleanProjection, 'connections')).toBe(false); + + const legacyConnections = [ + { + id: 'legacy-secret', + name: 'Legacy Secret', + config: { + id: 'legacy-secret', + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + password: 'secret', + }, + }, + ]; + const legacyProjection = partialize({ + ...cleanState, + connections: legacyConnections, + }) as Partial; + + expect(legacyProjection).not.toBe(cleanProjection); + expect(legacyProjection.connections).toBe(legacyConnections); + + const scrubbedConnections = legacyConnections.map((connection) => ({ + ...connection, + config: { ...connection.config, password: '' }, + })); + const scrubbedProjection = partialize({ + ...cleanState, + connections: scrubbedConnections, + }) as Partial; + + expect(scrubbedProjection).not.toBe(legacyProjection); + expect(Object.prototype.hasOwnProperty.call(scrubbedProjection, 'connections')).toBe(false); + }); +}); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index d45a7484..5ac84242 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,10 +1,5 @@ import { create } from "zustand"; -import { - createJSONStorage, - persist, - type PersistStorage, - type StateStorage, -} from "zustand/middleware"; +import { persist } from "zustand/middleware"; import { isNativeDetachedWindowRoute } from "./utils/nativeDetachedWindowRoute"; import { ConnectionConfig, @@ -135,6 +130,7 @@ import { } from "./utils/connectionTypeCatalog"; import { supportsSSLForType } from "./utils/connectionTypeCapabilities"; import { normalizeDriverType } from "./utils/connectionDriverType"; +import { createDebouncedPersistStorage } from "./utils/debouncedPersistStorage"; export type TableDoubleClickAction = "open-data" | "open-design"; export type ThemeMode = "light" | "dark"; @@ -291,94 +287,6 @@ const resolveSavedQueryBackend = (): SavedQueryBackend | undefined => { return (window as unknown as { go?: { app?: { App?: SavedQueryBackend } } }).go?.app?.App; }; -const createDebouncedPersistStorage = ( - getStorage: () => StateStorage, - debounceMs = PERSIST_WRITE_DEBOUNCE_MS, -): PersistStorage | undefined => { - const baseStorage = createJSONStorage(getStorage); - if (!baseStorage || isFrontendTestRuntime()) { - return baseStorage; - } - - type PersistedValue = Parameters["setItem"]>[1]; - let pendingWrite: { name: string; value: PersistedValue } | null = null; - let pendingTimer: number | null = null; - let listenersBound = false; - let pendingResolves: Array<() => void> = []; - let pendingRejects: Array<(error: unknown) => void> = []; - - const settlePending = (error?: unknown) => { - const resolves = pendingResolves; - const rejects = pendingRejects; - pendingResolves = []; - pendingRejects = []; - if (error !== undefined) { - rejects.forEach((reject) => reject(error)); - return; - } - resolves.forEach((resolve) => resolve()); - }; - - const flushPendingWrite = async (): Promise => { - if (pendingTimer !== null) { - window.clearTimeout(pendingTimer); - pendingTimer = null; - } - const nextWrite = pendingWrite; - pendingWrite = null; - if (!nextWrite) { - settlePending(); - return; - } - try { - await baseStorage.setItem(nextWrite.name, nextWrite.value); - settlePending(); - } catch (error) { - settlePending(error); - throw error; - } - }; - - const bindFlushListeners = () => { - if (listenersBound || typeof window === "undefined") { - return; - } - listenersBound = true; - const handleFlush = () => { - void flushPendingWrite(); - }; - window.addEventListener("pagehide", handleFlush, { capture: true }); - window.addEventListener("beforeunload", handleFlush, { capture: true }); - }; - - return { - getItem: baseStorage.getItem, - setItem: (name, value) => { - bindFlushListeners(); - pendingWrite = { name, value }; - if (pendingTimer !== null) { - window.clearTimeout(pendingTimer); - } - return new Promise((resolve, reject) => { - pendingResolves.push(resolve); - pendingRejects.push(reject); - pendingTimer = window.setTimeout(() => { - void flushPendingWrite(); - }, debounceMs); - }); - }, - removeItem: async (name) => { - pendingWrite = null; - if (pendingTimer !== null) { - window.clearTimeout(pendingTimer); - pendingTimer = null; - } - settlePending(); - await baseStorage.removeItem(name); - }, - }; -}; - const writePersistedStatePatch = ( patch: Record, ): void => { @@ -3358,6 +3266,146 @@ export async function loadAISessionFromBackend( return false; } +const PERSISTED_STATE_DEPENDENCY_KEYS = [ + "tabs", + "activeTabId", + "connectionTags", + "sidebarRootOrder", + "externalSQLDirectories", + "recentConnectionTargets", + "recentSQLFiles", + "theme", + "themePreference", + "brandIconId", + "languagePreference", + "appearance", + "uiScale", + "fontSize", + "startupFullscreen", + "aiChatOpenMode", + "aiChatDetachedBoundsMemory", + "globalProxy", + "sqlFormatOptions", + "queryOptions", + "dataEditTransactionOptions", + "sqlEditorTransactionOptions", + "shortcutOptions", + "sqlLogs", + "tableExportHistories", + "sqlSnippets", + "tableAccessCount", + "tableSortPreference", + "tableColumnOrders", + "enableColumnOrderMemory", + "tablePinnedLeftColumns", + "tableHiddenColumns", + "enableHiddenColumnMemory", + "pinnedSidebarTables", + "windowBounds", + "windowState", + "sidebarWidth", + "connections", +] as const satisfies readonly (keyof AppState)[]; + +type PersistedStateProjectionSource = Pick< + AppState, + (typeof PERSISTED_STATE_DEPENDENCY_KEYS)[number] +>; + +const buildPersistedStateProjection = ( + state: PersistedStateProjectionSource, +): AppState => { + const tabs = sanitizeQueryTabs(state.tabs); + const partialState: Partial = { + tabs, + activeTabId: sanitizeActiveTabId(state.activeTabId, tabs), + connectionTags: state.connectionTags, + sidebarRootOrder: state.sidebarRootOrder, + externalSQLDirectories: state.externalSQLDirectories, + recentConnectionTargets: sanitizeRecentConnectionTargets( + state.recentConnectionTargets, + ), + recentSQLFiles: sanitizeRecentSQLFiles(state.recentSQLFiles), + theme: state.theme, + themePreference: state.themePreference, + brandIconId: sanitizeBrandIconIdLocal(state.brandIconId), + languagePreference: state.languagePreference, + appearance: state.appearance, + uiScale: state.uiScale, + fontSize: state.fontSize, + startupFullscreen: state.startupFullscreen, + aiChatOpenMode: sanitizeAIChatOpenMode(state.aiChatOpenMode), + aiChatDetachedBoundsMemory: sanitizeAIChatDetachedBoundsMemory( + state.aiChatDetachedBoundsMemory, + ), + globalProxy: + toTrimmedString(state.globalProxy.password) !== "" + ? { ...state.globalProxy } + : toPersistedGlobalProxy(state.globalProxy), + sqlFormatOptions: state.sqlFormatOptions, + queryOptions: state.queryOptions, + dataEditTransactionOptions: state.dataEditTransactionOptions, + sqlEditorTransactionOptions: state.sqlEditorTransactionOptions, + shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions), + sqlLogs: sanitizePersistedSqlLogs(state.sqlLogs), + tableExportHistories: sanitizeTableExportHistories( + state.tableExportHistories, + ), + sqlSnippets: state.sqlSnippets, + tableAccessCount: state.tableAccessCount, + tableSortPreference: state.tableSortPreference, + tableColumnOrders: state.tableColumnOrders, + enableColumnOrderMemory: state.enableColumnOrderMemory, + tablePinnedLeftColumns: state.tablePinnedLeftColumns, + tableHiddenColumns: state.tableHiddenColumns, + enableHiddenColumnMemory: state.enableHiddenColumnMemory, + pinnedSidebarTables: state.pinnedSidebarTables, + windowBounds: state.windowBounds, + windowState: state.windowState, + sidebarWidth: state.sidebarWidth, + }; + + if (hasLegacyConnectionSecrets(state.connections)) { + partialState.connections = state.connections; + } + + // AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage + return partialState as AppState; +}; + +const createMemoizedPersistedStateProjection = () => { + let previousDependencies: unknown[] | null = null; + let previousProjection: AppState | null = null; + + return (state: AppState): AppState => { + if (previousDependencies && previousProjection) { + let unchanged = true; + for ( + let index = 0; + index < PERSISTED_STATE_DEPENDENCY_KEYS.length; + index += 1 + ) { + const key = PERSISTED_STATE_DEPENDENCY_KEYS[index]; + if (!Object.is(previousDependencies[index], state[key])) { + unchanged = false; + break; + } + } + if (unchanged) { + return previousProjection; + } + } + + previousDependencies = PERSISTED_STATE_DEPENDENCY_KEYS.map( + (key) => state[key], + ); + previousProjection = buildPersistedStateProjection(state); + return previousProjection; + }; +}; + +const partializePersistedState = createMemoizedPersistedStateProjection(); + export const useStore = create()( persist( (set, get) => ({ @@ -5548,7 +5596,10 @@ export const useStore = create()( }), { name: PERSIST_STORAGE_KEY, // name of the item in the storage (must be unique) - storage: createDebouncedPersistStorage(() => localStorage), + storage: createDebouncedPersistStorage(() => localStorage, { + debounceMs: PERSIST_WRITE_DEBOUNCE_MS, + enabled: !isFrontendTestRuntime(), + }), skipHydration: isNativeDetachedWindowRoute(), version: PERSIST_VERSION, migrate: (persistedState: unknown, version: number) => { @@ -5774,64 +5825,7 @@ export const useStore = create()( aiChatSessions: [], }; }, - partialize: (state) => { - const tabs = sanitizeQueryTabs(state.tabs); - const partialState: Partial = { - tabs, - activeTabId: sanitizeActiveTabId(state.activeTabId, tabs), - connectionTags: state.connectionTags, - sidebarRootOrder: state.sidebarRootOrder, - externalSQLDirectories: state.externalSQLDirectories, - recentConnectionTargets: sanitizeRecentConnectionTargets( - state.recentConnectionTargets, - ), - recentSQLFiles: sanitizeRecentSQLFiles(state.recentSQLFiles), - theme: state.theme, - themePreference: state.themePreference, - brandIconId: sanitizeBrandIconIdLocal(state.brandIconId), - languagePreference: state.languagePreference, - appearance: state.appearance, - uiScale: state.uiScale, - fontSize: state.fontSize, - startupFullscreen: state.startupFullscreen, - aiChatOpenMode: sanitizeAIChatOpenMode(state.aiChatOpenMode), - aiChatDetachedBoundsMemory: sanitizeAIChatDetachedBoundsMemory( - state.aiChatDetachedBoundsMemory, - ), - globalProxy: - toTrimmedString(state.globalProxy.password) !== "" - ? { ...state.globalProxy } - : toPersistedGlobalProxy(state.globalProxy), - sqlFormatOptions: state.sqlFormatOptions, - queryOptions: state.queryOptions, - dataEditTransactionOptions: state.dataEditTransactionOptions, - sqlEditorTransactionOptions: state.sqlEditorTransactionOptions, - shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions), - sqlLogs: sanitizePersistedSqlLogs(state.sqlLogs), - tableExportHistories: sanitizeTableExportHistories( - state.tableExportHistories, - ), - sqlSnippets: state.sqlSnippets, - tableAccessCount: state.tableAccessCount, - tableSortPreference: state.tableSortPreference, - tableColumnOrders: state.tableColumnOrders, - enableColumnOrderMemory: state.enableColumnOrderMemory, - tablePinnedLeftColumns: state.tablePinnedLeftColumns, - tableHiddenColumns: state.tableHiddenColumns, - enableHiddenColumnMemory: state.enableHiddenColumnMemory, - pinnedSidebarTables: state.pinnedSidebarTables, - windowBounds: state.windowBounds, - windowState: state.windowState, - sidebarWidth: state.sidebarWidth, - }; - - if (hasLegacyConnectionSecrets(state.connections)) { - partialState.connections = state.connections; - } - - // AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage - return partialState as AppState; - }, // Don't persist logs + partialize: partializePersistedState, }, ), ); diff --git a/frontend/src/utils/debouncedPersistStorage.test.ts b/frontend/src/utils/debouncedPersistStorage.test.ts new file mode 100644 index 00000000..9f00e69b --- /dev/null +++ b/frontend/src/utils/debouncedPersistStorage.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; +import { + createDebouncedPersistStorage, + type FlushablePersistStorage, +} from "./debouncedPersistStorage"; + +interface TestState { + value: number; +} + +const createFlushableStorage = ( + baseStorage: StateStorage, + flushEventTarget: EventTarget | null = null, +): FlushablePersistStorage => { + const storage = createDebouncedPersistStorage( + () => baseStorage, + { + debounceMs: 60_000, + enabled: true, + flushEventTarget, + }, + ); + if (!storage || !("flush" in storage)) { + throw new Error("expected a flushable persist storage"); + } + return storage; +}; + +const persistedValue = (value: number) => ({ + state: { value }, + version: 1, +}); + +describe("debounced persist storage", () => { + it("shares one pending promise across a large burst of writes", async () => { + const baseStorage: StateStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + }; + const storage = createFlushableStorage(baseStorage); + + const pendingWrites = Array.from({ length: 1_000 }, (_, index) => + storage.setItem("store", persistedValue(index)), + ); + + expect(new Set(pendingWrites).size).toBe(1); + await storage.removeItem("store"); + await Promise.all(pendingWrites); + }); + + it("does not settle a write queued while the previous flush is in flight", async () => { + const writes: Array<{ + value: string; + resolve: () => void; + }> = []; + const baseStorage: StateStorage = { + getItem: () => null, + setItem: (_name, value) => new Promise((resolve) => { + writes.push({ value, resolve }); + }), + removeItem: () => undefined, + }; + const storage = createFlushableStorage(baseStorage); + + const firstWrite = storage.setItem("store", persistedValue(1)); + const firstFlush = storage.flush(); + expect(writes).toHaveLength(1); + + const secondWrite = storage.setItem("store", persistedValue(2)); + let secondSettled = false; + void Promise.resolve(secondWrite).then(() => { + secondSettled = true; + }); + + writes[0].resolve(); + await Promise.all([Promise.resolve(firstWrite), firstFlush]); + await Promise.resolve(); + + expect(secondSettled).toBe(false); + expect(writes).toHaveLength(1); + + const secondFlush = storage.flush(); + expect(writes).toHaveLength(2); + expect(JSON.parse(writes[1].value).state.value).toBe(2); + writes[1].resolve(); + await Promise.all([Promise.resolve(secondWrite), secondFlush]); + }); + + it("flushes the latest snapshot synchronously when the page is hidden", async () => { + const eventTarget = new EventTarget(); + let persisted: string | null = null; + const baseStorage: StateStorage = { + getItem: () => persisted, + setItem: (_name, value) => { + persisted = value; + }, + removeItem: () => { + persisted = null; + }, + }; + const storage = createFlushableStorage(baseStorage, eventTarget); + + const firstWrite = storage.setItem("store", persistedValue(1)); + const latestWrite = storage.setItem("store", persistedValue(2)); + eventTarget.dispatchEvent(new Event("pagehide")); + + expect(JSON.parse(persisted || "{}").state.value).toBe(2); + await Promise.all([Promise.resolve(firstWrite), Promise.resolve(latestWrite)]); + }); +}); diff --git a/frontend/src/utils/debouncedPersistStorage.ts b/frontend/src/utils/debouncedPersistStorage.ts new file mode 100644 index 00000000..21cef5fc --- /dev/null +++ b/frontend/src/utils/debouncedPersistStorage.ts @@ -0,0 +1,184 @@ +import { + createJSONStorage, + type PersistStorage, + type StateStorage, +} from "zustand/middleware"; + +export interface DebouncedPersistStorageOptions { + debounceMs: number; + enabled: boolean; + flushEventTarget?: Pick | null; +} + +export interface FlushablePersistStorage extends PersistStorage { + flush: () => Promise; +} + +export const createDebouncedPersistStorage = ( + getStorage: () => StateStorage, + options: DebouncedPersistStorageOptions, +): PersistStorage | FlushablePersistStorage | undefined => { + const baseStorage = createJSONStorage(getStorage); + if (!baseStorage || !options.enabled) { + return baseStorage; + } + + type PersistedValue = Parameters["setItem"]>[1]; + type PendingOperation = + | { kind: "set"; name: string; value: PersistedValue } + | { kind: "remove"; name: string }; + type PendingBatch = { + promise: Promise; + resolve: () => void; + reject: (error: unknown) => void; + }; + + let pendingOperation: PendingOperation | null = null; + let pendingBatch: PendingBatch | null = null; + let pendingTimer: ReturnType | null = null; + let pendingFlushRequested = false; + let operationInFlight = false; + let activeBatchPromise: Promise | null = null; + let listenersBound = false; + + const createPendingBatch = (): PendingBatch => { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((batchResolve, batchReject) => { + resolve = batchResolve; + reject = batchReject; + }); + // Zustand intentionally ignores persistence promises for ordinary state + // updates. Keep the rejection observable to explicit callers without + // producing an unhandled rejection for fire-and-forget updates. + void promise.catch(() => undefined); + return { promise, resolve, reject }; + }; + + const clearPendingTimer = () => { + if (pendingTimer !== null) { + clearTimeout(pendingTimer); + pendingTimer = null; + } + }; + + const finishOperation = ( + batch: PendingBatch, + succeeded: boolean, + error?: unknown, + ) => { + operationInFlight = false; + activeBatchPromise = null; + if (succeeded) { + batch.resolve(); + } else { + batch.reject(error); + } + + if (pendingFlushRequested) { + startPendingOperation(); + } + }; + + const startPendingOperation = () => { + if (operationInFlight || !pendingOperation || !pendingBatch) { + return; + } + + clearPendingTimer(); + const operation = pendingOperation; + const batch = pendingBatch; + pendingOperation = null; + pendingBatch = null; + pendingFlushRequested = false; + operationInFlight = true; + activeBatchPromise = batch.promise; + + let result: unknown; + try { + result = operation.kind === "set" + ? baseStorage.setItem(operation.name, operation.value) + : baseStorage.removeItem(operation.name); + } catch (error) { + finishOperation(batch, false, error); + return; + } + + if ( + result && + typeof (result as PromiseLike).then === "function" + ) { + void Promise.resolve(result).then( + () => finishOperation(batch, true), + (error) => finishOperation(batch, false, error), + ); + return; + } + + finishOperation(batch, true); + }; + + const flushPendingWrite = (): Promise => { + clearPendingTimer(); + if (!pendingOperation || !pendingBatch) { + return activeBatchPromise ?? Promise.resolve(); + } + + const promise = pendingBatch.promise; + pendingFlushRequested = true; + startPendingOperation(); + return promise; + }; + + const bindFlushListeners = () => { + if (listenersBound) { + return; + } + const eventTarget = options.flushEventTarget === undefined + ? typeof window === "undefined" + ? null + : window + : options.flushEventTarget; + if (!eventTarget) { + return; + } + listenersBound = true; + const handleFlush = () => { + void flushPendingWrite().catch(() => undefined); + }; + eventTarget.addEventListener("pagehide", handleFlush, { capture: true }); + eventTarget.addEventListener("beforeunload", handleFlush, { capture: true }); + }; + + return { + getItem: baseStorage.getItem, + setItem: (name, value) => { + bindFlushListeners(); + pendingOperation = { kind: "set", name, value }; + if (!pendingBatch) { + pendingBatch = createPendingBatch(); + } + if (!pendingFlushRequested) { + clearPendingTimer(); + pendingTimer = setTimeout(() => { + pendingTimer = null; + void flushPendingWrite().catch(() => undefined); + }, options.debounceMs); + } + return pendingBatch.promise; + }, + removeItem: (name) => { + bindFlushListeners(); + pendingOperation = { kind: "remove", name }; + if (!pendingBatch) { + pendingBatch = createPendingBatch(); + } + const promise = pendingBatch.promise; + pendingFlushRequested = true; + clearPendingTimer(); + startPendingOperation(); + return promise; + }, + flush: flushPendingWrite, + }; +};