From e5ee064407e6dcf68f5fa0ccf2be894505920bd5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 00:03:38 +0200 Subject: [PATCH] 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 --- .changes/workspace-startup-first-card-ipc.md | 6 + .../src/app/api/main.preload.ts | 8 ++ .../app/app-startup-status.component.spec.ts | 23 ++++ .../src/app/app-startup-status.component.ts | 21 +++ ...pdate-notification-panel.component.spec.ts | 77 +++++++++++ ...app-update-notification-panel.component.ts | 15 ++- apps/web/src/app/app.config.ts | 4 + docs/architecture/performance-journeys.md | 29 +++++ docs/architecture/workspace-dashboard.md | 6 +- docs/development/electron-debugging.md | 1 + libs/services/src/index.ts | 1 + .../src/lib/downloads.service.spec.ts | 43 +++++++ libs/services/src/lib/downloads.service.ts | 6 + .../src/lib/playlist-migration.spec.ts | 2 + .../playlists.service.inventory-read.spec.ts | 120 ++++++++++++++++++ .../src/lib/playlists.service.spec.ts | 3 + libs/services/src/lib/playlists.service.ts | 53 +++++--- .../services/src/lib/shared-in-flight-read.ts | 52 ++++++++ .../src/lib/startup-deferral.service.spec.ts | 80 ++++++++++++ .../src/lib/startup-deferral.service.ts | 68 ++++++++++ .../src/lib/electron-api.interface.ts | 5 + .../src/lib/dashboard-data.service.spec.ts | 30 ++++- .../src/lib/dashboard-data.service.ts | 15 +++ .../workspace-dashboard-rails.component.ts | 3 +- 24 files changed, 648 insertions(+), 23 deletions(-) create mode 100644 .changes/workspace-startup-first-card-ipc.md create mode 100644 libs/services/src/lib/playlists.service.inventory-read.spec.ts create mode 100644 libs/services/src/lib/shared-in-flight-read.ts create mode 100644 libs/services/src/lib/startup-deferral.service.spec.ts create mode 100644 libs/services/src/lib/startup-deferral.service.ts diff --git a/.changes/workspace-startup-first-card-ipc.md b/.changes/workspace-startup-first-card-ipc.md new file mode 100644 index 000000000..a6e91feb9 --- /dev/null +++ b/.changes/workspace-startup-first-card-ipc.md @@ -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. diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 8c4557ebd..440c44551 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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), diff --git a/apps/web/src/app/app-startup-status.component.spec.ts b/apps/web/src/app/app-startup-status.component.spec.ts index dae454396..ce930aa9b 100644 --- a/apps/web/src/app/app-startup-status.component.spec.ts +++ b/apps/web/src/app/app-startup-status.component.spec.ts @@ -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, diff --git a/apps/web/src/app/app-startup-status.component.ts b/apps/web/src/app/app-startup-status.component.ts index 46375638c..c841f93be 100644 --- a/apps/web/src/app/app-startup-status.component.ts +++ b/apps/web/src/app/app-startup-status.component.ts @@ -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()); } diff --git a/apps/web/src/app/app-update-notification-panel.component.spec.ts b/apps/web/src/app/app-update-notification-panel.component.spec.ts index d92cc47d9..714510fb7 100644 --- a/apps/web/src/app/app-update-notification-panel.component.spec.ts +++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts @@ -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 + ); + }); +}); diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts index a99e814ad..0f4b0756f 100644 --- a/apps/web/src/app/app-update-notification-panel.component.ts +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -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 { diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 1fc0d8950..7b403525c 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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 diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index a6d59b474..ba79b4bc4 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -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 diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 352f8cb79..158bf202b 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index 1d88fc503..cf28059b5 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -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=` 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 diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 7eaa9165c..193f5b11b 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index 8595c4d2f..f20f52a90 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -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; @@ -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(); diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 7decad581..84ad72152 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -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(); diff --git a/libs/services/src/lib/playlist-migration.spec.ts b/libs/services/src/lib/playlist-migration.spec.ts index a68e974ee..181c4ad43 100644 --- a/libs/services/src/lib/playlist-migration.spec.ts +++ b/libs/services/src/lib/playlist-migration.spec.ts @@ -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 }; } diff --git a/libs/services/src/lib/playlists.service.inventory-read.spec.ts b/libs/services/src/lib/playlists.service.inventory-read.spec.ts new file mode 100644 index 000000000..a429e7adb --- /dev/null +++ b/libs/services/src/lib/playlists.service.inventory-read.spec.ts @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; + } + + function setup() { + const reads: ReturnType>[] = []; + 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(); + 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); + }); +}); diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 71a4961f6..ef61e091d 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -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: [], }); diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 9a3069fc9..1ccc170bd 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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 | 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(); + // The IndexedDB → SQLite receipt is written once and never cleared. + private sqliteMigrationConfirmed = false; private indexedDbMigrationPromise: Promise | null = null; private readonly playlistWriteQueues = new Map>(); @@ -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(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({}); } diff --git a/libs/services/src/lib/shared-in-flight-read.ts b/libs/services/src/lib/shared-in-flight-read.ts new file mode 100644 index 000000000..3289dadc8 --- /dev/null +++ b/libs/services/src/lib/shared-in-flight-read.ts @@ -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 { + private pending: Promise | null = null; + + constructor(private readonly copy: (value: T) => T = copyPlainData) {} + + run(read: () => Promise): Promise { + 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(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 = {}; + for (const [key, item] of Object.entries(value)) { + copy[key] = copyPlainData(item); + } + return copy as T; + } + return value; +} diff --git a/libs/services/src/lib/startup-deferral.service.spec.ts b/libs/services/src/lib/startup-deferral.service.spec.ts new file mode 100644 index 000000000..2d339e7e9 --- /dev/null +++ b/libs/services/src/lib/startup-deferral.service.spec.ts @@ -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); + }); +}); diff --git a/libs/services/src/lib/startup-deferral.service.ts b/libs/services/src/lib/startup-deferral.service.ts new file mode 100644 index 000000000..41e3e2d08 --- /dev/null +++ b/libs/services/src/lib/startup-deferral.service.ts @@ -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( + '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((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 { + 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 + ); +} diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index c9d3ecd95..ce1da491f 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -710,6 +710,11 @@ export interface ElectronBridgeApi { ) => () => void; getAppVersion: () => Promise; 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; checkForAppUpdate: () => Promise; downloadAppUpdate: () => Promise; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 51dc5212c..16743ba72 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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((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); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 4471d1e2b..59b748cb7 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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 { + await this.startupDeferral.whenFirstContentRendered(); + await Promise.all([ + this.reloadGlobalRecentItems(), + this.reloadGlobalFavorites(), + ]); + } + async reloadGlobalRecentItems(): Promise { if (!this.globalRecentLoaded()) { this.globalRecentLoadingState.set(true); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 12e4d2c7f..63beeed7b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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);