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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* test(web): type the stubbed toast ref as Observable<undefined>

CI's spec typecheck rejected `typeof EMPTY` (Observable<never>) for a
stub that returns `of(undefined)`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-15 22:59:31 +02:00
1 parent aabca1fae1
commit d57da4dfec
30 files changed
+667 -121

No files matched your search

+8
View File
@@ -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.
@@ -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<AppStartupStatusComponent>,
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);
});
});
@@ -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()) {
<section
@@ -98,9 +99,30 @@ import { SERVER_SIGN_IN_PATH } from '@iptvnator/services';
}}
</p>
@if (failed()) {
<button mat-flat-button type="button" (click)="retry()">
{{ 'RETRY' | translate }}
</button>
<!--
Material's button loads with the failure branch so the
first screen does not carry the button chunk. A native
button retries while the chunk downloads and stays when
the load fails.
-->
@defer (on immediate) {
<button mat-flat-button type="button" (click)="retry()">
{{ 'RETRY' | translate }}
</button>
} @placeholder {
<ng-container [ngTemplateOutlet]="nativeRetry" />
} @error {
<ng-container [ngTemplateOutlet]="nativeRetry" />
}
<ng-template #nativeRetry>
<button
class="startup-retry-native"
type="button"
(click)="retry()"
>
{{ 'RETRY' | translate }}
</button>
</ng-template>
}
</section>
}
@@ -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 <mat-spinner diameter="32">
+5 -5
View File
@@ -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<RuntimeCapabilitiesService>;
@@ -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;
+3 -3
View File
@@ -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 }
@@ -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,
@@ -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<undefined>;
}) => 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',
+24 -20
View File
@@ -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<string, EventListener>();
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 }
@@ -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'));
@@ -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,
}),
+35 -10
View File
@@ -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<void>;
beforeEach(() => {
versionUpdates = new Subject<void>();
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<undefined>;
}) => 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;
});
+14 -12
View File
@@ -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<string, EventListener>();
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',
{
+18 -6
View File
@@ -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 `<mat-form-field>`
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/<entry>`
+38 -10
View File
@@ -1134,7 +1134,22 @@ The bytes a browser fetches before Angular can bootstrap, read from the built
- `index.html` itself,
- every same-origin `<script src>`, including `assets/app-config.js`,
- every `<link rel="stylesheet">`,
- every `<link rel="modulepreload">` chunk.
- every `<link rel="modulepreload">` chunk,
- every chunk those scripts import statically, transitively.
The last item matters because Angular's builder emits at most ten
`modulepreload` hints (`MODULE_PRELOAD_MAX`). A startup graph with more
chunks than that leaves the rest for the browser to discover from `main.js`'s
own `import` statements; it still fetches and parses them before bootstrap.
Until #1939 the script counted only what `index.html` names, so once the
graph passed ten chunks the counter silently stopped growing: an eager
`MatSnackBar` import added on `master` brought Material's snack-bar and
button back (about 95 KB) without the ratchet noticing. The script parses
each module (TypeScript's parser), so only real `import`/`export … from`
declarations count, never text in a string or comment; dynamic `import()`
targets are lazy and never count; imports count once per request URL, as the
`index.html` references do; and a statically imported file the build did not
emit is an error, like a missing `index.html` reference.
Manifest, icons, external URLs, commented-out tags and lazy chunks are not
counted, so the value is the same for every language: a non-English launch
@@ -1175,15 +1190,28 @@ lazy route or dialog. Angular Material therefore stays off the startup path by
construction, with the rules and guards in the
[UI guidelines](iptvnator-ui-guidelines.md#material-off-the-startup-path):
form-field defaults are provided per component (#1928), services created with
the app resolve `MatDialog` through `LazyMatDialog`, the panels the shell
mounts on every launch defer their Material views, the theme emits tokens only
for imported components, and the app does not provide the animations module,
which nothing used (Material 22 animates with CSS). Together these lowered the
counter from 1,596,201 to 1,344,067 bytes (-252,134, 15.8%) on a local
production build at the branch point (#1931); the baseline follows the
measurement of the branch merged with `master`, 1,345,807. The snack-bar stays: root
services inject it, and with it Material's button, the icon button chunk and
the CDK overlay (about 190 KB) remain initial.
the app resolve `MatDialog` through `LazyMatDialog` and open toasts through
`LazySnackBar`, the panels the shell mounts on every launch defer their
Material views, the theme emits tokens only for imported components, and the
app does not provide the animations module, which nothing used (Material 22
animates with CSS). Together these lowered the counter from 1,596,201 to
1,344,067 bytes (-252,134, 15.8%) on a local production build at the branch
point (#1931); the baseline follows the measurement of the branch merged with
`master`, 1,345,807. The snack-bar followed in #1939: with the last seven
eager `MatSnackBar` injections moved to `LazySnackBar` and the startup
status deferring its Retry button, the snack-bar, Material's button, the icon
button chunk, ripple and the CDK a11y module left the initial bundle,
1,349,048 to 1,237,440 bytes (-111,608, 8.3%) on a local build, and no
Material module remains in it; merged with #1938 (the radio play control
no longer a fab, so its theme tokens left the stylesheet), #1936 (one
global kit layer replacing per-component overrides), #1929 (zoneless
renderer, no zone.js polyfill), #1903 (self-hosted server storage, which
raised master's baseline by 7,245 bytes) and #1934, whose Xtream favorites
toasts now also go through `LazySnackBar`, the branch measures 1,196,205
with the static-import rule below, which the baseline follows; the counter
without that rule read 1,098,535 for the same build. Of the CDK, the overlay, scrolling and
portal chunks (about 78 KB) stay through the `FullscreenOverlayContainer`
provider in `app.config.ts`, whose token lives in the overlay chunk.
`--summary` writes the journey summary shape (`journeys.<journey>.counters`)
that the ratchet checker consumes. `--dist <dir>` points the script at another
@@ -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 { firstValueFrom, of, skip } from 'rxjs';
import {
@@ -35,7 +35,7 @@ describe('EpgService', () => {
supportsProgramLookup: false,
};
snackBar = {
open: jest.fn(),
open: jest.fn().mockResolvedValue(null),
};
settingsStore = {
loadSettings: jest.fn().mockResolvedValue(undefined),
@@ -72,7 +72,7 @@ describe('EpgService', () => {
useValue: epgBridge,
},
{
provide: MatSnackBar,
provide: LazySnackBar,
useValue: snackBar,
},
{
+3 -3
View File
@@ -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 { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { catchError, map, tap, timeout } from 'rxjs/operators';
@@ -33,7 +33,7 @@ const debugEpgService = createDevLogger('EpgService');
providedIn: 'root',
})
export class EpgService {
private snackBar = inject(MatSnackBar);
private snackBar = inject(LazySnackBar);
private translate = inject(TranslateService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
@@ -175,7 +175,7 @@ export class EpgService {
*/
private showErrorSnackbar(message?: string): void {
const errorMessage = message || this.translate.instant('EPG.ERROR');
this.snackBar.open(errorMessage, this.translate.instant('CLOSE'), {
void this.snackBar.open(errorMessage, this.translate.instant('CLOSE'), {
duration: 3000,
horizontalPosition: 'start',
});
+4 -4
View File
@@ -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 { Router } from '@angular/router';
import { EpgService } from '@iptvnator/epg/data-access';
import {
@@ -72,7 +72,7 @@ export class PlaylistEffects {
private dataService = inject(DataService);
private epgService = inject(EpgService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private snackBar = inject(LazySnackBar);
private storage = inject(StorageMap);
private store = inject(Store);
private translate = inject(TranslateService);
@@ -105,7 +105,7 @@ export class PlaylistEffects {
);
}),
tap(() => {
this.snackBar.open(
void this.snackBar.open(
this.translate.instant('CHANNELS.FAVORITES_UPDATED'),
undefined,
{ duration: 2000 }
@@ -147,7 +147,7 @@ export class PlaylistEffects {
action.program,
activeChannel,
() =>
this.snackBar.open(
void this.snackBar.open(
this.translate.instant(
'EPG.TIMELINE.CATCHUP_FAILED'
),
@@ -1,5 +1,5 @@
import { Injector, runInInjectionContext } from '@angular/core';
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 { Store } from '@ngrx/store';
@@ -48,7 +48,10 @@ describe('PlaylistEffects updateFavorites$', () => {
},
{ provide: EpgService, useValue: { fetchEpg: jest.fn() } },
{ provide: Router, useValue: {} },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: LazySnackBar,
useValue: { open: jest.fn().mockResolvedValue(null) },
},
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
@@ -1,5 +1,5 @@
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore } from '@ngrx/signals';
@@ -22,7 +22,7 @@ const TestFavoritesStore = signalStore(
describe('withFavorites', () => {
const originalElectron = window.electron;
const snackBar = { open: jest.fn() };
const snackBar = { open: jest.fn().mockResolvedValue(null) };
const translate = { instant: jest.fn((key: string) => key) };
const runtime = { supportsXtreamSqliteDataSource: false };
let store: InstanceType<typeof TestFavoritesStore>;
@@ -46,7 +46,7 @@ describe('withFavorites', () => {
TestBed.configureTestingModule({
providers: [
TestFavoritesStore,
{ provide: MatSnackBar, useValue: snackBar },
{ provide: LazySnackBar, useValue: snackBar },
{ provide: TranslateService, useValue: translate },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{
@@ -1,6 +1,6 @@
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { LazySnackBar } from '@iptvnator/services/lazy-snack-bar';
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
type ContentMetadataPatch,
@@ -37,7 +37,9 @@ export const withFavorites = function () {
store,
dataSource = inject(XTREAM_DATA_SOURCE),
runtime = inject(RuntimeCapabilitiesService),
snackBar = inject(MatSnackBar),
// The store exists from startup: a static MatSnackBar import
// would put Material's snack-bar and button on the initial path.
snackBar = inject(LazySnackBar),
translate = inject(TranslateService)
) => {
let request = 0;
@@ -98,7 +100,7 @@ export const withFavorites = function () {
)
: null;
if (candidate && activeTarget === targetKey) {
snackBar.open(
void snackBar.open(
translate.instant(
'PORTALS.FAVORITE_IDENTITY_CONFLICT'
),
@@ -147,7 +149,7 @@ export const withFavorites = function () {
XTREAM_FAVORITE_IDENTITY_CONFLICT
? 'PORTALS.FAVORITE_IDENTITY_CONFLICT'
: 'PORTALS.FAVORITE_SAVE_FAILED';
snackBar.open(
void snackBar.open(
translate.instant(key),
undefined,
{ duration: 6000 }
+1
View File
@@ -2,6 +2,7 @@ export * from './lib/catalog-title-match.service';
export * from './lib/cross-portal-similar.service';
export * from './lib/data.service';
export * from './lib/host-connectivity-reset';
export * from './lib/lazy-snack-bar/lazy-snack-bar';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/recordings.service';
@@ -0,0 +1,69 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { LazySnackBar } from './lazy-snack-bar';
describe('LazySnackBar', () => {
const ref = { onAction: jest.fn() };
let snackBar: { open: jest.Mock };
beforeEach(() => {
snackBar = { open: jest.fn(() => ref) };
TestBed.configureTestingModule({
providers: [{ provide: MatSnackBar, useValue: snackBar }],
});
});
it('resolves the root MatSnackBar from the snack-bar chunk once', async () => {
const service = TestBed.inject(LazySnackBar);
const load = jest.spyOn(service, 'loadSnackBarModule');
await expect(service.resolve()).resolves.toBe(snackBar);
await expect(service.resolve()).resolves.toBe(snackBar);
expect(load).toHaveBeenCalledTimes(1);
});
it('opens a text snack-bar with the same arguments and returns its ref', async () => {
const service = TestBed.inject(LazySnackBar);
await expect(
service.open('Saved', 'Close', { duration: 2000 })
).resolves.toBe(ref);
expect(snackBar.open).toHaveBeenCalledWith('Saved', 'Close', {
duration: 2000,
});
});
it('retries the chunk load after a failure instead of caching it', async () => {
const service = TestBed.inject(LazySnackBar);
const original = service.loadSnackBarModule;
service.loadSnackBarModule = jest
.fn()
.mockRejectedValueOnce(new Error('chunk failed'))
.mockImplementation(original);
await expect(service.resolve()).rejects.toThrow('chunk failed');
await expect(service.resolve()).resolves.toBe(snackBar);
expect(service.loadSnackBarModule).toHaveBeenCalledTimes(2);
});
it('logs a failed chunk load from open and resolves to null', async () => {
const error = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const service = TestBed.inject(LazySnackBar);
service.loadSnackBarModule = jest
.fn()
.mockRejectedValue(new Error('chunk failed'));
await expect(service.open('Saved')).resolves.toBeNull();
expect(error).toHaveBeenCalledWith(
'Could not open the snack-bar:',
expect.any(Error)
);
expect(snackBar.open).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
import { EnvironmentInjector, inject, Injectable } from '@angular/core';
import type {
MatSnackBar,
MatSnackBarConfig,
MatSnackBarRef,
TextOnlySnackBar,
} from '@angular/material/snack-bar';
type SnackBarModule = typeof import('@angular/material/snack-bar');
/**
* Resolves the root `MatSnackBar` on first use. `@angular/material/snack-bar`
* statically imports Material's button, the icon button chunk and the CDK
* overlay, so one static import from a service created with the app puts
* all of them on the initial path although a toast shows only after an
* event. Services and components that exist at startup open their toasts
* through this helper; a component in a lazy chunk can inject `MatSnackBar`
* directly.
*/
@Injectable({ providedIn: 'root' })
export class LazySnackBar {
private readonly injector = inject(EnvironmentInjector);
private snackBar: Promise<MatSnackBar> | null = null;
/** The dynamic import; a field so specs can substitute it. */
loadSnackBarModule: () => Promise<SnackBarModule> = () =>
import('@angular/material/snack-bar');
resolve(): Promise<MatSnackBar> {
this.snackBar ??= this.loadSnackBarModule().then((module) =>
this.injector.get(module.MatSnackBar)
);
// A failed chunk load must not poison later attempts.
this.snackBar.catch(() => (this.snackBar = null));
return this.snackBar;
}
/**
* Opens a text snack-bar with `MatSnackBar.open`'s arguments once the
* chunk is loaded. The promise settles with the snack-bar's ref, or with
* `null` after a failed chunk load, which is logged; the message is not,
* as it can carry a playlist URL.
*/
async open(
message: string,
action?: string,
config?: MatSnackBarConfig
): Promise<MatSnackBarRef<TextOnlySnackBar> | null> {
let snackBar: MatSnackBar;
try {
snackBar = await this.resolve();
} catch (error) {
console.error('Could not open the snack-bar:', error);
return null;
}
return snackBar.open(message, action, config);
}
}
@@ -59,7 +59,7 @@ describe('PlaylistsService', () => {
...overrides,
},
snackBar: {
open: jest.fn(),
open: jest.fn().mockResolvedValue(null),
},
translateService: {
instant: jest.fn((key: string) => key),
+3 -3
View File
@@ -4,7 +4,7 @@ import {
} from './database-electron.service';
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { LazySnackBar } from './lazy-snack-bar/lazy-snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
aggregateFavoriteChannels,
@@ -90,7 +90,7 @@ export class PlaylistsService {
});
private readonly dbService = inject(NgxIndexedDBService);
private readonly databaseService = inject(DatabaseService);
private readonly snackBar = inject(MatSnackBar);
private readonly snackBar = inject(LazySnackBar);
private readonly translateService = inject(TranslateService);
private readonly storageBackend = inject(PlaylistStorageBackend);
private readonly playlistDeleteCleanups =
@@ -1259,7 +1259,7 @@ export class PlaylistsService {
uploadType
);
} catch (error) {
this.snackBar.open(
void this.snackBar.open(
this.translateService.instant('HOME.PARSING_ERROR'),
undefined,
{ duration: 2000 }
+3 -3
View File
@@ -3,13 +3,13 @@
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 1285523,
"value": 1196205,
"unit": "bytes",
"slack": 4096,
"updatedAt": "2026-10-14",
"evidencePr": 1903,
"evidencePr": 1939,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes",
"note": "Raised by 7,245 bytes (0.6%) over #1929's 1,278,278 for self-hosted server storage: what must run before the first route stays on the startup path (the /v1/instance probe and remembered server, the connection status, the access token bound to its instance, the playlist storage backend with its per-playlist write serialization). The storage code itself (user-data sync, migration, sign-in, Settings > Server) loads by dynamic import in server mode only."
"note": "No Angular Material module is in the initial bundle since #1939: services created with the app open toasts through LazySnackBar and dialogs through LazyMatDialog, so a new eager MatSnackBar or MatDialog import brings back Material's button, ripple and the CDK a11y chunks (about 110 KB). The CDK overlay stays initial through the FullscreenOverlayContainer provider in app.config.ts. Self-hosted server storage (#1903) keeps only what must run before the first route on the startup path (the /v1/instance probe and remembered server, the connection status, the token bound to its instance, the playlist storage backend); the storage code itself loads by dynamic import in server mode only."
},
"renderer.ipcCallsToFirstCard": {
"value": 15,
+86 -2
View File
@@ -1,7 +1,10 @@
/**
* Measures the bytes a browser fetches before the Angular app can bootstrap:
* `index.html` itself plus every same-origin script, stylesheet and
* `modulepreload` chunk it references. This is the J1 ("launch to usable")
* `modulepreload` chunk it references, and every chunk those modules import
* statically. Angular emits at most ten `modulepreload` hints, so a larger
* startup graph has chunks only the module imports name; the browser still
* fetches them before bootstrap. This is the J1 ("launch to usable")
* counter `renderer.initialBytes` from docs/architecture/performance-journeys.md.
*
* The number is read from the built output, not estimated from source, so it
@@ -17,6 +20,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'parse5';
import ts from 'typescript';
export const DEFAULT_DIST_DIR = 'dist/apps/web';
export const INITIAL_BYTES_COUNTER = 'renderer.initialBytes';
@@ -104,6 +108,37 @@ export function extractInitialResources(html) {
return resources;
}
/**
* Relative specifiers of the static `import` and `export … from`
* declarations in an emitted ES module, read from a parsed syntax tree, so
* text that only looks like an import (a string, template literal or comment)
* never counts. Static declarations are top-level statements only; a dynamic
* `import("…")` loads later and is an expression, not a declaration. Bare
* and absolute specifiers never point into the build.
*/
export function extractStaticImports(source) {
const tree = ts.createSourceFile(
'module.js',
source,
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.JS
);
const specifiers = new Set();
for (const statement of tree.statements) {
if (
(ts.isImportDeclaration(statement) ||
ts.isExportDeclaration(statement)) &&
statement.moduleSpecifier &&
ts.isStringLiteral(statement.moduleSpecifier) &&
/^\.{1,2}\//.test(statement.moduleSpecifier.text)
) {
specifiers.add(statement.moduleSpecifier.text);
}
}
return [...specifiers];
}
async function sizeOf(filePath) {
const stats = await stat(filePath);
return stats.size;
@@ -141,18 +176,66 @@ export async function measureInitialBytes({ distDir, readSize = sizeOf }) {
);
}
// Follow the static import graph of every counted script. Like the
// index.html references, imports count once per request URL: the browser
// fetches `chunk.js?v=1` and `chunk.js` as two modules. A file's own
// imports are read once, whichever URL loaded it.
const countedUrls = new Set(resources.map((resource) => resource.url));
const scanned = new Set();
const queue = resources
.filter((resource) => /\.m?js$/.test(resource.path))
.map(({ url, path: file }) => ({ url, file }));
const unresolved = [];
while (queue.length > 0) {
const importer = queue.shift();
if (scanned.has(importer.file)) continue;
scanned.add(importer.file);
const source = await readFile(
path.join(distDir, importer.file),
'utf8'
);
const base = path.posix.dirname(importer.url.replace(/\?.*$/, ''));
for (const specifier of extractStaticImports(source)) {
const url = path.posix
.normalize(path.posix.join(base, specifier))
.replace(/#.*$/, '');
if (countedUrls.has(url)) continue;
countedUrls.add(url);
const file = url.replace(/\?.*$/, '');
const absolute = path.join(distDir, file);
if (!existsSync(absolute)) {
unresolved.push(`${file} (imported by ${importer.file})`);
continue;
}
resources.push({
path: file,
url,
kind: 'staticImport',
bytes: await readSize(absolute),
});
queue.push({ url, file });
}
}
if (unresolved.length > 0) {
throw new Error(
`Startup modules import files that are not in ${distDir}: ${unresolved.join(', ')}`
);
}
const totals = {
indexHtml: indexBytes,
script: 0,
stylesheet: 0,
modulepreload: 0,
staticImport: 0,
};
for (const resource of resources) totals[resource.kind] += resource.bytes;
const initialBytes =
totals.indexHtml +
totals.script +
totals.stylesheet +
totals.modulepreload;
totals.modulepreload +
totals.staticImport;
return {
distDir,
@@ -201,6 +284,7 @@ export function formatReport(measurement) {
`scripts ${formatBytes(measurement.totals.script).padStart(11)}`,
`stylesheets ${formatBytes(measurement.totals.stylesheet).padStart(11)}`,
`modulepreload ${formatBytes(measurement.totals.modulepreload).padStart(11)}`,
`static imports ${formatBytes(measurement.totals.staticImport).padStart(11)}`,
`${INITIAL_BYTES_COUNTER} = ${formatBytes(measurement.totals.initialBytes)} bytes (${measurement.resources.length} files + index.html)`,
];
return lines.join('\n');
@@ -10,6 +10,7 @@ import {
DEFAULT_DIST_DIR,
INITIAL_BYTES_COUNTER,
extractInitialResources,
extractStaticImports,
formatReport,
measureInitialBytes,
parseArgs,
@@ -61,6 +62,19 @@ async function writeDist(
return distDir;
}
let GRAPH_FILES = {};
/** Writes files with real contents, for tests that read module imports. */
async function workDirWith(name, files) {
GRAPH_FILES = files;
const distDir = path.join(workDir, name);
await mkdir(distDir, { recursive: true });
for (const [file, contents] of Object.entries(files)) {
await writeFile(path.join(distDir, file), contents);
}
return distDir;
}
before(async () => {
workDir = await mkdtemp(path.join(os.tmpdir(), 'measure-initial-bytes-'));
});
@@ -228,6 +242,7 @@ test('sums index.html and every referenced file into the counter', async () => {
script,
stylesheet,
modulepreload,
staticImport: 0,
initialBytes: indexBytes + script + stylesheet + modulepreload,
});
assert.deepEqual(measurement.counters, {
@@ -236,6 +251,104 @@ test('sums index.html and every referenced file into the counter', async () => {
});
});
test('extracts static import and re-export specifiers, not dynamic imports', () => {
const source =
'import{a as b}from"./chunk-A.js";import"./chunk-B.js";' +
'export{c}from"./sub/chunk-C.js";export*from"../chunk-D.js";' +
'const l=()=>import("./chunk-LAZY.js");import"@angular/core";' +
"const t=\"from './not-a-file.css'\";import\n { e } from './chunk-E.mjs';";
assert.deepEqual(extractStaticImports(source).sort(), [
'../chunk-D.js',
'./chunk-A.js',
'./chunk-B.js',
'./chunk-E.mjs',
'./sub/chunk-C.js',
]);
});
test('counts chunks that only a static import names, beyond the preload hints', async () => {
// Angular emits at most ten modulepreload hints; main imports the rest.
const distDir = await workDirWith('import-graph', {
'index.html':
'<link rel="modulepreload" href="chunk-hinted.js"><script src="main.js" type="module"></script>',
'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': '<script src="main.js" type="module"></script>',
'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':
'<link rel="modulepreload" href="chunk-a.js?v=1"><script src="main.js" type="module"></script>',
'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': '<script src="main.js" type="module"></script>',
'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(
+1 -1
View File
@@ -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",
+3
View File
@@ -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"
],