perf(workspace): share the startup inventory read and defer non-first-card IPC (J1)

Journey J1 / renderer.ipcCallsToFirstCard: 12 -> 7.

- PlaylistsService.getAllPlaylists() shares one in-flight SQLite read
  between concurrent callers (the playlist effect and the XMLTV source
  reconciliation at startup). Settled reads are never reused and every
  SQLite write detaches the pending read.
- getM3uFavoriteChannels() stops re-reading the write-once IndexedDB ->
  SQLite migration receipt once it has been seen.
- StartupDeferralService holds the download list, app update status and
  the dashboard's recent items and favorites until one task after the
  render that reveals the routed content (5 s safety timeout).
  IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1 is the kill switch.
- reconcileEpgSources and the two distinct migration-flag reads stay on
  the critical path: the first is the #1548 revision fence, the second
  are different keys, not duplicates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 08:59:35 +02:00
1 parent 76dd8c099e
commit e5ee064407
24 files changed
+648 -23

No files matched your search

@@ -0,0 +1,6 @@
---
type: perf
area: workspace
---
The workspace shows its first source card sooner after launch (journey J1, `renderer.ipcCallsToFirstCard`): it reads the source list once instead of twice, and loads downloads, update status, recent items and favorites right after the first screen. Set `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1` to turn this off.
@@ -122,6 +122,11 @@ const xtreamPreloadPerformanceCapture = shouldCaptureXtreamPerformance
)
: null;
/** Same spelling as the connectivity-guard kill switch: `1` or `true`. */
function isEnvFlagSet(value: string | undefined): boolean {
return value === '1' || value === 'true';
}
function emitRendererTrace(payload: {
method: string;
phase: 'start' | 'success' | 'error';
@@ -412,6 +417,9 @@ const electronApi: ElectronBridgeApi = {
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
startupDeferralDisabled: isEnvFlagSet(
process.env['IPTVNATOR_DISABLE_STARTUP_DEFERRAL']
),
getAppUpdateStatus: () => ipcRenderer.invoke(APP_UPDATE_GET_STATUS),
checkForAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_CHECK),
downloadAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_DOWNLOAD),
@@ -6,6 +6,7 @@ import {
selectPlaylistsLoadFailed,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { StartupDeferralService } from '@iptvnator/services';
import { AppStartupStatusComponent } from './app-startup-status.component';
describe('AppStartupStatusComponent', () => {
@@ -48,6 +49,28 @@ describe('AppStartupStatusComponent', () => {
expect(fixture.componentInstance.complete()).toBe(true);
});
it('releases deferred startup work only after the completing render', async () => {
const mark = jest.spyOn(
TestBed.inject(StartupDeferralService),
'markFirstContentRendered'
);
const fixture = TestBed.createComponent(AppStartupStatusComponent);
fixture.componentRef.setInput('routeReady', true);
fixture.detectChanges();
await fixture.whenStable();
expect(mark).not.toHaveBeenCalled();
TestBed.inject(MockStore).overrideSelector(
selectPlaylistsLoadingFlag,
true
);
TestBed.inject(MockStore).refreshState();
fixture.detectChanges();
await fixture.whenStable();
expect(mark).toHaveBeenCalledTimes(1);
});
it('offers a retry on failure without rendering raw storage errors', () => {
TestBed.inject(MockStore).overrideSelector(
selectPlaylistsLoadFailed,
@@ -1,9 +1,13 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
Injector,
input,
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@@ -14,6 +18,7 @@ import {
selectPlaylistsLoadFailed,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { StartupDeferralService } from '@iptvnator/services';
/** The first route also waits for settings and XMLTV source reconciliation. */
@Component({
@@ -134,6 +139,22 @@ export class AppStartupStatusComponent {
() => this.routeReady() && this.sourcesReady()
);
constructor() {
const injector = inject(Injector);
const startupDeferral = inject(StartupDeferralService);
// The routed content is revealed in the render that completes the
// startup status; deferred startup work may start after it.
effect(() => {
if (!this.complete()) return;
untracked(() =>
afterNextRender(
() => startupDeferral.markFirstContentRendered(),
{ injector }
)
);
});
}
retry(): void {
this.store.dispatch(PlaylistActions.loadPlaylists());
}
@@ -5,6 +5,10 @@ import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import {
STARTUP_WORK_DEFERRAL,
StartupDeferralService,
} from '@iptvnator/services';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
@@ -166,3 +170,76 @@ describe('AppUpdateNotificationPanelComponent', () => {
open.mockRestore();
});
});
describe('AppUpdateNotificationPanelComponent at startup', () => {
const originalElectron = window.electron;
let statusHandler: ((status: ElectronBridgeAppUpdateStatus) => void) | null;
function setup() {
statusHandler = null;
window.electron = {
getAppUpdateStatus: jest.fn().mockResolvedValue(availableStatus),
onAppUpdateStatusChange: jest.fn((handler) => {
statusHandler = handler;
return jest.fn();
}),
} as unknown as typeof window.electron;
TestBed.configureTestingModule({
imports: [
AppUpdateNotificationPanelComponent,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
],
});
const fixture = TestBed.createComponent(
AppUpdateNotificationPanelComponent
);
fixture.detectChanges();
const openGate = async () => {
TestBed.inject(StartupDeferralService).markFirstContentRendered();
await new Promise((resolve) => setTimeout(resolve, 0));
await fixture.whenStable();
fixture.detectChanges();
};
return { fixture, openGate };
}
afterEach(() => {
window.electron = originalElectron;
});
it('reads the update status only after the first screen rendered', async () => {
const { fixture, openGate } = setup();
await fixture.whenStable();
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalled();
expect(window.electron.getAppUpdateStatus).not.toHaveBeenCalled();
await openGate();
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification"]'
)
).not.toBeNull();
});
it('keeps a status pushed while the read waited', async () => {
const { fixture, openGate } = setup();
statusHandler?.({
...availableStatus,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
await openGate();
expect(window.electron.getAppUpdateStatus).not.toHaveBeenCalled();
expect(fixture.componentInstance.status()?.status).toBe(
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
);
});
});
@@ -15,6 +15,7 @@ import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { StartupDeferralService } from '@iptvnator/services';
import { AppUpdateInstallService } from './services/app-update-install.service';
@Component({
@@ -180,6 +181,7 @@ import { AppUpdateInstallService } from './services/app-update-install.service';
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
private readonly dialog = inject(MatDialog);
private readonly installService = inject(AppUpdateInstallService);
private readonly startupDeferral = inject(StartupDeferralService);
private unsubscribeStatus: (() => void) | null = null;
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
@@ -223,14 +225,21 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
return;
}
let pushed = false;
this.unsubscribeStatus =
window.electron.onAppUpdateStatusChange?.((status) => {
pushed = true;
this.status.set(status);
}) ?? null;
void window.electron.getAppUpdateStatus().then((status) => {
this.status.set(status);
});
// The card is never part of the first screen (journey J1). A status
// pushed while the read waited is newer than the read would be.
void this.startupDeferral
.whenFirstContentRendered()
.then(() => (pushed ? null : window.electron.getAppUpdateStatus()))
.then((status) => {
if (status && !pushed) this.status.set(status);
});
}
ngOnDestroy(): void {
+4
View File
@@ -36,6 +36,7 @@ import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import {
provideParentalLockPlaylistCleanup,
DataService,
STARTUP_WORK_DEFERRAL,
} from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
import { AppConfig } from '../environments/environment';
@@ -111,6 +112,9 @@ export function DataFactory() {
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
// Startup work the first screen does not need waits for its render
// (journey J1); AppStartupStatusComponent opens the gate.
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
provideRouter(routes, withComponentInputBinding()),
provideAnimations(),
// CDK overlays (menus, tooltips, dialogs) live in a container under
+29
View File
@@ -179,6 +179,35 @@ harness, which is what the ratchet needs. The main process start
(`Date.now() - process.uptime()`) is recorded per iteration under
`evidence.epochs` for cross-checks.
### Startup work before the first card
`renderer.ipcCallsToFirstCard` counts what the renderer asks of the main
process before the first card. Only work that the first card needs may run
before it:
- `PlaylistsService.getAllPlaylists()` shares one in-flight SQLite read
between concurrent callers (`SharedInFlightRead`): at startup the playlist
effect and the XMLTV source reconciliation both read the inventory, and the
second caller joins the first read and receives a copy. A settled read is
never reused, and every SQLite write detaches the pending read, so a caller
that follows a write reads again.
- `StartupDeferralService` (`@iptvnator/services`) holds work the first
screen does not need: the initial download list (the header badge), the
app update status (the update card), and the dashboard's recent items and
favorites. `app.config.ts` arms it with `STARTUP_WORK_DEFERRAL`, and
`AppStartupStatusComponent` opens it one task after the render that
reveals the routed content, so released work never lands in the task of
the first card. It opens on its own after 5 s if that render never
happens. Without the token (tests, other hosts) the gate is open.
- `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1` (or `true`) is the short-lived kill
switch: the preload exposes it as `startupDeferralDisabled` and the gate
starts open. Remove it within two releases of the change that added it.
- `reconcileEpgSources` stays before the first card on purpose: its
completion bumps `EpgSourceSettingsService.revision()`, the fence that
keeps XMLTV lookups from returning data of a removed source. Deferring it
would let dashboard EPG lookups start first and then be discarded and
requeued.
### Summary schema
```json
+5 -1
View File
@@ -282,7 +282,11 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
`updateDate` / `importDate` for sources that have never been used.
3. `DashboardDataService` is passive on construction. The dashboard feature
owns the initial reloads for recent items, favorites, and Xtream recently
added rows on page entry.
added rows on page entry. Recent items and favorites reload through
`reloadForPageEntry()`, which on launch waits for `StartupDeferralService`
(the first rendered screen, journey J1); their rails keep their scoped
skeletons meanwhile, and the Xtream recently-added and TMDB rails follow
`globalFavoritesLoaded()` as before. Later page entries find the gate open.
4. No dashboard-local `Layout` state, no localStorage keys, no migrations.
Per-rail visibility is the one persisted preference, and it lives in the
global settings store (`Settings.dashboardRails`), not in a
+1
View File
@@ -34,6 +34,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
- `IPTVNATOR_PERF_COUNT_SQL=1`, together with `IPTVNATOR_PERF_CAPTURE=1`, also counts every SQL statement of the main-process and database-worker connections for `main.sqlStatementsBeforeReadyToShow`; it wraps each statement execution, including every row of a bulk insert, so only the launch journey sets it and the import benchmarks leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
- `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=<dir>` relocates it. The startup trace reports the outcome as `compile-cache`
- `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1` starts the renderer's startup gate open, so the download list, update status and dashboard recent/favorites reads run before the first screen again (kill switch; see "Startup work before the first card" in `docs/architecture/performance-journeys.md`)
- Settings, portal request/response, and trace payloads must use
`@iptvnator/shared/logging` or the redacting portal logger before reaching
+1
View File
@@ -16,6 +16,7 @@ export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/runtime-capabilities.service';
export * from './lib/settings-store.service';
export * from './lib/startup-deferral.service';
export * from './lib/sort.service';
export * from './lib/tmdb';
export * from './lib/xtream-pending-restore.service';
@@ -15,6 +15,10 @@ import type {
import { DownloadListLoadState } from './download-list-load-state';
import { DownloadItem, DownloadsService } from './downloads.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import {
STARTUP_WORK_DEFERRAL,
StartupDeferralService,
} from './startup-deferral.service';
type TestDownloadsService = {
downloads: WritableSignal<DownloadItem[]>;
@@ -150,6 +154,7 @@ describe('DownloadsService', () => {
const injector = createEnvironmentInjector(
[
DownloadsService,
StartupDeferralService,
{
provide: RuntimeCapabilitiesService,
useValue: { supportsDownloads: false },
@@ -170,6 +175,44 @@ describe('DownloadsService', () => {
}
});
it('subscribes at once but loads the list after the first screen rendered', async () => {
jest.useFakeTimers();
const onDownloadsUpdate = jest.fn(() => () => undefined);
const electron = {
onDownloadsUpdate,
downloadsGetList: jest.fn(async () => []),
downloadsGetDefaultFolder: jest.fn(async () => '/downloads'),
};
testWindow.electron = electron as unknown as DownloadsElectronStub;
const injector = createEnvironmentInjector(
[
DownloadsService,
StartupDeferralService,
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsDownloads: true },
},
],
Injector.NULL as unknown as EnvironmentInjector
);
try {
runInInjectionContext(injector, () => new DownloadsService());
await Promise.resolve();
expect(onDownloadsUpdate).toHaveBeenCalledTimes(1);
expect(electron.downloadsGetList).not.toHaveBeenCalled();
injector.get(StartupDeferralService).markFirstContentRendered();
await jest.advanceTimersByTimeAsync(0);
expect(electron.downloadsGetList).toHaveBeenCalledTimes(1);
} finally {
injector.destroy();
jest.useRealTimers();
}
});
it('loads the global download list without a playlist filter and tracks request state', async () => {
const item = createDownload(1);
const pending = createDeferred<DownloadItem[]>();
@@ -7,6 +7,7 @@ import { updateDownloadMetadata } from './downloads-metadata-update';
import type { DownloadItem, DownloadStartInput } from './downloads.models';
import { formatDownloadBytes } from './downloads.utils';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { StartupDeferralService } from './startup-deferral.service';
export type {
DownloadItem,
@@ -17,6 +18,7 @@ export type {
@Injectable({ providedIn: 'root' })
export class DownloadsService implements OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly startupDeferral = inject(StartupDeferralService);
private unsubscribe?: () => void;
private readonly downloadListLoadState = new DownloadListLoadState();
@@ -79,6 +81,10 @@ export class DownloadsService implements OnDestroy {
this.loadDownloads();
});
// The header badge is the only startup consumer; views that need
// the list load it themselves. Keep it off the first screen (J1).
await this.startupDeferral.whenFirstContentRendered();
// Load initial download list
await this.loadDownloads();
@@ -1,5 +1,6 @@
import { firstValueFrom, of } from 'rxjs';
import { PlaylistsService } from './playlists.service';
import { SharedInFlightRead } from './shared-in-flight-read';
describe('Electron legacy playlist migration', () => {
const original = window.electron;
@@ -38,6 +39,7 @@ describe('Electron legacy playlist migration', () => {
dbService,
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
});
return { playlists, dbService, electron, service };
}
@@ -0,0 +1,120 @@
import { firstValueFrom, of } from 'rxjs';
import { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistsService } from './playlists.service';
import { SharedInFlightRead } from './shared-in-flight-read';
// Performance journey J1: the playlist effect and the XMLTV source
// reconciliation both read the inventory at startup. They must share one
// worker round trip, without ever handing a caller a pre-write inventory.
describe('PlaylistsService inventory reads', () => {
const original = window.electron;
afterEach(() => {
window.electron = original;
jest.restoreAllMocks();
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => (resolve = done));
return { promise, resolve };
}
function setup() {
const reads: ReturnType<typeof deferred<Playlist[]>>[] = [];
const electron = {
dbGetAppState: jest.fn(async () => '1'),
dbSetAppState: jest.fn(),
dbRecoverLegacyPlaylists: jest.fn(async () => undefined),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppPlaylistMetas: jest.fn(() => {
const read = deferred<Playlist[]>();
reads.push(read);
return read.promise;
}),
dbGetAppPlaylistFavoriteChannels: jest.fn(async () => []),
dbUpsertAppPlaylist: jest.fn(async () => undefined),
};
window.electron = electron as unknown as typeof window.electron;
const service = Object.create(
PlaylistsService.prototype
) as PlaylistsService;
Object.assign(service, {
dbService: { getAll: jest.fn(() => of([])) },
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
sqliteMigrationConfirmed: false,
playlistWriteQueues: new Map(),
});
const settle = async (index: number, playlists: Playlist[]) => {
// The read starts after the memoized migration's awaits.
while (!reads[index]) await Promise.resolve();
reads[index].resolve(playlists);
};
return { electron, service, settle };
}
const source = (id: string) =>
({ _id: id, title: id, favorites: ['a'] }) as unknown as Playlist;
it('shares one metadata read between concurrent callers', async () => {
const { electron, service, settle } = setup();
const effectRead = firstValueFrom(service.getAllPlaylists());
const reconcileRead = firstValueFrom(service.getAllPlaylists());
await settle(0, [source('m3u')]);
const [first, second] = await Promise.all([effectRead, reconcileRead]);
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(1);
expect(second).toEqual(first);
// The joiner holds its own copy: no caller mutates another's result.
expect(second).not.toBe(first);
expect(second[0].favorites).not.toBe(first[0].favorites);
});
it('never reuses a settled read', async () => {
const { electron, service, settle } = setup();
const first = firstValueFrom(service.getAllPlaylists());
await settle(0, [source('before')]);
await first;
const second = firstValueFrom(service.getAllPlaylists());
await settle(1, [source('after')]);
await expect(second).resolves.toEqual([source('after')]);
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(2);
});
it('starts a fresh read for callers that arrive after a write', async () => {
const { electron, service, settle } = setup();
const added = source('added');
const staleRead = firstValueFrom(service.getAllPlaylists());
while (electron.dbGetAppPlaylistMetas.mock.calls.length === 0)
await Promise.resolve();
await firstValueFrom(service.addPlaylist(added));
const freshRead = firstValueFrom(service.getAllPlaylists());
await settle(0, []);
await settle(1, [added]);
await expect(staleRead).resolves.toEqual([]);
await expect(freshRead).resolves.toEqual([added]);
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(2);
});
it('reads the SQLite migration receipt once for M3U favorites', async () => {
const { electron, service, settle } = setup();
const inventory = firstValueFrom(service.getAllPlaylists());
await settle(0, []);
await inventory;
const receiptReads = electron.dbGetAppState.mock.calls.length;
await firstValueFrom(service.getM3uFavoriteChannels('one'));
await firstValueFrom(service.getM3uFavoriteChannels('two'));
expect(electron.dbGetAppState).toHaveBeenCalledTimes(receiptReads);
expect(
electron.dbGetAppPlaylistFavoriteChannels
).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,7 @@
import { EMPTY, firstValueFrom, from, of } from 'rxjs';
import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistsService, resolvePlaylistParser } from './playlists.service';
import { SharedInFlightRead } from './shared-in-flight-read';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
@@ -60,6 +61,8 @@ describe('PlaylistsService', () => {
},
electronMigrationPromise: null,
indexedDbMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
sqliteMigrationConfirmed: false,
playlistWriteQueues: new Map(),
playlistDeleteCleanups: [],
});
+37 -16
View File
@@ -45,6 +45,7 @@ import {
runWithPlaylistAuthorityReset,
} from './playlist-cross-context-lock';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { SharedInFlightRead } from './shared-in-flight-read';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
@@ -116,6 +117,11 @@ export class PlaylistsService {
private readonly playlistDeleteCleanups =
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
private electronMigrationPromise: Promise<void> | null = null;
// Startup reads the inventory twice at once (the playlist effect and the
// XMLTV source reconciliation); both share one worker round trip.
private readonly sqliteInventoryRead = new SharedInFlightRead<Playlist[]>();
// The IndexedDB → SQLite receipt is written once and never cleared.
private sqliteMigrationConfirmed = false;
private indexedDbMigrationPromise: Promise<void> | null = null;
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
@@ -195,8 +201,10 @@ export class PlaylistsService {
if (
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
'1'
)
) {
this.sqliteMigrationConfirmed = true;
return;
}
const playlists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
@@ -397,6 +405,7 @@ export class PlaylistsService {
return playlist;
}
this.sqliteInventoryRead.detach();
if (operationId === undefined) {
await electron.dbUpsertAppPlaylist(playlist);
} else {
@@ -414,6 +423,7 @@ export class PlaylistsService {
return playlists;
}
this.sqliteInventoryRead.detach();
await electron.dbUpsertAppPlaylists(playlists);
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
return playlists;
@@ -492,16 +502,22 @@ export class PlaylistsService {
getAllPlaylists() {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists())
: [];
return (playlists as Playlist[]).map((playlist) =>
this.toPlaylistMeta(playlist)
);
});
return from(
this.sqliteInventoryRead.run(() =>
firstValueFrom(
this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists())
: [];
return (playlists as Playlist[]).map((playlist) =>
this.toPlaylistMeta(playlist)
);
})
)
)
);
}
return this.runOnIndexedDb(() =>
@@ -555,6 +571,7 @@ export class PlaylistsService {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
this.sqliteInventoryRead.detach();
if (options) {
const deleted =
await this.databaseService.deletePlaylist(
@@ -1072,11 +1089,14 @@ export class PlaylistsService {
return from(
(async () => {
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
if (!this.sqliteMigrationConfirmed) {
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
}
this.sqliteMigrationConfirmed = true;
}
return getFavoriteChannels(playlistId);
@@ -1326,6 +1346,7 @@ export class PlaylistsService {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
this.sqliteInventoryRead.detach();
await electron.dbDeleteAllPlaylists();
this.healthEvidence?.connections.next({});
}
@@ -0,0 +1,52 @@
/**
* Lets concurrent callers share one pending read. A settled read is never
* reused, and `detach()` (called when a write is issued) makes the next
* caller start a fresh read, so a caller that follows a write never receives
* a result that was read before it. The first caller receives the read's own
* result; callers that join it receive a copy, so no caller can mutate what
* another one holds.
*/
export class SharedInFlightRead<T> {
private pending: Promise<T> | null = null;
constructor(private readonly copy: (value: T) => T = copyPlainData) {}
run(read: () => Promise<T>): Promise<T> {
if (this.pending) {
return this.pending.then(this.copy);
}
const pending = read();
this.pending = pending;
const settle = () => {
if (this.pending === pending) this.pending = null;
};
pending.then(settle, settle);
return pending;
}
detach(): void {
this.pending = null;
}
}
/**
* Deep-copies arrays and plain objects (the shape of worker results); any
* other value, such as a `Date`, is shared by reference.
*/
export function copyPlainData<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => copyPlainData(item)) as T;
}
if (
value !== null &&
typeof value === 'object' &&
Object.getPrototypeOf(value) === Object.prototype
) {
const copy: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
copy[key] = copyPlainData(item);
}
return copy as T;
}
return value;
}
@@ -0,0 +1,80 @@
import { TestBed } from '@angular/core/testing';
import {
STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS,
STARTUP_WORK_DEFERRAL,
StartupDeferralService,
} from './startup-deferral.service';
describe('StartupDeferralService', () => {
const original = window.electron;
afterEach(() => {
window.electron = original;
jest.useRealTimers();
});
function create(armed: boolean) {
TestBed.configureTestingModule({
providers: armed
? [{ provide: STARTUP_WORK_DEFERRAL, useValue: true }]
: [],
});
return TestBed.inject(StartupDeferralService);
}
function track(service: StartupDeferralService) {
const state = { open: false };
void service.whenFirstContentRendered().then(() => (state.open = true));
return state;
}
it('is open when the application did not arm it', async () => {
const state = track(create(false));
await Promise.resolve();
expect(state.open).toBe(true);
});
it('opens one task after the first content rendered', async () => {
jest.useFakeTimers();
const service = create(true);
const state = track(service);
await Promise.resolve();
expect(state.open).toBe(false);
service.markFirstContentRendered();
await Promise.resolve();
// Never in the task that rendered the first card.
expect(state.open).toBe(false);
jest.advanceTimersByTime(0);
await Promise.resolve();
expect(state.open).toBe(true);
});
it('opens after the safety timeout when nothing renders', async () => {
jest.useFakeTimers();
const state = track(create(true));
jest.advanceTimersByTime(STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS - 1);
await Promise.resolve();
expect(state.open).toBe(false);
jest.advanceTimersByTime(1);
await Promise.resolve();
expect(state.open).toBe(true);
});
it('honours the IPTVNATOR_DISABLE_STARTUP_DEFERRAL kill switch', async () => {
window.electron = {
startupDeferralDisabled: true,
} as unknown as typeof window.electron;
const state = track(create(true));
await Promise.resolve();
expect(state.open).toBe(true);
});
});
@@ -0,0 +1,68 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
/**
* Arms the startup gate. Only the application shell provides `true`; any
* other injector (unit tests, embedded hosts) sees an open gate, so deferred
* work runs immediately there, exactly as before the gate existed.
*/
export const STARTUP_WORK_DEFERRAL = new InjectionToken<boolean>(
'STARTUP_WORK_DEFERRAL'
);
/**
* Opens the gate even if the first content never renders (a failed source
* read, a route error), so deferred background work is late, never lost.
*/
export const STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS = 5000;
/**
* Holds startup work that the first rendered screen does not need until
* that screen has rendered (performance journey J1). The gate opens one task
* after the render in which the startup status completed, so work released
* by it can never be issued in the same task as the first card.
*/
@Injectable({ providedIn: 'root' })
export class StartupDeferralService {
private readonly enabled =
inject(STARTUP_WORK_DEFERRAL, { optional: true }) === true &&
!isStartupDeferralDisabled();
private open = false;
private openGate!: () => void;
private readonly gate = new Promise<void>((resolve) => {
this.openGate = resolve;
});
constructor() {
if (!this.enabled) {
this.release();
return;
}
setTimeout(() => this.release(), STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS);
}
/** Resolves once the first routed content has rendered. */
whenFirstContentRendered(): Promise<void> {
return this.gate;
}
/** Called after the render in which the startup status completed. */
markFirstContentRendered(): void {
if (!this.open) {
setTimeout(() => this.release(), 0);
}
}
private release(): void {
if (this.open) return;
this.open = true;
this.openGate();
}
}
/** `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1`, read by the Electron preload. */
function isStartupDeferralDisabled(): boolean {
return (
typeof window !== 'undefined' &&
window.electron?.startupDeferralDisabled === true
);
}
@@ -710,6 +710,11 @@ export interface ElectronBridgeApi {
) => () => void;
getAppVersion: () => Promise<string>;
platform: string;
/**
* `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1` kill switch: issue deferred
* startup work immediately instead of after the first rendered screen.
*/
startupDeferralDisabled?: boolean;
getAppUpdateStatus: () => Promise<ElectronBridgeAppUpdateStatus>;
checkForAppUpdate: () => Promise<ElectronBridgeAppUpdateStatus>;
downloadAppUpdate: () => Promise<ElectronBridgeAppUpdateStatus>;
@@ -7,7 +7,11 @@ import {
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of, Subject } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
DatabaseService,
PlaylistsService,
StartupDeferralService,
} from '@iptvnator/services';
import {
PlaybackPositionData,
Playlist,
@@ -270,6 +274,30 @@ describe('DashboardDataService', () => {
expect(service.dashboardReady()).toBe(false);
});
it('holds the page-entry reload until the first screen rendered', async () => {
let openGate!: () => void;
jest.spyOn(
TestBed.inject(StartupDeferralService),
'whenFirstContentRendered'
).mockReturnValue(new Promise<void>((resolve) => (openGate = resolve)));
const recent = jest
.spyOn(service, 'reloadGlobalRecentItems')
.mockResolvedValue(undefined);
const favorites = jest
.spyOn(service, 'reloadGlobalFavorites')
.mockResolvedValue(undefined);
const reload = service.reloadForPageEntry();
await Promise.resolve();
expect(recent).not.toHaveBeenCalled();
expect(favorites).not.toHaveBeenCalled();
openGate();
await reload;
expect(recent).toHaveBeenCalledTimes(1);
expect(favorites).toHaveBeenCalledTimes(1);
});
it('keeps dashboardReady false until xtream recently added finishes its first load', async () => {
expect(service.dashboardReady()).toBe(false);
@@ -20,6 +20,7 @@ import {
GlobalRecentlyAddedKind,
PlaylistsService,
RuntimeCapabilitiesService,
StartupDeferralService,
} from '@iptvnator/services';
import {
XTREAM_DATA_SOURCE,
@@ -116,6 +117,7 @@ export class DashboardDataService {
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly startupDeferral = inject(StartupDeferralService);
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
@@ -499,6 +501,19 @@ export class DashboardDataService {
readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4));
/**
* Page-entry reload of recent items and favorites. On launch it waits for
* the first rendered screen (journey J1) and the rails keep their
* skeletons meanwhile; later entries find the gate already open.
*/
async reloadForPageEntry(): Promise<void> {
await this.startupDeferral.whenFirstContentRendered();
await Promise.all([
this.reloadGlobalRecentItems(),
this.reloadGlobalFavorites(),
]);
}
async reloadGlobalRecentItems(): Promise<void> {
if (!this.globalRecentLoaded()) {
this.globalRecentLoadingState.set(true);
@@ -304,8 +304,7 @@ export class WorkspaceDashboardRailsComponent {
// Re-entering the dashboard should pick up any DB-backed recent/favorite
// changes made while viewing details, including newly backfilled
// backdrops that do not change recency ordering.
void this.data.reloadGlobalRecentItems();
void this.data.reloadGlobalFavorites();
void this.data.reloadForPageEntry();
this.liveEpg.connect(this.enabledLiveCards);