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

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

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

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

No files matched your search

@@ -6,6 +6,7 @@ import {
selectPlaylistsLoadFailed,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { StartupDeferralService } from '@iptvnator/services';
import { AppStartupStatusComponent } from './app-startup-status.component';
describe('AppStartupStatusComponent', () => {
@@ -48,6 +49,28 @@ describe('AppStartupStatusComponent', () => {
expect(fixture.componentInstance.complete()).toBe(true);
});
it('releases deferred startup work only after the completing render', async () => {
const mark = jest.spyOn(
TestBed.inject(StartupDeferralService),
'markFirstContentRendered'
);
const fixture = TestBed.createComponent(AppStartupStatusComponent);
fixture.componentRef.setInput('routeReady', true);
fixture.detectChanges();
await fixture.whenStable();
expect(mark).not.toHaveBeenCalled();
TestBed.inject(MockStore).overrideSelector(
selectPlaylistsLoadingFlag,
true
);
TestBed.inject(MockStore).refreshState();
fixture.detectChanges();
await fixture.whenStable();
expect(mark).toHaveBeenCalledTimes(1);
});
it('offers a retry on failure without rendering raw storage errors', () => {
TestBed.inject(MockStore).overrideSelector(
selectPlaylistsLoadFailed,
@@ -1,9 +1,13 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
Injector,
input,
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@@ -14,6 +18,7 @@ import {
selectPlaylistsLoadFailed,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { StartupDeferralService } from '@iptvnator/services';
/** The first route also waits for settings and XMLTV source reconciliation. */
@Component({
@@ -134,6 +139,22 @@ export class AppStartupStatusComponent {
() => this.routeReady() && this.sourcesReady()
);
constructor() {
const injector = inject(Injector);
const startupDeferral = inject(StartupDeferralService);
// The routed content is revealed in the render that completes the
// startup status; deferred startup work may start after it.
effect(() => {
if (!this.complete()) return;
untracked(() =>
afterNextRender(
() => startupDeferral.markFirstContentRendered(),
{ injector }
)
);
});
}
retry(): void {
this.store.dispatch(PlaylistActions.loadPlaylists());
}
@@ -5,6 +5,10 @@ import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import {
STARTUP_WORK_DEFERRAL,
StartupDeferralService,
} from '@iptvnator/services';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
@@ -166,3 +170,76 @@ describe('AppUpdateNotificationPanelComponent', () => {
open.mockRestore();
});
});
describe('AppUpdateNotificationPanelComponent at startup', () => {
const originalElectron = window.electron;
let statusHandler: ((status: ElectronBridgeAppUpdateStatus) => void) | null;
function setup() {
statusHandler = null;
window.electron = {
getAppUpdateStatus: jest.fn().mockResolvedValue(availableStatus),
onAppUpdateStatusChange: jest.fn((handler) => {
statusHandler = handler;
return jest.fn();
}),
} as unknown as typeof window.electron;
TestBed.configureTestingModule({
imports: [
AppUpdateNotificationPanelComponent,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
],
});
const fixture = TestBed.createComponent(
AppUpdateNotificationPanelComponent
);
fixture.detectChanges();
const openGate = async () => {
TestBed.inject(StartupDeferralService).markFirstContentRendered();
await new Promise((resolve) => setTimeout(resolve, 0));
await fixture.whenStable();
fixture.detectChanges();
};
return { fixture, openGate };
}
afterEach(() => {
window.electron = originalElectron;
});
it('reads the update status only after the first screen rendered', async () => {
const { fixture, openGate } = setup();
await fixture.whenStable();
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalled();
expect(window.electron.getAppUpdateStatus).not.toHaveBeenCalled();
await openGate();
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="app-update-notification"]'
)
).not.toBeNull();
});
it('keeps a status pushed while the read waited', async () => {
const { fixture, openGate } = setup();
statusHandler?.({
...availableStatus,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
await openGate();
expect(window.electron.getAppUpdateStatus).not.toHaveBeenCalled();
expect(fixture.componentInstance.status()?.status).toBe(
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
);
});
});
@@ -15,6 +15,7 @@ import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { StartupDeferralService } from '@iptvnator/services';
import { AppUpdateInstallService } from './services/app-update-install.service';
@Component({
@@ -180,6 +181,7 @@ import { AppUpdateInstallService } from './services/app-update-install.service';
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
private readonly dialog = inject(MatDialog);
private readonly installService = inject(AppUpdateInstallService);
private readonly startupDeferral = inject(StartupDeferralService);
private unsubscribeStatus: (() => void) | null = null;
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
@@ -223,14 +225,21 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
return;
}
let pushed = false;
this.unsubscribeStatus =
window.electron.onAppUpdateStatusChange?.((status) => {
pushed = true;
this.status.set(status);
}) ?? null;
void window.electron.getAppUpdateStatus().then((status) => {
this.status.set(status);
});
// The card is never part of the first screen (journey J1). A status
// pushed while the read waited is newer than the read would be.
void this.startupDeferral
.whenFirstContentRendered()
.then(() => (pushed ? null : window.electron.getAppUpdateStatus()))
.then((status) => {
if (status && !pushed) this.status.set(status);
});
}
ngOnDestroy(): void {
+4
View File
@@ -36,6 +36,7 @@ import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import {
provideParentalLockPlaylistCleanup,
DataService,
STARTUP_WORK_DEFERRAL,
} from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
import { AppConfig } from '../environments/environment';
@@ -111,6 +112,9 @@ export function DataFactory() {
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
// Startup work the first screen does not need waits for its render
// (journey J1); AppStartupStatusComponent opens the gate.
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
provideRouter(routes, withComponentInputBinding()),
provideAnimations(),
// CDK overlays (menus, tooltips, dialogs) live in a container under