diff --git a/.changes/web-lazy-snack-bar.md b/.changes/web-lazy-snack-bar.md new file mode 100644 index 000000000..dc2e59326 --- /dev/null +++ b/.changes/web-lazy-snack-bar.md @@ -0,0 +1,8 @@ +--- +type: perf +area: web +--- + +The app loads about 8% less code before it shows its first screen: the toast +messages and the buttons that come with them now load the first time one is +shown, so every launch has less to download and parse. diff --git a/apps/web/src/app/app-startup-status.component.spec.ts b/apps/web/src/app/app-startup-status.component.spec.ts index 76e56762b..1b026395f 100644 --- a/apps/web/src/app/app-startup-status.component.spec.ts +++ b/apps/web/src/app/app-startup-status.component.spec.ts @@ -1,4 +1,9 @@ -import { TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + DeferBlockBehavior, + DeferBlockState, + TestBed, +} from '@angular/core/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { @@ -13,6 +18,7 @@ describe('AppStartupStatusComponent', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [AppStartupStatusComponent, TranslateModule.forRoot()], + deferBlockBehavior: DeferBlockBehavior.Manual, providers: [ provideRouter([{ path: 'server-sign-in', children: [] }]), provideMockStore({ @@ -67,19 +73,54 @@ describe('AppStartupStatusComponent', () => { expect(fixture.nativeElement.querySelector('section')).toBeNull(); }); - it('offers a retry on failure without rendering raw storage errors', () => { + function createFailedFixture() { TestBed.inject(MockStore).overrideSelector( selectPlaylistsLoadFailed, true ); const fixture = TestBed.createComponent(AppStartupStatusComponent); fixture.detectChanges(); + return fixture; + } + + function expectRetryDispatch( + fixture: ComponentFixture, + material: boolean + ) { + const buttons: HTMLButtonElement[] = Array.from( + fixture.nativeElement.querySelectorAll('button') + ); + expect(buttons).toHaveLength(1); + expect(buttons[0].classList.contains('mat-mdc-unelevated-button')).toBe( + material + ); + const dispatch = jest.spyOn(TestBed.inject(MockStore), 'dispatch'); + buttons[0].click(); + expect(dispatch).toHaveBeenCalledWith(PlaylistActions.loadPlaylists()); + } + + it('offers a retry on failure without rendering raw storage errors', () => { + const fixture = createFailedFixture(); expect( fixture.nativeElement.querySelector('[role="alert"]') ).not.toBeNull(); expect(fixture.nativeElement.querySelector('mat-spinner')).toBeNull(); - const dispatch = jest.spyOn(TestBed.inject(MockStore), 'dispatch'); - fixture.nativeElement.querySelector('button').click(); - expect(dispatch).toHaveBeenCalledWith(PlaylistActions.loadPlaylists()); + // Material's button loads with the failure branch, not the first + // screen; the placeholder already retries while it downloads. + expectRetryDispatch(fixture, false); + }); + + it('swaps in the Material button once its chunk has loaded', async () => { + const fixture = createFailedFixture(); + const [retryBlock] = await fixture.getDeferBlocks(); + await retryBlock.render(DeferBlockState.Complete); + expectRetryDispatch(fixture, true); + }); + + it('keeps the native retry button when the button chunk fails to load', async () => { + const fixture = createFailedFixture(); + const [retryBlock] = await fixture.getDeferBlocks(); + await retryBlock.render(DeferBlockState.Error); + expectRetryDispatch(fixture, false); }); }); diff --git a/apps/web/src/app/app-startup-status.component.ts b/apps/web/src/app/app-startup-status.component.ts index b965ad015..cf37b18c3 100644 --- a/apps/web/src/app/app-startup-status.component.ts +++ b/apps/web/src/app/app-startup-status.component.ts @@ -5,8 +5,9 @@ import { inject, input, } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; import { toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; +import { MatButton } from '@angular/material/button'; import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -22,7 +23,7 @@ import { SERVER_SIGN_IN_PATH } from '@iptvnator/services'; @Component({ selector: 'app-startup-status', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatButtonModule, TranslatePipe], + imports: [MatButton, NgTemplateOutlet, TranslatePipe], template: ` @if (!complete()) {
@if (failed()) { - + + @defer (on immediate) { + + } @placeholder { + + } @error { + + } + + + }
} @@ -157,6 +179,19 @@ import { SERVER_SIGN_IN_PATH } from '@iptvnator/services'; margin-top: 8px; app-region: no-drag; } + /* The flat button's shape in the theme's colours, until the + Material button loads. */ + .startup-retry-native { + height: 40px; + padding: 0 24px; + border: 0; + border-radius: 20px; + background: var(--mat-sys-primary); + color: var(--mat-sys-on-primary); + font: inherit; + font-weight: 500; + cursor: pointer; + } /* * The same drawing as an indeterminate diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index e8e80fb61..be2237dcc 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -1,6 +1,6 @@ import { EventEmitter } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { Router } from '@angular/router'; import { Actions } from '@ngrx/effects'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; @@ -85,7 +85,7 @@ describe('AppComponent', () => { let epgService: EpgService; let router: Router; let settingsService: MockSettingsService; - let snackBar: MatSnackBar; + let snackBar: LazySnackBar; let store: MockStore; let translateService: TranslateService; let runtimeCapabilities: Partial; @@ -148,8 +148,8 @@ describe('AppComponent', () => { provide: WorkspaceShellContextDrawerService, useValue: { isOpen: () => false }, }, - MockProvider(MatSnackBar, { - open: jest.fn(), + MockProvider(LazySnackBar, { + open: jest.fn().mockResolvedValue(null), }), MockProvider(TranslateService, { instant: jest.fn((key: string) => key), @@ -192,7 +192,7 @@ describe('AppComponent', () => { settingsService = TestBed.inject( SettingsService ) as unknown as MockSettingsService; - snackBar = TestBed.inject(MatSnackBar); + snackBar = TestBed.inject(LazySnackBar); store = TestBed.inject(MockStore); translateService = TestBed.inject(TranslateService); component = fixture.componentInstance; diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 5332fcc7a..af0d0ce6f 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -6,7 +6,7 @@ import { signal, ChangeDetectionStrategy, } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { Router, RouterOutlet } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; @@ -73,7 +73,7 @@ export class AppComponent implements OnInit { private readonly dateLocales = inject(AppDateLocaleService); private epgBridge = inject(EpgRuntimeBridgeService); private epgService = inject(EpgService); - private snackBar = inject(MatSnackBar); + private snackBar = inject(LazySnackBar); private router = inject(Router); private store = inject(Store); private translate = inject(TranslateService); @@ -245,7 +245,7 @@ export class AppComponent implements OnInit { ); // Show snackbar if all EPG sources are fresh (no stale URLs) if (result.staleUrls.length === 0) { - this.snackBar.open( + void this.snackBar.open( this.translate.instant('EPG.UP_TO_DATE'), this.translate.instant('CLOSE'), { duration: 3000 } diff --git a/apps/web/src/app/services/electron.service.performance.spec.ts b/apps/web/src/app/services/electron.service.performance.spec.ts index a7e10ffe2..956bf879e 100644 --- a/apps/web/src/app/services/electron.service.performance.spec.ts +++ b/apps/web/src/app/services/electron.service.performance.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; @@ -41,8 +41,8 @@ describe('ElectronService performance phases', () => { useValue: { openConfirmDialog: jest.fn() }, }, { - provide: MatSnackBar, - useValue: { open: jest.fn() }, + provide: LazySnackBar, + useValue: { open: jest.fn().mockResolvedValue(null) }, }, { provide: SettingsStore, diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 93bc14def..a4890afb2 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -1,9 +1,9 @@ import { SourceActivityService } from '@iptvnator/services'; import { TestBed } from '@angular/core/testing'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { DialogService } from '@iptvnator/ui/components'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { SettingsStore } from '@iptvnator/services'; @@ -54,9 +54,9 @@ describe('ElectronService', () => { openInVlc: jest.fn().mockResolvedValue(session), }; snackBar = { - open: jest.fn(() => ({ - onAction: () => of(undefined), - })), + open: jest.fn(() => + Promise.resolve({ onAction: () => of(undefined) }) + ), }; store = { dispatch: jest.fn() }; translateService = { instant: jest.fn((key: string) => key) }; @@ -70,7 +70,7 @@ describe('ElectronService', () => { providers: [ ElectronService, { - provide: MatSnackBar, + provide: LazySnackBar, useValue: snackBar, }, { @@ -507,6 +507,62 @@ describe('ElectronService', () => { ); }); + it('opens the trust-host confirmation from the toast action and retries after approval', async () => { + let showToast!: (ref: { + onAction: () => Observable; + }) => void; + snackBar.open.mockImplementation( + () => new Promise((resolve) => (showToast = resolve)) + ); + const securityPayload = { + code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, + host: 'playlist.local', + message: 'Certificate for this playlist host is invalid.', + }; + electronBridge.fetchPlaylistByUrl.mockRejectedValue( + new Error( + `Error invoking remote method 'FETCH_PLAYLIST_BY_URL': Error: ${SECURITY_ERROR_PREFIX}${JSON.stringify( + securityPayload + )}` + ) + ); + const dialogService = TestBed.inject(DialogService) as unknown as { + openConfirmDialog: jest.Mock; + }; + const settingsStore = TestBed.inject(SettingsStore) as unknown as { + updateSettings: jest.Mock; + }; + + await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { + url: 'https://playlist.local/list.m3u', + }); + await new Promise((resolve) => setTimeout(resolve)); + + // Nothing happens before the toast chunk has loaded. + expect(dialogService.openConfirmDialog).not.toHaveBeenCalled(); + + showToast({ onAction: () => of(undefined) }); + await new Promise((resolve) => setTimeout(resolve)); + + expect(dialogService.openConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE', + confirmLabel: 'HOME.URL_UPLOAD.TRUST_TLS_HOST', + }) + ); + + dialogService.openConfirmDialog.mock.calls[0][0].onConfirm(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + trustedInsecureTlsHosts: ['playlist.local'], + }); + expect(electronBridge.fetchPlaylistByUrl).toHaveBeenCalledTimes(2); + expect(electronBridge.fetchPlaylistByUrl.mock.calls[1][0]).toBe( + 'https://playlist.local/list.m3u' + ); + }); + it('preserves an absent MPV user-agent so backend fallback headers can apply', async () => { await service.sendIpcEvent('OPEN_MPV_PLAYER', { url: 'https://example.test/live.m3u8', diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 46ec142e2..314c005da 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; @@ -70,7 +70,7 @@ export class ElectronService extends DataService { private eventListeners: { [key: string]: () => void } = {}; private messageListeners = new Map(); private readonly sourceActivity = inject(SourceActivityService); - private readonly snackBar = inject(MatSnackBar); + private readonly snackBar = inject(LazySnackBar); private readonly dialogService = inject(DialogService); private readonly store = inject(Store); private readonly settingsStore = inject(SettingsStore); @@ -97,7 +97,7 @@ export class ElectronService extends DataService { if (window.electron?.onPlayerError) { window.electron.onPlayerError((data: ElectronBridgePlayerError) => { this.logger.error(`${data.player} Error:`, data.error); - this.snackBar.open( + void this.snackBar.open( this.translateService.instant( data.code ? EXTERNAL_PLAYER_ERROR_KEYS[data.code] @@ -116,7 +116,7 @@ export class ElectronService extends DataService { private showPlayerLaunchError(player: 'MPV' | 'VLC', error: unknown): void { const code = readExternalPlayerErrorCode(error); - this.snackBar.open( + void this.snackBar.open( this.translateService.instant( code ? EXTERNAL_PLAYER_ERROR_KEYS[code] @@ -283,7 +283,7 @@ export class ElectronService extends DataService { } const feedback = buildAutoUpdatePlaylistsFeedback(result); - this.snackBar.open( + void this.snackBar.open( this.translateService.instant(feedback.messageKey, feedback.params), feedback.isError ? this.translateService.instant('CLOSE') @@ -340,7 +340,7 @@ export class ElectronService extends DataService { const errorInfo = this.getErrorDetails(err); this.logger.error('Stalker request error:', err); if (!payload.silent) { - this.snackBar.open( + void this.snackBar.open( this.translateService.instant( 'PORTALS.REQUEST_ERRORS.STALKER', { @@ -404,7 +404,7 @@ export class ElectronService extends DataService { } else if (statusCode === 401) { messageKey = 'HOME.URL_UPLOAD.ERROR_401'; } - this.snackBar.open( + void this.snackBar.open( this.translateService.instant(messageKey), this.translateService.instant('CLOSE'), { duration: 5000 } @@ -471,7 +471,7 @@ export class ElectronService extends DataService { }) ); - this.snackBar.open( + void this.snackBar.open( this.translateService.instant( 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' ), @@ -488,7 +488,7 @@ export class ElectronService extends DataService { ) { return; } - this.snackBar.open( + void this.snackBar.open( this.getPlaylistRefreshErrorMessage(error, data), this.translateService.instant('CLOSE'), { duration: 5000 } @@ -553,15 +553,19 @@ export class ElectronService extends DataService { return false; } - const ref = this.snackBar.open( - this.translateService.instant('HOME.URL_UPLOAD.ERROR_INVALID_TLS'), - this.translateService.instant('HOME.URL_UPLOAD.TRUST_TLS_HOST'), - { duration: 10000 } - ); - - ref.onAction().subscribe(() => { - this.confirmTrustPlaylistHost(securityError.host, retry); - }); + void this.snackBar + .open( + this.translateService.instant( + 'HOME.URL_UPLOAD.ERROR_INVALID_TLS' + ), + this.translateService.instant('HOME.URL_UPLOAD.TRUST_TLS_HOST'), + { duration: 10000 } + ) + .then((ref) => + ref?.onAction().subscribe(() => { + this.confirmTrustPlaylistHost(securityError.host, retry); + }) + ); return true; } @@ -570,7 +574,7 @@ export class ElectronService extends DataService { retry: () => void ): void { if (!host) { - this.snackBar.open( + void this.snackBar.open( this.translateService.instant( 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN' ), @@ -693,7 +697,7 @@ export class ElectronService extends DataService { // Only show snackbar for user-triggered Xtream requests if (!isSilentAction) { - this.snackBar.open( + void this.snackBar.open( this.translateService.instant( 'PORTALS.REQUEST_ERRORS.XTREAM', { message: normalizedMessage } diff --git a/apps/web/src/app/services/playlist-open-request.service.spec.ts b/apps/web/src/app/services/playlist-open-request.service.spec.ts index 5c37e74f3..e7d598eb7 100644 --- a/apps/web/src/app/services/playlist-open-request.service.spec.ts +++ b/apps/web/src/app/services/playlist-open-request.service.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util'; import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces'; @@ -40,7 +40,7 @@ describe('PlaylistOpenRequestService', () => { title: 'Playlist', }), }; - snackBar = { open: jest.fn() }; + snackBar = { open: jest.fn().mockResolvedValue(null) }; bridge = { acknowledgePlaylistOpenRequest: jest .fn() @@ -59,7 +59,7 @@ describe('PlaylistOpenRequestService', () => { providers: [ PlaylistOpenRequestService, { provide: PlaylistFileImportService, useValue: importService }, - { provide: MatSnackBar, useValue: snackBar }, + { provide: LazySnackBar, useValue: snackBar }, { provide: TranslateService, useValue: { instant: (key: string) => key }, @@ -101,7 +101,9 @@ describe('PlaylistOpenRequestService', () => { // `send()` returns before the listener runs, so the main process holds the // request until this confirms a live renderer got it. it('acknowledges a request as soon as it arrives', async () => { - importService.importFromPath.mockReturnValue(new Promise(() => undefined)); + importService.importFromPath.mockReturnValue( + new Promise(() => undefined) + ); service.start(); pushRequest(request('startup')); diff --git a/apps/web/src/app/services/playlist-open-request.service.ts b/apps/web/src/app/services/playlist-open-request.service.ts index 6b1be9fcc..5b17f0996 100644 --- a/apps/web/src/app/services/playlist-open-request.service.ts +++ b/apps/web/src/app/services/playlist-open-request.service.ts @@ -1,5 +1,5 @@ import { Injectable, inject } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util/playlist-file-import'; import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces'; @@ -24,7 +24,7 @@ type PlaylistOpenRequestBridge = { @Injectable({ providedIn: 'root' }) export class PlaylistOpenRequestService { private readonly importService = inject(PlaylistFileImportService); - private readonly snackBar = inject(MatSnackBar); + private readonly snackBar = inject(LazySnackBar); private readonly translate = inject(TranslateService); private started = false; @@ -102,7 +102,7 @@ export class PlaylistOpenRequestService { return; } - this.snackBar.open( + void this.snackBar.open( this.translate.instant('HOME.FILE_UPLOAD.OPEN_FAILED', { filename: request.fileName || request.filePath, }), diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index 6dba8849a..50909606d 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -3,11 +3,11 @@ import { HttpTestingController, } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { config as rxjsConfig, EMPTY } from 'rxjs'; +import { config as rxjsConfig, Observable, of, Subject } from 'rxjs'; import { buildHostConnectivityFastFailMessage, CONNECTIVITY_GUARD_RESET, @@ -30,8 +30,10 @@ import { PwaService } from './pwa.service'; describe('PwaService', () => { let http: HttpTestingController; let service: PwaService; + let versionUpdates: Subject; beforeEach(() => { + versionUpdates = new Subject(); jest.spyOn(console, 'log').mockImplementation(() => undefined); TestBed.configureTestingModule({ @@ -39,9 +41,9 @@ describe('PwaService', () => { providers: [ PwaService, { - provide: MatSnackBar, + provide: LazySnackBar, useValue: { - open: jest.fn(), + open: jest.fn().mockResolvedValue(null), }, }, { @@ -53,7 +55,7 @@ describe('PwaService', () => { { provide: SwUpdate, useValue: { - versionUpdates: EMPTY, + versionUpdates, }, }, { @@ -74,6 +76,29 @@ describe('PwaService', () => { jest.restoreAllMocks(); }); + it('reloads the page from the update toast once the toast is open and its action is chosen', async () => { + let showToast!: (ref: { + onAction: () => Observable; + }) => void; + const open = TestBed.inject(LazySnackBar).open as jest.Mock; + open.mockImplementation( + () => new Promise((resolve) => (showToast = resolve)) + ); + service.reloadWindow = jest.fn(); + + service.checkUpdates(); + versionUpdates.next(); + + expect(open).toHaveBeenCalledWith('UPDATE_AVAILABLE', 'REFRESH'); + expect(service.reloadWindow).not.toHaveBeenCalled(); + + // The toast chunk arrives, the user picks Refresh. + showToast({ onAction: () => of(undefined) }); + await new Promise((resolve) => setTimeout(resolve)); + + expect(service.reloadWindow).toHaveBeenCalledTimes(1); + }); + it('passes explicit connection-test evidence through without a playback response', async () => { const failure = { connectionFailure: { kind: 'connection', canTryHttp: true }, @@ -91,7 +116,7 @@ describe('PwaService', () => { expect(request.request.params.get('connectionTest')).toBe('true'); request.flush(failure); expect(await pending).toEqual(failure); - expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + expect(TestBed.inject(LazySnackBar).open).not.toHaveBeenCalled(); }); it('ignores URL imports without a payload or URL instead of calling the backend', () => { @@ -196,7 +221,7 @@ describe('PwaService', () => { ); await new Promise((resolve) => setTimeout(resolve)); - expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( + expect(TestBed.inject(LazySnackBar).open).toHaveBeenCalledWith( 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED (ETIMEDOUT)', 'CLOSE', { duration: 5000 } @@ -248,7 +273,7 @@ describe('PwaService', () => { ); await expect(pending).resolves.toBeUndefined(); - expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( + expect(TestBed.inject(LazySnackBar).open).toHaveBeenCalledWith( expect.any(String), 'CLOSE', { duration: 5000 } @@ -282,7 +307,7 @@ describe('PwaService', () => { ); await new Promise((resolve) => setTimeout(resolve)); - expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( + expect(TestBed.inject(LazySnackBar).open).toHaveBeenCalledWith( 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED (ENETUNREACH)', 'CLOSE', { duration: 5000 } @@ -328,7 +353,7 @@ describe('PwaService', () => { expect(error.message).toBe('HTTP Error 404: Not Found'); expect(error.status).toBe(404); // silent flag: discovery probes expect failures — no snackbar. - expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + expect(TestBed.inject(LazySnackBar).open).not.toHaveBeenCalled(); globalThis.fetch = originalFetch; }); diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 53583bfc7..689e6e952 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar'; import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; @@ -85,7 +85,7 @@ interface ProviderTargetRegistration { export class PwaService extends DataService { private messageListeners = new Map(); private readonly http = inject(HttpClient); - private readonly snackBar = inject(MatSnackBar); + private readonly snackBar = inject(LazySnackBar); private readonly store = inject(Store); private readonly swUpdate = inject(SwUpdate); private readonly translateService = inject(TranslateService); @@ -116,18 +116,20 @@ export class PwaService extends DataService { super(); } + /** The page reload; a field so specs can substitute it. */ + reloadWindow: () => void = () => window.location.reload(); + /** Uses service worker mechanism to check for available application updates */ checkUpdates() { this.swUpdate.versionUpdates.subscribe(() => { - this.snackBar + void this.snackBar .open( this.translateService.instant('UPDATE_AVAILABLE'), this.translateService.instant('REFRESH') ) - .onAction() - .subscribe(() => { - window.location.reload(); - }); + .then((ref) => + ref?.onAction().subscribe(() => this.reloadWindow()) + ); }); } @@ -253,7 +255,7 @@ export class PwaService extends DataService { }) ); - this.snackBar.open( + void this.snackBar.open( this.translateService.instant( 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' ), @@ -264,7 +266,7 @@ export class PwaService extends DataService { error: (error: unknown) => { // Redacted: the error can carry the playlist URL. this.logger.error('Playlist refresh error:', error); - this.snackBar.open( + void this.snackBar.open( this.appendProviderErrorCode( this.getPlaylistRefreshErrorMessage(error), error @@ -314,7 +316,7 @@ export class PwaService extends DataService { this.getPlaylistFromUrl(payload.url, payload.userAgent) .pipe( catchError((error) => { - this.snackBar.open( + void this.snackBar.open( this.appendProviderErrorCode( this.getErrorMessageByStatusCode( this.extractHttpStatusCode(error) @@ -527,7 +529,7 @@ export class PwaService extends DataService { } this.logger.error('Xtream request error:', normalizedMessage); - this.snackBar.open( + void this.snackBar.open( this.translateService.instant('PORTALS.REQUEST_ERRORS.XTREAM', { message: normalizedMessage, }), @@ -721,7 +723,7 @@ export class PwaService extends DataService { this.logger.error('Stalker request error:', err); if (!payload.silent) { - this.snackBar.open( + void this.snackBar.open( this.translateService.instant( 'PORTALS.REQUEST_ERRORS.STALKER', { diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index f17f94521..928e9540f 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -167,10 +167,10 @@ picker and the rating chip keep their look. `renderer.initialBytes` ([performance journeys](performance-journeys.md#rendererinitialbytes)) counts every module an eager file imports, and the bundler keeps a module in -one chunk, so one import of one export carries the whole module. Material -therefore reaches the initial bundle only through the snack-bar, which root -services inject (and with it `button`, the icon button and the CDK overlay). -Everything else is lazy by these rules: +one chunk, so one import of one export carries the whole module. No Material +module is in the initial bundle; of the CDK only the overlay stays, because +`app.config.ts` provides `FullscreenOverlayContainer` and that token lives +in the overlay chunk. Everything else is lazy by these rules: - **Form-field defaults.** A component that renders `` adds `providers: [provideFormFieldDefaults()]` @@ -186,11 +186,23 @@ Everything else is lazy by these rules: and the workspace shell actions do; `openConfirmDialog` returns a promise that settles once the dialog is open. A component in a lazy chunk injects `MatDialog` directly. +- **Toasts from services created with the app.** A root service or the app + shell opens its snack-bars through `LazySnackBar` + (`@iptvnator/services/lazy-snack-bar`), whose `open` takes + `MatSnackBar.open`'s arguments and returns a promise of the ref, or of + `null` after a failed chunk load, which it logs without the message (a + toast can carry a playlist URL). `snack-bar.mjs` statically imports + Material's button, the icon button chunk, ripple and the CDK a11y module, + so one eager `MatSnackBar` injection put about 110 KB on the initial + path. A component in a lazy chunk injects `MatSnackBar` directly; the + initial-bytes ratchet fails on a new eager importer. - **Panels the shell mounts on every launch.** The EPG progress panel and the update notification are an eager host with `@defer (when visible)` around the view that imports Material; the startup status draws its spinner as - static markup copied from `mat-spinner`. A new always-mounted component - follows the same split. + static markup copied from `mat-spinner` and renders its Retry button in + `@defer (on immediate)` inside the failure branch, with a native button + as the placeholder and the error view so a retry never waits for the + chunk. A new always-mounted component follows the same split. - **Theme scope.** `m3-theme.scss` themes only the components in its `$material-components` list, through `meta.apply` for both the light theme and the dark colours. Add an entry with a new `@angular/material/` diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index cc211beb1..401745eba 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1134,7 +1134,22 @@ The bytes a browser fetches before Angular can bootstrap, read from the built - `index.html` itself, - every same-origin `', + 'main.js': + 'import"./chunk-hinted.js";import{x}from"./chunk-unhinted.js";const lazy=()=>import("./chunk-lazy.js");', + 'chunk-hinted.js': 'export const h=1;', + 'chunk-unhinted.js': 'import"./chunk-deep.js";export const x=1;', + 'chunk-deep.js': 'export const d=1;', + 'chunk-lazy.js': 'export const never=1;', + }); + const measurement = await measureInitialBytes({ distDir }); + const counted = Object.fromEntries( + measurement.resources.map((resource) => [resource.path, resource.kind]) + ); + assert.deepEqual(counted, { + 'chunk-hinted.js': 'modulepreload', + 'main.js': 'script', + 'chunk-unhinted.js': 'staticImport', + 'chunk-deep.js': 'staticImport', + }); + const size = (file) => Buffer.byteLength(GRAPH_FILES[file]); + assert.equal( + measurement.totals.staticImport, + size('chunk-unhinted.js') + size('chunk-deep.js') + ); +}); + +test('import-like text in strings, templates and comments never counts', async () => { + const distDir = await workDirWith('import-lookalikes', { + 'index.html': '', + 'main.js': [ + 'const a=\'import "./chunk-string.js"\';', + 'const b=`export{x}from"./chunk-template.js"`;', + '/* import"./chunk-comment.js" */', + '// import { y } from "./chunk-line-comment.js";', + 'export const main=1;', + ].join('\n'), + }); + const measurement = await measureInitialBytes({ distDir }); + assert.equal(measurement.totals.staticImport, 0); + assert.deepEqual( + measurement.resources.map((resource) => resource.path), + ['main.js'] + ); +}); + +test('counts an import once per request URL, like index.html references', async () => { + // The preload and the import name the same file under different URLs, + // so the browser fetches two modules. + const distDir = await workDirWith('url-dedupe', { + 'index.html': + '', + 'main.js': 'import"./chunk-a.js";import"./chunk-a.js";', + 'chunk-a.js': 'export const a=1;', + }); + const measurement = await measureInitialBytes({ distDir }); + assert.deepEqual( + measurement.resources.map(({ url, kind }) => [url, kind]), + [ + ['chunk-a.js?v=1', 'modulepreload'], + ['main.js', 'script'], + ['chunk-a.js', 'staticImport'], + ] + ); + assert.equal( + measurement.totals.staticImport, + Buffer.byteLength(GRAPH_FILES['chunk-a.js']) + ); +}); + +test('fails when a startup module imports a file the build did not emit', async () => { + const distDir = await workDirWith('broken-import', { + 'index.html': '', + 'main.js': 'import"./chunk-gone.js";', + }); + await assert.rejects( + measureInitialBytes({ distDir }), + /import files that are not in .*broken-import: chunk-gone\.js \(imported by main\.js\)/ + ); +}); + test('fails when index.html is missing instead of reporting zero bytes', async () => { const distDir = await writeDist('no-index', { indexHtml: null, files: {} }); await assert.rejects( diff --git a/tools/performance/project.json b/tools/performance/project.json index 9f0e3936a..6889d628a 100644 --- a/tools/performance/project.json +++ b/tools/performance/project.json @@ -12,7 +12,7 @@ "{projectRoot}/*.mjs", "{projectRoot}/*.json", "{workspaceRoot}/.github/workflows/ci.yml", - { "externalDependencies": ["parse5"] } + { "externalDependencies": ["parse5", "typescript"] } ], "options": { "command": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs tools/performance/tighten-baselines.test.mjs", diff --git a/tsconfig.base.json b/tsconfig.base.json index ceed972dc..a4a1de641 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -133,6 +133,9 @@ "libs/shared/host-health/src/index.ts" ], "@iptvnator/services": ["libs/services/src/index.ts"], + "@iptvnator/services/lazy-snack-bar": [ + "libs/services/src/lib/lazy-snack-bar/lazy-snack-bar.ts" + ], "@iptvnator/services/playlist-backup": [ "libs/services/src/lib/playlist-backup.service.ts" ],