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:
1 parent
57b543806c
commit
137fce047a
3 files changed
+75
-15
No files matched your search
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user