| author | |
| committer | |
| log | 7839140e5e93d3f401d7eb45b86cf1a234eb3609 |
| tree | 40c4972ff05a62b0c9f4c0a4fcc7ace46414168a |
| parent | c7c1dfe4df99edf65a49d8a31b39ef1361f37f44 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 240 insertions(+), 82 deletions(-)
apps/mobile/src/state/thread-outbox-manager.ts+7-11| ... | @@ -276,17 +276,13 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -276,17 +276,13 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 276 | const revisionsAtRequest = new Map(revisions); | 276 | const revisionsAtRequest = new Map(revisions); |
| 277 | return serialize(async () => { | 277 | return serialize(async () => { |
| 278 | const persisted = await options.storage.load().catch((cause) => { | 278 | const persisted = await options.storage.load().catch((cause) => { |
| 279 | warn( | 279 | throw new ThreadOutboxManagerError({ |
| 280 | "[thread-outbox] failed to load messages while clearing environment", | 280 | operation: "clear-environment-load", |
| 281 | new ThreadOutboxManagerError({ | 281 | environmentId, |
| 282 | operation: "clear-environment-load", | 282 | threadId: null, |
| 283 | environmentId, | 283 | messageId: null, |
| 284 | threadId: null, | 284 | cause, |
| 285 | messageId: null, | 285 | }); |
| 286 | cause, | ||
| 287 | }), | ||
| 288 | ); | ||
| 289 | return []; | ||
| 290 | }); | 286 | }); |
| 291 | const allMessages = flattenQueuedThreadMessages( | 287 | const allMessages = flattenQueuedThreadMessages( |
| 292 | groupQueuedThreadMessages([...persisted, ...currentMessages()]), | 288 | groupQueuedThreadMessages([...persisted, ...currentMessages()]), |
apps/mobile/src/state/thread-outbox-storage.ts+11-11| ... | @@ -80,20 +80,20 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { | ... | @@ -80,20 +80,20 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 80 | try { | 80 | try { |
| 81 | messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); | 81 | messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); |
| 82 | } catch (cause) { | 82 | } catch (cause) { |
| 83 | console.warn( | 83 | // A partial queue hides attachment owners from cleanup. Keep all |
| 84 | "[thread-outbox] ignored invalid persisted message", | 84 | // records untouched until every persisted message can be read. |
| 85 | new ThreadOutboxStorageError({ | 85 | throw new ThreadOutboxStorageError({ |
| 86 | operation: "read-message", | 86 | operation: "read-message", |
| 87 | environmentId: null, | 87 | environmentId: null, |
| 88 | threadId: null, | 88 | threadId: null, |
| 89 | messageId: null, | 89 | messageId: null, |
| 90 | fileName: entry.name, | 90 | fileName: entry.name, |
| 91 | cause, | 91 | cause, |
| 92 | }), | 92 | }); |
| 93 | ); | ||
| 94 | } | 93 | } |
| 95 | } | 94 | } |
| 96 | } catch (cause) { | 95 | } catch (cause) { |
| 96 | if (cause instanceof ThreadOutboxStorageError) throw cause; | ||
| 97 | throw new ThreadOutboxStorageError({ | 97 | throw new ThreadOutboxStorageError({ |
| 98 | operation: "load", | 98 | operation: "load", |
| 99 | environmentId: null, | 99 | environmentId: null, |
apps/mobile/src/state/thread-outbox.test.ts+95-1| ... | @@ -8,6 +8,34 @@ import { | ... | @@ -8,6 +8,34 @@ import { |
| 8 | ThreadId, | 8 | ThreadId, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { AtomRegistry } from "effect/unstable/reactivity"; | 10 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 11 | import { onTestFinished, vi } from "vite-plus/test"; | ||
| 12 | |||
| 13 | const outboxFiles = vi.hoisted(() => new Map<string, string | Error>()); | ||
| 14 | |||
| 15 | vi.mock("expo-file-system", () => { | ||
| 16 | class File { | ||
| 17 | constructor(readonly name: string) {} | ||
| 18 | |||
| 19 | async text(): Promise<string> { | ||
| 20 | const contents = outboxFiles.get(this.name); | ||
| 21 | if (contents instanceof Error) throw contents; | ||
| 22 | if (contents === undefined) throw new Error("Missing file"); | ||
| 23 | return contents; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | return { | ||
| 28 | File, | ||
| 29 | Directory: class { | ||
| 30 | create() {} | ||
| 31 | |||
| 32 | list() { | ||
| 33 | return Array.from(outboxFiles.keys(), (name) => new File(name)); | ||
| 34 | } | ||
| 35 | }, | ||
| 36 | Paths: { document: "/documents" }, | ||
| 37 | }; | ||
| 38 | }); | ||
| 11 | 39 | ||
| 12 | import { | 40 | import { |
| 13 | decodeQueuedThreadMessage, | 41 | decodeQueuedThreadMessage, |
| ... | @@ -24,7 +52,7 @@ import { | ... | @@ -24,7 +52,7 @@ import { |
| 24 | type QueuedThreadMessage, | 52 | type QueuedThreadMessage, |
| 25 | } from "./thread-outbox-model"; | 53 | } from "./thread-outbox-model"; |
| 26 | import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; | 54 | import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; |
| 27 | import type { ThreadOutboxStorage } from "./thread-outbox-storage"; | 55 | import { expoThreadOutboxStorage, type ThreadOutboxStorage } from "./thread-outbox-storage"; |
| 28 | 56 | ||
| 29 | function queuedMessage(input: { | 57 | function queuedMessage(input: { |
| 30 | readonly environmentId?: string; | 58 | readonly environmentId?: string; |
| ... | @@ -44,6 +72,72 @@ function queuedMessage(input: { | ... | @@ -44,6 +72,72 @@ function queuedMessage(input: { |
| 44 | } | 72 | } |
| 45 | 73 | ||
| 46 | describe("thread outbox", () => { | 74 | describe("thread outbox", () => { |
| 75 | it.each(["read", "json", "schema"] as const)( | ||
| 76 | "does not load a partial outbox after a record %s failure", | ||
| 77 | async (failure) => { | ||
| 78 | onTestFinished(() => outboxFiles.clear()); | ||
| 79 | const first = queuedMessage({ | ||
| 80 | messageId: "message-1", | ||
| 81 | createdAt: "2026-06-08T10:00:01.000Z", | ||
| 82 | }); | ||
| 83 | const second = queuedMessage({ | ||
| 84 | messageId: "message-2", | ||
| 85 | createdAt: "2026-06-08T10:00:02.000Z", | ||
| 86 | }); | ||
| 87 | outboxFiles.set("message-1.json", JSON.stringify(encodeQueuedThreadMessage(first))); | ||
| 88 | outboxFiles.set( | ||
| 89 | "message-2.json", | ||
| 90 | failure === "read" | ||
| 91 | ? new Error("storage unavailable") | ||
| 92 | : failure === "json" | ||
| 93 | ? "{" | ||
| 94 | : JSON.stringify({ ...second, schemaVersion: 999 }), | ||
| 95 | ); | ||
| 96 | |||
| 97 | await expect(expoThreadOutboxStorage.load()).rejects.toMatchObject({ | ||
| 98 | operation: "read-message", | ||
| 99 | fileName: "message-2.json", | ||
| 100 | }); | ||
| 101 | |||
| 102 | outboxFiles.set("message-2.json", JSON.stringify(encodeQueuedThreadMessage(second))); | ||
| 103 | await expect(expoThreadOutboxStorage.load()).resolves.toEqual([first, second]); | ||
| 104 | }, | ||
| 105 | ); | ||
| 106 | |||
| 107 | it("preserves queued messages when environment cleanup cannot read the outbox", async () => { | ||
| 108 | const registry = AtomRegistry.make(); | ||
| 109 | onTestFinished(() => registry.dispose()); | ||
| 110 | const message = queuedMessage({ | ||
| 111 | messageId: "message-1", | ||
| 112 | createdAt: "2026-06-08T10:00:01.000Z", | ||
| 113 | }); | ||
| 114 | const stored = new Map<MessageId, QueuedThreadMessage>(); | ||
| 115 | const manager = createThreadOutboxManager({ | ||
| 116 | registry, | ||
| 117 | warn: () => {}, | ||
| 118 | storage: { | ||
| 119 | load: async () => { | ||
| 120 | throw new Error("storage unavailable"); | ||
| 121 | }, | ||
| 122 | write: async (entry) => { | ||
| 123 | stored.set(entry.messageId, entry); | ||
| 124 | }, | ||
| 125 | remove: async (entry) => { | ||
| 126 | stored.delete(entry.messageId); | ||
| 127 | }, | ||
| 128 | }, | ||
| 129 | }); | ||
| 130 | await manager.enqueue(message); | ||
| 131 | |||
| 132 | await expect(manager.clearEnvironment(message.environmentId)).rejects.toMatchObject({ | ||
| 133 | operation: "clear-environment-load", | ||
| 134 | }); | ||
| 135 | expect([...stored.values()]).toEqual([message]); | ||
| 136 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ | ||
| 137 | "environment-1:thread-1": [message], | ||
| 138 | }); | ||
| 139 | }); | ||
| 140 | |||
| 47 | it("groups messages by scoped thread and preserves creation order", () => { | 141 | it("groups messages by scoped thread and preserves creation order", () => { |
| 48 | const later = queuedMessage({ | 142 | const later = queuedMessage({ |
| 49 | messageId: "message-2", | 143 | messageId: "message-2", |
apps/mobile/src/state/use-composer-drafts.test.ts+66-3| ... | @@ -9,7 +9,8 @@ import { | ... | @@ -9,7 +9,8 @@ import { |
| 9 | import { onTestFinished, vi } from "vite-plus/test"; | 9 | import { onTestFinished, vi } from "vite-plus/test"; |
| 10 | 10 | ||
| 11 | const composerDraftFileMocks = vi.hoisted(() => { | 11 | const composerDraftFileMocks = vi.hoisted(() => { |
| 12 | let document = ""; | 12 | let document = JSON.stringify({ schemaVersion: 1, drafts: {} }); |
| 13 | let readError: Error | null = null; | ||
| 13 | let writeError: Error | null = null; | 14 | let writeError: Error | null = null; |
| 14 | let releaseRead: (() => void) | null = null; | 15 | let releaseRead: (() => void) | null = null; |
| 15 | let readBarrier = Promise.resolve(); | 16 | let readBarrier = Promise.resolve(); |
| ... | @@ -33,6 +34,9 @@ const composerDraftFileMocks = vi.hoisted(() => { | ... | @@ -33,6 +34,9 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 33 | setDocument(value: unknown) { | 34 | setDocument(value: unknown) { |
| 34 | document = JSON.stringify(value); | 35 | document = JSON.stringify(value); |
| 35 | }, | 36 | }, |
| 37 | setReadError(error: Error | null) { | ||
| 38 | readError = error; | ||
| 39 | }, | ||
| 36 | setWriteError(error: Error | null) { | 40 | setWriteError(error: Error | null) { |
| 37 | writeError = error; | 41 | writeError = error; |
| 38 | }, | 42 | }, |
| ... | @@ -50,6 +54,10 @@ const composerDraftFileMocks = vi.hoisted(() => { | ... | @@ -50,6 +54,10 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 50 | }, | 54 | }, |
| 51 | Directory: class { | 55 | Directory: class { |
| 52 | create() {} | 56 | create() {} |
| 57 | |||
| 58 | list() { | ||
| 59 | return []; | ||
| 60 | } | ||
| 53 | }, | 61 | }, |
| 54 | File: class { | 62 | File: class { |
| 55 | exists = true; | 63 | exists = true; |
| ... | @@ -61,6 +69,7 @@ const composerDraftFileMocks = vi.hoisted(() => { | ... | @@ -61,6 +69,7 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 61 | 69 | ||
| 62 | async text() { | 70 | async text() { |
| 63 | await readBarrier; | 71 | await readBarrier; |
| 72 | if (readError) throw readError; | ||
| 64 | return document; | 73 | return document; |
| 65 | } | 74 | } |
| 66 | 75 | ||
| ... | @@ -156,7 +165,8 @@ const DRAFT: ComposerDraft = { | ... | @@ -156,7 +165,8 @@ const DRAFT: ComposerDraft = { |
| 156 | afterEach(() => { | 165 | afterEach(() => { |
| 157 | vi.useRealTimers(); | 166 | vi.useRealTimers(); |
| 158 | resetComposerDraftsLoadState(); | 167 | resetComposerDraftsLoadState(); |
| 159 | composerDraftFileMocks.setDocument(""); | 168 | composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); |
| 169 | composerDraftFileMocks.setReadError(null); | ||
| 160 | composerDraftFileMocks.setWriteError(null); | 170 | composerDraftFileMocks.setWriteError(null); |
| 161 | composerDraftFileMocks.setNextWriteBarrier(null); | 171 | composerDraftFileMocks.setNextWriteBarrier(null); |
| 162 | composerDraftFileMocks.setOnWrite(null); | 172 | composerDraftFileMocks.setOnWrite(null); |
| ... | @@ -1043,9 +1053,62 @@ describe("mobile composer drafts", () => { | ... | @@ -1043,9 +1053,62 @@ describe("mobile composer drafts", () => { |
| 1043 | }); | 1053 | }); |
| 1044 | }); | 1054 | }); |
| 1045 | 1055 | ||
| 1056 | it.each(["read", "decode"] as const)( | ||
| 1057 | "preserves saved drafts and attachment files when the draft %s fails", | ||
| 1058 | async (failure) => { | ||
| 1059 | vi.useFakeTimers(); | ||
| 1060 | const file = { | ||
| 1061 | id: "saved-file", | ||
| 1062 | type: "file" as const, | ||
| 1063 | name: "report.pdf", | ||
| 1064 | mimeType: "application/pdf", | ||
| 1065 | sizeBytes: 42, | ||
| 1066 | fileUri: "file:///documents/t3-composer-attachments/report.pdf", | ||
| 1067 | }; | ||
| 1068 | composerDraftFileMocks.setDocument({ | ||
| 1069 | schemaVersion: failure === "decode" ? 999 : 1, | ||
| 1070 | drafts: { "environment-1:saved": { text: "Saved draft", attachments: [file] } }, | ||
| 1071 | }); | ||
| 1072 | const original = composerDraftFileMocks.getDocument(); | ||
| 1073 | if (failure === "read") { | ||
| 1074 | composerDraftFileMocks.setReadError(new Error("storage unavailable")); | ||
| 1075 | } | ||
| 1076 | |||
| 1077 | await expect(releaseUnusedComposerAttachmentFiles([file])).rejects.toMatchObject({ | ||
| 1078 | operation: failure, | ||
| 1079 | }); | ||
| 1080 | setComposerDraftText("environment-1:new", "Keep my new edits too"); | ||
| 1081 | await expect(flushComposerDrafts()).rejects.toMatchObject({ operation: failure }); | ||
| 1082 | |||
| 1083 | expect(composerDraftFileMocks.getDocument()).toBe(original); | ||
| 1084 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | ||
| 1085 | }, | ||
| 1086 | ); | ||
| 1087 | |||
| 1088 | it("retries a failed debounced read on final flush without dropping saved drafts or new edits", async () => { | ||
| 1089 | vi.useFakeTimers(); | ||
| 1090 | composerDraftFileMocks.setDocument({ | ||
| 1091 | schemaVersion: 1, | ||
| 1092 | drafts: { "environment-1:saved": DRAFT }, | ||
| 1093 | }); | ||
| 1094 | const original = composerDraftFileMocks.getDocument(); | ||
| 1095 | composerDraftFileMocks.setReadError(new Error("storage unavailable")); | ||
| 1096 | setComposerDraftText("environment-1:new", "New edits"); | ||
| 1097 | await vi.advanceTimersByTimeAsync(200); | ||
| 1098 | expect(composerDraftFileMocks.getDocument()).toBe(original); | ||
| 1099 | |||
| 1100 | composerDraftFileMocks.setReadError(null); | ||
| 1101 | await flushComposerDrafts(); | ||
| 1102 | |||
| 1103 | expect(JSON.parse(composerDraftFileMocks.getDocument()).drafts).toEqual({ | ||
| 1104 | "environment-1:saved": DRAFT, | ||
| 1105 | "environment-1:new": { text: "New edits", attachments: [] }, | ||
| 1106 | }); | ||
| 1107 | }); | ||
| 1108 | |||
| 1046 | it("serializes environment cleanup after an older queued write", async () => { | 1109 | it("serializes environment cleanup after an older queued write", async () => { |
| 1047 | vi.useFakeTimers(); | 1110 | vi.useFakeTimers(); |
| 1048 | composerDraftFileMocks.setDocument(JSON.stringify({ schemaVersion: 1, drafts: {} })); | 1111 | composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); |
| 1049 | composerDraftFileMocks.resetWrites(); | 1112 | composerDraftFileMocks.resetWrites(); |
| 1050 | let releaseFirstWrite!: () => void; | 1113 | let releaseFirstWrite!: () => void; |
| 1051 | const firstWriteBarrier = new Promise<void>((resolve) => { | 1114 | const firstWriteBarrier = new Promise<void>((resolve) => { |
apps/mobile/src/state/use-composer-drafts.ts+61-56| ... | @@ -148,11 +148,13 @@ export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({ | ... | @@ -148,11 +148,13 @@ export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({ |
| 148 | 148 | ||
| 149 | let loadPromise: Promise<void> | null = null; | 149 | let loadPromise: Promise<void> | null = null; |
| 150 | let persistTimer: ReturnType<typeof setTimeout> | null = null; | 150 | let persistTimer: ReturnType<typeof setTimeout> | null = null; |
| 151 | let persistRetryNeeded = false; | ||
| 151 | const persistenceQueue = new SerializedAsyncQueue(); | 152 | const persistenceQueue = new SerializedAsyncQueue(); |
| 152 | 153 | ||
| 153 | /** Resets module-level state between test runs. */ | 154 | /** Resets module-level state between test runs. */ |
| 154 | export function resetComposerDraftsLoadState(): void { | 155 | export function resetComposerDraftsLoadState(): void { |
| 155 | loadPromise = null; | 156 | loadPromise = null; |
| 157 | persistRetryNeeded = false; | ||
| 156 | } | 158 | } |
| 157 | 159 | ||
| 158 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { | 160 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { |
| ... | @@ -265,20 +267,12 @@ async function loadPersistedComposerState(): Promise< | ... | @@ -265,20 +267,12 @@ async function loadPersistedComposerState(): Promise< |
| 265 | operation = "decode"; | 267 | operation = "decode"; |
| 266 | return decodePersistedComposerState(JSON.parse(raw) as unknown); | 268 | return decodePersistedComposerState(JSON.parse(raw) as unknown); |
| 267 | } catch (cause) { | 269 | } catch (cause) { |
| 268 | console.warn( | 270 | throw new ComposerDraftPersistenceError({ |
| 269 | "[composer-drafts] ignored persisted draft failure", | 271 | operation, |
| 270 | new ComposerDraftPersistenceError({ | 272 | directory: COMPOSER_DRAFTS_DIRECTORY, |
| 271 | operation, | 273 | fileName: COMPOSER_DRAFTS_FILE, |
| 272 | directory: COMPOSER_DRAFTS_DIRECTORY, | 274 | cause, |
| 273 | fileName: COMPOSER_DRAFTS_FILE, | 275 | }); |
| 274 | cause, | ||
| 275 | }), | ||
| 276 | ); | ||
| 277 | return { | ||
| 278 | drafts: {}, | ||
| 279 | stickyModelSelection: null, | ||
| 280 | cloudDrafts: { accountId: null, signedOut: {} }, | ||
| 281 | }; | ||
| 282 | } | 276 | } |
| 283 | } | 277 | } |
| 284 | 278 | ||
| ... | @@ -341,20 +335,26 @@ export async function flushComposerDrafts(): Promise<void> { | ... | @@ -341,20 +335,26 @@ export async function flushComposerDrafts(): Promise<void> { |
| 341 | // An edit during an awaited write schedules another debounced write, so | 335 | // An edit during an awaited write schedules another debounced write, so |
| 342 | // keep landing snapshots until no debounce is pending after a queue drain. | 336 | // keep landing snapshots until no debounce is pending after a queue drain. |
| 343 | do { | 337 | do { |
| 344 | while (persistTimer !== null) { | 338 | while (persistTimer !== null || persistRetryNeeded) { |
| 345 | clearTimeout(persistTimer); | 339 | if (persistTimer !== null) clearTimeout(persistTimer); |
| 346 | persistTimer = null; | 340 | persistTimer = null; |
| 347 | await persistenceQueue.run(() => | 341 | persistRetryNeeded = false; |
| 348 | writePersistedComposerState( | 342 | try { |
| 349 | appAtomRegistry.get(composerDraftsAtom), | 343 | await persistenceQueue.run(() => |
| 350 | appAtomRegistry.get(stickyComposerModelSelectionAtom), | 344 | writePersistedComposerState( |
| 351 | ), | 345 | appAtomRegistry.get(composerDraftsAtom), |
| 352 | ); | 346 | appAtomRegistry.get(stickyComposerModelSelectionAtom), |
| 347 | ), | ||
| 348 | ); | ||
| 349 | } catch (error) { | ||
| 350 | persistRetryNeeded = true; | ||
| 351 | throw error; | ||
| 352 | } | ||
| 353 | } | 353 | } |
| 354 | // Draining also waits for an already-fired debounce whose write is still | 354 | // Draining also waits for an already-fired debounce whose write is still |
| 355 | // gated behind its own hydration await inside the queue. | 355 | // gated behind its own hydration await inside the queue. |
| 356 | await persistenceQueue.run(() => Promise.resolve()); | 356 | await persistenceQueue.run(() => Promise.resolve()); |
| 357 | } while (persistTimer !== null); | 357 | } while (persistTimer !== null || persistRetryNeeded); |
| 358 | } | 358 | } |
| 359 | 359 | ||
| 360 | function signedOutAttachmentOwners() { | 360 | function signedOutAttachmentOwners() { |
| ... | @@ -534,19 +534,20 @@ function schedulePersistComposerState(): void { | ... | @@ -534,19 +534,20 @@ function schedulePersistComposerState(): void { |
| 534 | } | 534 | } |
| 535 | persistTimer = setTimeout(() => { | 535 | persistTimer = setTimeout(() => { |
| 536 | persistTimer = null; | 536 | persistTimer = null; |
| 537 | ensureComposerDraftsLoaded(); | ||
| 538 | // The write enters the serialization queue before waiting on hydration, | 537 | // The write enters the serialization queue before waiting on hydration, |
| 539 | // so flushComposerDrafts' queue drain cannot resolve ahead of it. | 538 | // so flushComposerDrafts' queue drain cannot resolve ahead of it. |
| 540 | void persistenceQueue.run(async () => { | 539 | void persistenceQueue.run(async () => { |
| 541 | if (loadPromise !== null) { | ||
| 542 | await loadPromise; | ||
| 543 | } | ||
| 544 | try { | 540 | try { |
| 541 | await waitForComposerDraftsLoaded(); | ||
| 545 | await writePersistedComposerState( | 542 | await writePersistedComposerState( |
| 546 | appAtomRegistry.get(composerDraftsAtom), | 543 | appAtomRegistry.get(composerDraftsAtom), |
| 547 | appAtomRegistry.get(stickyComposerModelSelectionAtom), | 544 | appAtomRegistry.get(stickyComposerModelSelectionAtom), |
| 548 | ); | 545 | ); |
| 546 | persistRetryNeeded = false; | ||
| 549 | } catch (error) { | 547 | } catch (error) { |
| 548 | // A failed debounce has no timer left. A later final flush must retry | ||
| 549 | // these edits after persisted ownership can be read safely. | ||
| 550 | persistRetryNeeded = true; | ||
| 550 | console.warn("[composer-drafts] failed to persist drafts", error); | 551 | console.warn("[composer-drafts] failed to persist drafts", error); |
| 551 | // Draft persistence is best-effort; in-memory drafts still keep working. | 552 | // Draft persistence is best-effort; in-memory drafts still keep working. |
| 552 | } | 553 | } |
| ... | @@ -558,35 +559,39 @@ export function ensureComposerDraftsLoaded(): void { | ... | @@ -558,35 +559,39 @@ export function ensureComposerDraftsLoaded(): void { |
| 558 | if (loadPromise !== null) { | 559 | if (loadPromise !== null) { |
| 559 | return; | 560 | return; |
| 560 | } | 561 | } |
| 561 | loadPromise = loadPersistedComposerState() | 562 | const loading = loadPersistedComposerState().then((persisted) => { |
| 562 | .then((persisted) => { | 563 | appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); |
| 563 | appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); | 564 | if (Object.keys(persisted.drafts).length > 0) { |
| 564 | if (Object.keys(persisted.drafts).length > 0) { | 565 | const current = appAtomRegistry.get(composerDraftsAtom); |
| 565 | const current = appAtomRegistry.get(composerDraftsAtom); | 566 | appAtomRegistry.set(composerDraftsAtom, { |
| 566 | appAtomRegistry.set(composerDraftsAtom, { | 567 | ...persisted.drafts, |
| 567 | ...persisted.drafts, | 568 | ...current, |
| 568 | ...current, | 569 | }); |
| 569 | }); | 570 | } |
| 570 | } | 571 | if ( |
| 571 | if ( | 572 | persisted.stickyModelSelection !== null && |
| 572 | persisted.stickyModelSelection !== null && | 573 | appAtomRegistry.get(stickyComposerModelSelectionAtom) === null |
| 573 | appAtomRegistry.get(stickyComposerModelSelectionAtom) === null | 574 | ) { |
| 574 | ) { | 575 | appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); |
| 575 | appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); | 576 | } |
| 576 | } | 577 | }); |
| 577 | }) | 578 | loadPromise = loading; |
| 578 | .catch((cause) => { | 579 | // Handle fire-and-forget hook loads without swallowing failures from the |
| 579 | console.warn( | 580 | // write and cleanup callers that await this same promise. A later call retries. |
| 580 | "[composer-drafts] failed to hydrate drafts", | 581 | void loading.catch((cause) => { |
| 581 | new ComposerDraftPersistenceError({ | 582 | if (loadPromise === loading) loadPromise = null; |
| 582 | operation: "hydrate", | 583 | console.warn( |
| 583 | directory: COMPOSER_DRAFTS_DIRECTORY, | 584 | "[composer-drafts] failed to hydrate drafts", |
| 584 | fileName: COMPOSER_DRAFTS_FILE, | 585 | cause instanceof ComposerDraftPersistenceError |
| 585 | cause, | 586 | ? cause |
| 586 | }), | 587 | : new ComposerDraftPersistenceError({ |
| 587 | ); | 588 | operation: "hydrate", |
| 588 | // Draft loading is best-effort; in-memory drafts still keep working. | 589 | directory: COMPOSER_DRAFTS_DIRECTORY, |
| 589 | }); | 590 | fileName: COMPOSER_DRAFTS_FILE, |
| 591 | cause, | ||
| 592 | }), | ||
| 593 | ); | ||
| 594 | }); | ||
| 590 | } | 595 | } |
| 591 | 596 | ||
| 592 | /** Wait until persisted drafts have been merged into the in-memory composer state. */ | 597 | /** Wait until persisted drafts have been merged into the in-memory composer state. */ |