diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index a8329c97e..d415f9b48 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -230,7 +230,9 @@ settings and guards read them in every build. 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), and only a closed, stale or missing record is taken over: a duplicated tab, - which copies `sessionStorage`, leaves the running tab its writes; the writes of a tab closed before it + which copies `sessionStorage`, leaves the running tab its writes. A page + load that still looked live (or whose close flag storage refused) is + checked again every 30 s and taken over once it does not; the writes of a tab closed before it could send them are queued, ahead of its own, by the next re-read in another tab once that tab's record has gone stale; the result replays the closed tab's edits first and this tab's newer ones last. Tabs never clear each other's. 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 2e18749f9..266fe2f3c 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 @@ -60,6 +60,8 @@ 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; /** Closed tabs' page loads this one took over (once each). */ private readonly adopted = new Set(); /** Page loads taken over whose copy stays until this one saves. */ @@ -99,11 +101,11 @@ export class ServerUserDataStorage implements UserDataStorage { writeFailed: onWriteFailure, }); claimCache(local, instanceId); - const previous = swapPage(tabStore, instanceId, this.tabId); - // A duplicated tab copies the page load of a tab that still runs. - if (previous && !pageIsLive(local, instanceId, previous)) { - this.takeOver(previous); - } + // 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` + // takes it over once it no longer looks live. + this.awaited = swapPage(tabStore, instanceId, this.tabId); + this.touch(); } /** The page unloads: a reload of this tab may take its writes over. */ @@ -183,6 +185,11 @@ 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)) { + this.awaited = null; + this.takeOver(page); + } if (this.hasUnsentWrites()) { this.publishUnconfirmed(); } @@ -208,30 +215,27 @@ export class ServerUserDataStorage implements UserDataStorage { } setItem(key: string, value: string): void { - if (isServerUserStateNamespace(key)) { - this.write(key, value); - } else { - this.local.setItem(key, value); - } + this.write(key, value); } removeItem(key: string): void { - if (isServerUserStateNamespace(key)) { - this.write(key, null); - } else { - this.local.removeItem(key); - } + this.write(key, null); } /** * A caller's new map. When the cache changed since the caller read it * (another tab, a re-read), only the entries it changed are applied to - * the cache as it is now; the rest stays. + * the cache as it is now; the rest stays. Other keys use plain storage. */ - private write( - namespace: ServerUserStateNamespace, - value: string | null - ): void { + private write(namespace: string, value: string | null): void { + if (!isServerUserStateNamespace(namespace)) { + if (value === null) { + this.local.removeItem(namespace); + } else { + this.local.setItem(namespace, value); + } + return; + } const current = this.cache.read(namespace); const before = parseMap(current ?? '{}'); const read = this.handedOut.has(namespace) diff --git a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts index a3cc6cff7..681e9509b 100644 --- a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts @@ -1017,4 +1017,54 @@ describe('ServerUserDataStorage with other devices', () => { ) ).toHaveLength(1); }); + + it('takes over a reload whose close flag was lost once it goes quiet', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const before = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue( + new ServerApiError(0, 'network', 'offline') + ), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await before.hydrate(); + before.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await before.flush(); + // Storage is full when the page unloads: the close flag is lost. + const setItem = local.setItem; + local.setItem = () => { + throw new DOMException('full', 'QuotaExceededError'); + }; + before.close(); + local.setItem = setItem; + + const reloaded = new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + // It looks like a duplicated tab of a running one: nothing taken. + expect(reloaded.hasUnsentWrites()).toBe(false); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + expect(reloaded.hasUnsentWrites()).toBe(true); + await reloaded.hydrate(); + await reloaded.flush(); + clock.mockRestore(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); });