fix: the autosave writes what is pending when the page is hidden

The 1 s debounce lost a tab closed inside it. Writes now queue behind each
other, so an older write finishing late can neither store an older document
nor sweep the pictures a newer one added; a page revived from the
back/forward cache writes again.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:12 +02:00
1 parent 57b543806c
commit 137fce047a
3 files changed
+75 -15

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ Both margins and orientation are passed into the ODT export so the exported docu
All keys share the `edentext-` prefix — a namespace, not a description: on GitHub Pages every repo of an account shares one origin, so a generic prefix would collide.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor: it moves to `edentext-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor: it moves to `edentext-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. A file without a handle is never recorded (nothing could reopen it — no File System Access API, or a template), so the list simply stays empty in Firefox/Safari/Brave; a startup prune drops entries whose handle is gone. Reopening asks for `read` permission again (the API drops it on reload) — a declined prompt keeps the entry for the next try, only a moved or deleted file drops it (with a message). Save re-prompts for write on its own (Save-As fallback in `App.svelte`).
- **Theme:** `edentext-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
- **Toolbar expanded:** `edentext-toolbar-expanded` — boolean string.
+46 -14
View File
@@ -12,28 +12,60 @@ const BROKEN_KEY = 'edentext-doc-broken';
const DEBOUNCE_MS = 1000;
let timeout: ReturnType<typeof setTimeout> | null = null;
// The document as last handed in, until it is written.
let pending: object | null = null;
// Writes queue behind each other: an older write finishing after a newer one would
// store the older document and sweep the pictures the newer one just added.
let chain: Promise<void> = Promise.resolve();
// localStorage has a ~5 MB quota; embedded images (data-URIs) can exceed it. Warn
// the user once so a failed autosave isn't silent, and swallow the throw so the
// debounced timer doesn't surface an unhandled error.
let quotaWarned = false;
export function saveDocument(json: object): void {
pending = json;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(async () => {
// Pictures go to IndexedDB and the JSON keeps a key; where that fails they stay
// inline, which is the only thing localStorage ever held.
const { json: slim, blobs } = stashImages(json);
const stashed = await putImages(blobs);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stashed ? slim : json));
} catch (err) {
console.error('[autosave] Could not save the document:', err);
if (!quotaWarned) {
quotaWarned = true;
alert(t().dialogs.autosaveQuota);
}
timeout = setTimeout(() => { chain = chain.then(write); }, DEBOUNCE_MS);
}
async function write(): Promise<void> {
const json = pending;
if (!json) return;
// Pictures go to IndexedDB and the JSON keeps a key; where that fails they stay
// inline, which is the only thing localStorage ever held.
const { json: slim, blobs } = stashImages(json);
const stashed = await putImages(blobs);
store(stashed ? slim : json);
// Pending until stored, so a flush during the round trip still has it; a newer
// document handed in meanwhile stays pending for the write queued behind.
if (pending === json) pending = null;
}
// The tab is going away: no time for the IndexedDB round trip, so the JSON goes out
// at once and the pictures follow — an earlier save already holds all but the newest.
// Timer and pending stay, so a page revived from the back/forward cache writes again.
export function flushDocument(): void {
if (!pending) return;
const { json: slim, blobs } = stashImages(pending);
void putImages(blobs);
store(slim);
}
function store(json: object): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(json));
} catch (err) {
console.error('[autosave] Could not save the document:', err);
if (!quotaWarned) {
quotaWarned = true;
alert(t().dialogs.autosaveQuota);
}
}, DEBOUNCE_MS);
}
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushDocument(); });
addEventListener('pagehide', flushDocument);
}
export async function loadDocument(): Promise<object | null> {
+28
View File
@@ -37,3 +37,31 @@ describe('autosave crash recovery', () => {
expect(await loadDocument()).toEqual({ type: 'doc' });
});
});
// 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', () => {
beforeEach(() => {
localStorage.clear();
vi.stubGlobal('alert', () => {});
});
it('writes the pending document when the page is hidden', () => {
vi.useFakeTimers();
saveDocument({ type: 'doc', content: [{ type: 'paragraph' }] });
expect(localStorage.getItem('edentext-doc')).toBeNull();
window.dispatchEvent(new Event('pagehide'));
expect(JSON.parse(localStorage.getItem('edentext-doc')!)).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] });
vi.useRealTimers();
});
it('writes nothing when nothing is pending', async () => {
vi.useFakeTimers();
saveDocument({ type: 'doc' });
await vi.runAllTimersAsync();
localStorage.removeItem('edentext-doc');
window.dispatchEvent(new Event('pagehide'));
expect(localStorage.getItem('edentext-doc')).toBeNull();
vi.useRealTimers();
});
});