diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 440c44551..8c4557ebd 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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), 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 ce930aa9b..dae454396 100644 --- a/apps/web/src/app/app-startup-status.component.spec.ts +++ b/apps/web/src/app/app-startup-status.component.spec.ts @@ -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, diff --git a/apps/web/src/app/app-startup-status.component.ts b/apps/web/src/app/app-startup-status.component.ts index c841f93be..46375638c 100644 --- a/apps/web/src/app/app-startup-status.component.ts +++ b/apps/web/src/app/app-startup-status.component.ts @@ -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()); } 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 714510fb7..d92cc47d9 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,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 - ); - }); -}); 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 0f4b0756f..a99e814ad 100644 --- a/apps/web/src/app/app-update-notification-panel.component.ts +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -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 { diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 7b403525c..1fc0d8950 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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 diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index ba79b4bc4..41704612a 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -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 diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 158bf202b..352f8cb79 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index cf28059b5..1d88fc503 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -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=` 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 193f5b11b..7eaa9165c 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index f20f52a90..8595c4d2f 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -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; @@ -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(); diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 84ad72152..7decad581 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -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(); diff --git a/libs/services/src/lib/playlist-migration.spec.ts b/libs/services/src/lib/playlist-migration.spec.ts index 181c4ad43..ed66a5ac2 100644 --- a/libs/services/src/lib/playlist-migration.spec.ts +++ b/libs/services/src/lib/playlist-migration.spec.ts @@ -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 }; } diff --git a/libs/services/src/lib/playlists.service.inventory-read.spec.ts b/libs/services/src/lib/playlists.service.inventory-read.spec.ts index 6b6422e9f..127984a8a 100644 --- a/libs/services/src/lib/playlists.service.inventory-read.spec.ts +++ b/libs/services/src/lib/playlists.service.inventory-read.spec.ts @@ -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[]) => { diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index ef61e091d..172e2ef87 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -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: [], }); diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 3679c755b..2cc219fa3 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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 | 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(); + // XMLTV source reconciliation); both share one worker round trip. Only a + // pending read is shared, and every SQLite write detaches it. + private pendingMetas: Promise | null = null; // The IndexedDB → SQLite receipt is written once and never cleared. - private sqliteMigrationConfirmed = false; + private migrated = false; private indexedDbMigrationPromise: Promise | null = null; private readonly playlistWriteQueues = new Map>(); @@ -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({}); } diff --git a/libs/services/src/lib/shared-in-flight-read.ts b/libs/services/src/lib/shared-in-flight-read.ts deleted file mode 100644 index 3289dadc8..000000000 --- a/libs/services/src/lib/shared-in-flight-read.ts +++ /dev/null @@ -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 { - 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 deleted file mode 100644 index 2d339e7e9..000000000 --- a/libs/services/src/lib/startup-deferral.service.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/libs/services/src/lib/startup-deferral.service.ts b/libs/services/src/lib/startup-deferral.service.ts deleted file mode 100644 index 41e3e2d08..000000000 --- a/libs/services/src/lib/startup-deferral.service.ts +++ /dev/null @@ -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( - '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/services/src/test-setup.ts b/libs/services/src/test-setup.ts index 1ca0136b9..97f389d3a 100644 --- a/libs/services/src/test-setup.ts +++ b/libs/services/src/test-setup.ts @@ -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 ??= (value: T): T => + JSON.parse(JSON.stringify(value)) as T; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index ce1da491f..c9d3ecd95 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -710,11 +710,6 @@ 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 16743ba72..51dc5212c 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,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((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 59b748cb7..4471d1e2b 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,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 { - 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 63beeed7b..12e4d2c7f 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,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);