From 85c5a078fb0cf589438169c756b62342863b2e7b Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 09:38:26 +0200 Subject: [PATCH] perf(web): drop provideAnimations(), which nothing used app.config.ts provided the Angular animations module, the only importer of @angular/animations in the initial bundle: no code imports the package and no component declares an animation trigger. Material 22 animates its overlays with CSS and reads prefers-reduced-motion itself; the provider's ANIMATION_MODULE_TYPE only serves to disable that. apps/electron-backend-e2e/src/material-motion.e2e.ts records the document's animation and transition events while a tooltip, the sources sort menu, a settings select, the Clear EPG data confirmation and its snack-bar open and close, and checks that each enters and exits with Material's own animation and is removed afterwards; a second test reloads under emulated reduced motion and checks that the select, dialog and snack-bar run no motion and still open and close. renderer.initialBytes: 1,408,099 -> 1,344,067 bytes (-64,032) on a local production build. Co-Authored-By: Claude Fable 5.1 --- .../src/material-motion.e2e.ts | 232 ++++++++++++++++++ apps/web/src/app/app.config.ts | 6 +- 2 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 apps/electron-backend-e2e/src/material-motion.e2e.ts 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..67aea490b --- /dev/null +++ b/apps/electron-backend-e2e/src/material-motion.e2e.ts @@ -0,0 +1,232 @@ +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; +} + +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) => { + events.push({ + type, + name: + (event as AnimationEvent).animationName ?? + (event as TransitionEvent).propertyName, + }); + }, + true + ); + } + }); +} + +async function motionEvents(page: Page): Promise { + return page.evaluate(() => (window as MotionWindow).__motionEvents ?? []); +} + +async function expectMotion( + page: Page, + type: string, + name: string +): Promise { + await expect + .poll( + async () => + (await motionEvents(page)).some( + (event) => event.type === type && event.name === name + ), + `${type} ${name}` + ) + .toBe(true); +} + +/** 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 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$/); + await expectMotion(page, 'transitionend', 'transform'); + 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).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.config.ts b/apps/web/src/app/app.config.ts index 27e447a27..c5e9bdf37 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -9,7 +9,6 @@ import { OverlayContainer, } from '@angular/cdk/overlay'; import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core'; -import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; import { provideEffects } from '@ngrx/effects'; @@ -106,7 +105,10 @@ export const appConfig: ApplicationConfig = { providers: [ ...changeDetectionProviders, provideRouter(routes, withComponentInputBinding()), - provideAnimations(), + // No provideAnimations(): nothing declares an Angular animation + // trigger, and Material 22 animates with CSS, reading + // prefers-reduced-motion itself. The provider only put the 66 KB + // animation engine on the initial path. // CDK overlays (menus, tooltips, dialogs) live in a container under // , which the browser hides behind an element in DOM fullscreen. // This container follows the fullscreen element instead, so the