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
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:
parent
7316c92ff3
commit
350ec987e0
4 changed files with 60 additions and 11 deletions
|
|
@ -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' };
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue