diff --git a/apps/web/src/app/services/parental-lock-prompt.service.ts b/apps/web/src/app/services/parental-lock-prompt.service.ts index 9d40f9c55..dda9ae499 100644 --- a/apps/web/src/app/services/parental-lock-prompt.service.ts +++ b/apps/web/src/app/services/parental-lock-prompt.service.ts @@ -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 { let module: Awaited>; + let dialog: Awaited>; 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; } diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index fa686c46e..c37d1f57b 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -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. diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index 3e7dc0921..c87c0765a 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -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( + load: Promise, + open: (dialog: MatDialog, module: T) => void + ): void { + void Promise.all([this.lazyDialog.resolve(), load]).then( + ([dialog, module]) => open(dialog, module) + ); + } } export function provideWorkspaceShellActions(): Provider[] { diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 6a9d99d97..cf42bd748 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts index 3dda815f8..c371430fd 100644 --- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts +++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts @@ -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(); + }); }); diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts index 4e2bf4bca..b459d3ff6 100644 --- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts +++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts @@ -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 { + let dialogModule: Awaited>; + let dialog: Awaited>; + 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)', }); diff --git a/libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.spec.ts b/libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.spec.ts new file mode 100644 index 000000000..b16c09ede --- /dev/null +++ b/libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.spec.ts @@ -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); + }); +}); diff --git a/libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.ts b/libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.ts new file mode 100644 index 000000000..4deee74e2 --- /dev/null +++ b/libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.ts @@ -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 | null = null; + + /** The dynamic import; a field so specs can substitute it. */ + loadDialogModule: () => Promise = () => + import('@angular/material/dialog'); + + resolve(): Promise { + 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; + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index f40422ba4..20bfd93af 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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" ],