perf(web): keep channel lists, EPG views and the Stalker layer off the initial path (#1712)

The root shell imported WindowControlsComponent and DialogService through the @iptvnator/ui/components barrel, and esbuild keeps every Angular component module a barrel re-exports, so channel lists, EPG views, @angular/forms, date-fns and the whole Stalker data layer sat in main.js. The shell now uses file-level entries, the Stalker connection editor is a lazy proxy, and the release-notes and external-player info dialogs load on demand with a handled failure path.

renderer.initialBytes 2,714,336 -> 1,626,019 bytes (-40%); the baseline is lowered to the ubuntu ratchet measurement and the production/PWA initial budgets drop to 1.8/2 MB. J1: did-finish-load about -16 ms, first card within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-09-27 08:48:01 +02:00
1 parent 4e29bded5b
commit 5dbad2383f
16 files changed
+339 -23

No files matched your search

@@ -73,7 +73,7 @@ describe('AppUpdateNotificationPanelComponent', () => {
).not.toBeNull();
});
it('opens release notes without dismissing the notification', () => {
it('opens release notes without dismissing the notification', async () => {
statusHandler?.(availableStatus);
fixture.detectChanges();
@@ -82,6 +82,9 @@ describe('AppUpdateNotificationPanelComponent', () => {
'[data-test-id="app-update-notification-release-notes"]'
) as HTMLButtonElement
).click();
// The dialog component is imported on demand (it pulls in `marked`).
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
expect(TestBed.inject(MatDialog).open).toHaveBeenCalledWith(
AppUpdateReleaseNotesDialogComponent,
@@ -96,6 +99,29 @@ describe('AppUpdateNotificationPanelComponent', () => {
).not.toBeNull();
});
it('falls back to the releases page when the release notes dialog fails to load', async () => {
const open = jest.spyOn(window, 'open').mockReturnValue(null);
const error = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
fixture.componentInstance.loadReleaseNotesDialog = () =>
Promise.reject(new Error('chunk failed'));
statusHandler?.(availableStatus);
fixture.detectChanges();
await fixture.componentInstance.openReleaseNotes();
expect(TestBed.inject(MatDialog).open).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledWith(
availableStatus.manualDownloadUrl,
'_blank',
'noreferrer'
);
expect(error).toHaveBeenCalledTimes(1);
open.mockRestore();
error.mockRestore();
});
it('starts downloading the update from the notification action', async () => {
statusHandler?.(availableStatus);
fixture.detectChanges();
@@ -16,7 +16,6 @@ import {
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateInstallService } from './services/app-update-install.service';
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
@Component({
selector: 'app-update-notification-panel',
@@ -239,8 +238,31 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
this.unsubscribeStatus = null;
}
openReleaseNotes(): void {
const latestVersion = this.status()?.latestVersion;
/**
* Loaded on demand: the dialog renders Markdown with `marked`, which this
* always-mounted panel would otherwise put on the initial path. A field so
* specs can substitute it.
*/
loadReleaseNotesDialog = () =>
import('./settings/app-update-release-notes-dialog.component');
async openReleaseNotes(): Promise<void> {
const status = this.status();
const latestVersion = status?.latestVersion;
let dialogModule: Awaited<
ReturnType<typeof this.loadReleaseNotesDialog>
>;
try {
dialogModule = await this.loadReleaseNotesDialog();
} catch (error) {
// The same notes are on the releases page; the next click retries.
console.error('Could not load the release notes dialog:', error);
if (status?.manualDownloadUrl) {
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
}
return;
}
const { AppUpdateReleaseNotesDialogComponent } = dialogModule;
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
+3 -1
View File
@@ -21,7 +21,9 @@ import {
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel';
import { WindowControlsComponent } from '@iptvnator/ui/components';
// File-level entry: the @iptvnator/ui/components barrel would put the whole
// library (channel lists, EPG, forms, date-fns) on the initial path.
import { WindowControlsComponent } from '@iptvnator/ui/components/window-controls';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { filter, take } from 'rxjs';
import {
+3 -3
View File
@@ -31,7 +31,7 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui';
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui/stalker-connection-editor';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
@@ -43,7 +43,7 @@ import { PlayerService } from './services/player.service';
import { providePortalPlaybackPositions } from './services/portal-playback-positions.service';
import { PwaService } from './services/pwa.service';
import { shouldEnableServiceWorker } from './services/runtime-config';
import { AppStalkerPlaylistConnectionEditorService } from './services/stalker-playlist-connection-editor.service';
import { LazyStalkerPlaylistConnectionEditor } from './services/lazy-stalker-playlist-connection-editor';
import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service';
// AoT requires an exported function for factories
@@ -157,7 +157,7 @@ export const appConfig: ApplicationConfig = {
...providePortalPlaybackPositions(),
{
provide: STALKER_PLAYLIST_CONNECTION_EDITOR,
useExisting: AppStalkerPlaylistConnectionEditorService,
useExisting: LazyStalkerPlaylistConnectionEditor,
},
...provideWorkspaceShellActions(),
...provideXtreamDataSource(),
@@ -3,7 +3,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { DialogService } from '@iptvnator/ui/components';
import { DialogService } from '@iptvnator/ui/components/confirm-dialog';
import {
DataService,
SettingsStore,
@@ -0,0 +1,9 @@
/**
* Lazy boundary for PlayerService: the service imports this file dynamically,
* so the dialog and the @angular/forms it brings (via the Material checkbox)
* stay off the initial path. The boundary is a local file rather than the
* library path because other web files, such as specs, import ui-playback
* statically, which @nx/enforce-module-boundaries forbids for a library the
* project also loads dynamically.
*/
export { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog';
@@ -0,0 +1,89 @@
import { Injectable } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import type {
StalkerPlaylistConnectionEditor,
StalkerPlaylistConnectionResult,
} from '@iptvnator/playlist/shared/ui/stalker-connection-editor';
import type {
PlaylistMeta,
PlaylistMetaUpdate,
} from '@iptvnator/shared/interfaces';
import { LazyStalkerPlaylistConnectionEditor } from './lazy-stalker-playlist-connection-editor';
const resolved: StalkerPlaylistConnectionResult = {
status: 'resolved',
playlist: { _id: 'p1' } as PlaylistMetaUpdate,
};
@Injectable({ providedIn: 'root' })
class FakeEditor implements StalkerPlaylistConnectionEditor {
resolveConnection = jest.fn(async () => resolved);
applyResolvedConnection = jest.fn(
async (playlist: PlaylistMetaUpdate) => playlist
);
}
type EditorModule = Awaited<
ReturnType<LazyStalkerPlaylistConnectionEditor['loadEditorModule']>
>;
function moduleWith(editorClass: typeof FakeEditor): EditorModule {
return {
AppStalkerPlaylistConnectionEditorService: editorClass,
} as unknown as EditorModule;
}
describe('LazyStalkerPlaylistConnectionEditor', () => {
let lazy: LazyStalkerPlaylistConnectionEditor;
beforeEach(() => {
TestBed.configureTestingModule({});
lazy = TestBed.inject(LazyStalkerPlaylistConnectionEditor);
});
it('loads nothing until an editor method is called', () => {
const load = jest.fn(async () => moduleWith(FakeEditor));
lazy.loadEditorModule = load;
expect(load).not.toHaveBeenCalled();
});
it('delegates both methods to the root-provided implementation, loading it once', async () => {
const load = jest.fn(async () => moduleWith(FakeEditor));
lazy.loadEditorModule = load;
const playlist = { _id: 'p1' } as PlaylistMeta;
const source = { _id: 'p0' } as PlaylistMeta;
const update = { _id: 'p1', title: 'Renamed' } as PlaylistMetaUpdate;
await expect(lazy.resolveConnection(playlist, source)).resolves.toBe(
resolved
);
await expect(
lazy.applyResolvedConnection(update, {
preserveCurrentMetadata: true,
})
).resolves.toBe(update);
const editor = TestBed.inject(FakeEditor);
expect(editor.resolveConnection).toHaveBeenCalledWith(playlist, source);
expect(editor.applyResolvedConnection).toHaveBeenCalledWith(update, {
preserveCurrentMetadata: true,
});
expect(load).toHaveBeenCalledTimes(1);
});
it('retries the import after a failed chunk load', async () => {
const load = jest
.fn<Promise<EditorModule>, []>()
.mockRejectedValueOnce(new Error('chunk failed'))
.mockResolvedValueOnce(moduleWith(FakeEditor));
lazy.loadEditorModule = load;
const playlist = { _id: 'p1' } as PlaylistMeta;
await expect(lazy.resolveConnection(playlist)).rejects.toThrow(
'chunk failed'
);
await expect(lazy.resolveConnection(playlist)).resolves.toBe(resolved);
expect(load).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,56 @@
import { EnvironmentInjector, inject, Injectable } from '@angular/core';
import type {
StalkerPlaylistConnectionEditor,
StalkerPlaylistConnectionResult,
StalkerResolvedConnectionApplyOptions,
} from '@iptvnator/playlist/shared/ui/stalker-connection-editor';
import type {
PlaylistMeta,
PlaylistMetaUpdate,
} from '@iptvnator/shared/interfaces';
type EditorModule =
typeof import('./stalker-playlist-connection-editor.service');
/**
* Registered as STALKER_PLAYLIST_CONNECTION_EDITOR in app.config. The real
* editor depends on the whole Stalker portal data layer, which would
* otherwise be evaluated before the first paint although it is only needed
* when a user edits or re-checks a Stalker source. Every editor method is
* asynchronous, so the implementation is imported on first use and resolved
* from the root injector (it is providedIn: 'root').
*/
@Injectable({ providedIn: 'root' })
export class LazyStalkerPlaylistConnectionEditor implements StalkerPlaylistConnectionEditor {
private readonly injector = inject(EnvironmentInjector);
private editor: Promise<StalkerPlaylistConnectionEditor> | null = null;
/** The dynamic import; a field so specs can substitute it. */
loadEditorModule: () => Promise<EditorModule> = () =>
import('./stalker-playlist-connection-editor.service');
async resolveConnection(
playlist: PlaylistMeta,
sourcePlaylist?: PlaylistMeta
): Promise<StalkerPlaylistConnectionResult> {
const editor = await this.resolveEditor();
return editor.resolveConnection(playlist, sourcePlaylist);
}
async applyResolvedConnection(
playlist: PlaylistMetaUpdate,
options?: StalkerResolvedConnectionApplyOptions
): Promise<PlaylistMetaUpdate> {
const editor = await this.resolveEditor();
return editor.applyResolvedConnection(playlist, options);
}
private resolveEditor(): Promise<StalkerPlaylistConnectionEditor> {
this.editor ??= this.loadEditorModule().then((module) =>
this.injector.get(module.AppStalkerPlaylistConnectionEditorService)
);
// A failed chunk load must not poison later attempts.
this.editor.catch(() => (this.editor = null));
return this.editor;
}
}
@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { DataService } from '@iptvnator/services';
import { ExternalPlayerSession, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
ExternalPlayerSession,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from './settings-store.service';
import { PlayerService } from './player.service';
@@ -92,6 +95,57 @@ describe('PlayerService', () => {
expect(result).toEqual(session);
});
it('opens the external-player info dialog once its chunk has loaded', async () => {
settingsStore.player.mockReturnValue(VideoPlayer.MPV);
dataService.sendIpcEvent.mockResolvedValue(undefined);
await service.openResolvedPlayback(
{ streamUrl: 'https://example.com/video.mp4', title: 'Example' },
false
);
await new Promise((resolve) => setTimeout(resolve));
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
'OPEN_MPV_PLAYER',
expect.anything()
);
});
it('still starts playback and logs when the info dialog fails to load', async () => {
const error = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const unhandled = jest.fn();
process.on('unhandledRejection', unhandled);
try {
settingsStore.player.mockReturnValue(VideoPlayer.VLC);
dataService.sendIpcEvent.mockResolvedValue(undefined);
service.loadExternalPlayerInfoDialog = () =>
Promise.reject(new Error('chunk failed'));
await service.openResolvedPlayback(
{
streamUrl: 'https://example.com/video.mp4',
title: 'Example',
},
false
);
await new Promise((resolve) => setTimeout(resolve));
expect(dialog.open).not.toHaveBeenCalled();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
'OPEN_VLC_PLAYER',
expect.anything()
);
expect(error).toHaveBeenCalledTimes(1);
expect(unhandled).not.toHaveBeenCalled();
} finally {
process.off('unhandledRejection', unhandled);
error.mockRestore();
}
});
it('forces external playback without changing the selected embedded player', async () => {
const session: ExternalPlayerSession = {
id: 'session-2',
+26 -5
View File
@@ -1,6 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog';
import { DataService } from '@iptvnator/services';
import {
ExternalPlayerSession,
@@ -70,12 +69,12 @@ export class PlayerService {
if (player === VideoPlayer.MPV) {
if (!hideExternalInfoDialog) {
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.showExternalPlayerInfoDialog();
}
return await this.openExternalPlayback(playback, 'mpv');
} else if (player === VideoPlayer.VLC) {
if (!hideExternalInfoDialog) {
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.showExternalPlayerInfoDialog();
}
return await this.openExternalPlayback(playback, 'vlc');
}
@@ -83,12 +82,34 @@ export class PlayerService {
return;
}
/**
* Loaded on demand: the dialog's Material checkbox brings @angular/forms,
* and this service is created with the app, so a static import would put
* both on the initial path. Playback does not wait for the dialog.
*/
private showExternalPlayerInfoDialog(): void {
this.loadExternalPlayerInfoDialog()
.then(({ ExternalPlayerInfoDialogComponent }) =>
this.dialog.open(ExternalPlayerInfoDialogComponent)
)
.catch((error: unknown) => {
// Informational only: playback is already starting.
console.error(
'Could not load the external player info dialog:',
error
);
});
}
/** The dynamic import; a field so specs can substitute it. */
loadExternalPlayerInfoDialog = () =>
import('./external-player-info-dialog.lazy');
async openExternalPlayback(
playback: ResolvedPortalPlayback,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
const ipcEvent =
player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER;
const ipcEvent = player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER;
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
ipcEvent,
@@ -15,7 +15,7 @@ import {
type StalkerPlaylistConnectionEditor,
type StalkerPlaylistConnectionResult,
type StalkerResolvedConnectionApplyOptions,
} from '@iptvnator/playlist/shared/ui';
} from '@iptvnator/playlist/shared/ui/stalker-connection-editor';
import {
normalizeStalkerPortalIdentity,
type Playlist,