mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user