From 5a90fd6c30a64c22e9e243e446da789bf0575e39 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 15:58:08 +0200 Subject: [PATCH] fix(server-storage): a closed page record stays closed when a late write rewrites it On pagehide the page flushes with keepalive and then marks its record closed. When writes were held (offline), the keepalive write fails at once and its requeue saved the journal again a microtask later, which rewrote the record without `closed`: the reload took the previous page load for live and left its favorites and progress unsent until the record aged out (ten minutes). markUnconfirmed now keeps an existing record's closed flag. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 3 +- .../server-storage/server-unsent-writes.ts | 11 ++++- .../server-user-data-adopt.spec.ts | 45 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index ed1292677..9488624c8 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -235,7 +235,8 @@ settings and guards read them in every build. tab remembers its page load in `sessionStorage`, so a reload takes its writes over and sends them first (the old copy goes only once the new one is saved, at the latest with this page's next saved copy). A page marks - its record closed on `pagehide` (not into the back-forward cache, where + its record closed on `pagehide`, and it stays closed when a page-hide + write failing afterwards rewrites it (not into the back-forward cache, where its record goes stale, as in a background tab the browser suspends; restored or shown again after another tab adopted it, the page reloads rather than send those writes again, and until then it neither sends nor diff --git a/libs/services/src/lib/server-storage/server-unsent-writes.ts b/libs/services/src/lib/server-storage/server-unsent-writes.ts index c2eff6b37..e223b7e2e 100644 --- a/libs/services/src/lib/server-storage/server-unsent-writes.ts +++ b/libs/services/src/lib/server-storage/server-unsent-writes.ts @@ -406,7 +406,9 @@ export function pageIsLive( /** * Writes only this page load's record (none without keys); true when one - * now stands for it. + * now stands for it. A closed record stays closed: a page-hide write that + * fails after `pagehide` rewrites it, and the reload must still see a page + * that unloaded, not one that seems to run. */ export function markUnconfirmed( local: UserDataStorage, @@ -420,9 +422,14 @@ export function markUnconfirmed( local.removeItem(recordKey(instanceId, tabId)); return false; } + const closed = readRecord(local, instanceId, tabId)?.closed === true; local.setItem( recordKey(instanceId, tabId), - JSON.stringify({ at: now, keys: [...keys] }) + JSON.stringify({ + at: now, + keys: [...keys], + ...(closed ? { closed } : {}), + }) ); return true; } catch { diff --git a/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts b/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts index 9761d5536..26269874d 100644 --- a/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts @@ -352,4 +352,49 @@ describe('ServerUserDataStorage taking over other page loads', () => { await expect(tab.flushed()).resolves.toBe(false); expect(server.state['xtream-favorites']['p1']).toEqual(['live:1']); }); + + it('stays closed when a page-hide write fails after pagehide', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const page = new ServerUserDataStorage( + { + ...server.api, + // Offline: the page-hide write fails at once. + sendJson: jest.fn(() => + Promise.reject(new ServerApiError(0, 'network', 'offline')) + ), + sendKeepalive: jest.fn(async () => ({ + status: 'failed' as const, + })), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await page.hydrate(); + page.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + + // pagehide: flush, then close; the failure is answered after. + page.flushWithKeepalive(); + page.close(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + + // The reload takes its writes over at once, not after ten minutes. + const reloaded = new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + await reloaded.flush(); + await reloaded.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); });