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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 09:35:45 +02:00
1 parent c8afa382fe
commit cfda131c41
9 files changed
+117 -19

No files matched your search

+11 -8
View File
@@ -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
@@ -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<string, string>([
['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'] })
);
});
});
});
@@ -767,6 +767,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
contentId: XtreamCollectionRef,
playlistId: string
): Promise<boolean> {
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<void> {
await this.userDataStorage.ready?.();
private async userDataRead(): Promise<boolean> {
return (await this.userDataStorage.ready?.()) ?? true;
}
private getPlaybackPositionsFromStorage(): Record<
@@ -1403,6 +1405,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
options?: XtreamOperationOptions
): Promise<void> {
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
@@ -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<T>(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<string, unknown> {
@@ -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', () => {
@@ -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();
}
@@ -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.
@@ -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 ?? {};
@@ -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 (