From 32ca1c251ef5d2123e8359e4abba1fd3642bd276 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 8 Jun 2026 13:13:49 +0200 Subject: [PATCH] =?UTF-8?q?fix(drafts):=20drop=20the=20visibilitychange=20?= =?UTF-8?q?flush=20=E2=80=94=20debouncer=20keeps=20running=20on=20hidden?= =?UTF-8?q?=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab switching just hides the page; the JS context survives and the debouncer's `setTimeout` keeps counting down. When it fires, the runner POSTs normally and the server's response updates `lastSync`. There's nothing left for a visibilitychange-driven flush to do that the ordinary pipeline doesn't already handle, and adding one only creates extra POSTs to reason about. `pagehide` remains the single trigger for the keepalive flush — that's the case where the JS context is actually being torn down and the runner's pending fetch would otherwise be killed mid-flight. --- frontend/src/lib/userDraftDbSyncer.svelte.ts | 42 ++++---------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index 587fa62cc4..30a47d4215 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -205,32 +205,6 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise { } } -/** - * Tab/app switch flush — the document is hidden but the page is still - * alive. Route every pending edit through the normal runner pipeline so - * the server's response can land and `setLastSync` can bump the local - * baseline. Without that, the next foreground edit attaches the stale - * `last_sync` we sent before the flush, the server's `created_at` is - * now ahead, and the user gets a spurious conflict modal for their own - * background-tab write. - * - * `debouncer.cancel(key)` first so a still-pending keystroke debounce - * can't fire a second runner POST with the same stale `last_sync` - * after the flush submission — that would also self-conflict. - * - * Doesn't clear `pendingSaveOpts`: `postSave` deletes the entry on - * success (gated on `pendingSaveOpts.get(key) === opts`), and a later - * `pagehide` keepalive needs to see anything that's still in flight or - * that the user edited after this flush. - */ -function flushOnVisibilityHidden(): void { - if (pendingSaveOpts.size === 0) return - for (const [key, opts] of pendingSaveOpts) { - debouncer.cancel(key) - runner.submit(key, () => postSave(opts)) - } -} - /** * True-unload flush — `pagehide` is the browser's commitment that the * document is going away. Use `keepalive: true` so the network stack @@ -291,14 +265,14 @@ function flushOnPageHide(): void { } if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') flushOnVisibilityHidden() - }) - // `pagehide` is the only signal we trust to mean "the document is - // truly going away" — `visibilitychange → hidden` is too broad - // (fires on every tab/app switch with the page surviving), which is - // why we route THAT through the normal runner above and reserve - // keepalive for here. + // `pagehide` is the only signal that means "the document is truly + // going away". We deliberately do NOT listen for + // `visibilitychange → hidden`: it fires on every tab/app switch with + // the page surviving, and on a surviving page the debouncer's + // pending `setTimeout` keeps running in the background, the runner + // POST eventually fires, and the server's response updates + // `lastSync` — no flush needed, no spurious conflict modal on the + // user's own background-tab write. window.addEventListener('pagehide', flushOnPageHide) }