perf(services): keep only the shared startup inventory read (J1)

Drop the startup deferral gate, its kill switch and the deferred loads:
they lowered renderer.ipcCallsToFirstCard but moved neither
spawnToFirstCardMs nor load->card beyond drift, and they grew
renderer.initialBytes. Keep the shared in-flight inventory read, inline
it in PlaylistsService with short property names, and copy the joiner's
result with structuredClone (the Electron renderer has it; the services
test setup polyfills it for jsdom).

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 0a5d5a984f
commit 8a33516c31
24 files changed
+64 -504

No files matched your search

@@ -122,11 +122,6 @@ 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';
@@ -417,9 +412,6 @@ 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,7 +6,6 @@ import {
selectPlaylistsLoadFailed,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { StartupDeferralService } from '@iptvnator/services';
import { AppStartupStatusComponent } from './app-startup-status.component';
describe('AppStartupStatusComponent', () => {
@@ -49,28 +48,6 @@ 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,13 +1,9 @@
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';
@@ -18,7 +14,6 @@ import {
selectPlaylistsLoadFailed,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { StartupDeferralService } from '@iptvnator/services';
/** The first route also waits for settings and XMLTV source reconciliation. */
@Component({
@@ -139,22 +134,6 @@ 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,10 +5,6 @@ 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';
@@ -170,76 +166,3 @@ 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,7 +15,6 @@ 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({
@@ -181,7 +180,6 @@ 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;
@@ -225,21 +223,14 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
return;
}
let pushed = false;
this.unsubscribeStatus =
window.electron.onAppUpdateStatusChange?.((status) => {
pushed = true;
this.status.set(status);
}) ?? null;
// 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);
});
void window.electron.getAppUpdateStatus().then((status) => {
this.status.set(status);
});
}
ngOnDestroy(): void {
-4
View File
@@ -36,7 +36,6 @@ 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';
@@ -112,9 +111,6 @@ 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
+13 -17
View File
@@ -182,31 +182,27 @@ harness, which is what the ratchet needs. The main process start
### 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:
process before the first card.
- `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.
that follows a write reads again. This took the counter from 12 to 7 in
the #1716 profile... see the validation note below.
- `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.
keeps XMLTV lookups from returning data of a removed source.
Validation (#1716, Principle 3): deferring the download list, update status
and dashboard recent/favorites reads until after the first render lowered
the counter by four more, but moved neither `spawnToFirstCardMs` nor
load→card beyond run-to-run drift on a quiet machine, and it grew
`renderer.initialBytes`, so it was dropped. Those calls were never on the
path the first card waits for. That path is a serial chain of round trips
(the migration reads, the inventory read and `reconcileEpgSources`), so a
serial-depth counter is a better guardrail candidate than a raw call count.
### Summary schema
+1 -5
View File
@@ -282,11 +282,7 @@ 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. 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.
added rows on page entry.
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,7 +34,6 @@ 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,7 +16,6 @@ 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,10 +15,6 @@ 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[]>;
@@ -154,7 +150,6 @@ describe('DownloadsService', () => {
const injector = createEnvironmentInjector(
[
DownloadsService,
StartupDeferralService,
{
provide: RuntimeCapabilitiesService,
useValue: { supportsDownloads: false },
@@ -175,44 +170,6 @@ 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,7 +7,6 @@ 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,
@@ -18,7 +17,6 @@ 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();
@@ -81,10 +79,6 @@ 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,6 +1,5 @@
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;
@@ -39,7 +38,7 @@ describe('Electron legacy playlist migration', () => {
dbService,
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
pendingMetas: null,
});
return { playlists, dbService, electron, service };
}
@@ -1,7 +1,6 @@
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
@@ -44,8 +43,8 @@ describe('PlaylistsService inventory reads', () => {
dbService: { getAll: jest.fn(() => of([])) },
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
sqliteMigrationConfirmed: false,
pendingMetas: null,
migrated: false,
playlistWriteQueues: new Map(),
});
const settle = async (index: number, playlists: Playlist[]) => {
@@ -1,7 +1,6 @@
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 =
@@ -61,8 +60,8 @@ describe('PlaylistsService', () => {
},
electronMigrationPromise: null,
indexedDbMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
sqliteMigrationConfirmed: false,
pendingMetas: null,
migrated: false,
playlistWriteQueues: new Map(),
playlistDeleteCleanups: [],
});
+34 -28
View File
@@ -45,7 +45,6 @@ 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 =
@@ -120,10 +119,11 @@ export class PlaylistsService {
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[]>();
// XMLTV source reconciliation); both share one worker round trip. Only a
// pending read is shared, and every SQLite write detaches it.
private pendingMetas: Promise<Playlist[]> | null = null;
// The IndexedDB → SQLite receipt is written once and never cleared.
private sqliteMigrationConfirmed = false;
private migrated = false;
private indexedDbMigrationPromise: Promise<void> | null = null;
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
@@ -204,7 +204,7 @@ export class PlaylistsService {
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
'1'
) {
this.sqliteMigrationConfirmed = true;
this.migrated = true;
return;
}
const playlists = await firstValueFrom(
@@ -214,10 +214,10 @@ export class PlaylistsService {
// The worker commits rows and the receipt atomically. Keep the
// original IndexedDB as a recovery source, even after success.
const result = await electron.dbMigrateAppPlaylists(playlists);
this.sqliteMigrationConfirmed = result?.success === true;
this.migrated = result?.success === true;
} else {
await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
this.sqliteMigrationConfirmed = true;
this.migrated = true;
}
}
@@ -409,7 +409,7 @@ export class PlaylistsService {
return playlist;
}
this.sqliteInventoryRead.detach();
this.pendingMetas = null;
if (operationId === undefined) {
await electron.dbUpsertAppPlaylist(playlist);
} else {
@@ -427,7 +427,7 @@ export class PlaylistsService {
return playlists;
}
this.sqliteInventoryRead.detach();
this.pendingMetas = null;
await electron.dbUpsertAppPlaylists(playlists);
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
return playlists;
@@ -506,22 +506,28 @@ export class PlaylistsService {
getAllPlaylists() {
if (this.isElectronStorageAvailable) {
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)
);
})
)
)
const pending = this.pendingMetas;
// A joining caller gets its own copy of the shared result.
if (pending) return from(pending.then((p) => structuredClone(p)));
const read = 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)
);
})
);
this.pendingMetas = read;
const settle = () => {
if (this.pendingMetas === read)
this.pendingMetas = null;
};
read.then(settle, settle);
return from(read);
}
return this.runOnIndexedDb(() =>
@@ -575,7 +581,7 @@ export class PlaylistsService {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
this.sqliteInventoryRead.detach();
this.pendingMetas = null;
if (options) {
const deleted =
await this.databaseService.deletePlaylist(
@@ -1093,14 +1099,14 @@ export class PlaylistsService {
return from(
(async () => {
if (!this.sqliteMigrationConfirmed) {
if (!this.migrated) {
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
}
this.sqliteMigrationConfirmed = true;
this.migrated = true;
}
return getFavoriteChannels(playlistId);
@@ -1350,7 +1356,7 @@ export class PlaylistsService {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
this.sqliteInventoryRead.detach();
this.pendingMetas = null;
await electron.dbDeleteAllPlaylists();
this.healthEvidence?.connections.next({});
}
@@ -1,52 +0,0 @@
/**
* 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;
}
@@ -1,80 +0,0 @@
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);
});
});
@@ -1,68 +0,0 @@
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
);
}
+5
View File
@@ -7,3 +7,8 @@ setupZonelessTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
// jsdom lacks structuredClone; Electron's renderer, the only runtime that
// reads playlists from SQLite, has it.
globalThis.structuredClone ??= <T>(value: T): T =>
JSON.parse(JSON.stringify(value)) as T;
@@ -710,11 +710,6 @@ 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,11 +7,7 @@ import {
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of, Subject } from 'rxjs';
import {
DatabaseService,
PlaylistsService,
StartupDeferralService,
} from '@iptvnator/services';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
PlaybackPositionData,
Playlist,
@@ -274,30 +270,6 @@ 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,7 +20,6 @@ import {
GlobalRecentlyAddedKind,
PlaylistsService,
RuntimeCapabilitiesService,
StartupDeferralService,
} from '@iptvnator/services';
import {
XTREAM_DATA_SOURCE,
@@ -117,7 +116,6 @@ 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);
@@ -501,19 +499,6 @@ 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,7 +304,8 @@ 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.reloadForPageEntry();
void this.data.reloadGlobalRecentItems();
void this.data.reloadGlobalFavorites();
this.liveEpg.connect(this.enabledLiveCards);