From 0435fdf285b33ece7229403fdb9814929b8f65e6 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 23:23:29 +0200 Subject: [PATCH] fix: a document that hung the editor is offered again before it is parked A reload that cut a slow start short looked like a freeze, and the parked document was reachable only through the browser's storage tools. The user is asked for another try; only declining parks it. --- src/lib/i18n/locales/de.ts | 2 +- src/lib/i18n/locales/en.ts | 2 +- src/lib/storage/CLAUDE.md | 2 +- src/lib/storage/autosave.ts | 8 ++++---- tests/unit/autosave-recovery.test.ts | 10 ++++++++++ 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index fdb66e1..c9c7559 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -1095,7 +1095,7 @@ const de: Messages = { autosaveQuota: 'Das Dokument ist zu groß, um automatisch gespeichert zu werden (Speicherlimit des Browsers erreicht). Das passiert meist bei großen eingebetteten Bildern. Speichere es als .odt-Datei, um deine Arbeit zu sichern.', documentNotLoaded: - 'Das zuletzt bearbeitete Dokument hat den Start des Editors blockiert und wurde deshalb nicht geladen. Du arbeitest in einem neuen, leeren Dokument; das alte bleibt im Browser-Speicher unter „edentext-doc-broken" erhalten.', + 'Das zuletzt bearbeitete Dokument hat den Start des Editors blockiert. Trotzdem noch einmal laden?\n\nAbbrechen startet mit einem neuen, leeren Dokument; das alte bleibt im Browser-Speicher unter „edentext-doc-broken“ erhalten.', picturesNotRestored: (n) => `${n} ${n === 1 ? 'Bild konnte' : 'Bilder konnten'} nicht aus dem Browser-Speicher wiederhergestellt werden und ${n === 1 ? 'wird' : 'werden'} als defekt angezeigt. Öffne die gespeicherte Datei des Dokuments, um ${n === 1 ? 'es' : 'sie'} zurückzuholen.`, }, diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index bb7be8a..8eb72c8 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -1101,7 +1101,7 @@ const en = { autosaveQuota: 'The document is too large to save automatically (browser storage limit reached). This usually happens with large embedded images. Save it as an .odt file to keep your work.', documentNotLoaded: - 'The last document stopped the editor from starting, so it was not loaded. You are working in a new, empty document; the old one is kept in the browser storage under "edentext-doc-broken".', + 'The last document stopped the editor from starting. Load it again anyway?\n\nCancel starts a new, empty document; the old one is kept in the browser storage under "edentext-doc-broken".', picturesNotRestored: (n: number) => `${n} picture${n === 1 ? '' : 's'} could not be restored from the browser storage and ${n === 1 ? 'is' : 'are'} shown as broken. Open the document's saved file to get ${n === 1 ? 'it' : 'them'} back.`, }, diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index b382de9..fc9cb0d 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`); 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. +- **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, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the app starts empty, so a freeze is escaped instead of repeated. **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`). - **Document format:** `edentext-doc-format` — `'docx'` while the open document round-trips as .docx; absent = .odt (`documentName.ts`). - **Theme:** `edentext-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`. diff --git a/src/lib/storage/autosave.ts b/src/lib/storage/autosave.ts index f437c85..03f6b92 100644 --- a/src/lib/storage/autosave.ts +++ b/src/lib/storage/autosave.ts @@ -72,13 +72,13 @@ export async function loadDocument(): Promise { const raw = localStorage.getItem(STORAGE_KEY); if (localStorage.getItem(BOOT_KEY)) { localStorage.removeItem(BOOT_KEY); - if (raw) { + // The user decides: a reload that cut a slow start short is the usual cause, and + // another try costs nothing a reload would not fix. Given up, the document is parked. + if (raw && !confirm(t().dialogs.documentNotLoaded)) { localStorage.setItem(BROKEN_KEY, raw); localStorage.removeItem(STORAGE_KEY); - // After paint, so the message doesn't land on a blank app. - requestAnimationFrame(() => alert(t().dialogs.documentNotLoaded)); + return null; } - return null; } if (!raw) return null; let doc: object; diff --git a/tests/unit/autosave-recovery.test.ts b/tests/unit/autosave-recovery.test.ts index 12b6d2f..056820f 100644 --- a/tests/unit/autosave-recovery.test.ts +++ b/tests/unit/autosave-recovery.test.ts @@ -9,6 +9,7 @@ describe('autosave crash recovery', () => { localStorage.clear(); vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { void cb; return 0; }); vi.stubGlobal('alert', () => {}); + vi.stubGlobal('confirm', () => false); }); it('loads the document again after a startup that completed', async () => { @@ -26,6 +27,15 @@ describe('autosave crash recovery', () => { expect(localStorage.getItem('edentext-doc')).toBeNull(); }); + it('loads the document again when the user asks for another try', async () => { + vi.stubGlobal('confirm', () => true); + localStorage.setItem('edentext-doc', '{"type":"doc"}'); + await loadDocument(); + expect(await loadDocument()).toEqual({ type: 'doc' }); + expect(localStorage.getItem('edentext-doc-broken')).toBeNull(); + expect(localStorage.getItem('edentext-doc-loading')).toBe('1'); + }); + it('keeps the next document after a recovery', async () => { localStorage.setItem('edentext-doc', '{"type":"doc","content":[]}'); await loadDocument();