outbox: survive a refused storage write, mark reload-interrupted sends, hide Resend on live sends
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-12 21:17:06 +00:00
commit 350ec987e0
4 changed files with 60 additions and 11 deletions

View file

@ -20,7 +20,9 @@ const fakeClient = (send: (...a: unknown[]) => Promise<unknown>) =>
describe('sendTracked', () => {
beforeEach(stubLocalStorage);
afterEach(() => vi.unstubAllGlobals());
afterEach(() => {
vi.unstubAllGlobals();
});
const content = { msgtype: 'm.text', body: 'typed before reload' };

View file

@ -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 (
<Box key={entry.txnId} alignItems="Center" gap="200" data-outbox-status={entry.status}>
<Box grow="Yes" direction="Column">
@ -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…';
})()}
</Text>
<Text size="T300" truncate>
{String(entry.content.body ?? '')}
</Text>
</Box>
{canResend && (
<>
<Chip
variant="Surface"
radii="Pill"
@ -88,6 +98,8 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) {
<Chip variant="Surface" radii="Pill" onClick={() => removeOutbox(userId, entry.txnId)}>
<Text size="B300">Discard</Text>
</Chip>
</>
)}
</Box>
);
})}

View file

@ -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([]);
});
});

View file

@ -35,20 +35,31 @@ const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}.
const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`;
const readJson = <T>(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<string, OutboxEntry[]>();
export const loadOutbox = (
userId: string,
store: Store | undefined = defaultStore()
): OutboxEntry[] => {
const held = unpersisted.get(userId);
if (held) return held;
const entries = readJson<OutboxEntry[]>(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());
};