diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts index a504d429185af..41dffdf77f519 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts @@ -51,6 +51,8 @@ const projectRef = scopeProjectRef(environmentId, ProjectId.make("snap-shot-proj function setup(): Disposable { storage.clear(); vi.stubGlobal("localStorage", storage); + // Resets the store's record of what it last wrote along with the storage it wrote to. + void useComposerDraftStore.persist.clearStorage(); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index cd0e74d231aac..404c2663f1f3a 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -721,6 +721,236 @@ describe("composerDraftStore context persistence", () => { expect(draft?.previewAnnotations).toEqual([annotation]); } }); + + // Each tab is a fresh module instance sharing one localStorage. A browser sends a write's + // storage event only to the other tabs, whenever it gets to it. + const openTabs = () => { + const storage = createMockStorage(); + vi.stubGlobal("localStorage", storage); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + const read = () => storage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + const openTab = async () => { + const page = Object.assign(new EventTarget(), { localStorage: storage }); + vi.stubGlobal("window", page); + vi.resetModules(); + const store = (await import("./composerDraftStore")).useComposerDraftStore; + return { + store, + write: () => page.dispatchEvent(new Event("beforeunload")), + receive: (newValue = read(), key: string | null = COMPOSER_DRAFT_STORAGE_KEY) => + page.dispatchEvent( + Object.assign(new Event("storage"), { key, newValue, storageArea: storage }), + ), + prompts: () => + Object.fromEntries( + Object.entries(store.getState().draftsByThreadKey).map(([key, draft]) => [ + key, + draft.prompt, + ]), + ), + }; + }; + return { storage, read, openTab }; + }; + const crossTabRef = (id: string) => + scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make(`cross-tab-${id}`)); + const crossTabKey = (id: string) => scopedThreadKey(crossTabRef(id)); + + it("keeps drafts written in other tabs and does not restore drafts they cleared", async () => { + const { storage, read, openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + + tabB.store.getState().setPrompt(crossTabRef("x"), "from tab B"); + tabB.store.getState().setStickyModelSelection(modelSelection(CODEX_DRIVER, "gpt-5.4")); + tabB.write(); + const tabBWrite = read(); + tabA.store.getState().setPrompt(crossTabRef("y"), "from tab A"); + tabA.write(); + const both = { [crossTabKey("x")]: "from tab B", [crossTabKey("y")]: "from tab A" }; + expect((await openTab()).prompts()).toEqual(both); + // The late event still carries tab B's write, which predates tab A's. + tabA.receive(tabBWrite); + tabB.receive(); + expect(tabA.prompts()).toEqual(both); + expect(tabA.store.getState().stickyActiveProvider).toBe(CODEX_INSTANCE); + expect(tabA.store.getState().stickyModelSelectionByProvider).toEqual( + tabB.store.getState().stickyModelSelectionByProvider, + ); + const settled = read(); + tabA.write(); + tabB.write(); + expect(read()).toBe(settled); + + tabA.store.getState().setPrompt(crossTabRef("x"), ""); + tabA.write(); + tabB.store.getState().setPrompt(crossTabRef("z"), "from tab B again"); + tabB.write(); + tabA.receive(); + tabB.receive(); + const expected = { [crossTabKey("y")]: "from tab A", [crossTabKey("z")]: "from tab B again" }; + expect((await openTab()).prompts()).toEqual(expected); + expect(tabA.prompts()).toEqual(expected); + expect(tabB.prompts()).toEqual(expected); + + // localStorage.clear() reports a null key. + storage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + tabA.receive(null, null); + expect(tabA.prompts()).toEqual({}); + expect(tabA.store.getState().stickyActiveProvider).toBeNull(); + tabA.write(); + expect(read()).toBeNull(); + }); + + it("does not overwrite another tab's edit with an edit undone before the write", async () => { + const { storage, openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + + tabB.store.getState().setPrompt(crossTabRef("z"), "from tab B"); + tabB.write(); + tabA.receive(); + tabB.store.getState().setPrompt(crossTabRef("z"), "edited in tab B"); + tabB.write(); + tabA.store.getState().setPrompt(crossTabRef("z"), "undone in tab A"); + tabA.receive(); + tabA.store.getState().setPrompt(crossTabRef("z"), "from tab B"); + tabA.store.getState().setPrompt(crossTabRef("w"), "from tab A"); + tabA.write(); + expect((await openTab()).prompts()).toEqual({ + [crossTabKey("z")]: "edited in tab B", + [crossTabKey("w")]: "from tab A", + }); + + // Storage from a newer build is neither adopted nor merged into: this tab's write replaces it. + const newerBuild = { [crossTabKey("v")]: { prompt: "from a newer build", attachments: [] } }; + storage.setItem( + COMPOSER_DRAFT_STORAGE_KEY, + JSON.stringify({ + version: Number.MAX_SAFE_INTEGER, + state: { draftsByThreadKey: newerBuild }, + }), + ); + tabA.receive(); + expect(tabA.prompts()[crossTabKey("v")]).toBeUndefined(); + tabA.store.getState().setPrompt(crossTabRef("y"), "from tab A"); + tabA.write(); + expect((await openTab()).prompts()).toEqual(tabA.prompts()); + }); + + it("adopts a remote edit after a local edit is undone", async () => { + const { openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + const ref = crossTabRef("deferred-edit"); + + tabA.store.getState().setPrompt(ref, "baseline"); + tabA.write(); + tabA.store.getState().setPrompt(ref, "local edit"); + tabB.store.getState().setPrompt(ref, "remote edit"); + tabB.write(); + tabA.receive(); + tabA.store.getState().setPrompt(ref, "baseline"); + tabA.write(); + + expect(tabA.prompts()[crossTabKey("deferred-edit")]).toBe("remote edit"); + }); + + it("adopts a remote deletion after a local edit is undone", async () => { + const { openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + const ref = crossTabRef("deferred-delete"); + const key = crossTabKey("deferred-delete"); + + tabB.store.getState().setPrompt(ref, "remote baseline"); + tabB.write(); + tabA.receive(); + tabA.store.getState().setPrompt(ref, "local edit"); + tabB.store.getState().setPrompt(ref, ""); + tabB.write(); + tabA.receive(); + tabA.store.getState().setPrompt(ref, "remote baseline"); + tabA.write(); + + expect(tabA.prompts()[key]).toBeUndefined(); + }); + + it("does not take a draft thread rebuilt in another key order as an edit", async () => { + const { openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + const draftId = DraftId.make("cross-tab-draft"); + + tabA.store + .getState() + .setProjectDraftThreadId( + scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make("cross-tab-project")), + draftId, + { environmentSelection: "auto", loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID }, + ); + tabA.write(); + tabB.receive(); + tabA.store.getState().setDraftThreadContext(draftId, { branch: "feature" }); + tabA.write(); + // Rebuilds tab B's copy with the same content in another key order. + tabB.store.getState().setDraftThreadContext(draftId, { branch: "scratch" }); + tabB.store + .getState() + .setDraftThreadContext(draftId, { branch: null, environmentSelection: "auto" }); + tabB.store.getState().setPrompt(crossTabRef("w"), "unrelated"); + tabB.write(); + tabB.receive(); + expect((await openTab()).store.getState().getDraftThread(draftId)?.branch).toBe("feature"); + expect(tabB.store.getState().getDraftThread(draftId)?.branch).toBe("feature"); + }); + + it("keeps a draft thread with unsaved changes that another tab discards", async () => { + const { openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + const imageDraft = DraftId.make("cross-tab-image-draft"); + const promptDraft = DraftId.make("cross-tab-prompt-draft"); + for (const draftId of [imageDraft, promptDraft]) { + tabA.store + .getState() + .setProjectDraftThreadId( + scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make(`${draftId}-project`)), + draftId, + { environmentSelection: "auto", loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID }, + ); + } + tabA.write(); + tabB.receive(); + // The image is unsaved until its serialization finishes, the prompt until the next write. + const image = makeImage({ id: "cross-tab-image", previewUrl: "blob:cross-tab" }); + tabA.store.getState().addImage(imageDraft, image); + tabA.store.getState().setPrompt(promptDraft, "unwritten"); + tabB.store.getState().clearDraftThread(imageDraft); + tabB.store.getState().clearDraftThread(promptDraft); + tabB.write(); + tabA.receive(); + expect(tabA.store.getState().getDraftSession(imageDraft)).not.toBeNull(); + expect(tabA.store.getState().getDraftSession(promptDraft)).not.toBeNull(); + + await tabA.store.getState().syncPersistedAttachments(imageDraft, [ + { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: "data:image/png;base64,AQEBAQ==", + }, + ]); + tabA.write(); + const reopened = (await openTab()).store.getState(); + expect(reopened.getDraftSession(imageDraft)).not.toBeNull(); + expect(reopened.getComposerDraft(imageDraft)?.images.map(({ id }) => id)).toEqual([image.id]); + expect(reopened.getDraftSession(promptDraft)).not.toBeNull(); + expect(reopened.getComposerDraft(promptDraft)?.prompt).toBe("unwritten"); + }); }); describe("composerDraftStore thread contexts", () => { @@ -2236,11 +2466,8 @@ function createMockStorage() { } describe("createDeferredStorage", () => { - const serialize = vi.fn((value: string) => `s:${value}`); - beforeEach(() => { vi.useFakeTimers(); - serialize.mockClear(); }); afterEach(() => { @@ -2249,29 +2476,27 @@ describe("createDeferredStorage", () => { it("removeItem cancels a pending setItem write", () => { const base = createMockStorage(); - const storage = createDeferredStorage(base, serialize); + const storage = createDeferredStorage(base, base.setItem); storage.setItem("key", "v1"); storage.removeItem("key"); vi.advanceTimersByTime(300); - expect(serialize).not.toHaveBeenCalled(); expect(base.setItem).not.toHaveBeenCalled(); expect(base.removeItem).toHaveBeenCalledWith("key"); }); - it("flush serializes and writes the pending value immediately", () => { + it("flush writes the pending value immediately", () => { const base = createMockStorage(); - const storage = createDeferredStorage(base, serialize); + const storage = createDeferredStorage(base, base.setItem); storage.setItem("key", "v1"); expect(base.setItem).not.toHaveBeenCalled(); storage.flush(); - expect(base.setItem).toHaveBeenCalledWith("key", "s:v1"); + expect(base.setItem).toHaveBeenCalledWith("key", "v1"); vi.advanceTimersByTime(300); - expect(serialize).toHaveBeenCalledTimes(1); expect(base.setItem).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aef9e88a38572..afa189e756453 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -37,6 +37,7 @@ import { } from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; +import * as Predicate from "effect/Predicate"; import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; import { @@ -124,35 +125,324 @@ type ComposerPersistState = | { capturedState: ComposerDraftStoreState } | PersistedComposerDraftStoreState; -const composerDebouncedStorage = createDeferredStorage>( - typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), - (value) => - JSON.stringify({ - state: - "capturedState" in value.state - ? partializeComposerDraftStoreState(value.state.capturedState) - : value.state, - version: value.version, - }), +const composerDraftLocalStorage = + typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(); + +function readComposerDraftText(): string | null { + const text = composerDraftLocalStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + return typeof text === "string" ? text : null; +} + +/** Persisted values by entry: one per record key and one per other field. */ +type ComposerDraftEntries = ReadonlyMap; + +const entryId = (field: string, key?: string) => + key === undefined ? field : `${field}\u0000${key}`; + +function entryIdParts(id: string): [field: string, key?: string] { + const separator = id.indexOf("\u0000"); + return separator === -1 ? [id] : [id.slice(0, separator), id.slice(separator + 1)]; +} + +function entriesOf(state: Readonly>): ComposerDraftEntries { + const entries = new Map(); + for (const [field, value] of Object.entries(state)) { + const items = Predicate.isObject(value) ? Object.entries(value) : [[undefined, value] as const]; + for (const [key, item] of items) { + if (item !== undefined) entries.set(entryId(field, key), item); + } + } + return entries; +} + +/** The state `entries` describe, keeping each record field of `shape` even when it is empty. */ +function persistedStateOf(entries: ComposerDraftEntries, shape: Readonly>) { + const state: Record = {}; + for (const [field, value] of Object.entries(shape)) { + if (Predicate.isObject(value)) state[field] = {}; + } + for (const [id, value] of entries) { + const [field, key] = entryIdParts(id); + const record = state[field]; + if (key === undefined) state[field] = value; + else if (Predicate.isObject(record)) record[key] = value; + else state[field] = { [key]: value }; + } + return state; +} + +/** Equality of JSON values; key order is ignored. */ +function jsonEquals(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) + return a.length === b.length && a.every((item, index) => jsonEquals(item, b[index])); + if (!Predicate.isObject(a) || !Predicate.isObject(b)) return false; + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every((key) => Object.hasOwn(b, key) && jsonEquals(a[key], b[key])) + ); +} + +function changedEntryIds(next: ComposerDraftEntries, base: ComposerDraftEntries): string[] { + return [...new Set([...next.keys(), ...base.keys()])].filter( + (id) => !jsonEquals(next.get(id), base.get(id)), + ); +} + +function withEntries( + base: ComposerDraftEntries, + ids: Iterable, + source: ComposerDraftEntries, +): ComposerDraftEntries { + const next = new Map(base); + for (const id of ids) { + const value = source.get(id); + if (value === undefined) next.delete(id); + else next.set(id, value); + } + return next; +} + +/** `null` when storage holds nothing this build can read, so nothing is adopted or merged. */ +function storedStateOf(value: unknown): PersistedComposerDraftStoreState | null { + if (value === null) return EMPTY_PERSISTED_DRAFT_STORE_STATE; + if (!Predicate.isObject(value) || !Predicate.isObject(value.state)) return null; + const version = + typeof value.version === "number" ? value.version : COMPOSER_DRAFT_STORAGE_VERSION; + if (version > COMPOSER_DRAFT_STORAGE_VERSION) return null; + return version < COMPOSER_DRAFT_STORAGE_VERSION + ? migratePersistedComposerDraftStoreState(value.state) + : normalizeCurrentPersistedComposerDraftStoreState(value.state); +} + +function readStoredComposerDrafts(text: string | null): PersistedComposerDraftStoreState | null { + try { + return storedStateOf(text === null ? null : JSON.parse(text)); + } catch { + return null; + } +} + +/** What this tab knew about storage when it last wrote, read or adopted it. */ +interface ComposerDraftSync { + /** Per entry: this tab's partialized value and storage's value at its last sync. */ + readonly local: ComposerDraftEntries; + readonly stored: ComposerDraftEntries; + /** The storage text last written or read and its entries. */ + readonly text?: string | null; + readonly textEntries?: ComposerDraftEntries | null; +} +const UNSYNCED: ComposerDraftSync = { local: new Map(), stored: new Map() }; +let sync = UNSYNCED; + +/** Writes only the entries this tab changed since its last sync over what storage holds now. */ +function writeComposerDrafts(name: string, value: StorageValue): void { + if (!("capturedState" in value.state)) { + composerDraftLocalStorage.setItem(name, JSON.stringify(value)); + return; + } + let partialized = partializeComposerDraftStoreState(value.state.capturedState); + let local = entriesOf(partialized); + let changed = changedEntryIds(local, sync.local); + const deferred = changedEntryIds(sync.textEntries ?? sync.stored, sync.local).filter((id) => + jsonEquals(local.get(id), sync.local.get(id)), + ); + if (deferred.length > 0) { + adoptStoredComposerDrafts(); + partialized = partializeComposerDraftStoreState(composerDraftStore.getState()); + local = entriesOf(partialized); + changed = changedEntryIds(local, sync.local); + } + if (changed.length === 0) return; + const text = readComposerDraftText(); + const parsed = sync.text === text ? undefined : readStoredComposerDrafts(text); + const stored = parsed === undefined ? sync.textEntries : parsed && entriesOf(parsed); + // Unreadable or newer storage: overwrite it with this tab's state. + const merged = stored ? withEntries(stored, changed, local) : local; + const nextText = JSON.stringify({ + state: persistedStateOf(merged, partialized), + version: value.version, + }); + composerDraftLocalStorage.setItem(name, nextText); + sync = { + local, + stored: stored ? withEntries(sync.stored, changed, local) : local, + text: nextText, + textEntries: merged, + }; +} + +const composerDebouncedStorage = createDeferredStorage( + composerDraftLocalStorage, + writeComposerDrafts, COMPOSER_PERSIST_DEBOUNCE_MS, ); const composerPersistStorage: PersistStorage = { - getItem: (name) => { - const raw = composerDebouncedStorage.getItem(name); - if (typeof raw !== "string") { - return null; - } - return JSON.parse(raw) as StorageValue; + getItem: () => { + const text = readComposerDraftText(); + const value = text === null ? null : (JSON.parse(text) as StorageValue); + const stored = storedStateOf(value); + const seen = stored && entriesOf(stored); + // onRehydrateStorage sets `local` once the state is hydrated. + sync = { ...UNSYNCED, stored: seen ?? new Map(), text, textEntries: seen }; + return value; }, setItem: (name, value) => composerDebouncedStorage.setItem(name, value), - removeItem: (name) => composerDebouncedStorage.removeItem(name), + removeItem: (name) => { + composerDebouncedStorage.removeItem(name); + sync = UNSYNCED; + }, }; +function adoptStoredRecord>>>( + ids: ReadonlySet, + field: keyof PersistedComposerDraftStoreState, + stored: Readonly>> | undefined, + memory: M, + hydrate: (stored: P | undefined, current: H | undefined) => H | undefined, +): M | Record { + const keys = [...ids] + .map(entryIdParts) + .flatMap(([idField, key]) => (idField === field && key !== undefined ? [key] : [])); + if (keys.length === 0) return memory; + const next: Record = {}; + for (const [key, value] of Object.entries(memory)) { + if (value !== undefined) next[key] = value; + } + for (const key of keys) { + const value = hydrate(stored?.[key], memory[key]); + if (value === undefined) delete next[key]; + else next[key] = value; + } + return next; +} + +/** Keeps `File` handles, decoded images, and images not yet persisted, in their current order. */ +function adoptStoredDraft( + stored: PersistedComposerThreadDraftState | undefined, + current: ComposerThreadDraftState | undefined, +): ComposerThreadDraftState | undefined { + const attachments = stored?.attachments ?? []; + const storedIds = new Set(attachments.map((attachment) => attachment.id)); + const persistedIds = new Set(current?.persistedAttachments.map((attachment) => attachment.id)); + // An image is in persistedAttachments only once serializeComposerImages has finished. + const keptImages = + current?.images.filter((image) => storedIds.has(image.id) || !persistedIds.has(image.id)) ?? []; + const keptIds = new Set(keptImages.map((image) => image.id)); + const currentFiles = new Map(current?.files.map((file) => [file.id, file.file])); + const adopted = stored + ? toHydratedThreadDraft({ ...stored, attachments: [] }) + : createEmptyThreadDraft(); + const next: ComposerThreadDraftState = { + ...adopted, + images: [ + ...keptImages, + ...hydrateImagesFromPersisted( + attachments.filter((attachment) => !keptIds.has(attachment.id)), + ), + ], + nonPersistedImageIds: current?.nonPersistedImageIds ?? [], + persistedAttachments: [...attachments], + files: adopted.files.map((file) => ({ ...file, file: currentFiles.get(file.id) ?? file.file })), + }; + for (const image of current?.images ?? []) { + if (!keptIds.has(image.id)) { + revokeObjectPreviewUrl(image.previewUrl); + } + } + return shouldRemoveDraft(next) ? undefined : next; +} + +/** Takes in every entry another tab changed in storage that this tab has not changed itself. */ +function adoptStoredComposerDrafts(): void { + const text = readComposerDraftText(); + const stored = readStoredComposerDrafts(text); + const entries = stored && entriesOf(stored); + const fromStorage = entries ? changedEntryIds(entries, sync.stored) : []; + // Shares unchanged entries with the baseline instead of keeping a second parsed copy. + const seen = entries && withEntries(sync.stored, fromStorage, entries); + sync = { ...sync, text, textEntries: seen }; + const state = composerDraftStore.getState(); + const local = entriesOf(partializeComposerDraftStoreState(state)); + const deferred = changedEntryIds(seen ?? sync.stored, sync.local); + const ids = new Set( + [...new Set([...fromStorage, ...deferred])].filter((id) => + jsonEquals(local.get(id), sync.local.get(id)), + ), + ); + if (!stored || !seen || ids.size === 0) return; + const keep = (stored: T | undefined) => stored; + const draftsByThreadKey = adoptStoredRecord( + ids, + "draftsByThreadKey", + stored.draftsByThreadKey, + state.draftsByThreadKey, + adoptStoredDraft, + ); + // A draft this tab keeps (an unwritten edit or an unsaved image) keeps its draft thread, and + // the thread leaves the baseline so the next write puts it back. + const keptThreadIds = Object.keys(draftsByThreadKey) + .filter((key) => stored.draftThreadsByThreadKey[key] === undefined) + .map((key) => entryId("draftThreadsByThreadKey", key)) + .filter((id) => ids.has(id)); + for (const id of keptThreadIds) ids.delete(id); + composerDraftStore.setState({ + draftThreadsByThreadKey: adoptStoredRecord( + ids, + "draftThreadsByThreadKey", + stored.draftThreadsByThreadKey, + state.draftThreadsByThreadKey, + (value) => value && toHydratedDraftThreadState(value), + ), + logicalProjectDraftThreadKeyByLogicalProjectKey: adoptStoredRecord( + ids, + "logicalProjectDraftThreadKeyByLogicalProjectKey", + stored.logicalProjectDraftThreadKeyByLogicalProjectKey, + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + keep, + ), + stickyModelSelectionByProvider: adoptStoredRecord( + ids, + "stickyModelSelectionByProvider", + stored.stickyModelSelectionByProvider, + state.stickyModelSelectionByProvider, + keep, + ), + stickyOptionsByModelByProvider: adoptStoredRecord( + ids, + "stickyOptionsByModelByProvider", + stored.stickyOptionsByModelByProvider, + state.stickyOptionsByModelByProvider, + (value): Partial>> | undefined => value, + ), + ...(ids.has(entryId("stickyActiveProvider")) && { + stickyActiveProvider: stored.stickyActiveProvider ?? null, + }), + draftsByThreadKey, + }); + const adoptedLocal = entriesOf(partializeComposerDraftStoreState(composerDraftStore.getState())); + sync = { + ...sync, + local: withEntries(withEntries(sync.local, ids, adoptedLocal), keptThreadIds, seen), + stored: withEntries(sync.stored, [...ids, ...keptThreadIds], seen), + }; +} + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); }); + // The event's newValue can predate this tab's own later write, so read storage instead. + window.addEventListener("storage", (event) => { + if ( + event.storageArea === composerDraftLocalStorage && + (event.key === COMPOSER_DRAFT_STORAGE_KEY || event.key === null) + ) { + adoptStoredComposerDrafts(); + } + }); } export const PersistedComposerImageAttachment = Schema.Struct({ @@ -4237,6 +4527,9 @@ const composerDraftStore = create()( stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, }; }, + onRehydrateStorage: () => (state) => { + if (state) sync = { ...sync, local: entriesOf(partializeComposerDraftStoreState(state)) }; + }, }, ), ); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 948b8f427f971..cbed51230f7a1 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -44,16 +44,11 @@ export function resolveStorage(storage: Partial | null | undefined export function createDeferredStorage( baseStorage: Partial | null | undefined, - serialize: (value: TValue) => string, + write: (name: string, value: TValue) => void, debounceMs: number = 300, ): DeferredStorage { const resolvedStorage = resolveStorage(baseStorage); - const debouncedSetItem = new Debouncer( - (name: string, value: TValue) => { - resolvedStorage.setItem(name, serialize(value)); - }, - { wait: debounceMs }, - ); + const debouncedSetItem = new Debouncer(write, { wait: debounceMs }); return { getItem: (name) => resolvedStorage.getItem(name),