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

+8
View File
@@ -0,0 +1,8 @@
---
type: perf
area: web
---
The app now loads about 40% less code before it shows its first screen:
channel lists, program guide views, Stalker portal tools and a few dialogs
are loaded when they are first needed instead of at startup.
+4 -4
View File
@@ -57,8 +57,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "4.5mb",
"maximumError": "5mb"
"maximumWarning": "1.8mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
@@ -88,8 +88,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "4.5mb",
"maximumError": "5mb"
"maximumWarning": "1.8mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
@@ -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,
@@ -236,6 +236,26 @@ and value-imports those two dependency-free modules directly.
reason: the main `workspace-shell-util` barrel is imported eagerly, and the
settings search index must stay in the lazy settings and shell chunks.
The web app's root shell (`app.component.ts`, `app.config.ts` and the services
they construct) is on the renderer's initial path, where a barrel costs its
whole library: esbuild keeps every Angular component module a barrel
re-exports, because their static definitions count as side effects. Importing
`WindowControlsComponent` and `DialogService` from `@iptvnator/ui/components`
once put the channel lists, EPG views, `@angular/forms`, `date-fns` and the
Stalker data layer into `main.js`. The root shell therefore uses file-level
entries: `@iptvnator/ui/components/window-controls`,
`@iptvnator/ui/components/confirm-dialog` and
`@iptvnator/playlist/shared/ui/stalker-connection-editor` (like the existing
`@iptvnator/ui/epg/progress-panel`), and loads anything used only on demand
through a local file it imports dynamically (the Stalker connection editor,
the release-notes dialog, the external-player info dialog). A local file,
not the library alias, is the dynamic-import target because
`@nx/enforce-module-boundaries` forbids static imports of a library the same
project also loads dynamically. `renderer.initialBytes` in
[performance journeys](performance-journeys.md) guards the result; to see why a
module is eager, build with `pnpm nx build web --stats-json` and follow the
static imports in `dist/apps/web/stats.json` from `apps/web/src/main.ts`.
For a buildable library that has a local `package.json`, its `name` must match
the scoped alias. Nx uses that package name when rewriting buildable dependency
paths to `dist/` during `@nx/js:tsc` builds.
+3 -3
View File
@@ -3,10 +3,10 @@
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 2715087,
"value": 1626019,
"unit": "bytes",
"updatedAt": "2026-09-26",
"evidencePr": 1695,
"updatedAt": "2026-09-27",
"evidencePr": 1712,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
}
}
+9
View File
@@ -37,6 +37,9 @@
"@iptvnator/playlist/shared/ui": [
"libs/playlist/shared/ui/src/index.ts"
],
"@iptvnator/playlist/shared/ui/stalker-connection-editor": [
"libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token.ts"
],
"@iptvnator/playlist/shared/util": [
"libs/playlist/shared/util/src/index.ts"
],
@@ -81,6 +84,12 @@
],
"@iptvnator/pipes": ["libs/ui/pipes/src/index.ts"],
"@iptvnator/ui/components": ["libs/ui/components/src/index.ts"],
"@iptvnator/ui/components/confirm-dialog": [
"libs/ui/components/src/lib/confirm-dialog/dialog.service.ts"
],
"@iptvnator/ui/components/window-controls": [
"libs/ui/components/src/lib/window-controls/window-controls.component.ts"
],
"@iptvnator/m3u-state": ["libs/m3u-state/src/index.ts"],
"@iptvnator/shared/m3u-utils": [
"libs/shared/m3u-utils/src/index.ts"