diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 174af7b99..393cb6079 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -237,7 +237,8 @@ settings and guards read them in every build. 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; until then the tab's pointer names the kept page loads too, so another reload finds - them even without their registry records). A page marks + them even without their registry records; a duplicate of a tab that + still runs leaves them, like that tab's page load, to it). A page marks 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; 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 92891861c..5b61d9461 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 @@ -539,4 +539,46 @@ describe('ServerUserDataStorage taking over other page loads', () => { expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); }); + + it("leaves a running tab's kept page loads to it in a duplicate", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const api = { + ...server.api, + sendJson: jest.fn(() => + Promise.reject(new ServerApiError(0, 'network', 'offline')) + ), + } as never; + const page = (store: ReturnType) => + new ServerUserDataStorage(api, local, jest.fn(), 'default', store); + const first = page(tabStore); + await first.hydrate(); + first.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await first.flush(); + first.close(); + // The reload cannot save its copy: it keeps the first one's. + const setItem = local.setItem; + local.setItem = (key: string, value: string) => { + if (key.startsWith('iptvnator-server:unsent-writes:')) { + throw new DOMException('full', 'QuotaExceededError'); + } + setItem(key, value); + }; + const running = page(tabStore); + expect(running.hasUnsentWrites()).toBe(true); + + // A duplicate copies the tab's session storage, pointer and all. + const copied = memoryStorage(); + for (const key of tabStore.keys()) { + copied.setItem(key, tabStore.getItem(key) ?? ''); + } + const duplicate = page(copied); + + // The running tab owns those writes and may hold newer ones. + expect(duplicate.hasUnsentWrites()).toBe(false); + running.close(); + duplicate.touch(); + expect(duplicate.hasUnsentWrites()).toBe(true); + }); }); diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts index 7790ea167..99f50c635 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -61,7 +61,7 @@ export class ServerUserDataStorage implements UserDataStorage { private readonly deferred = new Map(); private hydrated = false; /** The previous page load in this tab, while it still looks live. */ - private awaited: string | null = null; + private awaited: string[] = []; /** A record stands for this page: a stale one is adopted elsewhere. */ private recorded = false; /** Playlists deleted here, in order: a read sent earlier skips them. */ @@ -108,14 +108,14 @@ export class ServerUserDataStorage implements UserDataStorage { claimCache(local, instanceId); const [previous, ...kept] = swapPage(tabStore, instanceId, this.tabId); // Older page loads whose copies a reload kept come first. - kept.forEach((page) => this.takeOver(page)); - // A duplicated tab copies the page load of a tab that still runs; - // so does a reload whose close flag could not be saved: `touch` - // adopts it once it no longer looks live. + const pages = previous ? [...kept, previous] : kept; + // A duplicated tab copies the page load of a tab that still runs, + // and the ones it keeps; so does a reload whose close flag could not + // be saved: `touch` adopts them once it no longer looks live. if (previous && pageIsLive(local, instanceId, previous)) { - this.awaited = previous; - } else if (previous) { - this.takeOver(previous); + this.awaited = pages; + } else { + pages.forEach((page) => this.takeOver(page)); } } @@ -173,11 +173,12 @@ export class ServerUserDataStorage implements UserDataStorage { /** Tells other tabs this one is alive and still sending its writes. */ touch(): void { - const page = this.awaited; - if (page && !pageIsLive(this.local, this.instanceId, page)) { + const pages = this.awaited; + const owner = pages[pages.length - 1]; + if (owner && !pageIsLive(this.local, this.instanceId, owner)) { // This page may have read and edited since: adopt, not restore. - this.awaited = null; - this.adoptPage(page); + this.awaited = []; + pages.forEach((page) => this.adoptPage(page)); } // A tab with nothing to sync never re-reads, which is where closed // tabs' saved writes are otherwise picked up.