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']); + }); });