authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 07:50:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 07:50:15-07:00
logdab5f6e6e02e78675655e69503aa89654e5b8050
tree1dfcda353297683202f74a8910a67e01a87e5f60
parent777f5bb2e11fe30e7fdcb5741b0b1d9bb20924d6
signature Signed by PGP key B5690EEEBB952194

perf(web): defer composer draft serialization (#9695)

Defer the draft walk and JSON serialization until the storage write flushes. Preserve hydration, migrations, attachment verification, and final flushes. Continues the web portion of [#9049](https://github.com/pingdotgg/t3code/pull/9049). The mobile storage migration remains separate. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 185 insertions(+), 86 deletions(-)

apps/web/src/composerDraftStore.test.ts+137-75
...@@ -75,6 +75,7 @@ import {...@@ -75,6 +75,7 @@ import {
75 type ComposerFileAttachment,75 type ComposerFileAttachment,
76 type ComposerImageAttachment,76 type ComposerImageAttachment,
77 composerFileNeedsReattach,77 composerFileNeedsReattach,
78 partializeComposerDraftStoreState,
78 useComposerDraftStore,79 useComposerDraftStore,
79 DraftId,80 DraftId,
80} from "./composerDraftStore";81} from "./composerDraftStore";
...@@ -84,7 +85,7 @@ import {...@@ -84,7 +85,7 @@ import {
84 insertInlineTerminalContextPlaceholder,85 insertInlineTerminalContextPlaceholder,
85 type TerminalContextDraft,86 type TerminalContextDraft,
86} from "./lib/terminalContext";87} from "./lib/terminalContext";
87import { createDebouncedStorage } from "./lib/storage";88import { createDeferredStorage } from "./lib/storage";
8889
89function makeImage(input: {90function makeImage(input: {
90 id: string;91 id: string;
...@@ -196,37 +197,42 @@ describe("composerDraftStore assistant citations", () => {...@@ -196,37 +197,42 @@ describe("composerDraftStore assistant citations", () => {
196 beforeEach(resetComposerDraftStore);197 beforeEach(resetComposerDraftStore);
197 afterEach(resetComposerDraftStore);198 afterEach(resetComposerDraftStore);
198199
199 it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", () => {200 it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", async () => {
200 const threadId = ThreadId.make("citation-draft");201 await useComposerDraftStore.persist.clearStorage();
201 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);202 vi.useFakeTimers();
202 const citation = {203 try {
203 version: 1 as const,204 const threadId = ThreadId.make("citation-draft");
204 environmentId: EnvironmentId.make("remote-source"),205 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
205 threadId: ThreadId.make("source-thread"),206 const citation = {
206 messageId: MessageId.make("source-message"),207 version: 1 as const,
207 text: "Preserve the selected text after reload.",208 environmentId: EnvironmentId.make("remote-source"),
208 comment: "Why is this important?\nPlease show an example.",209 threadId: ThreadId.make("source-thread"),
209 start: 12,210 messageId: MessageId.make("source-message"),
210 end: 52,211 text: "Preserve the selected text after reload.",
211 prefix: "Before. ",212 comment: "Why is this important?\nPlease show an example.",
212 suffix: " After.",213 start: 12,
213 };214 end: 52,
214 const prompt = `Explain ${serializeAssistantCitation(citation)} further.`;215 prefix: "Before. ",
215 useComposerDraftStore.getState().setPrompt(threadRef, prompt);216 suffix: " After.",
216 const options = useComposerDraftStore.persist.getOptions();217 };
217 const saved = JSON.parse(218 const prompt = `Explain ${serializeAssistantCitation(citation)} further.`;
218 JSON.stringify(options.partialize!(useComposerDraftStore.getState())),219 useComposerDraftStore.getState().setPrompt(threadRef, prompt);
219 ) as unknown;220 await vi.advanceTimersByTimeAsync(300);
220 resetComposerDraftStore();221 resetComposerDraftStore();
221 const hydrated = options.merge!(saved, useComposerDraftStore.getState());222 await useComposerDraftStore.persist.rehydrate();
222 useComposerDraftStore.setState(hydrated);223 const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? "";
223 const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? "";224 expect(restored).toBe(prompt);
224 expect(restored).toBe(prompt);225 expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([
225 expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([citation]);226 citation,
226 useComposerDraftStore.getState().clearComposerContent(threadRef);227 ]);
227 expect(228 useComposerDraftStore.getState().clearComposerContent(threadRef);
228 collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""),229 expect(
229 ).toEqual([]);230 collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""),
231 ).toEqual([]);
232 } finally {
233 await useComposerDraftStore.persist.clearStorage();
234 vi.useRealTimers();
235 }
230 });236 });
231});237});
232238
...@@ -388,7 +394,6 @@ describe("composerDraftStore file attachments", () => {...@@ -388,7 +394,6 @@ describe("composerDraftStore file attachments", () => {
388394
389 const persistApi = useComposerDraftStore.persist as unknown as {395 const persistApi = useComposerDraftStore.persist as unknown as {
390 getOptions: () => {396 getOptions: () => {
391 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
392 merge: (397 merge: (
393 persistedState: unknown,398 persistedState: unknown,
394 currentState: ReturnType<typeof useComposerDraftStore.getState>,399 currentState: ReturnType<typeof useComposerDraftStore.getState>,
...@@ -396,9 +401,7 @@ describe("composerDraftStore file attachments", () => {...@@ -396,9 +401,7 @@ describe("composerDraftStore file attachments", () => {
396 };401 };
397 };402 };
398 const options = persistApi.getOptions();403 const options = persistApi.getOptions();
399 const persisted = options.partialize(useComposerDraftStore.getState()) as {404 const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState());
400 draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>;
401 };
402405
403 expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(406 expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(
404 [407 [
...@@ -436,7 +439,6 @@ describe("composerDraftStore file attachments", () => {...@@ -436,7 +439,6 @@ describe("composerDraftStore file attachments", () => {
436439
437 const persistApi = useComposerDraftStore.persist as unknown as {440 const persistApi = useComposerDraftStore.persist as unknown as {
438 getOptions: () => {441 getOptions: () => {
439 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
440 merge: (442 merge: (
441 persistedState: unknown,443 persistedState: unknown,
442 currentState: ReturnType<typeof useComposerDraftStore.getState>,444 currentState: ReturnType<typeof useComposerDraftStore.getState>,
...@@ -444,9 +446,7 @@ describe("composerDraftStore file attachments", () => {...@@ -444,9 +446,7 @@ describe("composerDraftStore file attachments", () => {
444 };446 };
445 };447 };
446 const options = persistApi.getOptions();448 const options = persistApi.getOptions();
447 const persisted = options.partialize(useComposerDraftStore.getState()) as {449 const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState());
448 draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>;
449 };
450450
451 expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(451 expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(
452 [452 [
...@@ -835,12 +835,9 @@ describe("composerDraftStore terminal contexts", () => {...@@ -835,12 +835,9 @@ describe("composerDraftStore terminal contexts", () => {
835 .getState()835 .getState()
836 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" }));836 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" }));
837837
838 const persistApi = useComposerDraftStore.persist as unknown as {838 const persistedState = partializeComposerDraftStoreState(
839 getOptions: () => {839 useComposerDraftStore.getState(),
840 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;840 ) as unknown as {
841 };
842 };
843 const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
844 draftsByThreadKey?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;841 draftsByThreadKey?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;
845 };842 };
846843
...@@ -1009,12 +1006,9 @@ describe("composerDraftStore element contexts", () => {...@@ -1009,12 +1006,9 @@ describe("composerDraftStore element contexts", () => {
10091006
1010 it("persists element contexts via the partializer (round-trippable)", () => {1007 it("persists element contexts via the partializer (round-trippable)", () => {
1011 useComposerDraftStore.getState().addElementContext(threadRef, baseSelection);1008 useComposerDraftStore.getState().addElementContext(threadRef, baseSelection);
1012 const persistApi = useComposerDraftStore.persist as unknown as {1009 const persisted = partializeComposerDraftStoreState(
1013 getOptions: () => {1010 useComposerDraftStore.getState(),
1014 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;1011 ) as unknown as {
1015 };
1016 };
1017 const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
1018 draftsByThreadKey?: Record<string, { elementContexts?: Array<Record<string, unknown>> }>;1012 draftsByThreadKey?: Record<string, { elementContexts?: Array<Record<string, unknown>> }>;
1019 };1013 };
1020 const entry =1014 const entry =
...@@ -1067,12 +1061,9 @@ describe("composerDraftStore review comments", () => {...@@ -1067,12 +1061,9 @@ describe("composerDraftStore review comments", () => {
1067 it("persists review comments and clears them with composer content", () => {1061 it("persists review comments and clears them with composer content", () => {
1068 const store = useComposerDraftStore.getState();1062 const store = useComposerDraftStore.getState();
1069 store.addReviewComment(threadRef, comment);1063 store.addReviewComment(threadRef, comment);
1070 const persistApi = useComposerDraftStore.persist as unknown as {1064 const persisted = partializeComposerDraftStoreState(
1071 getOptions: () => {1065 useComposerDraftStore.getState(),
1072 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;1066 ) as unknown as {
1073 };
1074 };
1075 const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
1076 draftsByThreadKey?: Record<string, { reviewComments?: Array<Record<string, unknown>> }>;1067 draftsByThreadKey?: Record<string, { reviewComments?: Array<Record<string, unknown>> }>;
1077 };1068 };
10781069
...@@ -2517,7 +2508,7 @@ describe("composerDraftStore runtime and interaction settings", () => {...@@ -2517,7 +2508,7 @@ describe("composerDraftStore runtime and interaction settings", () => {
2517});2508});
25182509
2519// ---------------------------------------------------------------------------2510// ---------------------------------------------------------------------------
2520// createDebouncedStorage2511// createDeferredStorage
2521// ---------------------------------------------------------------------------2512// ---------------------------------------------------------------------------
25222513
2523function createMockStorage() {2514function createMockStorage() {
...@@ -2533,9 +2524,75 @@ function createMockStorage() {...@@ -2533,9 +2524,75 @@ function createMockStorage() {
2533 };2524 };
2534}2525}
25352526
2536describe("createDebouncedStorage", () => {2527describe("composer draft persistence", () => {
2528 it("defers attachment reads and serialization until typing stops, then restores the last draft", async () => {
2529 await useComposerDraftStore.persist.clearStorage();
2530 vi.useFakeTimers();
2531 const stringify = vi.spyOn(JSON, "stringify");
2532 try {
2533 resetComposerDraftStore();
2534 const heavyThreadId = ThreadId.make("heavy-draft");
2535 const typingThreadId = ThreadId.make("typing-draft");
2536 const heavyRef = scopeThreadRef(TEST_ENVIRONMENT_ID, heavyThreadId);
2537 const typingRef = scopeThreadRef(TEST_ENVIRONMENT_ID, typingThreadId);
2538 const heavyKey = scopedThreadKey(heavyRef);
2539 useComposerDraftStore.getState().setPrompt(heavyRef, "Keep this image");
2540 const attachments = [
2541 {
2542 id: "heavy-image",
2543 name: "image.png",
2544 mimeType: "image/png",
2545 sizeBytes: 49_152,
2546 dataUrl: `data:image/png;base64,${"AQID".repeat(16_384)}`,
2547 },
2548 ];
2549 let attachmentReads = 0;
2550 useComposerDraftStore.setState((state) => ({
2551 draftsByThreadKey: {
2552 ...state.draftsByThreadKey,
2553 [heavyKey]: {
2554 ...state.draftsByThreadKey[heavyKey]!,
2555 get persistedAttachments() {
2556 attachmentReads += 1;
2557 return attachments;
2558 },
2559 },
2560 },
2561 }));
2562 attachmentReads = 0;
2563 stringify.mockClear();
2564
2565 for (let index = 1; index <= 20; index++) {
2566 useComposerDraftStore.getState().setPrompt(typingRef, `Draft ${index}`);
2567 }
2568 expect(attachmentReads).toBe(0);
2569 expect(stringify).not.toHaveBeenCalled();
2570
2571 await vi.advanceTimersByTimeAsync(300);
2572 expect(attachmentReads).toBe(1);
2573 expect(stringify).toHaveBeenCalledTimes(1);
2574
2575 resetComposerDraftStore();
2576 await useComposerDraftStore.persist.rehydrate();
2577 expect(draftFor(typingThreadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("Draft 20");
2578 expect(draftFor(heavyThreadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual(
2579 attachments,
2580 );
2581 } finally {
2582 stringify.mockRestore();
2583 await useComposerDraftStore.persist.clearStorage();
2584 vi.useRealTimers();
2585 resetComposerDraftStore();
2586 }
2587 });
2588});
2589
2590describe("createDeferredStorage", () => {
2591 const serialize = vi.fn((value: string) => `s:${value}`);
2592
2537 beforeEach(() => {2593 beforeEach(() => {
2538 vi.useFakeTimers();2594 vi.useFakeTimers();
2595 serialize.mockClear();
2539 });2596 });
25402597
2541 afterEach(() => {2598 afterEach(() => {
...@@ -2545,69 +2602,74 @@ describe("createDebouncedStorage", () => {...@@ -2545,69 +2602,74 @@ describe("createDebouncedStorage", () => {
2545 it("delegates getItem immediately", () => {2602 it("delegates getItem immediately", () => {
2546 const base = createMockStorage();2603 const base = createMockStorage();
2547 base.getItem.mockReturnValueOnce("value");2604 base.getItem.mockReturnValueOnce("value");
2548 const storage = createDebouncedStorage(base);2605 const storage = createDeferredStorage(base, serialize);
25492606
2550 expect(storage.getItem("key")).toBe("value");2607 expect(storage.getItem("key")).toBe("value");
2551 expect(base.getItem).toHaveBeenCalledWith("key");2608 expect(base.getItem).toHaveBeenCalledWith("key");
2552 });2609 });
25532610
2554 it("does not write to base storage until the debounce fires", () => {2611 it("neither serializes nor writes until the debounce fires", () => {
2555 const base = createMockStorage();2612 const base = createMockStorage();
2556 const storage = createDebouncedStorage(base);2613 const storage = createDeferredStorage(base, serialize);
25572614
2558 storage.setItem("key", "v1");2615 storage.setItem("key", "v1");
2616 expect(serialize).not.toHaveBeenCalled();
2559 expect(base.setItem).not.toHaveBeenCalled();2617 expect(base.setItem).not.toHaveBeenCalled();
25602618
2561 vi.advanceTimersByTime(299);2619 vi.advanceTimersByTime(299);
2620 expect(serialize).not.toHaveBeenCalled();
2562 expect(base.setItem).not.toHaveBeenCalled();2621 expect(base.setItem).not.toHaveBeenCalled();
25632622
2564 vi.advanceTimersByTime(1);2623 vi.advanceTimersByTime(1);
2565 expect(base.setItem).toHaveBeenCalledWith("key", "v1");2624 expect(base.setItem).toHaveBeenCalledWith("key", "s:v1");
2566 });2625 });
25672626
2568 it("only writes the last value when setItem is called rapidly", () => {2627 it("serializes and writes only the last value when setItem is called rapidly", () => {
2569 const base = createMockStorage();2628 const base = createMockStorage();
2570 const storage = createDebouncedStorage(base);2629 const storage = createDeferredStorage(base, serialize);
25712630
2572 storage.setItem("key", "v1");2631 storage.setItem("key", "v1");
2573 storage.setItem("key", "v2");2632 storage.setItem("key", "v2");
2574 storage.setItem("key", "v3");2633 storage.setItem("key", "v3");
25752634
2576 vi.advanceTimersByTime(300);2635 vi.advanceTimersByTime(300);
2636 expect(serialize).toHaveBeenCalledTimes(1);
2577 expect(base.setItem).toHaveBeenCalledTimes(1);2637 expect(base.setItem).toHaveBeenCalledTimes(1);
2578 expect(base.setItem).toHaveBeenCalledWith("key", "v3");2638 expect(base.setItem).toHaveBeenCalledWith("key", "s:v3");
2579 });2639 });
25802640
2581 it("removeItem cancels a pending setItem write", () => {2641 it("removeItem cancels a pending setItem write", () => {
2582 const base = createMockStorage();2642 const base = createMockStorage();
2583 const storage = createDebouncedStorage(base);2643 const storage = createDeferredStorage(base, serialize);
25842644
2585 storage.setItem("key", "v1");2645 storage.setItem("key", "v1");
2586 storage.removeItem("key");2646 storage.removeItem("key");
25872647
2588 vi.advanceTimersByTime(300);2648 vi.advanceTimersByTime(300);
2649 expect(serialize).not.toHaveBeenCalled();
2589 expect(base.setItem).not.toHaveBeenCalled();2650 expect(base.setItem).not.toHaveBeenCalled();
2590 expect(base.removeItem).toHaveBeenCalledWith("key");2651 expect(base.removeItem).toHaveBeenCalledWith("key");
2591 });2652 });
25922653
2593 it("flush writes the pending value immediately", () => {2654 it("flush serializes and writes the pending value immediately", () => {
2594 const base = createMockStorage();2655 const base = createMockStorage();
2595 const storage = createDebouncedStorage(base);2656 const storage = createDeferredStorage(base, serialize);
25962657
2597 storage.setItem("key", "v1");2658 storage.setItem("key", "v1");
2598 expect(base.setItem).not.toHaveBeenCalled();2659 expect(base.setItem).not.toHaveBeenCalled();
25992660
2600 storage.flush();2661 storage.flush();
2601 expect(base.setItem).toHaveBeenCalledWith("key", "v1");2662 expect(base.setItem).toHaveBeenCalledWith("key", "s:v1");
26022663
2603 // Timer should be cancelled; no duplicate write.2664 // Timer should be cancelled; no duplicate write.
2604 vi.advanceTimersByTime(300);2665 vi.advanceTimersByTime(300);
2666 expect(serialize).toHaveBeenCalledTimes(1);
2605 expect(base.setItem).toHaveBeenCalledTimes(1);2667 expect(base.setItem).toHaveBeenCalledTimes(1);
2606 });2668 });
26072669
2608 it("flush is a no-op when nothing is pending", () => {2670 it("flush is a no-op when nothing is pending", () => {
2609 const base = createMockStorage();2671 const base = createMockStorage();
2610 const storage = createDebouncedStorage(base);2672 const storage = createDeferredStorage(base, serialize);
26112673
2612 storage.flush();2674 storage.flush();
2613 expect(base.setItem).not.toHaveBeenCalled();2675 expect(base.setItem).not.toHaveBeenCalled();
...@@ -2615,7 +2677,7 @@ describe("createDebouncedStorage", () => {...@@ -2615,7 +2677,7 @@ describe("createDebouncedStorage", () => {
26152677
2616 it("flush after removeItem is a no-op", () => {2678 it("flush after removeItem is a no-op", () => {
2617 const base = createMockStorage();2679 const base = createMockStorage();
2618 const storage = createDebouncedStorage(base);2680 const storage = createDeferredStorage(base, serialize);
26192681
2620 storage.setItem("key", "v1");2682 storage.setItem("key", "v1");
2621 storage.removeItem("key");2683 storage.removeItem("key");
...@@ -2626,7 +2688,7 @@ describe("createDebouncedStorage", () => {...@@ -2626,7 +2688,7 @@ describe("createDebouncedStorage", () => {
26262688
2627 it("setItem works normally after removeItem cancels a pending write", () => {2689 it("setItem works normally after removeItem cancels a pending write", () => {
2628 const base = createMockStorage();2690 const base = createMockStorage();
2629 const storage = createDebouncedStorage(base);2691 const storage = createDeferredStorage(base, serialize);
26302692
2631 storage.setItem("key", "v1");2693 storage.setItem("key", "v1");
2632 storage.removeItem("key");2694 storage.removeItem("key");
...@@ -2634,6 +2696,6 @@ describe("createDebouncedStorage", () => {...@@ -2634,6 +2696,6 @@ describe("createDebouncedStorage", () => {
26342696
2635 vi.advanceTimersByTime(300);2697 vi.advanceTimersByTime(300);
2636 expect(base.setItem).toHaveBeenCalledTimes(1);2698 expect(base.setItem).toHaveBeenCalledTimes(1);
2637 expect(base.setItem).toHaveBeenCalledWith("key", "v2");2699 expect(base.setItem).toHaveBeenCalledWith("key", "s:v2");
2638 });2700 });
2639});2701});
apps/web/src/composerDraftStore.ts+36-6
...@@ -53,9 +53,9 @@ import {...@@ -53,9 +53,9 @@ import {
53 newElementContextId,53 newElementContextId,
54} from "./lib/elementContext";54} from "./lib/elementContext";
55import { create } from "zustand";55import { create } from "zustand";
56import { createJSONStorage, persist } from "zustand/middleware";56import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
57import { useShallow } from "zustand/react/shallow";57import { useShallow } from "zustand/react/shallow";
58import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";58import { createDeferredStorage, createMemoryStorage } from "./lib/storage";
59import { getDefaultServerModel } from "./providerModels";59import { getDefaultServerModel } from "./providerModels";
60import { UnifiedSettings } from "@t3tools/contracts/settings";60import { UnifiedSettings } from "@t3tools/contracts/settings";
61import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext";61import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext";
...@@ -73,11 +73,39 @@ export type DraftId = typeof DraftId.Type;...@@ -73,11 +73,39 @@ export type DraftId = typeof DraftId.Type;
7373
74const COMPOSER_PERSIST_DEBOUNCE_MS = 300;74const COMPOSER_PERSIST_DEBOUNCE_MS = 300;
7575
76const composerDebouncedStorage = createDebouncedStorage(76// Keep the immutable state until flush. Migration writebacks already have the persisted shape.
77type ComposerPersistState =
78 | { capturedState: ComposerDraftStoreState }
79 | PersistedComposerDraftStoreState;
80
81const composerDebouncedStorage = createDeferredStorage<StorageValue<ComposerPersistState>>(
77 typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(),82 typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(),
83 (value) =>
84 JSON.stringify({
85 state:
86 "capturedState" in value.state
87 ? partializeComposerDraftStoreState(value.state.capturedState)
88 : value.state,
89 version: value.version,
90 }),
78 COMPOSER_PERSIST_DEBOUNCE_MS,91 COMPOSER_PERSIST_DEBOUNCE_MS,
79);92);
8093
94const composerPersistStorage: PersistStorage<ComposerPersistState> = {
95 getItem: (name) => {
96 // The base storage is localStorage (or in-memory), which is synchronous.
97 const raw = composerDebouncedStorage.getItem(name);
98 if (typeof raw !== "string") {
99 return null;
100 }
101 // Parsed persisted JSON. `migrate` and `merge` normalize it from unknown,
102 // so the cast mirrors the one zustand's createJSONStorage performs.
103 return JSON.parse(raw) as StorageValue<ComposerPersistState>;
104 },
105 setItem: (name, value) => composerDebouncedStorage.setItem(name, value),
106 removeItem: (name) => composerDebouncedStorage.removeItem(name),
107};
108
81// Flush pending composer draft writes before page unload to prevent data loss.109// Flush pending composer draft writes before page unload to prevent data loss.
82if (typeof window !== "undefined" && typeof window.addEventListener === "function") {110if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
83 window.addEventListener("beforeunload", () => {111 window.addEventListener("beforeunload", () => {
...@@ -2013,7 +2041,8 @@ function migratePersistedComposerDraftStoreState(...@@ -2013,7 +2041,8 @@ function migratePersistedComposerDraftStoreState(
2013 };2041 };
2014}2042}
20152043
2016function partializeComposerDraftStoreState(2044/** Select the persisted draft fields when the storage write is ready to flush. */
2045export function partializeComposerDraftStoreState(
2017 state: ComposerDraftStoreState,2046 state: ComposerDraftStoreState,
2018): PersistedComposerDraftStoreState {2047): PersistedComposerDraftStoreState {
2019 // Draft sessions worth persisting: mapped (a new-thread flow targets2048 // Draft sessions worth persisting: mapped (a new-thread flow targets
...@@ -3876,9 +3905,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3876,9 +3905,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3876 {3905 {
3877 name: COMPOSER_DRAFT_STORAGE_KEY,3906 name: COMPOSER_DRAFT_STORAGE_KEY,
3878 version: COMPOSER_DRAFT_STORAGE_VERSION,3907 version: COMPOSER_DRAFT_STORAGE_VERSION,
3879 storage: createJSONStorage(() => composerDebouncedStorage),3908 storage: composerPersistStorage,
3880 migrate: migratePersistedComposerDraftStoreState,3909 migrate: migratePersistedComposerDraftStoreState,
3881 partialize: partializeComposerDraftStoreState,3910 // Defer the draft walk and serialization until the storage write flushes.
3911 partialize: (state): ComposerPersistState => ({ capturedState: state }),
3882 merge: (persistedState, currentState) => {3912 merge: (persistedState, currentState) => {
3883 const normalizedPersisted =3913 const normalizedPersisted =
3884 normalizeCurrentPersistedComposerDraftStoreState(persistedState);3914 normalizeCurrentPersistedComposerDraftStoreState(persistedState);
apps/web/src/lib/storage.ts+12-5
...@@ -6,7 +6,10 @@ export interface StateStorage<R = unknown> {...@@ -6,7 +6,10 @@ export interface StateStorage<R = unknown> {
6 removeItem: (name: string) => R;6 removeItem: (name: string) => R;
7}7}
88
9export interface DebouncedStorage<R = unknown> extends StateStorage<R> {9export interface DeferredStorage<TValue> {
10 getItem: (name: string) => string | null | Promise<string | null>;
11 setItem: (name: string, value: TValue) => void;
12 removeItem: (name: string) => void;
10 flush: () => void;13 flush: () => void;
11}14}
1215
...@@ -39,14 +42,16 @@ export function resolveStorage(storage: Partial<StateStorage> | null | undefined...@@ -39,14 +42,16 @@ export function resolveStorage(storage: Partial<StateStorage> | null | undefined
39 return isStateStorage(storage) ? storage : createMemoryStorage();42 return isStateStorage(storage) ? storage : createMemoryStorage();
40}43}
4144
42export function createDebouncedStorage(45/** Keep the latest value and serialize it when the debounce fires or `flush` runs. */
46export function createDeferredStorage<TValue>(
43 baseStorage: Partial<StateStorage> | null | undefined,47 baseStorage: Partial<StateStorage> | null | undefined,
48 serialize: (value: TValue) => string,
44 debounceMs: number = 300,49 debounceMs: number = 300,
45): DebouncedStorage {50): DeferredStorage<TValue> {
46 const resolvedStorage = resolveStorage(baseStorage);51 const resolvedStorage = resolveStorage(baseStorage);
47 const debouncedSetItem = new Debouncer(52 const debouncedSetItem = new Debouncer(
48 (name: string, value: string) => {53 (name: string, value: TValue) => {
49 resolvedStorage.setItem(name, value);54 resolvedStorage.setItem(name, serialize(value));
50 },55 },
51 { wait: debounceMs },56 { wait: debounceMs },
52 );57 );
...@@ -58,6 +63,8 @@ export function createDebouncedStorage(...@@ -58,6 +63,8 @@ export function createDebouncedStorage(
58 },63 },
59 removeItem: (name) => {64 removeItem: (name) => {
60 debouncedSetItem.cancel();65 debouncedSetItem.cancel();
66 // cancel() leaves the captured value in Pacer's lastArgs.
67 debouncedSetItem.reset();
61 resolvedStorage.removeItem(name);68 resolvedStorage.removeItem(name);
62 },69 },
63 flush: () => {70 flush: () => {