mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-17 12:24:09 +08:00
⚡️ perf(store): 合并持久化写入并复用状态投影
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
112
frontend/src/utils/debouncedPersistStorage.test.ts
Normal file
112
frontend/src/utils/debouncedPersistStorage.test.ts
Normal 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)]);
|
||||
});
|
||||
});
|
||||
184
frontend/src/utils/debouncedPersistStorage.ts
Normal file
184
frontend/src/utils/debouncedPersistStorage.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user