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)) {