From 137fce047a91f9b2d86f24d06dcc4eb516ee35b7 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 23:20:36 +0200 Subject: [PATCH] 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. --- src/lib/storage/CLAUDE.md | 2 +- src/lib/storage/autosave.ts | 60 +++++++++++++++++++++------- tests/unit/autosave-recovery.test.ts | 28 +++++++++++++ 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 19d0331..92fe3d4 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -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:` 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:` 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. diff --git a/src/lib/storage/autosave.ts b/src/lib/storage/autosave.ts index b613bfd..f437c85 100644 --- a/src/lib/storage/autosave.ts +++ b/src/lib/storage/autosave.ts @@ -12,28 +12,60 @@ const BROKEN_KEY = 'edentext-doc-broken'; const DEBOUNCE_MS = 1000; let timeout: ReturnType | 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 = 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 { + 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 { diff --git a/tests/unit/autosave-recovery.test.ts b/tests/unit/autosave-recovery.test.ts index ebe3d5f..12b6d2f 100644 --- a/tests/unit/autosave-recovery.test.ts +++ b/tests/unit/autosave-recovery.test.ts @@ -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(); + }); +});