From 15608896a64d2f3d151d2dbdf11c37f8956e9e7d Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 8 Sep 2026 00:19:36 +0200 Subject: [PATCH] feat: closing the tab warns about a document that has no file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unload warning followed the changes a file had not got yet; a document that never was a file got none, on the grounds that the autosave keeps it. It now warns as soon as such a document has content — the same emptiness test the confirm before a new document uses. --- src/App.svelte | 9 ++++++--- src/lib/components/CLAUDE.md | 5 +++-- tests/dom/run.mjs | 10 ++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 9e94c91..d46050a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -166,8 +166,8 @@ let dirty = $state(false); let cleanSum: number | null = null; let sumTimer: ReturnType | undefined; - // Whether there is a file to lose those changes from. A document that only ever - // lived in the browser is kept by the autosave, so leaving is not worth a warning. + // Whether there is a file to lose those changes from — a saved file makes the dot, + // and the unload warning, follow the changes rather than the document's existence. let documentHasFile = $state(false); // The page count rides in with the zones but is a layout result, not an edit: a @@ -202,7 +202,10 @@ }); $effect(() => { - if (!dirty || !documentHasFile) return; + // A document with no file behind it lives in this browser alone, so closing the tab + // is worth the browser's warning as much as changes a file has not got yet. The + // same emptiness test the confirm before a new document uses. + if (documentHasFile ? !dirty : tick < 0 || !isDocNonEmpty()) return; const warn = (e: BeforeUnloadEvent) => e.preventDefault(); addEventListener('beforeunload', warn); return () => removeEventListener('beforeunload', warn); diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 2443836..a4220fb 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -228,8 +228,9 @@ settle pass or the spell checker keeps the document moving), and the baseline is **synchronously** on the effect's first run, not in that timer: under a repagination the timer can be pushed out past the reader's first edit, which would make that edit the baseline. A save, an open or a new document takes a fresh baseline (`markSaved`). -Leaving the page warns only when the document actually has a file — a browser-only document -is kept by the autosave, so there would be nothing to lose. +Leaving the page warns while a document with a file differs from it, and for a document +with no file at all as soon as it has content (the emptiness test the confirm before a new +document uses). The wording is the browser's own — `beforeunload` carries no message. ## Built-in templates (`TemplateGalleryDialog.svelte`, `lib/templates/`) diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 361ebb5..d517d83 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -30,11 +30,20 @@ try { await page.reload({ waitUntil: 'load' }); await page.waitForSelector('.tiptap', { timeout: 15_000 }); + // What the browser asks before closing the tab: a beforeunload nobody cancels lets it go. + const holdsOn = () => page.evaluate(() => { + const e = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(e); + return e.defaultPrevented; + }); + check(!(await holdsOn()), 'an empty document lets the tab close'); + // Save on a document with no file behind it settles the format first: the download // route cannot read one back from the browser's own dialog. await page.evaluate(() => { window.showSaveFilePicker = undefined; }); await page.click('.tiptap'); await page.keyboard.type('Format first'); + check(await holdsOn(), 'a document that was never saved warns before the tab closes'); await page.keyboard.press(`${MOD}+s`); const [firstSave] = await Promise.all([ page.waitForEvent('download', { timeout: 30_000 }), @@ -42,6 +51,7 @@ try { ]); check(firstSave.suggestedFilename().endsWith('.docx'), `Ctrl+S on an unsaved document asks for the format (${firstSave.suggestedFilename()})`); + check(!(await holdsOn()), 'the saved document lets the tab close again'); // A multi-page corpus document, through the file input (no picker in headless). await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/05-breaks.odt'));