From b3a493fa7f83e664450df811ba987deb5328857b Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 21:58:32 +0200 Subject: [PATCH] fix(server-storage): a reload finds page loads a reload before it kept; restore master's tick baselines When storage refused a reload's copy of the last page load's saved writes, the old copy stayed but only this page's memory named it: the tab's pointer already named the new page, so a second reload before any copy was saved recovered from the empty new page, and without a registry record no other tab could find the old one. The pointer now also lists the page loads a page keeps (keepPages), and a reload takes them over, oldest first, before its last page load. The pointer helpers move to server-page-pointer.ts. Both merges of master resolved journey-baselines.json by taking this branch's whole file, which dropped #1929's cdTicksToFirstCard, cdTicksIdle30s and cdTicksToFirstPage. The file is master's again with only this branch's initialBytes and domMutationsToFirstCard raises. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 4 +- .../lib/server-storage/server-page-pointer.ts | 64 +++++++++++++++++ .../server-storage/server-unsent-writes.ts | 19 ----- .../server-user-data-adopt.spec.ts | 72 +++++++++++++++++++ .../server-user-data-storage.ts | 12 +++- tools/performance/journey-baselines.json | 28 ++++++++ 6 files changed, 176 insertions(+), 23 deletions(-) create mode 100644 libs/services/src/lib/server-storage/server-page-pointer.ts diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index b60169aa1..174af7b99 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -235,7 +235,9 @@ settings and guards read them in every build. per page load (`iptvnator-server:unsent-writes::`). The 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 + 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 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-page-pointer.ts b/libs/services/src/lib/server-storage/server-page-pointer.ts new file mode 100644 index 000000000..18aaed9bd --- /dev/null +++ b/libs/services/src/lib/server-storage/server-page-pointer.ts @@ -0,0 +1,64 @@ +import type { UserDataStorage } from '../user-data-storage'; + +/** + * The tab's pointer to its page load, in `sessionStorage` (one per tab, + * kept on reload): a reload finds the writes the page loads before it + * saved. + */ +export const PAGE_KEY = 'iptvnator-server:page'; + +/** + * Records `page` as the tab's current page load and returns the ones + * before it: the last page load (a reload, whose saved writes this page + * takes over), then, oldest first, older ones whose copies a page load + * kept because storage refused its own (`keepPages`). + */ +export function swapPage( + tabStore: UserDataStorage, + instanceId: string, + page: string +): string[] { + try { + const previous = tabStore.getItem(`${PAGE_KEY}:${instanceId}`); + tabStore.setItem(`${PAGE_KEY}:${instanceId}`, page); + return previous ? pageList(previous) : []; + } catch { + return []; + } +} + +/** + * The tab's pointer names `page` and the older page loads whose saved + * copies it still keeps: a reload before this page saved its own copy must + * find them too, as their registry records may be missing. + */ +export function keepPages( + tabStore: UserDataStorage, + instanceId: string, + page: string, + kept: readonly string[] +): void { + try { + tabStore.setItem( + `${PAGE_KEY}:${instanceId}`, + kept.length > 0 ? JSON.stringify([page, ...kept]) : page + ); + } catch { + // Session storage refused: the registry records remain. + } +} + +/** A plain page id, or a page and the older ones it keeps (`keepPages`). */ +function pageList(value: string): string[] { + try { + const parsed: unknown = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.filter( + (page): page is string => typeof page === 'string' + ); + } + } catch { + // A plain page id. + } + return [value]; +} 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 e223b7e2e..a2f61012b 100644 --- a/libs/services/src/lib/server-storage/server-unsent-writes.ts +++ b/libs/services/src/lib/server-storage/server-unsent-writes.ts @@ -17,7 +17,6 @@ export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes'; /** Which server database the cached user data belongs to. */ export const CACHE_OWNER_KEY = 'iptvnator-server:cache-owner'; /** The tab's current page load, in the tab's own storage. */ -export const PAGE_KEY = 'iptvnator-server:page'; const writesKey = (instanceId: string, page: string) => `${UNSENT_WRITES_KEY}:${instanceId}:${page}`; @@ -119,24 +118,6 @@ function readSteps(stored: unknown): Steps { const saveSteps = (steps: Steps) => steps.map(({ before, after, yields }) => ({ before, after, yields })); -/** - * Records `page` as the tab's current page load and returns the one - * before it: a reload, whose saved writes this page takes over. - */ -export function swapPage( - tabStore: UserDataStorage, - instanceId: string, - page: string -): string | null { - try { - const previous = tabStore.getItem(`${PAGE_KEY}:${instanceId}`); - tabStore.setItem(`${PAGE_KEY}:${instanceId}`, page); - return previous; - } catch { - return null; - } -} - /** * Page loads whose record went stale: closed tabs nobody sends for. Saved * writes without any record are left alone: a tab that could save them but 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 ada385f6d..92891861c 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 @@ -467,4 +467,76 @@ describe('ServerUserDataStorage taking over other page loads', () => { expect(server.state['xtream-favorites']['p1']).toEqual(['X']); }); + + it('finds a kept page load again on a second reload', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + let online = false; + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + const api = { + ...server.api, + sendJson: jest.fn((...args: Parameters) => + online + ? send(...args) + : Promise.reject( + new ServerApiError(0, 'network', 'offline') + ) + ), + } as never; + const first = new ServerUserDataStorage( + api, + local, + jest.fn(), + 'default', + tabStore + ); + await first.hydrate(); + first.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await first.flush(); + first.close(); + // Its registry record is gone too: only this tab can find it. + for (const key of local.keys()) { + if (key.startsWith('iptvnator-server:unconfirmed:')) { + local.removeItem(key); + } + } + // Storage is full: the reloads cannot save their own copies. + 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 second = new ServerUserDataStorage( + api, + local, + jest.fn(), + 'default', + tabStore + ); + expect(second.hasUnsentWrites()).toBe(true); + second.close(); + + // Reloaded again before any copy was saved. + const third = new ServerUserDataStorage( + api, + local, + jest.fn(), + 'default', + tabStore + ); + expect(third.hasUnsentWrites()).toBe(true); + local.setItem = setItem; + online = true; + await third.hydrate(); + await third.flush(); + await third.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); }); 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 1e4481b04..7790ea167 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 @@ -19,6 +19,7 @@ import { restorePage, settleWrites, } from './server-user-data-adopt'; +import { keepPages, swapPage } from './server-page-pointer'; import { UserDataCache } from './server-user-data-cache'; import { changedEntries, @@ -39,7 +40,6 @@ import { removePage, saveDeferredEdits, saveUnsentWrites, - swapPage, } from './server-unsent-writes'; /** @@ -95,7 +95,7 @@ export class ServerUserDataStorage implements UserDataStorage { * The tab's own storage (`sessionStorage` in the app): remembers * its page load, so a reload takes over the writes it saved. */ - tabStore: UserDataStorage = local + private readonly tabStore: UserDataStorage = local ) { this.cache = new UserDataCache(local, instanceId); this.writes = new UserDataWriteQueue(api, { @@ -106,7 +106,9 @@ export class ServerUserDataStorage implements UserDataStorage { maySend: () => !this.superseded(), }); claimCache(local, instanceId); - const previous = swapPage(tabStore, instanceId, this.tabId); + 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. @@ -495,6 +497,7 @@ export class ServerUserDataStorage implements UserDataStorage { removePage(this.local, this.instanceId, page); } this.retained.clear(); + keepPages(this.tabStore, this.instanceId, this.tabId, []); } return saved; } @@ -505,6 +508,9 @@ export class ServerUserDataStorage implements UserDataStorage { removePage(this.local, this.instanceId, page); } else { this.retained.add(page); + keepPages(this.tabStore, this.instanceId, this.tabId, [ + ...this.retained, + ]); } } diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index 12e25608e..5bf37ed6e 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -58,6 +58,25 @@ "evidencePr": null, "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956", "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04" + }, + "renderer.cdTicksToFirstCard": { + "value": 5, + "unit": "ticks", + "slack": 1, + "note": "one-tick race since #1931 moved Angular Material off the startup path: 5 in all 15 iterations of the three dispatched runs before it, 5, 5, 5, 5, 6 in #1929's journeys job after merging it (https://github.com/4gray/iptvnator/actions/runs/38476954719/job/115583649036)", + "updatedAt": "2026-10-13", + "evidencePr": 1929, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38359088033", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 15 measured iterations of 3 runs of #1929 (zoneless, d88d00506)" + }, + "renderer.cdTicksIdle30s": { + "value": 3, + "unit": "ticks", + "slack": 0, + "updatedAt": "2026-10-13", + "evidencePr": 1929, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38359088033", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 15 measured iterations of 3 runs of #1929 (zoneless, d88d00506)" } }, "open-source": { @@ -90,6 +109,15 @@ "evidencePr": null, "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38147072417", "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), 0 with no shift recorded in all 5 measured iterations of PR #1895 (d2cb9445d), which slides the content frame and header controls instead of jumping (was 0.233)" + }, + "renderer.cdTicksToFirstPage": { + "value": 9, + "unit": "ticks", + "slack": 0, + "updatedAt": "2026-10-13", + "evidencePr": 1929, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38359088033", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 15 measured iterations of 3 runs of #1929 (zoneless, d88d00506)" } }, "playback": {