refactor: the kept versions are offered at a failed load, not in the menu

A list of timestamps in both file menus paid for itself only on the day
something went wrong, and it was in the way on every other. The entries
are gone; loadDocument now offers the versions where the browser copy has
actually failed the reader — newest first, one question each, and the one
taken up starts under the boot flag like any other document. The store
loses its reactive singleton with them.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:13 +02:00
1 parent ea327174e8
commit 6abc7f7704
11 files changed
+109 -104

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
// A two-call IndexedDB in memory — what openDb/idbRequest (storage/idb.ts) ask for.
export function fakeIndexedDb() {
const data = new Map<number, unknown>();
const req = (result: unknown) => {
const r: Record<string, unknown> = { result };
queueMicrotask(() => (r.onsuccess as (() => void) | undefined)?.());
return r;
};
const store = {
put: (v: unknown, k: number) => { data.set(k, v); return req(undefined); },
get: (k: number) => req(data.get(k)),
getAllKeys: () => req([...data.keys()]),
delete: (k: number) => { data.delete(k); return req(undefined); },
};
const db = { transaction: () => ({ objectStore: () => store }), createObjectStore: () => {}, close: () => {} };
return {
data,
open: () => {
const r: Record<string, unknown> = { result: db };
queueMicrotask(() => {
(r.onupgradeneeded as (() => void) | undefined)?.();
(r.onsuccess as (() => void) | undefined)?.();
});
return r;
},
};
}
+39
View File
@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { loadDocument, markDocumentLoaded, saveDocument } from '../../src/lib/storage/autosave';
import { keepSnapshot } from '../../src/lib/storage/snapshots';
import { fakeIndexedDb } from '../fakeIdb';
// A document that freezes the editor would be reloaded from localStorage forever, so
// loading raises a boot flag that only a completed startup clears. Loading is async
@@ -48,6 +50,43 @@ describe('autosave crash recovery', () => {
});
});
// Nothing in the UI points at the kept versions: a document that cannot be loaded is
// where they are offered, one question each, newest first.
describe('a document that cannot be loaded falls back on a kept version', () => {
// A version is kept at most every five minutes, and an earlier test in this file has
// already taken one — so each case sets its own clock well past that window.
let clock = 2_000_000_000_000;
beforeEach(() => {
localStorage.clear();
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { void cb; return 0; });
vi.stubGlobal('alert', () => {});
vi.stubGlobal('indexedDB', { open: fakeIndexedDb().open });
vi.spyOn(Date, 'now').mockReturnValue((clock += 600_000));
});
it('offers the version once the reader gives up on the document', async () => {
await keepSnapshot({ type: 'doc', content: [{ type: 'paragraph' }] });
localStorage.setItem('edentext-doc', '{"type":"doc"}');
await loadDocument(); // raises the boot flag, then the "editor hangs"
// First question: another try, declined. Second: the kept version, taken.
let asked = 0;
vi.stubGlobal('confirm', () => asked++ > 0);
expect(await loadDocument()).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] });
expect(asked).toBe(2);
// The parked document stays where it was, and the version starts under the flag.
expect(localStorage.getItem('edentext-doc-broken')).toBe('{"type":"doc"}');
expect(localStorage.getItem('edentext-doc-loading')).toBe('1');
});
it('starts empty when every version is turned down', async () => {
await keepSnapshot({ type: 'doc', content: [] });
localStorage.setItem('edentext-doc', '{"type":"doc"}');
await loadDocument();
vi.stubGlobal('confirm', () => false);
expect(await loadDocument()).toBeNull();
});
});
// The debounce is a second; a tab closed inside it would lose that second. Hiding
// the page writes what is pending at once, without the IndexedDB round trip.
describe('autosave flush on pagehide', () => {
+4 -31
View File
@@ -1,38 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
// A two-call IndexedDB in memory — what openDb/idbRequest (storage/idb.ts) ask for.
function fakeIndexedDb() {
const data = new Map<number, unknown>();
const req = (result: unknown) => {
const r: Record<string, unknown> = { result };
queueMicrotask(() => (r.onsuccess as (() => void) | undefined)?.());
return r;
};
const store = {
put: (v: unknown, k: number) => { data.set(k, v); return req(undefined); },
get: (k: number) => req(data.get(k)),
getAllKeys: () => req([...data.keys()]),
delete: (k: number) => { data.delete(k); return req(undefined); },
};
const db = { transaction: () => ({ objectStore: () => store }), createObjectStore: () => {}, close: () => {} };
return {
data,
open: () => {
const r: Record<string, unknown> = { result: db };
queueMicrotask(() => {
(r.onupgradeneeded as (() => void) | undefined)?.();
(r.onsuccess as (() => void) | undefined)?.();
});
return r;
},
};
}
import { fakeIndexedDb } from '../fakeIdb';
describe('the autosave keeps a few earlier versions', () => {
it('takes one every few minutes and holds on to the newest three', async () => {
const idb = fakeIndexedDb();
vi.stubGlobal('indexedDB', { open: idb.open });
const { keepSnapshot, snapshots, readSnapshot } = await import('../../src/lib/storage/snapshots.svelte');
const { keepSnapshot, listSnapshots, readSnapshot } = await import('../../src/lib/storage/snapshots');
const now = vi.spyOn(Date, 'now');
const at = (min: number) => 1_800_000_000_000 + min * 60_000;
@@ -40,13 +13,13 @@ describe('the autosave keeps a few earlier versions', () => {
await keepSnapshot({ type: 'doc', v: 1 });
now.mockReturnValue(at(1));
await keepSnapshot({ type: 'doc', v: 2 }); // a minute on: still the same version
expect(snapshots()).toEqual([at(0)]);
expect(await listSnapshots()).toEqual([at(0)]);
for (const m of [6, 12, 18]) {
now.mockReturnValue(at(m));
await keepSnapshot({ type: 'doc', v: m });
}
expect(snapshots()).toEqual([at(18), at(12), at(6)]);
expect(await listSnapshots()).toEqual([at(18), at(12), at(6)]);
expect(idb.data.size).toBe(3);
expect(await readSnapshot(at(12))).toEqual({ type: 'doc', v: 12 });
expect(await readSnapshot(at(0))).toBeNull();