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 {
7575 type ComposerFileAttachment,
7676 type ComposerImageAttachment,
7777 composerFileNeedsReattach,
78 partializeComposerDraftStoreState,
7879 useComposerDraftStore,
7980 DraftId,
8081} from "./composerDraftStore";
......@@ -84,7 +85,7 @@ import {
8485 insertInlineTerminalContextPlaceholder,
8586 type TerminalContextDraft,
8687} from "./lib/terminalContext";
87import { createDebouncedStorage } from "./lib/storage";
88import { createDeferredStorage } from "./lib/storage";
8889
8990function makeImage(input: {
9091 id: string;
......@@ -196,37 +197,42 @@ describe("composerDraftStore assistant citations", () => {
196197 beforeEach(resetComposerDraftStore);
197198 afterEach(resetComposerDraftStore);
198199
199 it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", () => {
200 const threadId = ThreadId.make("citation-draft");
201 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
202 const citation = {
203 version: 1 as const,
204 environmentId: EnvironmentId.make("remote-source"),
205 threadId: ThreadId.make("source-thread"),
206 messageId: MessageId.make("source-message"),
207 text: "Preserve the selected text after reload.",
208 comment: "Why is this important?\nPlease show an example.",
209 start: 12,
210 end: 52,
211 prefix: "Before. ",
212 suffix: " After.",
213 };
214 const prompt = `Explain ${serializeAssistantCitation(citation)} further.`;
215 useComposerDraftStore.getState().setPrompt(threadRef, prompt);
216 const options = useComposerDraftStore.persist.getOptions();
217 const saved = JSON.parse(
218 JSON.stringify(options.partialize!(useComposerDraftStore.getState())),
219 ) as unknown;
220 resetComposerDraftStore();
221 const hydrated = options.merge!(saved, useComposerDraftStore.getState());
222 useComposerDraftStore.setState(hydrated);
223 const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? "";
224 expect(restored).toBe(prompt);
225 expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([citation]);
226 useComposerDraftStore.getState().clearComposerContent(threadRef);
227 expect(
228 collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""),
229 ).toEqual([]);
200 it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", async () => {
201 await useComposerDraftStore.persist.clearStorage();
202 vi.useFakeTimers();
203 try {
204 const threadId = ThreadId.make("citation-draft");
205 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
206 const citation = {
207 version: 1 as const,
208 environmentId: EnvironmentId.make("remote-source"),
209 threadId: ThreadId.make("source-thread"),
210 messageId: MessageId.make("source-message"),
211 text: "Preserve the selected text after reload.",
212 comment: "Why is this important?\nPlease show an example.",
213 start: 12,
214 end: 52,
215 prefix: "Before. ",
216 suffix: " After.",
217 };
218 const prompt = `Explain ${serializeAssistantCitation(citation)} further.`;
219 useComposerDraftStore.getState().setPrompt(threadRef, prompt);
220 await vi.advanceTimersByTimeAsync(300);
221 resetComposerDraftStore();
222 await useComposerDraftStore.persist.rehydrate();
223 const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? "";
224 expect(restored).toBe(prompt);
225 expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([
226 citation,
227 ]);
228 useComposerDraftStore.getState().clearComposerContent(threadRef);
229 expect(
230 collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""),
231 ).toEqual([]);
232 } finally {
233 await useComposerDraftStore.persist.clearStorage();
234 vi.useRealTimers();
235 }
230236 });
231237});
232238
......@@ -388,7 +394,6 @@ describe("composerDraftStore file attachments", () => {
388394
389395 const persistApi = useComposerDraftStore.persist as unknown as {
390396 getOptions: () => {
391 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
392397 merge: (
393398 persistedState: unknown,
394399 currentState: ReturnType<typeof useComposerDraftStore.getState>,
......@@ -396,9 +401,7 @@ describe("composerDraftStore file attachments", () => {
396401 };
397402 };
398403 const options = persistApi.getOptions();
399 const persisted = options.partialize(useComposerDraftStore.getState()) as {
400 draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>;
401 };
404 const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState());
402405
403406 expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(
404407 [
......@@ -436,7 +439,6 @@ describe("composerDraftStore file attachments", () => {
436439
437440 const persistApi = useComposerDraftStore.persist as unknown as {
438441 getOptions: () => {
439 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
440442 merge: (
441443 persistedState: unknown,
442444 currentState: ReturnType<typeof useComposerDraftStore.getState>,
......@@ -444,9 +446,7 @@ describe("composerDraftStore file attachments", () => {
444446 };
445447 };
446448 const options = persistApi.getOptions();
447 const persisted = options.partialize(useComposerDraftStore.getState()) as {
448 draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>;
449 };
449 const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState());
450450
451451 expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(
452452 [
......@@ -835,12 +835,9 @@ describe("composerDraftStore terminal contexts", () => {
835835 .getState()
836836 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" }));
837837
838 const persistApi = useComposerDraftStore.persist as unknown as {
839 getOptions: () => {
840 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
841 };
842 };
843 const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
838 const persistedState = partializeComposerDraftStoreState(
839 useComposerDraftStore.getState(),
840 ) as unknown as {
844841 draftsByThreadKey?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;
845842 };
846843
......@@ -1009,12 +1006,9 @@ describe("composerDraftStore element contexts", () => {
10091006
10101007 it("persists element contexts via the partializer (round-trippable)", () => {
10111008 useComposerDraftStore.getState().addElementContext(threadRef, baseSelection);
1012 const persistApi = useComposerDraftStore.persist as unknown as {
1013 getOptions: () => {
1014 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
1015 };
1016 };
1017 const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
1009 const persisted = partializeComposerDraftStoreState(
1010 useComposerDraftStore.getState(),
1011 ) as unknown as {
10181012 draftsByThreadKey?: Record<string, { elementContexts?: Array<Record<string, unknown>> }>;
10191013 };
10201014 const entry =
......@@ -1067,12 +1061,9 @@ describe("composerDraftStore review comments", () => {
10671061 it("persists review comments and clears them with composer content", () => {
10681062 const store = useComposerDraftStore.getState();
10691063 store.addReviewComment(threadRef, comment);
1070 const persistApi = useComposerDraftStore.persist as unknown as {
1071 getOptions: () => {
1072 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
1073 };
1074 };
1075 const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
1064 const persisted = partializeComposerDraftStoreState(
1065 useComposerDraftStore.getState(),
1066 ) as unknown as {
10761067 draftsByThreadKey?: Record<string, { reviewComments?: Array<Record<string, unknown>> }>;
10771068 };
10781069
......@@ -2517,7 +2508,7 @@ describe("composerDraftStore runtime and interaction settings", () => {
25172508});
25182509
25192510// ---------------------------------------------------------------------------
2520// createDebouncedStorage
2511// createDeferredStorage
25212512// ---------------------------------------------------------------------------
25222513
25232514function createMockStorage() {
......@@ -2533,9 +2524,75 @@ function createMockStorage() {
25332524 };
25342525}
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
25372593 beforeEach(() => {
25382594 vi.useFakeTimers();
2595 serialize.mockClear();
25392596 });
25402597
25412598 afterEach(() => {
......@@ -2545,69 +2602,74 @@ describe("createDebouncedStorage", () => {
25452602 it("delegates getItem immediately", () => {
25462603 const base = createMockStorage();
25472604 base.getItem.mockReturnValueOnce("value");
2548 const storage = createDebouncedStorage(base);
2605 const storage = createDeferredStorage(base, serialize);
25492606
25502607 expect(storage.getItem("key")).toBe("value");
25512608 expect(base.getItem).toHaveBeenCalledWith("key");
25522609 });
25532610
2554 it("does not write to base storage until the debounce fires", () => {
2611 it("neither serializes nor writes until the debounce fires", () => {
25552612 const base = createMockStorage();
2556 const storage = createDebouncedStorage(base);
2613 const storage = createDeferredStorage(base, serialize);
25572614
25582615 storage.setItem("key", "v1");
2616 expect(serialize).not.toHaveBeenCalled();
25592617 expect(base.setItem).not.toHaveBeenCalled();
25602618
25612619 vi.advanceTimersByTime(299);
2620 expect(serialize).not.toHaveBeenCalled();
25622621 expect(base.setItem).not.toHaveBeenCalled();
25632622
25642623 vi.advanceTimersByTime(1);
2565 expect(base.setItem).toHaveBeenCalledWith("key", "v1");
2624 expect(base.setItem).toHaveBeenCalledWith("key", "s:v1");
25662625 });
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", () => {
25692628 const base = createMockStorage();
2570 const storage = createDebouncedStorage(base);
2629 const storage = createDeferredStorage(base, serialize);
25712630
25722631 storage.setItem("key", "v1");
25732632 storage.setItem("key", "v2");
25742633 storage.setItem("key", "v3");
25752634
25762635 vi.advanceTimersByTime(300);
2636 expect(serialize).toHaveBeenCalledTimes(1);
25772637 expect(base.setItem).toHaveBeenCalledTimes(1);
2578 expect(base.setItem).toHaveBeenCalledWith("key", "v3");
2638 expect(base.setItem).toHaveBeenCalledWith("key", "s:v3");
25792639 });
25802640
25812641 it("removeItem cancels a pending setItem write", () => {
25822642 const base = createMockStorage();
2583 const storage = createDebouncedStorage(base);
2643 const storage = createDeferredStorage(base, serialize);
25842644
25852645 storage.setItem("key", "v1");
25862646 storage.removeItem("key");
25872647
25882648 vi.advanceTimersByTime(300);
2649 expect(serialize).not.toHaveBeenCalled();
25892650 expect(base.setItem).not.toHaveBeenCalled();
25902651 expect(base.removeItem).toHaveBeenCalledWith("key");
25912652 });
25922653
2593 it("flush writes the pending value immediately", () => {
2654 it("flush serializes and writes the pending value immediately", () => {
25942655 const base = createMockStorage();
2595 const storage = createDebouncedStorage(base);
2656 const storage = createDeferredStorage(base, serialize);
25962657
25972658 storage.setItem("key", "v1");
25982659 expect(base.setItem).not.toHaveBeenCalled();
25992660
26002661 storage.flush();
2601 expect(base.setItem).toHaveBeenCalledWith("key", "v1");
2662 expect(base.setItem).toHaveBeenCalledWith("key", "s:v1");
26022663
26032664 // Timer should be cancelled; no duplicate write.
26042665 vi.advanceTimersByTime(300);
2666 expect(serialize).toHaveBeenCalledTimes(1);
26052667 expect(base.setItem).toHaveBeenCalledTimes(1);
26062668 });
26072669
26082670 it("flush is a no-op when nothing is pending", () => {
26092671 const base = createMockStorage();
2610 const storage = createDebouncedStorage(base);
2672 const storage = createDeferredStorage(base, serialize);
26112673
26122674 storage.flush();
26132675 expect(base.setItem).not.toHaveBeenCalled();
......@@ -2615,7 +2677,7 @@ describe("createDebouncedStorage", () => {
26152677
26162678 it("flush after removeItem is a no-op", () => {
26172679 const base = createMockStorage();
2618 const storage = createDebouncedStorage(base);
2680 const storage = createDeferredStorage(base, serialize);
26192681
26202682 storage.setItem("key", "v1");
26212683 storage.removeItem("key");
......@@ -2626,7 +2688,7 @@ describe("createDebouncedStorage", () => {
26262688
26272689 it("setItem works normally after removeItem cancels a pending write", () => {
26282690 const base = createMockStorage();
2629 const storage = createDebouncedStorage(base);
2691 const storage = createDeferredStorage(base, serialize);
26302692
26312693 storage.setItem("key", "v1");
26322694 storage.removeItem("key");
......@@ -2634,6 +2696,6 @@ describe("createDebouncedStorage", () => {
26342696
26352697 vi.advanceTimersByTime(300);
26362698 expect(base.setItem).toHaveBeenCalledTimes(1);
2637 expect(base.setItem).toHaveBeenCalledWith("key", "v2");
2699 expect(base.setItem).toHaveBeenCalledWith("key", "s:v2");
26382700 });
26392701});
apps/web/src/composerDraftStore.ts+36-6
......@@ -53,9 +53,9 @@ import {
5353 newElementContextId,
5454} from "./lib/elementContext";
5555import { create } from "zustand";
56import { createJSONStorage, persist } from "zustand/middleware";
56import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
5757import { useShallow } from "zustand/react/shallow";
58import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";
58import { createDeferredStorage, createMemoryStorage } from "./lib/storage";
5959import { getDefaultServerModel } from "./providerModels";
6060import { UnifiedSettings } from "@t3tools/contracts/settings";
6161import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext";
......@@ -73,11 +73,39 @@ export type DraftId = typeof DraftId.Type;
7373
7474const 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>>(
7782 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 }),
7891 COMPOSER_PERSIST_DEBOUNCE_MS,
7992);
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
81109// Flush pending composer draft writes before page unload to prevent data loss.
82110if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
83111 window.addEventListener("beforeunload", () => {
......@@ -2013,7 +2041,8 @@ function migratePersistedComposerDraftStoreState(
20132041 };
20142042}
20152043
2016function partializeComposerDraftStoreState(
2044/** Select the persisted draft fields when the storage write is ready to flush. */
2045export function partializeComposerDraftStoreState(
20172046 state: ComposerDraftStoreState,
20182047): PersistedComposerDraftStoreState {
20192048 // Draft sessions worth persisting: mapped (a new-thread flow targets
......@@ -3876,9 +3905,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
38763905 {
38773906 name: COMPOSER_DRAFT_STORAGE_KEY,
38783907 version: COMPOSER_DRAFT_STORAGE_VERSION,
3879 storage: createJSONStorage(() => composerDebouncedStorage),
3908 storage: composerPersistStorage,
38803909 migrate: migratePersistedComposerDraftStoreState,
3881 partialize: partializeComposerDraftStoreState,
3910 // Defer the draft walk and serialization until the storage write flushes.
3911 partialize: (state): ComposerPersistState => ({ capturedState: state }),
38823912 merge: (persistedState, currentState) => {
38833913 const normalizedPersisted =
38843914 normalizeCurrentPersistedComposerDraftStoreState(persistedState);
apps/web/src/lib/storage.ts+12-5
......@@ -6,7 +6,10 @@ export interface StateStorage<R = unknown> {
66 removeItem: (name: string) => R;
77}
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;
1013 flush: () => void;
1114}
1215
......@@ -39,14 +42,16 @@ export function resolveStorage(storage: Partial<StateStorage> | null | undefined
3942 return isStateStorage(storage) ? storage : createMemoryStorage();
4043}
4144
42export function createDebouncedStorage(
45/** Keep the latest value and serialize it when the debounce fires or `flush` runs. */
46export function createDeferredStorage<TValue>(
4347 baseStorage: Partial<StateStorage> | null | undefined,
48 serialize: (value: TValue) => string,
4449 debounceMs: number = 300,
45): DebouncedStorage {
50): DeferredStorage<TValue> {
4651 const resolvedStorage = resolveStorage(baseStorage);
4752 const debouncedSetItem = new Debouncer(
48 (name: string, value: string) => {
49 resolvedStorage.setItem(name, value);
53 (name: string, value: TValue) => {
54 resolvedStorage.setItem(name, serialize(value));
5055 },
5156 { wait: debounceMs },
5257 );
......@@ -58,6 +63,8 @@ export function createDebouncedStorage(
5863 },
5964 removeItem: (name) => {
6065 debouncedSetItem.cancel();
66 // cancel() leaves the captured value in Pacer's lastArgs.
67 debouncedSetItem.reset();
6168 resolvedStorage.removeItem(name);
6269 },
6370 flush: () => {