mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
1 parent
c8afa382fe
commit
cfda131c41
9 files changed
+117
-19
No files matched your search
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user