From e6ef8cdd542822f9f71a85d1602deea39671c91e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:08:58 +0200 Subject: [PATCH] perf(web): take Angular Material off the startup path (#1931) Takes Angular Material off the renderer's startup path: renderer.initialBytes 1,596,201 -> 1,344,067 bytes (-252,134, 15.8%) at the branch point; merged with master, where #1928 lands the form-field defaults per component, the branch measures 1,345,807 (-188,797, 12.3% against master's baseline). The bundler keeps each module in one chunk, so one eager import of any export carries the module's whole code; each item removes the last eager importer of one Material module. - EPG progress and update panels: eager host + `@defer (when visible)` view with an `@error` notice; the startup spinner is static markup copied from mat-spinner. - LazyMatDialog: the confirm dialog service, player service, parental lock prompt and shell actions resolve MatDialog on first use. - The theme emits tokens for the 21 imported components only, through a `$material-components` list and meta.apply; tools/nx/check-material-theme-scope.mjs (CI) keeps it in step with the imports in both directions. - No provideAnimations(): Material 22 animates with CSS and reads prefers-reduced-motion itself; apps/electron-backend-e2e/src/material-motion.e2e.ts proves dialogs, selects, menus, snack-bars and tooltips animate in and out and stop under reduced motion. Its renderer had deferred every DOM removal to the end of the tick, so the catalog list now tracks the grid offset from scroll events and the player settings panel decides "focus was inside" in a microtask, both with specs that detach the view first. - The snack-bar stays, and with it button, the icon-button chunk and the CDK overlay (about 190 KB): root services inject it. Co-Authored-By: Claude Fable 5.1 --- .changes/web-material-off-startup-path.md | 9 + .github/workflows/ci.yml | 5 + .../src/material-motion.e2e.ts | 267 ++++++++++++++++ .../src/app/app-startup-status.component.ts | 180 ++++++++++- .../app-update-notification-card.component.ts | 264 ++++++++++++++++ ...pdate-notification-panel.component.spec.ts | 108 ++++++- ...app-update-notification-panel.component.ts | 296 ++++-------------- apps/web/src/app/app.config.ts | 6 +- .../services/parental-lock-prompt.service.ts | 35 ++- apps/web/src/app/services/player.service.ts | 16 +- .../workspace-shell-actions.service.ts | 41 ++- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + apps/web/src/m3-theme.scss | 56 +++- apps/web/src/styles.scss | 4 - docs/architecture/iptvnator-ui-guidelines.md | 49 +++ docs/architecture/performance-journeys.md | 18 ++ .../category-content-view.component.spec.ts | 20 ++ .../category-content-view.component.ts | 32 +- libs/ui/components/src/index.ts | 1 + .../lib/confirm-dialog/dialog.service.spec.ts | 40 ++- .../src/lib/confirm-dialog/dialog.service.ts | 40 ++- .../lib/lazy-dialog/lazy-mat-dialog.spec.ts | 37 +++ .../src/lib/lazy-dialog/lazy-mat-dialog.ts | 31 ++ ...=> epg-progress-panel-view.component.html} | 0 ...=> epg-progress-panel-view.component.scss} | 0 .../epg-progress-panel-view.component.ts | 229 ++++++++++++++ .../epg-progress-panel.component.spec.ts | 109 +++++++ .../epg-progress-panel.component.ts | 264 +++------------- ...-controls.component.settings-panel.spec.ts | 46 +++ .../player-settings-panel.component.ts | 15 +- package.json | 3 + tools/nx/check-material-theme-scope.mjs | 214 +++++++++++++ tools/nx/check-material-theme-scope.test.mjs | 131 ++++++++ tools/performance/journey-baselines.json | 4 +- tsconfig.base.json | 3 + 53 files changed, 2068 insertions(+), 543 deletions(-) create mode 100644 .changes/web-material-off-startup-path.md create mode 100644 apps/electron-backend-e2e/src/material-motion.e2e.ts create mode 100644 apps/web/src/app/app-update-notification-card.component.ts create mode 100644 libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.spec.ts create mode 100644 libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.ts rename libs/ui/epg/src/lib/epg-progress-panel/{epg-progress-panel.component.html => epg-progress-panel-view.component.html} (100%) rename libs/ui/epg/src/lib/epg-progress-panel/{epg-progress-panel.component.scss => epg-progress-panel-view.component.scss} (100%) create mode 100644 libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel-view.component.ts create mode 100644 libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.spec.ts create mode 100644 tools/nx/check-material-theme-scope.mjs create mode 100644 tools/nx/check-material-theme-scope.test.mjs diff --git a/.changes/web-material-off-startup-path.md b/.changes/web-material-off-startup-path.md new file mode 100644 index 000000000..dd92c0114 --- /dev/null +++ b/.changes/web-material-off-startup-path.md @@ -0,0 +1,9 @@ +--- +type: perf +area: web +--- + +The app loads about 12% less code before it shows its first screen: dialog, +tooltip and progress-bar code, the animation engine and the theme colours of +unused components now load only when something needs them, so every launch +has less to download and parse. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d25580c26..098c2e082 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -436,6 +436,11 @@ jobs: - name: Validate form-field defaults run: pnpm run ui:form-field-defaults:validate + # m3-theme.scss themes only the Material components the app + # imports; a new import without a theme entry renders unthemed. + - name: Validate the Material theme scope + run: pnpm run styles:material-theme-scope:validate + # Nx rejects a non-parallel task with continuous dependencies, and # the Playwright plugin infers serve dependencies only when CI is # unset, so this checks both the local and the CI inference. diff --git a/apps/electron-backend-e2e/src/material-motion.e2e.ts b/apps/electron-backend-e2e/src/material-motion.e2e.ts new file mode 100644 index 000000000..4adc52cd0 --- /dev/null +++ b/apps/electron-backend-e2e/src/material-motion.e2e.ts @@ -0,0 +1,267 @@ +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openSettingsSection, + openSources, + test, +} from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromRenderer, +} from './renderer-reload.support'; + +// --------------------------------------------------------------------------- +// app.config.ts does not provide the Angular animations module: nothing +// declares an animation trigger, and Material 22 animates its overlays with +// CSS, reading prefers-reduced-motion itself. These checks keep the overlays' +// enter and exit motion, and the close paths that wait for it, working +// without the module, and off under reduced motion. +// --------------------------------------------------------------------------- + +interface MotionEvent { + type: string; + name: string; + /** The class attribute of the element the event fired on. */ + target: string; +} + +type MotionWindow = Window & { __motionEvents?: MotionEvent[] }; + +/** Records every CSS animation and transition event of the document. */ +async function recordMotion(page: Page): Promise { + await page.evaluate(() => { + const events: MotionEvent[] = []; + (window as MotionWindow).__motionEvents = events; + for (const type of [ + 'animationstart', + 'animationend', + 'transitionrun', + 'transitionend', + ]) { + document.addEventListener( + type, + (event) => { + const target = event.target; + events.push({ + type, + name: + (event as AnimationEvent).animationName ?? + (event as TransitionEvent).propertyName, + target: + target instanceof Element + ? (target.getAttribute('class') ?? '') + : '', + }); + }, + true + ); + } + }); +} + +async function motionEvents(page: Page): Promise { + return page.evaluate(() => (window as MotionWindow).__motionEvents ?? []); +} + +/** + * Waits for an event of that type and name, on an element wearing + * `targetClass` when given, among the events recorded from `since` on. + */ +async function expectMotion( + page: Page, + type: string, + name: string, + { targetClass, since = 0 }: { targetClass?: string; since?: number } = {} +): Promise { + await expect + .poll( + async () => + (await motionEvents(page)) + .slice(since) + .some( + (event) => + event.type === type && + event.name === name && + (targetClass === undefined || + event.target.split(/\s+/).includes(targetClass)) + ), + `${type} ${name}${targetClass ? ` on .${targetClass}` : ''}` + ) + .toBe(true); +} + +async function recordedCount(page: Page): Promise { + return (await motionEvents(page)).length; +} + +/** Opens the Clear EPG data confirmation from the EPG settings section. */ +async function openClearEpgDialog(page: Page) { + await openSettingsSection(page, 'epg'); + await page + .getByRole('button', { name: 'Clear EPG data', exact: true }) + .click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + return dialog; +} + +test.describe('Material motion without the animations module', () => { + test('@theme @electron overlays animate in and out and close after their exit motion', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + await addXtreamPortal(page); + await openSources(page); + await recordMotion(page); + + // Tooltip: keyframes on show and on hide. + const settingsLink = page.locator('a[href$="/workspace/settings"]'); + await settingsLink.hover(); + // Scoped to the one tooltip: the rail link hovered on the way + // to Sources may still be fading out. + const tooltip = page.locator('.mat-mdc-tooltip', { + hasText: 'Settings', + }); + await expect(tooltip).toBeVisible(); + await expect(tooltip).not.toHaveClass(/_mat-animation-noopable/); + await expectMotion(page, 'animationstart', 'mat-mdc-tooltip-show'); + await expectMotion(page, 'animationend', 'mat-mdc-tooltip-show'); + await page.mouse.move(640, 360); + await expectMotion(page, 'animationstart', 'mat-mdc-tooltip-hide'); + await expect(tooltip).toHaveCount(0); + + // Menu: the sources sort menu. + await page.locator('.sort-trigger').click(); + const menu = page.locator('.mat-mdc-menu-panel'); + await expect(menu).toBeVisible(); + await expect(menu).not.toHaveClass( + /mat-menu-panel-animations-disabled/ + ); + await expectMotion(page, 'animationstart', '_mat-menu-enter'); + // Dismissed only once the enter animation is over: a swap while + // it still runs cancels it, and Material treats the cancelled + // enter as the exit's end and detaches the panel at once. + await expectMotion(page, 'animationend', '_mat-menu-enter'); + await page.keyboard.press('Escape'); + await expectMotion(page, 'animationstart', '_mat-menu-exit'); + await expect(menu).toHaveCount(0); + + // Select: the language list, opened and dismissed unchanged. + await openSettings(page); + await openSettingsSection(page, 'general'); + await page.getByTestId('select-language').click(); + const panel = page.locator('.mat-mdc-select-panel'); + await expect(panel).toBeVisible(); + await expect(panel).toHaveClass( + /mat-select-panel-animations-enabled/ + ); + await expectMotion(page, 'animationstart', '_mat-select-enter'); + await expectMotion(page, 'animationend', '_mat-select-enter'); + await page.keyboard.press('Escape'); + await expectMotion(page, 'animationstart', '_mat-select-exit'); + await expect(panel).toHaveCount(0); + + // Dialog: Material drives it with transitions whose duration it + // writes on the container; the surface scales in. Confirming + // closes it after the exit transition and shows a snack-bar. + const beforeDialog = await recordedCount(page); + const dialog = await openClearEpgDialog(page); + await expect(dialog).not.toHaveClass(/_mat-animation-noopable/); + await expect(dialog).toHaveClass(/mdc-dialog--open/); + expect( + await dialog.evaluate((el) => + el.style.getPropertyValue( + '--mat-dialog-transition-duration' + ) + ) + ).toMatch(/^[1-9]\d*ms$/); + // The surface scales in while opening. + await expectMotion(page, 'transitionend', 'transform', { + targetClass: 'mat-mdc-dialog-surface', + since: beforeDialog, + }); + const beforeClose = await recordedCount(page); + await dialog.getByTestId('confirm-dialog-confirm').click(); + // Closing fades the inner container out. Material detaches the + // container on a timer of the same length, so the fade's end + // may never be observed; its start is, in the first frame. + await expectMotion(page, 'transitionrun', 'opacity', { + targetClass: 'mat-mdc-dialog-inner-container', + since: beforeClose, + }); + await expect(dialog).toHaveCount(0); + + const snackBar = page.locator('.mat-mdc-snack-bar-container'); + await expect(snackBar).toBeVisible(); + await expect(snackBar).toHaveClass( + /mat-snack-bar-container-animations-enabled/ + ); + await expectMotion(page, 'animationstart', '_mat-snack-bar-enter'); + // The confirmation toast lasts 2s, then leaves through its exit + // animation; the container is removed when that one ends. + await expectMotion(page, 'animationstart', '_mat-snack-bar-exit'); + await expect(snackBar).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('@theme @electron reduced motion turns the overlay motion off', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + // Material reads the preference once per document, so a fresh + // document must start under the emulated media. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await reloadFromRenderer(app); + await expectRendererReloadedOnRoute(page, /\/workspace/); + await openSettings(page); + await openSettingsSection(page, 'general'); + await recordMotion(page); + + await page.getByTestId('select-language').click(); + const panel = page.locator('.mat-mdc-select-panel'); + await expect(panel).toBeVisible(); + await expect(panel).not.toHaveClass( + /mat-select-panel-animations-enabled/ + ); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + + const dialog = await openClearEpgDialog(page); + await expect(dialog).toHaveClass(/_mat-animation-noopable/); + // Material writes the transition duration only when it animates. + expect( + await dialog.evaluate((el) => + el.style.getPropertyValue( + '--mat-dialog-transition-duration' + ) + ) + ).toBe(''); + await dialog.getByTestId('confirm-dialog-confirm').click(); + await expect(dialog).toHaveCount(0); + + const snackBar = page.locator('.mat-mdc-snack-bar-container'); + await expect(snackBar).toBeVisible(); + await expect(snackBar).not.toHaveClass( + /mat-snack-bar-container-animations-enabled/ + ); + await expect(snackBar).toHaveCount(0, { timeout: 10_000 }); + + const names = (await motionEvents(page)) + .filter((event) => event.type === 'animationstart') + .map((event) => event.name); + expect(names.filter((name) => /^_mat-/.test(name))).toEqual([]); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/web/src/app/app-startup-status.component.ts b/apps/web/src/app/app-startup-status.component.ts index 46375638c..ac99aae8b 100644 --- a/apps/web/src/app/app-startup-status.component.ts +++ b/apps/web/src/app/app-startup-status.component.ts @@ -6,7 +6,6 @@ import { input, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; import { @@ -19,7 +18,7 @@ import { @Component({ selector: 'app-startup-status', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatButtonModule, MatProgressSpinnerModule, TranslatePipe], + imports: [MatButtonModule, TranslatePipe], template: ` @if (!complete()) {
@if (!failed()) { -