authorgravatar for cjnwob@gmail.comChukwudi Nwobodo <cjnwob@gmail.com> 2026-03-08 06:22:02+00:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 14:02:29-07:00
log7a377fc5e4d607a475a7d36957689562d5dcccb9
tree8aa6767ba647bed6003162e2e8d4299df49ab80c
parent90e0c2ab15e7034d8e6e0688b3996fd905c03017

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.

1 files changed, 32 insertions(+), 4 deletions(-)

apps/web/src/store.ts+32-4
...@@ -63,6 +63,8 @@ function readPersistedState(): AppState {...@@ -63,6 +63,8 @@ function readPersistedState(): AppState {
63 }63 }
64}64}
6565
66let legacyKeysCleanedUp = false;
67
66function persistState(state: AppState): void {68function persistState(state: AppState): void {
67 if (typeof window === "undefined") return;69 if (typeof window === "undefined") return;
68 try {70 try {
...@@ -74,14 +76,29 @@ function persistState(state: AppState): void {...@@ -74,14 +76,29 @@ function persistState(state: AppState): void {
74 .map((project) => project.cwd),76 .map((project) => project.cwd),
75 }),77 }),
76 );78 );
77 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {79 if (!legacyKeysCleanedUp) {
78 window.localStorage.removeItem(legacyKey);80 legacyKeysCleanedUp = true;
81 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
82 window.localStorage.removeItem(legacyKey);
83 }
79 }84 }
80 } catch {85 } catch {
81 // Ignore quota/storage errors to avoid breaking chat UX.86 // Ignore quota/storage errors to avoid breaking chat UX.
82 }87 }
83}88}
8489
90let persistTimer: ReturnType<typeof setTimeout> | null = null;
91
92function debouncedPersistState(state: AppState): void {
93 if (persistTimer !== null) {
94 clearTimeout(persistTimer);
95 }
96 persistTimer = setTimeout(() => {
97 persistTimer = null;
98 persistState(state);
99 }, 500);
100}
101
85// ── Pure helpers ──────────────────────────────────────────────────────102// ── Pure helpers ──────────────────────────────────────────────────────
86103
87function updateThread(104function updateThread(
...@@ -395,8 +412,19 @@ export const useStore = create<AppStore>((set) => ({...@@ -395,8 +412,19 @@ export const useStore = create<AppStore>((set) => ({
395 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),412 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
396}));413}));
397414
398// Persist on every state change415// Persist state changes with debouncing to avoid localStorage thrashing
399useStore.subscribe((state) => persistState(state));416useStore.subscribe((state) => debouncedPersistState(state));
417
418// Flush pending writes synchronously before page unload to prevent data loss.
419if (typeof window !== "undefined") {
420 window.addEventListener("beforeunload", () => {
421 if (persistTimer !== null) {
422 clearTimeout(persistTimer);
423 persistTimer = null;
424 persistState(useStore.getState());
425 }
426 });
427}
400428
401export function StoreProvider({ children }: { children: ReactNode }) {429export function StoreProvider({ children }: { children: ReactNode }) {
402 useEffect(() => {430 useEffect(() => {