From 90e0c2ab15e7034d8e6e0688b3996fd905c03017 Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Sun, 8 Mar 2026 06:21:52 +0000 Subject: [PATCH] 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. --- apps/web/src/composerDraftStore.test.ts | 128 +++++++++++++++++++++++- apps/web/src/composerDraftStore.ts | 62 +++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index f133d377f24fe9af080bfe20c07864285673bcaa..2bcd9cbbceec3d2e33e4490370dae7f1a008116b 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,7 +1,11 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; +import { + type ComposerImageAttachment, + createDebouncedStorage, + useComposerDraftStore, +} from "./composerDraftStore"; function makeImage(input: { id: string; @@ -451,3 +455,125 @@ describe("composerDraftStore runtime and interaction settings", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// createDebouncedStorage +// --------------------------------------------------------------------------- + +function createMockStorage() { + const store = new Map(); + return { + getItem: vi.fn((name: string) => store.get(name) ?? null), + setItem: vi.fn((name: string, value: string) => { + store.set(name, value); + }), + removeItem: vi.fn((name: string) => { + store.delete(name); + }), + }; +} + +describe("createDebouncedStorage", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("delegates getItem immediately", () => { + const base = createMockStorage(); + base.getItem.mockReturnValueOnce("value"); + const storage = createDebouncedStorage(base); + + expect(storage.getItem("key")).toBe("value"); + expect(base.getItem).toHaveBeenCalledWith("key"); + }); + + it("does not write to base storage until the debounce fires", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + expect(base.setItem).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(299); + expect(base.setItem).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v1"); + }); + + it("only writes the last value when setItem is called rapidly", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.setItem("key", "v2"); + storage.setItem("key", "v3"); + + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v3"); + }); + + it("removeItem cancels a pending setItem write", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + + vi.advanceTimersByTime(300); + expect(base.setItem).not.toHaveBeenCalled(); + expect(base.removeItem).toHaveBeenCalledWith("key"); + }); + + it("flush writes the pending value immediately", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + expect(base.setItem).not.toHaveBeenCalled(); + + storage.flush(); + expect(base.setItem).toHaveBeenCalledWith("key", "v1"); + + // Timer should be cancelled; no duplicate write. + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + }); + + it("flush is a no-op when nothing is pending", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.flush(); + expect(base.setItem).not.toHaveBeenCalled(); + }); + + it("flush after removeItem is a no-op", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + storage.flush(); + + expect(base.setItem).not.toHaveBeenCalled(); + }); + + it("setItem works normally after removeItem cancels a pending write", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.removeItem("key"); + storage.setItem("key", "v2"); + + vi.advanceTimersByTime(300); + expect(base.setItem).toHaveBeenCalledTimes(1); + expect(base.setItem).toHaveBeenCalledWith("key", "v2"); + }); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2ac03a3ed3aa5e376b19d1ad4965b1579f8bfb7d..6be7efe464f7406c6c36d346b695f0c0a099a0ca 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,11 +15,69 @@ import { type ChatImageAttachment, } from "./types"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; export type DraftThreadEnvMode = "local" | "worktree"; +const COMPOSER_PERSIST_DEBOUNCE_MS = 300; + +interface DebouncedStorage extends StateStorage { + flush: () => void; +} + +export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage { + let timer: ReturnType | null = null; + let pendingName: string | null = null; + let pendingValue: string | null = null; + return { + getItem: (name) => baseStorage.getItem(name), + setItem: (name, value) => { + pendingName = name; + pendingValue = value; + if (timer !== null) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = null; + pendingName = null; + pendingValue = null; + baseStorage.setItem(name, value); + }, COMPOSER_PERSIST_DEBOUNCE_MS); + }, + removeItem: (name) => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + pendingName = null; + pendingValue = null; + } + baseStorage.removeItem(name); + }, + flush: () => { + if (timer !== null && pendingName !== null && pendingValue !== null) { + clearTimeout(timer); + timer = null; + baseStorage.setItem(pendingName, pendingValue); + pendingName = null; + pendingValue = null; + } + }, + }; +} + +const composerDebouncedStorage: DebouncedStorage = + typeof localStorage !== "undefined" + ? createDebouncedStorage(localStorage) + : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; + +// Flush pending composer draft writes before page unload to prevent data loss. +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + composerDebouncedStorage.flush(); + }); +} + export interface PersistedComposerImageAttachment { id: string; name: string; @@ -1169,7 +1227,7 @@ export const useComposerDraftStore = create()( { name: COMPOSER_DRAFT_STORAGE_KEY, version: 1, - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => composerDebouncedStorage), partialize: (state) => { const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { -- 2.54.0