perf(settings): keep the PIN dialog and the Stalker enforcement step off the initial path

Master (#1712) moved the UI component barrel and the Stalker data layer out
of main.js and tightened the initial budget to 2 MB. The parental-lock
prompt imported the PIN dialog through the ui/components barrel and the
enforcement service injected the Stalker store at startup, which pulled
both back in (2.55 MB, over budget). The PIN dialog now loads through a
local lazy file on the first prompt, and the Stalker step loads only while
a Stalker route is open: initial total 1.65 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 09:39:44 +02:00
1 parent c035085edf
commit 5c3e675b41
9 files changed
+206 -68

No files matched your search

@@ -120,6 +120,19 @@ describe('ParentalLockEnforcementService', () => {
});
describe('Stalker', () => {
it('does not load the Stalker step outside a Stalker route', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
const load = jest.spyOn(
service as unknown as { loadStalkerEnforcement: () => unknown },
'loadStalkerEnforcement'
);
await service.applyStalker();
expect(load).not.toHaveBeenCalled();
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
});
it('clears a detail opened from All whose own genre is withheld', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
@@ -1,19 +1,21 @@
import { effect, inject, Injectable, untracked } from '@angular/core';
import {
effect,
EnvironmentInjector,
inject,
Injectable,
untracked,
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import {
toParentalLockStalkerCategoryType,
toParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
const XTREAM_ROUTE =
/^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/;
const STALKER_ROUTE =
export const STALKER_ROUTE =
/^\/workspace\/stalker\/([^/?#]+)\/(itv|vod|series|radio)(?:\/([^/?#]+))?/;
/**
@@ -27,7 +29,7 @@ const STALKER_ROUTE =
export class ParentalLockEnforcementService {
private readonly parentalLock = inject(ParentalLockService);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly injector = inject(EnvironmentInjector);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly keepAwake = inject(PlaybackKeepAwakeService);
@@ -221,61 +223,27 @@ export class ParentalLockEnforcementService {
}
}
/** The dynamic import; a field so specs can substitute it. */
loadStalkerEnforcement = () =>
import('./parental-lock-stalker-enforcement');
/**
* The Stalker data layer stays off the initial path: the step is loaded
* only while a Stalker route is open. Outside one there is nothing to
* step off — the Stalker route session clears the selection (and the
* live layout its playback) when the route is left.
*/
private async applyStalker(): Promise<void> {
const playlist = this.stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (!playlistId) {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
const contentType = toParentalLockStalkerCategoryType(
this.stalkerStore.selectedContentType()
const { applyParentalLockToStalker } =
await this.loadStalkerEnforcement();
applyParentalLockToStalker(
this.injector,
this.parentalLock,
this.router
);
if (!contentType) {
return;
}
const selectedCategoryId = this.stalkerStore.selectedCategoryId();
const categoryWithheld =
!!selectedCategoryId &&
this.parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedCategoryId
);
// An item opened from "All" (`*`) or search has its own genre to be
// judged by; the list dropping its row is not enough. Live and radio
// rows carry that genre in `tv_genre_id`, VOD and series rows in
// `category_id` (the store's withheld filter applies the same rule).
const selectedItem = this.stalkerStore.selectedItem?.() as {
category_id?: string | number;
tv_genre_id?: string | number;
} | null;
const itemCategoryId =
contentType === 'itv' || contentType === 'radio'
? selectedItem?.tv_genre_id
: selectedItem?.category_id;
const itemWithheld =
!!selectedItem &&
this.parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
itemCategoryId
);
if (!categoryWithheld && !itemWithheld) {
return;
}
this.stalkerStore.clearSelectedItem();
if (categoryWithheld) {
this.stalkerStore.setSelectedCategory(null);
}
const match = STALKER_ROUTE.exec(this.router.url);
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'stalker',
match[1],
match[2],
]);
}
}
private applyM3u(): void {
@@ -0,0 +1,8 @@
/**
* Lazy boundary for the parental-lock prompt: the prompt service imports
* this file dynamically, so the PIN dialog and the @angular/forms and
* Material form-field modules it brings stay off the initial path. The
* boundary is a local file backed by a file-level alias, like the other
* root-shell lazy dialogs (see docs/architecture/nx-workspace-boundaries.md).
*/
export { ParentalLockPinDialogComponent } from '@iptvnator/ui/components/parental-lock-pin-dialog';
@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { AppParentalLockPromptService } from './parental-lock-prompt.service';
describe('AppParentalLockPromptService', () => {
const dialog = { open: jest.fn() };
let service: AppParentalLockPromptService;
beforeEach(() => {
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [
AppParentalLockPromptService,
{ provide: MatDialog, useValue: dialog },
],
});
service = TestBed.inject(AppParentalLockPromptService);
});
it('loads the PIN dialog on demand and returns the entered PIN', async () => {
const open = jest.fn(() => ({ afterClosed: () => of('1234') }));
service.loadPinDialog = jest.fn(async () => ({
ParentalLockPinDialogComponent: { open },
})) as never;
await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234');
expect(open).toHaveBeenCalledWith(
dialog,
expect.objectContaining({ mode: 'set' })
);
});
it('treats a failed dialog chunk load as a cancelled prompt', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
service.loadPinDialog = jest.fn(async () => {
throw new Error('ChunkLoadError');
}) as never;
await expect(
service.requestPin({ mode: 'unlock' })
).resolves.toBeNull();
});
});
@@ -6,26 +6,45 @@ import {
ParentalLockPrompt,
ParentalLockPromptRequest,
} from '@iptvnator/services';
import { ParentalLockPinDialogComponent } from '@iptvnator/ui/components';
/**
* Application implementation of the parental lock prompt: the shared PIN
* dialog from `@iptvnator/ui/components`, which the data-access lock
* service cannot import itself.
* service cannot import itself. The dialog is loaded on first use (the
* service is constructed at startup, the dialog is not needed until a PIN
* is asked for).
*/
@Injectable({ providedIn: 'root' })
export class AppParentalLockPromptService implements ParentalLockPrompt {
private readonly dialog = inject(MatDialog);
/** The dynamic import; a field so specs can substitute it. */
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
async requestPin(
request: ParentalLockPromptRequest
): Promise<string | null> {
const dialogRef = ParentalLockPinDialogComponent.open(this.dialog, {
mode: request.mode,
verify: request.verify,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
});
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
try {
module = await this.loadPinDialog();
} catch (error) {
// A failed chunk load reads as a cancelled prompt: the gate it
// guards stays closed.
console.error(
'The parental lock PIN dialog could not be loaded.',
error
);
return null;
}
const dialogRef = module.ParentalLockPinDialogComponent.open(
this.dialog,
{
mode: request.mode,
verify: request.verify,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
}
);
const pin = await firstValueFrom(dialogRef.afterClosed());
return typeof pin === 'string' ? pin : null;
}
@@ -0,0 +1,69 @@
import { Injector } from '@angular/core';
import { Router } from '@angular/router';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { toParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
import { STALKER_ROUTE } from './parental-lock-enforcement.service';
/**
* The Stalker step of `ParentalLockEnforcementService`, in its own file so
* the enforcement service can import it dynamically: a static import of the
* Stalker store would put the whole Stalker data layer back on the initial
* path (see docs/architecture/nx-workspace-boundaries.md).
*/
export function applyParentalLockToStalker(
injector: Injector,
parentalLock: ParentalLockService,
router: Router
): void {
const stalkerStore = injector.get(StalkerStore);
const playlist = stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (!playlistId) {
return;
}
const contentType = toParentalLockStalkerCategoryType(
stalkerStore.selectedContentType()
);
if (!contentType) {
return;
}
const selectedCategoryId = stalkerStore.selectedCategoryId();
const categoryWithheld =
!!selectedCategoryId &&
parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedCategoryId
);
// An item opened from "All" (`*`) or search has its own genre to be
// judged by; the list dropping its row is not enough. Live and radio
// rows carry that genre in `tv_genre_id`, VOD and series rows in
// `category_id` (the store's withheld filter applies the same rule).
const selectedItem = stalkerStore.selectedItem?.() as {
category_id?: string | number;
tv_genre_id?: string | number;
} | null;
const itemCategoryId =
contentType === 'itv' || contentType === 'radio'
? selectedItem?.tv_genre_id
: selectedItem?.category_id;
const itemWithheld =
!!selectedItem &&
parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
itemCategoryId
);
if (!categoryWithheld && !itemWithheld) {
return;
}
stalkerStore.clearSelectedItem();
if (categoryWithheld) {
stalkerStore.setSelectedCategory(null);
}
const match = STALKER_ROUTE.exec(router.url);
if (match && match[1] === playlistId) {
void router.navigate(['/workspace', 'stalker', match[1], match[2]]);
}
}
+3 -1
View File
@@ -248,7 +248,9 @@ entries: `@iptvnator/ui/components/window-controls`,
`@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,
the release-notes dialog, the external-player info dialog, the parental-lock
PIN dialog and the Stalker step of the parental-lock enforcement, which runs
only while a Stalker route is open). 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
+12
View File
@@ -344,6 +344,18 @@ on either side.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
## Startup footprint
The lock service, lock store and enforcement are on the renderer's initial
path by necessity (the workspace resolver and the catalog data sources gate
on them). Everything else stays lazy: the PIN dialog loads through
`parental-lock-pin-dialog.lazy.ts` on the first prompt (a failed chunk load
reads as a cancelled prompt), and the enforcement's Stalker step loads
through `parental-lock-stalker-enforcement.ts` only while a Stalker route is
open — a static import of the Stalker store would put the whole Stalker
data layer back into `main.js`. The feature costs about 30 KB of
`renderer.initialBytes`.
## Lock store lifetime
Every lock-store mutation runs through one write queue in
+3
View File
@@ -87,6 +87,9 @@
"@iptvnator/ui/components/confirm-dialog": [
"libs/ui/components/src/lib/confirm-dialog/dialog.service.ts"
],
"@iptvnator/ui/components/parental-lock-pin-dialog": [
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
],
"@iptvnator/ui/components/window-controls": [
"libs/ui/components/src/lib/window-controls/window-controls.component.ts"
],