From 350ec987e0e06d312809a2627f8097db9f8d71d7 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:17:06 +0000 Subject: [PATCH] outbox: survive a refused storage write, mark reload-interrupted sends, hide Resend on live sends Soul pass findings: a failed localStorage write no longer loses the entry (memory copy wins until a write succeeds), a stale draft is dropped when its rewrite fails, storage reads/removes cannot throw, and pending entries older than the page load read as interrupted with Resend available. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.test.ts | 4 ++- src/app/features/room/RoomOutbox.tsx | 14 ++++++++- src/app/state/room/composerStore.test.ts | 15 ++++++++++ src/app/state/room/composerStore.ts | 38 ++++++++++++++++++------ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index f0efe1ff..df22f9d4 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -20,7 +20,9 @@ const fakeClient = (send: (...a: unknown[]) => Promise) => describe('sendTracked', () => { beforeEach(stubLocalStorage); - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + vi.unstubAllGlobals(); + }); const content = { msgtype: 'm.text', body: 'typed before reload' }; diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx index 4d3d6aca..cf164472 100644 --- a/src/app/features/room/RoomOutbox.tsx +++ b/src/app/features/room/RoomOutbox.tsx @@ -42,6 +42,9 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => return entries; }; +/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ +const pageStart = Date.now(); + type RoomOutboxProps = { mx: MatrixClient; roomId: string }; /** @@ -62,6 +65,8 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { > {entries.map((entry) => { const failed = entry.status === 'failed'; + const interrupted = !failed && entry.ts < pageStart; + const canResend = failed || interrupted; return ( @@ -69,12 +74,17 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { size="T200" style={{ color: failed ? color.Critical.Main : color.Surface.OnContainer }} > - {failed ? `Not sent${entry.error ? `: ${entry.error}` : ''}` : 'Sending…'} + {(() => { + if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`; + return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…'; + })()} {String(entry.content.body ?? '')} + {canResend && ( + <> removeOutbox(userId, entry.txnId)}> Discard + + )} ); })} diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index d3957d68..8eb36e4f 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -113,4 +113,19 @@ describe('outbox', () => { enqueueOutbox('@a:x', entry, 5, store); expect(n).toBe(2); }); + + it('when storage refuses the write, memory keeps the entry so a failed send still shows', () => { + const broken = { + getItem: () => null, + setItem: () => { + throw new Error('quota'); + }, + removeItem: () => {}, + }; + enqueueOutbox('@q:x', entry, 5, broken); + failOutbox('@q:x', 't1', 'offline', broken); + expect(loadOutbox('@q:x', broken)).toMatchObject([{ txnId: 't1', status: 'failed' }]); + removeOutbox('@q:x', 't1', broken); + expect(loadOutbox('@q:x', broken)).toEqual([]); + }); }); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts index 8401e0d7..1cbe54aa 100644 --- a/src/app/state/room/composerStore.ts +++ b/src/app/state/room/composerStore.ts @@ -35,20 +35,31 @@ const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}. const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`; const readJson = (store: Store | undefined, key: string): T | undefined => { - const raw = store?.getItem(key); - if (raw === null || raw === undefined) return undefined; try { + const raw = store?.getItem(key); + if (raw === null || raw === undefined) return undefined; return JSON.parse(raw) as T; } catch { return undefined; } }; -const writeJson = (store: Store | undefined, key: string, value: unknown): void => { +const writeJson = (store: Store | undefined, key: string, value: unknown): boolean => { try { - store?.setItem(key, JSON.stringify(value)); + if (!store) return false; + store.setItem(key, JSON.stringify(value)); + return true; } catch { // quota or private mode: the in-memory atom still holds the draft for this session + return false; + } +}; + +const removeKey = (store: Store | undefined, key: string): void => { + try { + store?.removeItem(key); + } catch { + // nothing durable to remove } }; @@ -76,10 +87,10 @@ export const saveDraft = ( store: Store | undefined = defaultStore() ): void => { if (isEmptyDraft(draft)) { - store?.removeItem(draftKey(userId, roomId)); + removeKey(store, draftKey(userId, roomId)); return; } - writeJson(store, draftKey(userId, roomId), draft); + if (!writeJson(store, draftKey(userId, roomId), draft)) removeKey(store, draftKey(userId, roomId)); }; export const clearDraft = ( @@ -87,13 +98,18 @@ export const clearDraft = ( roomId: string, store: Store | undefined = defaultStore() ): void => { - store?.removeItem(draftKey(userId, roomId)); + removeKey(store, draftKey(userId, roomId)); }; +/** Outbox contents whose last write to storage failed: memory is then the only copy, so it wins. */ +const unpersisted = new Map(); + export const loadOutbox = ( userId: string, store: Store | undefined = defaultStore() ): OutboxEntry[] => { + const held = unpersisted.get(userId); + if (held) return held; const entries = readJson(store, outboxKey(userId)); return Array.isArray(entries) ? entries : []; }; @@ -105,8 +121,12 @@ export const subscribeOutbox = (listener: () => void): (() => void) => { }; const saveOutbox = (userId: string, entries: OutboxEntry[], store: Store | undefined) => { - if (entries.length === 0) store?.removeItem(outboxKey(userId)); - else writeJson(store, outboxKey(userId), entries); + const ok = + entries.length === 0 + ? (removeKey(store, outboxKey(userId)), true) + : writeJson(store, outboxKey(userId), entries); + if (ok) unpersisted.delete(userId); + else unpersisted.set(userId, entries); outboxListeners.forEach((l) => l()); };