From d57da4dfec3606c3f9e6791f5553d8b166616e23 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:59:31 +0200 Subject: [PATCH] perf(web): take the Material snack-bar off the startup path (#1939) * perf(web): take the Material snack-bar off the startup path The seven services and the app shell created with the app injected MatSnackBar, and snack-bar.mjs statically imports Material's button, the icon button chunk, ripple and the CDK a11y module, so about 110 KB sat in the initial bundle although a toast shows only after an event. A root LazySnackBar (@iptvnator/services/lazy-snack-bar) resolves the snack-bar from a dynamic import the way LazyMatDialog does, retrying after a failed chunk load, and opens text toasts with MatSnackBar.open's arguments; a failed load is logged without the message. The eager importers open their toasts through it with unchanged messages, actions, durations and panel classes; lazy code keeps injecting MatSnackBar. The startup status defers its Retry button, the other eager importer of the button chunk, with a native button as the error fallback. Local production build: renderer.initialBytes 1,349,048 -> 1,236,918 (-112,130, -8.3%); no Material module is in the initial bundle any more. The CDK overlay, scrolling and portal chunks (about 78 KB) stay through the FullscreenOverlayContainer provider in app.config.ts. Co-Authored-By: Claude Fable 5.1 * fix(web): keep a native Retry while the startup button chunk loads Greptile (local round 1): the failure screen had no Retry button until the deferred Material button chunk arrived. One native button, drawn in the flat button's shape with theme colours, is now the placeholder and the error view of the defer block, so a retry is possible while the chunk downloads and after a failed load. Baseline and docs follow the re-measured value, 1,237,440 bytes. Co-Authored-By: Claude Fable 5.1 * docs(perf): point the initialBytes evidence at the opened pull request The baseline and the performance doc named the predicted number; the pull request opened as #1939. Co-Authored-By: Claude Fable 5.1 * test(web): cover the toast actions behind the lazy snack-bar Greptile (PR round 1): the Refresh and Trust-host actions now run after LazySnackBar.open resolves, and no test exercised them. The PWA update toast test holds the toast promise, then resolves it and picks Refresh; the page reload is a field the spec substitutes, as jsdom's location cannot be spied on. The Electron test does the same for the TLS trust toast: the confirmation opens only once the toast is up, approving it records the host and retries the fetch. Co-Authored-By: Claude Fable 5.1 * test(web): type the stubbed toast ref as Observable CI's spec typecheck rejected `typeof EMPTY` (Observable) for a stub that returns `of(undefined)`. Co-Authored-By: Claude Fable 5.1 * perf(web): lower the initialBytes baseline to the merged measurement Merged with #1938 the branch measures 1,231,455 bytes on a local production build (the radio play control's fab tokens left the stylesheet); the baseline follows it. Co-Authored-By: Claude Fable 5.1 * perf(web): lower the initialBytes baseline to the zoneless merged measurement Merged with #1929, #1936 and #1938 the branch measures 1,178,109 bytes on a local production build; the baseline follows it. Co-Authored-By: Claude Fable 5.1 * perf(web): re-measure initialBytes after merging #1903 and #1942 Merged with #1903 (self-hosted server storage, which raised master's baseline by 7,245 bytes on purpose) and #1942 the branch measures 1,187,983 bytes on a local production build, below master's 1,285,523; no Material module is in the initial bundle. The new favorites effect spec provides the LazySnackBar stub the merged effect injects. Co-Authored-By: Claude Opus 5.5 * perf(xtream): open favorites toasts through LazySnackBar #1934 gave the Xtream store's favorites feature a static MatSnackBar injection. The store exists from startup, so that one import put Material's snack-bar, button, icon-button and ripple chunks (about 95 KB) back on the renderer's initial path. The feature now opens its two toasts through LazySnackBar with the same messages and durations. Co-Authored-By: Claude Opus 5.5 * fix(perf): count statically imported chunks in renderer.initialBytes Angular's builder emits at most ten modulepreload hints. Once the startup graph passed ten chunks, the rest were fetched through main.js's own imports but never counted, so the counter stopped growing and an eager MatSnackBar import on master went unnoticed. The script now follows the static import graph of every counted script (dynamic import() targets stay lazy; a missing imported file is an error), which matches the bundler's metafile closure exactly: 1,196,205 bytes for this branch, where the old counter read 1,098,535. The baseline follows the new measurement and its note now explains the current limit (Greptile). Co-Authored-By: Claude Opus 5.5 * fix(perf): parse startup imports and count them per request URL Codex (PR round): the regexes also matched import-shaped text in strings, templates and comments, which could count a lazy chunk or fail on a file that does not exist. Static declarations are now read from TypeScript's syntax tree (top-level import/export-from statements only). Imports are also deduplicated by request URL, like the index.html references, so a preload of chunk.js?v=1 no longer hides an import of ./chunk.js. The real build still measures 1,196,205 bytes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 --- .changes/web-lazy-snack-bar.md | 8 ++ .../app/app-startup-status.component.spec.ts | 51 +++++++- .../src/app/app-startup-status.component.ts | 45 ++++++- apps/web/src/app/app.component.spec.ts | 10 +- apps/web/src/app/app.component.ts | 6 +- .../electron.service.performance.spec.ts | 6 +- .../src/app/services/electron.service.spec.ts | 68 ++++++++++- apps/web/src/app/services/electron.service.ts | 44 +++---- .../playlist-open-request.service.spec.ts | 10 +- .../services/playlist-open-request.service.ts | 6 +- apps/web/src/app/services/pwa.service.spec.ts | 45 +++++-- apps/web/src/app/services/pwa.service.ts | 26 ++-- docs/architecture/iptvnator-ui-guidelines.md | 24 +++- docs/architecture/performance-journeys.md | 48 ++++++-- .../data-access/src/lib/epg.service.spec.ts | 6 +- libs/epg/data-access/src/lib/epg.service.ts | 6 +- libs/m3u-state/src/lib/effects.ts | 8 +- .../src/lib/favorites.effect.spec.ts | 7 +- .../src/lib/with-favorites.feature.spec.ts | 6 +- .../src/lib/with-favorites.feature.ts | 10 +- libs/services/src/index.ts | 1 + .../lib/lazy-snack-bar/lazy-snack-bar.spec.ts | 69 +++++++++++ .../src/lib/lazy-snack-bar/lazy-snack-bar.ts | 58 +++++++++ .../src/lib/playlists.service.spec.ts | 2 +- libs/services/src/lib/playlists.service.ts | 6 +- tools/performance/journey-baselines.json | 6 +- tools/performance/measure-initial-bytes.mjs | 88 +++++++++++++- .../measure-initial-bytes.test.mjs | 113 ++++++++++++++++++ tools/performance/project.json | 2 +- tsconfig.base.json | 3 + 30 files changed, 667 insertions(+), 121 deletions(-) create mode 100644 .changes/web-lazy-snack-bar.md create mode 100644 libs/services/src/lib/lazy-snack-bar/lazy-snack-bar.spec.ts create mode 100644 libs/services/src/lib/lazy-snack-bar/lazy-snack-bar.ts 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" ],