️ perf(store): 合并持久化写入并复用状态投影

This commit is contained in:
Syngnat
2026-07-22 01:14:26 +08:00
parent 074f695e15
commit cd55228e1e
4 changed files with 549 additions and 153 deletions

View File

@@ -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<typeof state>;
const transientOnly = partialize({
...state,
aiPanelVisible: !state.aiPanelVisible,
}) as Partial<typeof state>;
const changedTheme = partialize({
...state,
theme: state.theme === 'light' ? 'dark' : 'light',
}) as Partial<typeof state>;
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<typeof state>;
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<typeof state>;
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<typeof state>;
expect(scrubbedProjection).not.toBe(legacyProjection);
expect(Object.prototype.hasOwnProperty.call(scrubbedProjection, 'connections')).toBe(false);
});
});

View File

@@ -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 = <S>(
getStorage: () => StateStorage,
debounceMs = PERSIST_WRITE_DEBOUNCE_MS,
): PersistStorage<S> | undefined => {
const baseStorage = createJSONStorage<S>(getStorage);
if (!baseStorage || isFrontendTestRuntime()) {
return baseStorage;
}
type PersistedValue = Parameters<PersistStorage<S>["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<void> => {
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<void>((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<string, unknown>,
): 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<AppState> = {
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<AppState>()(
persist(
(set, get) => ({
@@ -5548,7 +5596,10 @@ export const useStore = create<AppState>()(
}),
{
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<AppState>()(
aiChatSessions: [],
};
},
partialize: (state) => {
const tabs = sanitizeQueryTabs(state.tabs);
const partialState: Partial<AppState> = {
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,
},
),
);

View File

@@ -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<TestState> => {
const storage = createDebouncedPersistStorage<TestState>(
() => 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<void>((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)]);
});
});

View File

@@ -0,0 +1,184 @@
import {
createJSONStorage,
type PersistStorage,
type StateStorage,
} from "zustand/middleware";
export interface DebouncedPersistStorageOptions {
debounceMs: number;
enabled: boolean;
flushEventTarget?: Pick<EventTarget, "addEventListener"> | null;
}
export interface FlushablePersistStorage<S> extends PersistStorage<S> {
flush: () => Promise<void>;
}
export const createDebouncedPersistStorage = <S>(
getStorage: () => StateStorage,
options: DebouncedPersistStorageOptions,
): PersistStorage<S> | FlushablePersistStorage<S> | undefined => {
const baseStorage = createJSONStorage<S>(getStorage);
if (!baseStorage || !options.enabled) {
return baseStorage;
}
type PersistedValue = Parameters<PersistStorage<S>["setItem"]>[1];
type PendingOperation =
| { kind: "set"; name: string; value: PersistedValue }
| { kind: "remove"; name: string };
type PendingBatch = {
promise: Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
};
let pendingOperation: PendingOperation | null = null;
let pendingBatch: PendingBatch | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
let pendingFlushRequested = false;
let operationInFlight = false;
let activeBatchPromise: Promise<void> | null = null;
let listenersBound = false;
const createPendingBatch = (): PendingBatch => {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((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<void>).then === "function"
) {
void Promise.resolve(result).then(
() => finishOperation(batch, true),
(error) => finishOperation(batch, false, error),
);
return;
}
finishOperation(batch, true);
};
const flushPendingWrite = (): Promise<void> => {
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,
};
};