authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 09:20:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 09:20:31-07:00
log7839140e5e93d3f401d7eb45b86cf1a234eb3609
tree40c4972ff05a62b0c9f4c0a4fcc7ace46414168a
parentc7c1dfe4df99edf65a49d8a31b39ef1361f37f44
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve saved work after storage read failures (#9710)


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) {
276276 const revisionsAtRequest = new Map(revisions);
277277 return serialize(async () => {
278278 const persisted = await options.storage.load().catch((cause) => {
279 warn(
280 "[thread-outbox] failed to load messages while clearing environment",
281 new ThreadOutboxManagerError({
282 operation: "clear-environment-load",
283 environmentId,
284 threadId: null,
285 messageId: null,
286 cause,
287 }),
288 );
289 return [];
279 throw new ThreadOutboxManagerError({
280 operation: "clear-environment-load",
281 environmentId,
282 threadId: null,
283 messageId: null,
284 cause,
285 });
290286 });
291287 const allMessages = flattenQueuedThreadMessages(
292288 groupQueuedThreadMessages([...persisted, ...currentMessages()]),
apps/mobile/src/state/thread-outbox-storage.ts+11-11
......@@ -80,20 +80,20 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = {
8080 try {
8181 messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown));
8282 } catch (cause) {
83 console.warn(
84 "[thread-outbox] ignored invalid persisted message",
85 new ThreadOutboxStorageError({
86 operation: "read-message",
87 environmentId: null,
88 threadId: null,
89 messageId: null,
90 fileName: entry.name,
91 cause,
92 }),
93 );
83 // A partial queue hides attachment owners from cleanup. Keep all
84 // records untouched until every persisted message can be read.
85 throw new ThreadOutboxStorageError({
86 operation: "read-message",
87 environmentId: null,
88 threadId: null,
89 messageId: null,
90 fileName: entry.name,
91 cause,
92 });
9493 }
9594 }
9695 } catch (cause) {
96 if (cause instanceof ThreadOutboxStorageError) throw cause;
9797 throw new ThreadOutboxStorageError({
9898 operation: "load",
9999 environmentId: null,
apps/mobile/src/state/thread-outbox.test.ts+95-1
......@@ -8,6 +8,34 @@ import {
88 ThreadId,
99} from "@t3tools/contracts";
1010import { AtomRegistry } from "effect/unstable/reactivity";
11import { onTestFinished, vi } from "vite-plus/test";
12
13const outboxFiles = vi.hoisted(() => new Map<string, string | Error>());
14
15vi.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});
1139
1240import {
1341 decodeQueuedThreadMessage,
......@@ -24,7 +52,7 @@ import {
2452 type QueuedThreadMessage,
2553} from "./thread-outbox-model";
2654import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager";
27import type { ThreadOutboxStorage } from "./thread-outbox-storage";
55import { expoThreadOutboxStorage, type ThreadOutboxStorage } from "./thread-outbox-storage";
2856
2957function queuedMessage(input: {
3058 readonly environmentId?: string;
......@@ -44,6 +72,72 @@ function queuedMessage(input: {
4472}
4573
4674describe("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
47141 it("groups messages by scoped thread and preserves creation order", () => {
48142 const later = queuedMessage({
49143 messageId: "message-2",
apps/mobile/src/state/use-composer-drafts.test.ts+66-3
......@@ -9,7 +9,8 @@ import {
99import { onTestFinished, vi } from "vite-plus/test";
1010
1111const composerDraftFileMocks = vi.hoisted(() => {
12 let document = "";
12 let document = JSON.stringify({ schemaVersion: 1, drafts: {} });
13 let readError: Error | null = null;
1314 let writeError: Error | null = null;
1415 let releaseRead: (() => void) | null = null;
1516 let readBarrier = Promise.resolve();
......@@ -33,6 +34,9 @@ const composerDraftFileMocks = vi.hoisted(() => {
3334 setDocument(value: unknown) {
3435 document = JSON.stringify(value);
3536 },
37 setReadError(error: Error | null) {
38 readError = error;
39 },
3640 setWriteError(error: Error | null) {
3741 writeError = error;
3842 },
......@@ -50,6 +54,10 @@ const composerDraftFileMocks = vi.hoisted(() => {
5054 },
5155 Directory: class {
5256 create() {}
57
58 list() {
59 return [];
60 }
5361 },
5462 File: class {
5563 exists = true;
......@@ -61,6 +69,7 @@ const composerDraftFileMocks = vi.hoisted(() => {
6169
6270 async text() {
6371 await readBarrier;
72 if (readError) throw readError;
6473 return document;
6574 }
6675
......@@ -156,7 +165,8 @@ const DRAFT: ComposerDraft = {
156165afterEach(() => {
157166 vi.useRealTimers();
158167 resetComposerDraftsLoadState();
159 composerDraftFileMocks.setDocument("");
168 composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} });
169 composerDraftFileMocks.setReadError(null);
160170 composerDraftFileMocks.setWriteError(null);
161171 composerDraftFileMocks.setNextWriteBarrier(null);
162172 composerDraftFileMocks.setOnWrite(null);
......@@ -1043,9 +1053,62 @@ describe("mobile composer drafts", () => {
10431053 });
10441054 });
10451055
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
10461109 it("serializes environment cleanup after an older queued write", async () => {
10471110 vi.useFakeTimers();
1048 composerDraftFileMocks.setDocument(JSON.stringify({ schemaVersion: 1, drafts: {} }));
1111 composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} });
10491112 composerDraftFileMocks.resetWrites();
10501113 let releaseFirstWrite!: () => void;
10511114 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>({
148148
149149let loadPromise: Promise<void> | null = null;
150150let persistTimer: ReturnType<typeof setTimeout> | null = null;
151let persistRetryNeeded = false;
151152const persistenceQueue = new SerializedAsyncQueue();
152153
153154/** Resets module-level state between test runs. */
154155export function resetComposerDraftsLoadState(): void {
155156 loadPromise = null;
157 persistRetryNeeded = false;
156158}
157159
158160function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
......@@ -265,20 +267,12 @@ async function loadPersistedComposerState(): Promise<
265267 operation = "decode";
266268 return decodePersistedComposerState(JSON.parse(raw) as unknown);
267269 } catch (cause) {
268 console.warn(
269 "[composer-drafts] ignored persisted draft failure",
270 new ComposerDraftPersistenceError({
271 operation,
272 directory: COMPOSER_DRAFTS_DIRECTORY,
273 fileName: COMPOSER_DRAFTS_FILE,
274 cause,
275 }),
276 );
277 return {
278 drafts: {},
279 stickyModelSelection: null,
280 cloudDrafts: { accountId: null, signedOut: {} },
281 };
270 throw new ComposerDraftPersistenceError({
271 operation,
272 directory: COMPOSER_DRAFTS_DIRECTORY,
273 fileName: COMPOSER_DRAFTS_FILE,
274 cause,
275 });
282276 }
283277}
284278
......@@ -341,20 +335,26 @@ export async function flushComposerDrafts(): Promise<void> {
341335 // An edit during an awaited write schedules another debounced write, so
342336 // keep landing snapshots until no debounce is pending after a queue drain.
343337 do {
344 while (persistTimer !== null) {
345 clearTimeout(persistTimer);
338 while (persistTimer !== null || persistRetryNeeded) {
339 if (persistTimer !== null) clearTimeout(persistTimer);
346340 persistTimer = null;
347 await persistenceQueue.run(() =>
348 writePersistedComposerState(
349 appAtomRegistry.get(composerDraftsAtom),
350 appAtomRegistry.get(stickyComposerModelSelectionAtom),
351 ),
352 );
341 persistRetryNeeded = false;
342 try {
343 await persistenceQueue.run(() =>
344 writePersistedComposerState(
345 appAtomRegistry.get(composerDraftsAtom),
346 appAtomRegistry.get(stickyComposerModelSelectionAtom),
347 ),
348 );
349 } catch (error) {
350 persistRetryNeeded = true;
351 throw error;
352 }
353353 }
354354 // Draining also waits for an already-fired debounce whose write is still
355355 // gated behind its own hydration await inside the queue.
356356 await persistenceQueue.run(() => Promise.resolve());
357 } while (persistTimer !== null);
357 } while (persistTimer !== null || persistRetryNeeded);
358358}
359359
360360function signedOutAttachmentOwners() {
......@@ -534,19 +534,20 @@ function schedulePersistComposerState(): void {
534534 }
535535 persistTimer = setTimeout(() => {
536536 persistTimer = null;
537 ensureComposerDraftsLoaded();
538537 // The write enters the serialization queue before waiting on hydration,
539538 // so flushComposerDrafts' queue drain cannot resolve ahead of it.
540539 void persistenceQueue.run(async () => {
541 if (loadPromise !== null) {
542 await loadPromise;
543 }
544540 try {
541 await waitForComposerDraftsLoaded();
545542 await writePersistedComposerState(
546543 appAtomRegistry.get(composerDraftsAtom),
547544 appAtomRegistry.get(stickyComposerModelSelectionAtom),
548545 );
546 persistRetryNeeded = false;
549547 } 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;
550551 console.warn("[composer-drafts] failed to persist drafts", error);
551552 // Draft persistence is best-effort; in-memory drafts still keep working.
552553 }
......@@ -558,35 +559,39 @@ export function ensureComposerDraftsLoaded(): void {
558559 if (loadPromise !== null) {
559560 return;
560561 }
561 loadPromise = loadPersistedComposerState()
562 .then((persisted) => {
563 appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts);
564 if (Object.keys(persisted.drafts).length > 0) {
565 const current = appAtomRegistry.get(composerDraftsAtom);
566 appAtomRegistry.set(composerDraftsAtom, {
567 ...persisted.drafts,
568 ...current,
569 });
570 }
571 if (
572 persisted.stickyModelSelection !== null &&
573 appAtomRegistry.get(stickyComposerModelSelectionAtom) === null
574 ) {
575 appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection);
576 }
577 })
578 .catch((cause) => {
579 console.warn(
580 "[composer-drafts] failed to hydrate drafts",
581 new ComposerDraftPersistenceError({
582 operation: "hydrate",
583 directory: COMPOSER_DRAFTS_DIRECTORY,
584 fileName: COMPOSER_DRAFTS_FILE,
585 cause,
586 }),
587 );
588 // Draft loading is best-effort; in-memory drafts still keep working.
589 });
562 const loading = loadPersistedComposerState().then((persisted) => {
563 appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts);
564 if (Object.keys(persisted.drafts).length > 0) {
565 const current = appAtomRegistry.get(composerDraftsAtom);
566 appAtomRegistry.set(composerDraftsAtom, {
567 ...persisted.drafts,
568 ...current,
569 });
570 }
571 if (
572 persisted.stickyModelSelection !== null &&
573 appAtomRegistry.get(stickyComposerModelSelectionAtom) === null
574 ) {
575 appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection);
576 }
577 });
578 loadPromise = loading;
579 // Handle fire-and-forget hook loads without swallowing failures from the
580 // write and cleanup callers that await this same promise. A later call retries.
581 void loading.catch((cause) => {
582 if (loadPromise === loading) loadPromise = null;
583 console.warn(
584 "[composer-drafts] failed to hydrate drafts",
585 cause instanceof ComposerDraftPersistenceError
586 ? cause
587 : new ComposerDraftPersistenceError({
588 operation: "hydrate",
589 directory: COMPOSER_DRAFTS_DIRECTORY,
590 fileName: COMPOSER_DRAFTS_FILE,
591 cause,
592 }),
593 );
594 });
590595}
591596
592597/** Wait until persisted drafts have been merged into the in-memory composer state. */