Add notification toasts and surface persistence failures instead of failing silently

This commit is contained in:
2026-06-05 11:19:50 +03:00
parent c50f141d57
commit 3839f92f1d
10 changed files with 644 additions and 5 deletions
+128
View File
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSnippet, type Snippet } from '@core/snippet';
/**
* The persistence subscribers are the subtlest glue in the app: a debounced
* auto-save and a reference-diff write-through that decides what to upsert vs.
* delete. They run against a mocked IndexedDB adapter so we assert the *wiring*
* (when each adapter call fires, with what, and that a rejection surfaces) with
* no real database.
*/
// Mock the adapter, but keep the real StorageQuotaError so the error-mapping
// path (instanceof check) behaves exactly as in production.
vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
const actual = await importOriginal<typeof import('../infrastructure/snippet-store')>();
return { ...actual, saveSnippet: vi.fn(), deleteSnippet: vi.fn() };
});
import { saveSnippet, deleteSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { AUTOSAVE_DEBOUNCE_MS, wirePersistence } from './persistence';
const save = vi.mocked(saveSnippet);
const del = vi.mocked(deleteSnippet);
let teardown: () => void = () => {};
/** Flush pending microtasks (e.g. a `.catch` on a rejected adapter promise). */
const flush = () => Promise.resolve().then(() => Promise.resolve());
function snippetWith(id: string, spec: string): Snippet {
return createSnippet({ id, spec });
}
beforeEach(() => {
vi.useFakeTimers();
save.mockReset().mockResolvedValue(undefined);
del.mockReset().mockResolvedValue(undefined);
useNotificationStore.getState().clear();
useSnippetStore.getState().reset();
});
afterEach(() => {
teardown();
teardown = () => {};
vi.clearAllTimers();
vi.useRealTimers();
});
describe('wireDraftAutoSave', () => {
it('persists a settled, valid edit after the debounce', async () => {
useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]);
teardown = wirePersistence();
save.mockClear();
useSnippetStore.getState().updateDraft('{"a":2}');
expect(save).not.toHaveBeenCalled(); // nothing yet — waiting for the pause
await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS);
expect(save).toHaveBeenCalledTimes(1);
expect(save.mock.calls[0][0]).toMatchObject({ id: 'a', draftSpec: '{"a":2}' });
});
it('does not persist a half-typed, unparseable buffer', async () => {
useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]);
teardown = wirePersistence();
save.mockClear();
useSnippetStore.getState().updateDraft('{ "a":');
await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS);
expect(save).not.toHaveBeenCalled();
});
});
describe('wireWriteThrough', () => {
it('persists a newly created snippet', () => {
useSnippetStore.getState().hydrate([]);
teardown = wirePersistence();
save.mockClear();
useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' });
expect(save).toHaveBeenCalledTimes(1);
expect(save.mock.calls[0][0]).toMatchObject({ id: 'n' });
expect(del).not.toHaveBeenCalled();
});
it('deletes a removed snippet without re-saving the survivors', () => {
useSnippetStore.getState().hydrate([snippetWith('a', '{}'), snippetWith('b', '{}')]);
teardown = wirePersistence();
save.mockClear();
useSnippetStore.getState().removeSnippet('a');
expect(del).toHaveBeenCalledTimes(1);
expect(del).toHaveBeenCalledWith('a');
// 'b' kept its object reference through the filter, so it is not rewritten.
expect(save).not.toHaveBeenCalled();
});
it('ignores state changes that do not touch the snippets array', () => {
useSnippetStore.getState().hydrate([snippetWith('a', '{}')]);
teardown = wirePersistence();
save.mockClear();
useSnippetStore.getState().setEditorView('published');
expect(save).not.toHaveBeenCalled();
expect(del).not.toHaveBeenCalled();
});
it('surfaces a failed save as an error notification instead of losing it silently', async () => {
useSnippetStore.getState().hydrate([]);
teardown = wirePersistence();
save.mockClear();
save.mockRejectedValueOnce(new StorageQuotaError());
useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' });
await flush();
const notes = useNotificationStore.getState().notifications;
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({ kind: 'error', title: 'Storage full' });
});
});
+12 -2
View File
@@ -14,6 +14,8 @@
*/
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
import { storageErrorNotification } from '../services/storage-errors';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
/** Delay before a settled edit is committed to the stored draft (spec §03B). */
@@ -40,12 +42,20 @@ function wireWriteThrough(): Unsubscribe {
const prev = prevSnippets;
prevSnippets = next;
// A failed write must surface, not vanish — these promises are otherwise
// fire-and-forget, so a rejection becomes a toast rather than silent loss
// (spec §10 → "told when a save fails rather than losing data silently").
for (const old of prev) {
if (!next.some((n) => n.id === old.id)) void deleteSnippet(old.id);
if (!next.some((n) => n.id === old.id)) {
deleteSnippet(old.id).catch((err) => notify(storageErrorNotification('delete', err)));
}
}
for (const n of next) {
const old = prev.find((p) => p.id === n.id);
if (old !== n) void saveSnippet(n); // new, or a changed reference
if (old !== n) {
// new, or a changed reference
saveSnippet(n).catch((err) => notify(storageErrorNotification('save', err)));
}
}
});
}
+25 -3
View File
@@ -7,8 +7,10 @@
* the UI renders immediately and fills in when hydration completes.
*/
import { createSnippet } from '@core/snippet';
import { createSnippet, type Snippet } from '@core/snippet';
import { loadSnippets, saveSnippet } from '../infrastructure/snippet-store';
import { storageErrorNotification } from '../services/storage-errors';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { wirePersistence } from './persistence';
@@ -18,11 +20,31 @@ export async function initApp(): Promise<void> {
if (started) return; // idempotent — guard against double-invocation (StrictMode)
started = true;
let snippets = await loadSnippets();
// If storage can't be opened (private-browsing, blocked storage), don't fail
// startup silently: warn, then run this session in memory so the app is still
// usable. `storageOk` gates the seed-save below — there's no point trying to
// persist a seed into storage we just failed to read.
let snippets: Snippet[] = [];
let storageOk = true;
try {
snippets = await loadSnippets();
} catch (err) {
storageOk = false;
notify(storageErrorNotification('load', err));
}
if (snippets.length === 0) {
const sample = createSnippet();
await saveSnippet(sample); // ensure the seed survives even before the first edit
snippets = [sample];
if (storageOk) {
// Ensure the seed survives even before the first edit; a failure here is
// surfaced, not swallowed.
try {
await saveSnippet(sample);
} catch (err) {
notify(storageErrorNotification('save', err));
}
}
}
useSnippetStore.getState().hydrate(snippets);