mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
4e29bded5b
commit
5dbad2383f
16 files changed
+339
-23
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user