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.
Unified · Split
| @@ -62,6 +62,8 @@ function readPersistedState(): AppState { · 61 unmodified lines | |||
| 62 | } | 62 | } |
| 63 | } | 63 | } |
| 64 | 64 | ||
| 65 | let legacyKeysCleanedUp = false; | ||
| 66 | |||
| 65 | function persistState(state: AppState): void { | 67 | function persistState(state: AppState): void { |
| 66 | if (typeof window === "undefined") return; | 68 | if (typeof window === "undefined") return; |
| 67 | try { | 69 | try { |
| @@ -73,14 +75,29 @@ function persistState(state: AppState): void { · 5 unmodified lines | |||
| 73 | .map((project) => project.cwd), | 75 | .map((project) => project.cwd), |
| 74 | }), | 76 | }), |
| 75 | ); | 77 | ); |
| 76 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { | 78 | if (!legacyKeysCleanedUp) { |
| 77 | window.localStorage.removeItem(legacyKey); | 79 | legacyKeysCleanedUp = true; |
| 80 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { | ||
| 81 | window.localStorage.removeItem(legacyKey); | ||
| 82 | } | ||
| 78 | } | 83 | } |
| 79 | } catch { | 84 | } catch { |
| 80 | // Ignore quota/storage errors to avoid breaking chat UX. | 85 | // Ignore quota/storage errors to avoid breaking chat UX. |
| 81 | } | 86 | } |
| 82 | } | 87 | } |
| 83 | 88 | ||
| 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 | |||
| 84 | // ── Pure helpers ────────────────────────────────────────────────────── | 101 | // ── Pure helpers ────────────────────────────────────────────────────── |
| 85 | 102 | ||
| 86 | function updateThread( | 103 | function updateThread( |
| @@ -394,8 +411,19 @@ export const useStore = create<AppStore>((set) => ({ · 307 unmodified lines | |||
| 394 | set((state) => setThreadBranch(state, threadId, branch, worktreePath)), | 411 | set((state) => setThreadBranch(state, threadId, branch, worktreePath)), |
| 395 | })); | 412 | })); |
| 396 | 413 | ||
| 397 | // Persist on every state change | 414 | // Persist state changes with debouncing to avoid localStorage thrashing |
| 398 | useStore.subscribe((state) => persistState(state)); | 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 | } | ||
| 399 | 427 | ||
| 400 | export function StoreProvider({ children }: { children: ReactNode }) { | 428 | export function StoreProvider({ children }: { children: ReactNode }) { |
| 401 | useEffect(() => { | 429 | useEffect(() => { |