authorgravatar for cjnwob@gmail.comChukwudi Nwobodo <cjnwob@gmail.com> 2026-03-08 06:21:52+00:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 14:02:29-07:00
log90e0c2ab15e7034d8e6e0688b3996fd905c03017
treede4fbfce739db30ed26723fd7ea0e9a0cbfbadf0
parent7a3d4d1f31e4316eb832f820012bf575aa57669a

perf: debounce composer draft localStorage writes

Every keystroke triggered full JSON serialization of all composer drafts (including base64 image attachments) and a synchronous localStorage write. At normal typing speed this caused 5+ writes/sec, blocking the main thread and creating noticeable input lag. Wrap the Zustand persist storage with a 300ms debounce. In-memory state updates remain immediate; only the serialization and storage write are deferred. A beforeunload handler flushes pending writes to prevent data loss. The removeItem method cancels any pending setItem to avoid resurrecting cleared drafts. Adds unit tests for the DebouncedStorage utility covering debounce timing, rapid writes, removeItem cancellation, flush, and edge cases.

2 files changed, 187 insertions(+), 3 deletions(-)

apps/web/src/composerDraftStore.test.ts+127-1
......@@ -1,7 +1,11 @@
11import { ProjectId, ThreadId } from "@t3tools/contracts";
22import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
4import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore";
4import {
5 type ComposerImageAttachment,
6 createDebouncedStorage,
7 useComposerDraftStore,
8} from "./composerDraftStore";
59
610function makeImage(input: {
711 id: string;
......@@ -451,3 +455,125 @@ describe("composerDraftStore runtime and interaction settings", () => {
451455 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
452456 });
453457});
458
459// ---------------------------------------------------------------------------
460// createDebouncedStorage
461// ---------------------------------------------------------------------------
462
463function createMockStorage() {
464 const store = new Map<string, string>();
465 return {
466 getItem: vi.fn((name: string) => store.get(name) ?? null),
467 setItem: vi.fn((name: string, value: string) => {
468 store.set(name, value);
469 }),
470 removeItem: vi.fn((name: string) => {
471 store.delete(name);
472 }),
473 };
474}
475
476describe("createDebouncedStorage", () => {
477 beforeEach(() => {
478 vi.useFakeTimers();
479 });
480
481 afterEach(() => {
482 vi.useRealTimers();
483 });
484
485 it("delegates getItem immediately", () => {
486 const base = createMockStorage();
487 base.getItem.mockReturnValueOnce("value");
488 const storage = createDebouncedStorage(base);
489
490 expect(storage.getItem("key")).toBe("value");
491 expect(base.getItem).toHaveBeenCalledWith("key");
492 });
493
494 it("does not write to base storage until the debounce fires", () => {
495 const base = createMockStorage();
496 const storage = createDebouncedStorage(base);
497
498 storage.setItem("key", "v1");
499 expect(base.setItem).not.toHaveBeenCalled();
500
501 vi.advanceTimersByTime(299);
502 expect(base.setItem).not.toHaveBeenCalled();
503
504 vi.advanceTimersByTime(1);
505 expect(base.setItem).toHaveBeenCalledWith("key", "v1");
506 });
507
508 it("only writes the last value when setItem is called rapidly", () => {
509 const base = createMockStorage();
510 const storage = createDebouncedStorage(base);
511
512 storage.setItem("key", "v1");
513 storage.setItem("key", "v2");
514 storage.setItem("key", "v3");
515
516 vi.advanceTimersByTime(300);
517 expect(base.setItem).toHaveBeenCalledTimes(1);
518 expect(base.setItem).toHaveBeenCalledWith("key", "v3");
519 });
520
521 it("removeItem cancels a pending setItem write", () => {
522 const base = createMockStorage();
523 const storage = createDebouncedStorage(base);
524
525 storage.setItem("key", "v1");
526 storage.removeItem("key");
527
528 vi.advanceTimersByTime(300);
529 expect(base.setItem).not.toHaveBeenCalled();
530 expect(base.removeItem).toHaveBeenCalledWith("key");
531 });
532
533 it("flush writes the pending value immediately", () => {
534 const base = createMockStorage();
535 const storage = createDebouncedStorage(base);
536
537 storage.setItem("key", "v1");
538 expect(base.setItem).not.toHaveBeenCalled();
539
540 storage.flush();
541 expect(base.setItem).toHaveBeenCalledWith("key", "v1");
542
543 // Timer should be cancelled; no duplicate write.
544 vi.advanceTimersByTime(300);
545 expect(base.setItem).toHaveBeenCalledTimes(1);
546 });
547
548 it("flush is a no-op when nothing is pending", () => {
549 const base = createMockStorage();
550 const storage = createDebouncedStorage(base);
551
552 storage.flush();
553 expect(base.setItem).not.toHaveBeenCalled();
554 });
555
556 it("flush after removeItem is a no-op", () => {
557 const base = createMockStorage();
558 const storage = createDebouncedStorage(base);
559
560 storage.setItem("key", "v1");
561 storage.removeItem("key");
562 storage.flush();
563
564 expect(base.setItem).not.toHaveBeenCalled();
565 });
566
567 it("setItem works normally after removeItem cancels a pending write", () => {
568 const base = createMockStorage();
569 const storage = createDebouncedStorage(base);
570
571 storage.setItem("key", "v1");
572 storage.removeItem("key");
573 storage.setItem("key", "v2");
574
575 vi.advanceTimersByTime(300);
576 expect(base.setItem).toHaveBeenCalledTimes(1);
577 expect(base.setItem).toHaveBeenCalledWith("key", "v2");
578 });
579});
apps/web/src/composerDraftStore.ts+60-2
......@@ -15,11 +15,69 @@ import {
1515 type ChatImageAttachment,
1616} from "./types";
1717import { create } from "zustand";
18import { createJSONStorage, persist } from "zustand/middleware";
18import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
1919
2020export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
2121export type DraftThreadEnvMode = "local" | "worktree";
2222
23const COMPOSER_PERSIST_DEBOUNCE_MS = 300;
24
25interface DebouncedStorage extends StateStorage {
26 flush: () => void;
27}
28
29export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage {
30 let timer: ReturnType<typeof setTimeout> | null = null;
31 let pendingName: string | null = null;
32 let pendingValue: string | null = null;
33 return {
34 getItem: (name) => baseStorage.getItem(name),
35 setItem: (name, value) => {
36 pendingName = name;
37 pendingValue = value;
38 if (timer !== null) {
39 clearTimeout(timer);
40 }
41 timer = setTimeout(() => {
42 timer = null;
43 pendingName = null;
44 pendingValue = null;
45 baseStorage.setItem(name, value);
46 }, COMPOSER_PERSIST_DEBOUNCE_MS);
47 },
48 removeItem: (name) => {
49 if (timer !== null) {
50 clearTimeout(timer);
51 timer = null;
52 pendingName = null;
53 pendingValue = null;
54 }
55 baseStorage.removeItem(name);
56 },
57 flush: () => {
58 if (timer !== null && pendingName !== null && pendingValue !== null) {
59 clearTimeout(timer);
60 timer = null;
61 baseStorage.setItem(pendingName, pendingValue);
62 pendingName = null;
63 pendingValue = null;
64 }
65 },
66 };
67}
68
69const composerDebouncedStorage: DebouncedStorage =
70 typeof localStorage !== "undefined"
71 ? createDebouncedStorage(localStorage)
72 : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} };
73
74// Flush pending composer draft writes before page unload to prevent data loss.
75if (typeof window !== "undefined") {
76 window.addEventListener("beforeunload", () => {
77 composerDebouncedStorage.flush();
78 });
79}
80
2381export interface PersistedComposerImageAttachment {
2482 id: string;
2583 name: string;
......@@ -1169,7 +1227,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
11691227 {
11701228 name: COMPOSER_DRAFT_STORAGE_KEY,
11711229 version: 1,
1172 storage: createJSONStorage(() => localStorage),
1230 storage: createJSONStorage(() => composerDebouncedStorage),
11731231 partialize: (state) => {
11741232 const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
11751233 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {