History

perf: debounce Zustand store persistence and deduplicate legacy cleanup

The useStore subscriber called persistState on every state mutation, triggering JSON.stringify + localStorage.setItem synchronously. It also ran 8 localStorage.removeItem calls for legacy keys on every fire. Wrap the subscriber with a 500ms debounce so rapid state changes batch into a single write. Move legacy key cleanup behind a one-time flag so it runs only once per page load. Add a beforeunload handler to flush the final state.

Chukwudi Nwobodo committed 8 months ago

Commit dd51d4f1 parent e2168731 file · +32 −4Browse files

Unified · Split

apps/web/src/store.ts+32−4
@@ -62,6 +62,8 @@ function readPersistedState(): AppState { · 61 unmodified lines
6262 }
6363 }
6464
65+let legacyKeysCleanedUp = false;
66+
6567 function persistState(state: AppState): void {
6668 if (typeof window === "undefined") return;
6769 try {
@@ -73,14 +75,29 @@ function persistState(state: AppState): void { · 5 unmodified lines
7375 .map((project) => project.cwd),
7476 }),
7577 );
76− for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
77− window.localStorage.removeItem(legacyKey);
78+ if (!legacyKeysCleanedUp) {
79+ legacyKeysCleanedUp = true;
80+ for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
81+ window.localStorage.removeItem(legacyKey);
82+ }
7883 }
7984 } catch {
8085 // Ignore quota/storage errors to avoid breaking chat UX.
8186 }
8287 }
8388
89+let persistTimer: ReturnType<typeof setTimeout> | null = null;
90+
91+function debouncedPersistState(state: AppState): void {
92+ if (persistTimer !== null) {
93+ clearTimeout(persistTimer);
94+ }
95+ persistTimer = setTimeout(() => {
96+ persistTimer = null;
97+ persistState(state);
98+ }, 500);
99+}
100+
84101 // ── Pure helpers ──────────────────────────────────────────────────────
85102
86103 function updateThread(
@@ -394,8 +411,19 @@ export const useStore = create<AppStore>((set) => ({ · 307 unmodified lines
394411 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
395412 }));
396413
397−// Persist on every state change
398−useStore.subscribe((state) => persistState(state));
414+// Persist state changes with debouncing to avoid localStorage thrashing
415+useStore.subscribe((state) => debouncedPersistState(state));
416+
417+// Flush pending writes synchronously before page unload to prevent data loss.
418+if (typeof window !== "undefined") {
419+ window.addEventListener("beforeunload", () => {
420+ if (persistTimer !== null) {
421+ clearTimeout(persistTimer);
422+ persistTimer = null;
423+ persistState(useStore.getState());
424+ }
425+ });
426+}
399427
400428 export function StoreProvider({ children }: { children: ReactNode }) {
401429 useEffect(() => {