From 28a39e017ad44d88a0057e236643d7fad7f07572 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 9 Aug 2026 14:13:17 +0000 Subject: [PATCH] feat: a document that blocks the start is not loaded twice An autosaved document that hangs or throws while the editor comes up was reloaded from localStorage on every refresh, leaving no way back into the app. Loading now raises a boot flag that only a completed startup clears; finding it still set means the last attempt never finished, so the document moves to odf-editor-doc-broken and the app starts empty with a message. --- src/lib/components/Editor.svelte | 5 +++- 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 | 28 +++++++++++++++++++- tests/unit/autosave-recovery.test.ts | 38 ++++++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/unit/autosave-recovery.test.ts diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index bd1ec57..ea7a9fe 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -17,7 +17,7 @@ import ContextMenu from './ContextMenu.svelte'; import HeaderFooterLayer from './HeaderFooterLayer.svelte'; import Ruler from './Ruler.svelte'; - import { saveDocument, loadDocument } from '../storage/autosave'; + import { saveDocument, loadDocument, markDocumentLoaded } from '../storage/autosave'; import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval'; import { type Orientation } from '../storage/pageOrientation'; @@ -791,6 +791,9 @@ // also fires pm-pagecount shortly after with the precise document height. recomputeScaledSize(); requestAnimationFrame(recomputeScaledSize); + // Two frames in: the first pagination pass has run and painted. A document that + // hangs or throws on the way here leaves the boot flag set and is skipped next load. + requestAnimationFrame(() => requestAnimationFrame(markDocumentLoaded)); }); function onEditorScroll() { diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 9796c54..60bf525 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -488,6 +488,8 @@ const de: Messages = { couldNotPrintPdf: 'Druck als PDF nicht möglich.', 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 „odf-editor-doc-broken" erhalten.', }, importWarn: { imagesSkipped: 'Einige Bilder konnten nicht gelesen werden und wurden übersprungen', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 1a762f9..3cb21f0 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -492,6 +492,8 @@ const en = { couldNotPrintPdf: 'Could not print to PDF.', 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 "odf-editor-doc-broken".', }, // Values mirror the canonical English messages the import parsers emit; the UI // maps a parser message to its localized form at display time (importMessages.ts). diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 6eb90f7..40e274e 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -10,7 +10,7 @@ Both margins and orientation are passed into the ODT export so the exported docu ## localStorage keys -- **Document:** `odf-editor-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). +- **Document:** `odf-editor-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `odf-editor-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 `odf-editor-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. - **Theme:** `odf-editor-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`. - **Toolbar expanded:** `odf-editor-toolbar-expanded` — boolean string. - **Formatting marks:** `odf-editor-formatting-marks` — boolean string. diff --git a/src/lib/storage/autosave.ts b/src/lib/storage/autosave.ts index 4359284..62c5d62 100644 --- a/src/lib/storage/autosave.ts +++ b/src/lib/storage/autosave.ts @@ -1,6 +1,13 @@ import { t } from '../i18n/i18n.svelte'; const STORAGE_KEY = 'odf-editor-doc'; +// Set while a stored document is being handed to the editor, cleared once the editor +// has actually come up (markDocumentLoaded). Finding it still set at load time means +// the last attempt hung or threw — reloading would only freeze again. +const BOOT_KEY = 'odf-editor-doc-loading'; +// Where such a document is parked instead of being loaded, so nothing is lost and +// it can still be pulled out of localStorage. +const BROKEN_KEY = 'odf-editor-doc-broken'; const DEBOUNCE_MS = 1000; let timeout: ReturnType | null = null; @@ -26,12 +33,31 @@ export function saveDocument(json: object): void { export function loadDocument(): object | null { const raw = localStorage.getItem(STORAGE_KEY); + if (localStorage.getItem(BOOT_KEY)) { + localStorage.removeItem(BOOT_KEY); + if (raw) { + 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; + } if (!raw) return null; + let doc: object; try { - return JSON.parse(raw); + doc = JSON.parse(raw); } catch { return null; } + localStorage.setItem(BOOT_KEY, '1'); + return doc; +} + +// Called once the editor is up and has laid out the document. Until then the boot +// flag stands, so a document that freezes the editor is skipped on the next load. +export function markDocumentLoaded(): void { + localStorage.removeItem(BOOT_KEY); } export function clearDocument(): void { diff --git a/tests/unit/autosave-recovery.test.ts b/tests/unit/autosave-recovery.test.ts new file mode 100644 index 0000000..d526c7b --- /dev/null +++ b/tests/unit/autosave-recovery.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { loadDocument, markDocumentLoaded, saveDocument } from '../../src/lib/storage/autosave'; + +// A document that freezes the editor would be reloaded from localStorage forever, so +// loading raises a boot flag that only a completed startup clears. +describe('autosave crash recovery', () => { + beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { void cb; return 0; }); + vi.stubGlobal('alert', () => {}); + }); + + it('loads the document again after a startup that completed', () => { + localStorage.setItem('odf-editor-doc', '{"type":"doc"}'); + expect(loadDocument()).toEqual({ type: 'doc' }); + markDocumentLoaded(); + expect(loadDocument()).toEqual({ type: 'doc' }); + }); + + it('parks the document and starts empty when the last startup never finished', () => { + localStorage.setItem('odf-editor-doc', '{"type":"doc"}'); + loadDocument(); // raises the boot flag, then the "editor hangs" + expect(loadDocument()).toBeNull(); + expect(localStorage.getItem('odf-editor-doc-broken')).toBe('{"type":"doc"}'); + expect(localStorage.getItem('odf-editor-doc')).toBeNull(); + }); + + it('keeps the next document after a recovery', () => { + localStorage.setItem('odf-editor-doc', '{"type":"doc","content":[]}'); + loadDocument(); + expect(loadDocument()).toBeNull(); // recovered: boot flag cleared, doc parked + vi.useFakeTimers(); + saveDocument({ type: 'doc' }); + vi.runAllTimers(); + vi.useRealTimers(); + expect(loadDocument()).toEqual({ type: 'doc' }); + }); +});