From cfda131c418da7e3f81115e598736a594dc0d1a5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 09:35:45 +0200 Subject: [PATCH] fix(server-storage): suspended tabs give up adopted writes; history stays newest first; favorite checks wait A background tab the browser suspended past the record lifetime can have its writes adopted elsewhere, like a back-forward cache page: shown again, it now checks resumed() and reloads instead of sending them. Conflict replays appended a newly viewed item at the end of the history, where the next 50-item cut dropped it; a replayed recently viewed list is now kept newest first. isFavorite() waits for the first user-data read like the list reads, so a detail page opened during a slow first read does not toggle an existing favorite the wrong way. A parked backup restore replayed when the playlist opens waits too, and stays parked while no read succeeded. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 19 +++++---- .../pwa-xtream-data-source.spec.ts | 39 +++++++++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 13 ++++++- .../server-storage/server-migration-merge.ts | 15 ++++++- .../server-storage-session.spec.ts | 11 +++++- .../server-storage/server-storage-session.ts | 5 ++- .../server-user-data-edit.spec.ts | 17 ++++++++ .../server-storage/server-user-data-edit.ts | 10 ++++- .../server-user-data-storage.ts | 7 ++-- 9 files changed, 117 insertions(+), 19 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 02e31f6e0..16aa9b28a 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -235,8 +235,9 @@ 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). A page marks its record closed on `pagehide` (not into the back-forward cache, where - its record goes stale; restored after another tab adopted it, the page - reloads rather than send those writes again), and + 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 only a closed, stale or missing record is taken over: a duplicated tab, which copies `sessionStorage`, leaves the running tab its writes. A page load that still looked live (or whose close flag storage refused) is @@ -256,12 +257,14 @@ settings and guards read them in every build. starts clean. Startup waits for the first read only a few seconds; `UserDataStorage.ready()` waits for a first read in flight (it starts none, so an offline page is not held up) and tells whether one - succeeded. The Xtream data source's favorites, history and position - reads wait for it, so a new device does not show empty lists or start - playback without its resume position. A backup export or restore fails - while no read succeeded: unread maps would export as empty lists, and a - restore's lists would merge into the server's instead of replacing them - (the restore stays parked). Also, + succeeded. The Xtream data source's favorites, favorite checks, history + and position reads wait for it, so a new device does not show empty + lists or start playback without its resume position. A backup export or + restore (also its replay when the playlist opens) fails while no read + succeeded: unread maps would export as empty lists, and a restore's lists + would merge into the server's instead of replacing them (the restore + stays parked). Replayed edits keep recently viewed lists newest first, + as the app cuts them at their limit from the end. Also, re-reads run one at a time and keep entries edited while they were in flight or not yet saved. A response body cut off after its headers counts as offline. Each write is sent on the tag of the entry it was made diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index acbc64034..d31e13a8d 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -1008,5 +1008,44 @@ describe('PwaXtreamDataSource', () => { positionSeconds: 120, }); }); + + it('keeps a restore parked while the server user data is unread', async () => { + const values = new Map([ + ['xtream-favorites', JSON.stringify({ p1: ['live:1'] })], + ]); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + ready: async () => false, + }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { provide: USER_DATA_STORAGE, useValue: storage }, + { provide: ParentalLockService, useValue: parentalLock }, + { provide: XtreamApiService, useValue: apiService }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }); + const source = TestBed.inject(PwaXtreamDataSource); + + await expect( + source.restoreUserData('p1', { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + } as never) + ).rejects.toThrow(/restore waits/); + expect(values.get('xtream-favorites')).toBe( + JSON.stringify({ p1: ['live:1'] }) + ); + }); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 1a40dffb2..424f88789 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -767,6 +767,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { contentId: XtreamCollectionRef, playlistId: string ): Promise { + await this.userDataRead(); const normalizedContentId = await this.resolveCollectionKey( playlistId, contentId @@ -950,9 +951,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { * Server mode: favorites, history and positions show the server's data * once its first read settled, which startup does not wait for beyond a * few seconds. A read before it would show a new device empty lists. + * Whether a read succeeded (always true outside server mode). */ - private async userDataRead(): Promise { - await this.userDataStorage.ready?.(); + private async userDataRead(): Promise { + return (await this.userDataStorage.ready?.()) ?? true; } private getPlaybackPositionsFromStorage(): Record< @@ -1403,6 +1405,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { void options; + // Its lists replace the server's: before the server's are read they + // would merge into them instead. The restore stays parked. + if (!(await this.userDataRead())) { + throw new Error( + "The server's user data could not be read yet; the restore waits." + ); + } // Restore favorites const favorites = this.collectionStorage.getFavoritesFromStorage(); favorites[playlistId] = restoreState.favorites diff --git a/libs/services/src/lib/server-storage/server-migration-merge.ts b/libs/services/src/lib/server-storage/server-migration-merge.ts index f48299087..de893cb62 100644 --- a/libs/services/src/lib/server-storage/server-migration-merge.ts +++ b/libs/services/src/lib/server-storage/server-migration-merge.ts @@ -146,14 +146,25 @@ export function combineRecentViews(values: readonly unknown[]): unknown { if (present.length === 0 || !present.every(Array.isArray)) { return combineUserData(values); } + return unionItems( + newestViewsFirst(([] as unknown[]).concat(...(present as unknown[][]))) + ); +} + +/** Items with a `viewedAt` (recently viewed), newest first; stable. */ +export function newestViewsFirst(items: readonly T[]): T[] { const viewedAt = (item: unknown) => { const at = isPlainObject(item) ? Date.parse(String(item['viewedAt'])) : NaN; return Number.isNaN(at) ? 0 : at; }; - const views = ([] as unknown[]).concat(...(present as unknown[][])); - return unionItems(views.sort((a, b) => viewedAt(b) - viewedAt(a))); + return [...items].sort((a, b) => viewedAt(b) - viewedAt(a)); +} + +/** Whether a list holds recently viewed items, which stay newest first. */ +export function isViewHistory(items: readonly unknown[]): boolean { + return items.some((item) => isPlainObject(item) && 'viewedAt' in item); } function isPlainObject(value: unknown): value is Record { diff --git a/libs/services/src/lib/server-storage/server-storage-session.spec.ts b/libs/services/src/lib/server-storage/server-storage-session.spec.ts index 7ebba30b2..d679c17fb 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.spec.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -175,7 +175,16 @@ describe('ServerStorageSession', () => { resumed.mockReturnValue(false); pageshow(true); - expect(reload).toHaveBeenCalled(); + expect(reload).toHaveBeenCalledTimes(1); + + // So does a background tab the browser suspended as long. + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); + document.dispatchEvent(new Event('visibilitychange')); + expect(reload).toHaveBeenCalledTimes(2); + delete (document as { visibilityState?: unknown }).visibilityState; }); describe('after losing the server', () => { diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts index de18904e9..2cefadd30 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -170,13 +170,16 @@ export class ServerStorageSession { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { this.userData.flushWithKeepalive(); + } else if (!this.userData.resumed()) { + // Suspended long enough for another tab to take its writes. + this.reloadPage(); } else { void this.refresh(); } }); window.addEventListener('pageshow', (event) => { // Back from the back-forward cache: another tab may have taken - // its writes over while it was frozen, so this page reloads. + // its writes over while it was frozen; then this page reloads. if (event.persisted && !this.userData.resumed()) { this.reloadPage(); } diff --git a/libs/services/src/lib/server-storage/server-user-data-edit.spec.ts b/libs/services/src/lib/server-storage/server-user-data-edit.spec.ts index 9e2cc4a93..b179f7079 100644 --- a/libs/services/src/lib/server-storage/server-user-data-edit.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-edit.spec.ts @@ -60,6 +60,23 @@ describe('applyEdit', () => { }); }); +describe('applyEdit of recently viewed items', () => { + it('puts a replayed view ahead of older ones', () => { + const view = (id: string, day: number) => ({ + id, + viewedAt: `2026-10-0${day}T20:00:00.000Z`, + }); + // Another device added a view meanwhile; this one is newer still. + expect( + applyEdit( + [view('b', 2), view('a', 1)], + [view('a', 1)], + [view('c', 3), view('a', 1)] + ) + ).toEqual([view('c', 3), view('b', 2), view('a', 1)]); + }); +}); + describe('applyEdit of a gone page (yields)', () => { it('changes only what is still as that page saw it', () => { // Map keys: `a` changed since, `b` did not. diff --git a/libs/services/src/lib/server-storage/server-user-data-edit.ts b/libs/services/src/lib/server-storage/server-user-data-edit.ts index 8671ab7a7..1dc704c42 100644 --- a/libs/services/src/lib/server-storage/server-user-data-edit.ts +++ b/libs/services/src/lib/server-storage/server-user-data-edit.ts @@ -3,7 +3,11 @@ import { type ServerUserStateNamespace, type ServerUserStateResponse, } from '@iptvnator/shared/interfaces'; -import { itemKey } from './server-migration-merge'; +import { + isViewHistory, + itemKey, + newestViewsFirst, +} from './server-migration-merge'; import { type EditStep, idMap } from './server-user-data-entries'; /** Edits made before the first read, applied once the server's value is known. */ @@ -90,7 +94,9 @@ export function applyEdit( result[index] = item; } } - return result; + // History is cut at its limit from the end: a replayed view must + // not land behind older ones. + return isViewHistory(result) ? newestViewsFirst(result) : result; } if (isMap(after) && isMapOrMissing(server) && isMapOrMissing(before)) { const was = before ?? {}; 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 a5d03a287..ee72b0ac4 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 @@ -203,9 +203,10 @@ export class ServerUserDataStorage implements UserDataStorage { } /** - * Back from the back-forward cache, where its record went stale: false - * when another tab took its writes over meanwhile, which this page - * must then not send again (it reloads instead). + * Back from the back-forward cache or a suspended background tab, where + * its record could go stale: false when another tab took its writes + * over meanwhile, which this page must then not send again (it reloads + * instead). */ resumed(): boolean { if (