mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
perf(web): resolve MatDialog on first use in the services created at startup
Four root services imported MatDialog statically: the confirm dialog service, the player service (external player info), the parental lock prompt and the workspace shell actions (add playlist, account info). Each already loaded its dialog component on demand, but the service import alone kept @angular/material/dialog and @angular/cdk/dialog in the initial bundle. LazyMatDialog (@iptvnator/ui/components/lazy-dialog) imports the dialog module on first use and resolves the root MatDialog from it, retrying after a failed chunk load; the four services open their dialogs once both the service and the component's chunk are in. openConfirmDialog now returns a promise that settles when the dialog is open, logs a failed load and performs no action for it; its callers never used the return value. renderer.initialBytes: 1,463,083 -> 1,432,237 bytes (-30,846) on a local production build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
51361c90f4
commit
60bccc96e8
9 files changed
+192
-52
No files matched your search
@@ -1,22 +1,22 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
PARENTAL_LOCK_PROMPT,
|
||||
ParentalLockPrompt,
|
||||
ParentalLockPromptRequest,
|
||||
} from '@iptvnator/services';
|
||||
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
|
||||
|
||||
/**
|
||||
* Application implementation of the parental lock prompt: the shared PIN
|
||||
* dialog from `@iptvnator/ui/components`, which the data-access lock
|
||||
* 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).
|
||||
* service cannot import itself. The dialog, and Material's dialog service
|
||||
* with it, 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);
|
||||
private readonly lazyDialog = inject(LazyMatDialog);
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
|
||||
@@ -25,8 +25,12 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
|
||||
request: ParentalLockPromptRequest
|
||||
): Promise<string | null> {
|
||||
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
|
||||
let dialog: Awaited<ReturnType<LazyMatDialog['resolve']>>;
|
||||
try {
|
||||
module = await this.loadPinDialog();
|
||||
[dialog, module] = await Promise.all([
|
||||
this.lazyDialog.resolve(),
|
||||
this.loadPinDialog(),
|
||||
]);
|
||||
} catch (error) {
|
||||
// A failed chunk load reads as a cancelled prompt: the gate it
|
||||
// guards stays closed.
|
||||
@@ -36,17 +40,14 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const dialogRef = module.ParentalLockPinDialogComponent.open(
|
||||
this.dialog,
|
||||
{
|
||||
mode: request.mode,
|
||||
verify: request.verify,
|
||||
throttle: request.throttle,
|
||||
titleKey: request.titleKey,
|
||||
descriptionKey: request.descriptionKey,
|
||||
submitKey: request.submitKey,
|
||||
}
|
||||
);
|
||||
const dialogRef = module.ParentalLockPinDialogComponent.open(dialog, {
|
||||
mode: request.mode,
|
||||
verify: request.verify,
|
||||
throttle: request.throttle,
|
||||
titleKey: request.titleKey,
|
||||
descriptionKey: request.descriptionKey,
|
||||
submitKey: request.submitKey,
|
||||
});
|
||||
const pin = await firstValueFrom(dialogRef.afterClosed());
|
||||
return typeof pin === 'string' ? pin : null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
@@ -10,13 +9,14 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
||||
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PlayerService {
|
||||
private dialog = inject(MatDialog);
|
||||
private lazyDialog = inject(LazyMatDialog);
|
||||
private dataService = inject(DataService);
|
||||
private settingsStore = inject(SettingsStore);
|
||||
|
||||
@@ -85,12 +85,16 @@ export class PlayerService {
|
||||
/**
|
||||
* 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.
|
||||
* both, and Material's dialog, on the initial path. Playback does not
|
||||
* wait for the dialog.
|
||||
*/
|
||||
private showExternalPlayerInfoDialog(): void {
|
||||
this.loadExternalPlayerInfoDialog()
|
||||
.then(({ ExternalPlayerInfoDialogComponent }) =>
|
||||
this.dialog.open(ExternalPlayerInfoDialogComponent)
|
||||
Promise.all([
|
||||
this.lazyDialog.resolve(),
|
||||
this.loadExternalPlayerInfoDialog(),
|
||||
])
|
||||
.then(([dialog, { ExternalPlayerInfoDialogComponent }]) =>
|
||||
dialog.open(ExternalPlayerInfoDialogComponent)
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
// Informational only: playback is already starting.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import type { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util/tokens';
|
||||
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
WorkspaceAccountInfoData,
|
||||
@@ -10,15 +11,21 @@ import {
|
||||
WorkspaceStalkerAccountInfoData,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
|
||||
/**
|
||||
* Created with the app (the shell's keyboard shortcuts reach it from the
|
||||
* root), so every dialog it opens, and Material's dialog service itself,
|
||||
* is imported on first use.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly lazyDialog = inject(LazyMatDialog);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
openAddPlaylistDialog(type?: WorkspacePlaylistType): void {
|
||||
void import('@iptvnator/playlist/import/feature').then(
|
||||
({ AddPlaylistDialogComponent }) => {
|
||||
this.dialog.open(AddPlaylistDialogComponent, {
|
||||
this.openDialog(
|
||||
import('@iptvnator/playlist/import/feature'),
|
||||
(dialog, { AddPlaylistDialogComponent }) => {
|
||||
dialog.open(AddPlaylistDialogComponent, {
|
||||
// Width sized for the 5-card method picker plus the
|
||||
// selected method's form below. Matches the v0.22
|
||||
// mockup; falls back to viewport-clamped width on
|
||||
@@ -44,9 +51,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
}
|
||||
|
||||
openAccountInfo(data: WorkspaceAccountInfoData): void {
|
||||
void import('@iptvnator/portal/xtream/feature').then(
|
||||
({ AccountInfoComponent }) => {
|
||||
this.dialog.open(AccountInfoComponent, {
|
||||
this.openDialog(
|
||||
import('@iptvnator/portal/xtream/feature'),
|
||||
(dialog, { AccountInfoComponent }) => {
|
||||
dialog.open(AccountInfoComponent, {
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
@@ -57,9 +65,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
}
|
||||
|
||||
openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void {
|
||||
void import('@iptvnator/portal/stalker/feature').then(
|
||||
({ StalkerAccountInfoComponent }) => {
|
||||
this.dialog.open(StalkerAccountInfoComponent, {
|
||||
this.openDialog(
|
||||
import('@iptvnator/portal/stalker/feature'),
|
||||
(dialog, { StalkerAccountInfoComponent }) => {
|
||||
dialog.open(StalkerAccountInfoComponent, {
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
@@ -68,6 +77,16 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens once both the dialog service and the component's chunk are in. */
|
||||
private openDialog<T>(
|
||||
load: Promise<T>,
|
||||
open: (dialog: MatDialog, module: T) => void
|
||||
): void {
|
||||
void Promise.all([this.lazyDialog.resolve(), load]).then(
|
||||
([dialog, module]) => open(dialog, module)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function provideWorkspaceShellActions(): Provider[] {
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from './lib/content-hero/content-hero.component';
|
||||
export * from './lib/expandable-text/expandable-text.component';
|
||||
export * from './lib/external-playback-dock/external-playback-dock.component';
|
||||
export * from './lib/form-field/app-form-field.module';
|
||||
export * from './lib/lazy-dialog/lazy-mat-dialog';
|
||||
export * from './lib/portal-detail-shell/content-about.component';
|
||||
export * from './lib/portal-detail-shell/detail-template.directives';
|
||||
export * from './lib/portal-detail-shell/portal-detail-shell.component';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of } from 'rxjs';
|
||||
import { ConfirmDialogComponent } from './confirm-dialog.component';
|
||||
import { DialogService } from './dialog.service';
|
||||
|
||||
describe('DialogService', () => {
|
||||
@@ -25,12 +26,12 @@ describe('DialogService', () => {
|
||||
service = TestBed.inject(DialogService);
|
||||
});
|
||||
|
||||
it('opens the confirm dialog with the expected config', () => {
|
||||
it('opens the confirm dialog with the expected config once its chunk is in', async () => {
|
||||
dialog.open.mockReturnValue({
|
||||
afterClosed: () => of(false),
|
||||
});
|
||||
|
||||
service.openConfirmDialog({
|
||||
await service.openConfirmDialog({
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
@@ -38,7 +39,7 @@ describe('DialogService', () => {
|
||||
});
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
ConfirmDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
title: 'Remove',
|
||||
@@ -50,12 +51,12 @@ describe('DialogService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a custom dialog width when provided', () => {
|
||||
it('uses a custom dialog width when provided', async () => {
|
||||
dialog.open.mockReturnValue({
|
||||
afterClosed: () => of(false),
|
||||
});
|
||||
|
||||
service.openConfirmDialog({
|
||||
await service.openConfirmDialog({
|
||||
title: 'Refresh Xtream Playlist',
|
||||
message: 'Confirm refresh?',
|
||||
confirmLabel: 'Refresh playlist',
|
||||
@@ -71,13 +72,13 @@ describe('DialogService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('invokes onConfirm only after the dialog resolves truthy', () => {
|
||||
it('invokes onConfirm only after the dialog resolves truthy', async () => {
|
||||
const onConfirm = jest.fn();
|
||||
dialog.open.mockReturnValue({
|
||||
afterClosed: () => of(true),
|
||||
});
|
||||
|
||||
service.openConfirmDialog({
|
||||
await service.openConfirmDialog({
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
@@ -86,10 +87,10 @@ describe('DialogService', () => {
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not repeat a persistent dialog action on programmatic close', () => {
|
||||
it('does not repeat a persistent dialog action on programmatic close', async () => {
|
||||
const onConfirm = jest.fn();
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
service.openConfirmDialog({
|
||||
await service.openConfirmDialog({
|
||||
title: 'Recovery',
|
||||
message: 'Path',
|
||||
confirmLabel: 'Copy path',
|
||||
@@ -98,4 +99,25 @@ describe('DialogService', () => {
|
||||
});
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a failed chunk load and performs no action', async () => {
|
||||
const error = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const onConfirm = jest.fn();
|
||||
service.loadConfirmDialog = () =>
|
||||
Promise.reject(new Error('chunk failed'));
|
||||
|
||||
await service.openConfirmDialog({
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
error.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import {
|
||||
import { LazyMatDialog } from '../lazy-dialog/lazy-mat-dialog';
|
||||
import type {
|
||||
ConfirmDialogComponent,
|
||||
ConfirmDialogData,
|
||||
} from './confirm-dialog.component';
|
||||
@@ -9,20 +9,42 @@ import {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DialogService {
|
||||
private dialog = inject(MatDialog);
|
||||
private readonly lazyDialog = inject(LazyMatDialog);
|
||||
|
||||
/**
|
||||
* Opens the confirm dialog with provided data
|
||||
* The dynamic import; a field so specs can substitute it. The component
|
||||
* loads with the first confirmation: this service is created with the
|
||||
* app, so a static import would put the component, Material's dialog
|
||||
* and buttons on the initial path.
|
||||
*/
|
||||
loadConfirmDialog = () => import('./confirm-dialog.component');
|
||||
|
||||
/**
|
||||
* Opens the confirm dialog with provided data. The returned promise
|
||||
* settles once the dialog is open; a failed chunk load is logged, and
|
||||
* the action is not performed.
|
||||
* @param data dialog meta info
|
||||
*/
|
||||
openConfirmDialog(data: ConfirmDialogData): void {
|
||||
const dialogRef = this.dialog.open<
|
||||
async openConfirmDialog(data: ConfirmDialogData): Promise<void> {
|
||||
let dialogModule: Awaited<ReturnType<typeof this.loadConfirmDialog>>;
|
||||
let dialog: Awaited<ReturnType<LazyMatDialog['resolve']>>;
|
||||
try {
|
||||
[dialog, dialogModule] = await Promise.all([
|
||||
this.lazyDialog.resolve(),
|
||||
this.loadConfirmDialog(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Could not open the confirmation dialog:', error);
|
||||
return;
|
||||
}
|
||||
const dialogRef = dialog.open<
|
||||
ConfirmDialogComponent,
|
||||
ConfirmDialogData
|
||||
>(ConfirmDialogComponent, {
|
||||
>(dialogModule.ConfirmDialogComponent, {
|
||||
data,
|
||||
// Wide enough for two side-by-side action buttons with translated
|
||||
// labels; 300px forced them to wrap into a vertical stack.
|
||||
// Wide enough for two side-by-side action buttons with
|
||||
// translated labels; 300px forced them to wrap into a
|
||||
// vertical stack.
|
||||
width: data.width ?? '420px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { LazyMatDialog } from './lazy-mat-dialog';
|
||||
|
||||
describe('LazyMatDialog', () => {
|
||||
const dialog = { open: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: MatDialog, useValue: dialog }],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the root MatDialog from the dialog chunk once', async () => {
|
||||
const service = TestBed.inject(LazyMatDialog);
|
||||
const load = jest.spyOn(service, 'loadDialogModule');
|
||||
|
||||
await expect(service.resolve()).resolves.toBe(dialog);
|
||||
await expect(service.resolve()).resolves.toBe(dialog);
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries the chunk load after a failure instead of caching it', async () => {
|
||||
const service = TestBed.inject(LazyMatDialog);
|
||||
const original = service.loadDialogModule;
|
||||
service.loadDialogModule = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('chunk failed'))
|
||||
.mockImplementation(original);
|
||||
|
||||
await expect(service.resolve()).rejects.toThrow('chunk failed');
|
||||
await expect(service.resolve()).resolves.toBe(dialog);
|
||||
|
||||
expect(service.loadDialogModule).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EnvironmentInjector, inject, Injectable } from '@angular/core';
|
||||
import type { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
type DialogModule = typeof import('@angular/material/dialog');
|
||||
|
||||
/**
|
||||
* Resolves the root `MatDialog` on first use. `@angular/material/dialog`
|
||||
* and `@angular/cdk/dialog` share one bundler chunk, and one static import
|
||||
* from a service created with the app puts both on the initial path although
|
||||
* a dialog opens only after a user action. Services that exist at startup
|
||||
* resolve the dialog through this helper; a component in a lazy chunk can
|
||||
* inject `MatDialog` directly.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LazyMatDialog {
|
||||
private readonly injector = inject(EnvironmentInjector);
|
||||
private dialog: Promise<MatDialog> | null = null;
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadDialogModule: () => Promise<DialogModule> = () =>
|
||||
import('@angular/material/dialog');
|
||||
|
||||
resolve(): Promise<MatDialog> {
|
||||
this.dialog ??= this.loadDialogModule().then((module) =>
|
||||
this.injector.get(module.MatDialog)
|
||||
);
|
||||
// A failed chunk load must not poison later attempts.
|
||||
this.dialog.catch(() => (this.dialog = null));
|
||||
return this.dialog;
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,9 @@
|
||||
"@iptvnator/ui/components/form-field": [
|
||||
"libs/ui/components/src/lib/form-field/app-form-field.module.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/lazy-dialog": [
|
||||
"libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/parental-lock-pin-dialog": [
|
||||
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user