diff --git a/.changes/performance-zoneless-change-detection.md b/.changes/performance-zoneless-change-detection.md new file mode 100644 index 000000000..85b46ab19 --- /dev/null +++ b/.changes/performance-zoneless-change-detection.md @@ -0,0 +1,8 @@ +--- +type: perf +area: performance +--- + +The app does less background work and starts with less code: screens now +update only when something they show changes, instead of after every timer, +network reply or click anywhere in the app. diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index e6bfbdabe..567bf70e8 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -10,7 +10,6 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe'; interface TargetConfiguration { configurations?: Record>; - defaultConfiguration?: string; dependsOn?: unknown; executor?: unknown; options?: Record; @@ -163,12 +162,7 @@ test('only the web performance build installs the tick counter the journeys read for (const [name, configuration] of Object.entries( webProject.targets['build'].configurations ?? {} )) { - if ( - name === 'electron-performance' || - name === 'electron-performance-zoneless' - ) { - continue; - } + if (name === 'electron-performance') continue; assert.doesNotMatch( JSON.stringify(configuration['fileReplacements'] ?? []), /environment\.performance/, @@ -186,12 +180,7 @@ test('only development web builds provide the NgRx store devtools', () => { replace: 'apps/web/src/environments/store-devtools.providers.ts', with: 'apps/web/src/environments/store-devtools.providers.dev.ts', }; - // electron-e2e-zoneless is electron-e2e plus the zoneless swap. - const developmentConfigurations = new Set([ - 'development', - 'electron-e2e', - 'electron-e2e-zoneless', - ]); + const developmentConfigurations = new Set(['development', 'electron-e2e']); assert.match( readFileSync(join(environments, 'store-devtools.providers.ts'), 'utf8'), @@ -230,42 +219,23 @@ test('only development web builds provide the NgRx store devtools', () => { } }); -// Plan item C6 measures zoneless change detection behind a build-time flag: -// each *-zoneless configuration is its base configuration plus one swap of -// the change-detection providers, and nothing else selects that swap. -test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => { - const configurations = webProject.targets['build'].configurations ?? {}; - const zonelessReplacement = { - replace: 'apps/web/src/environments/change-detection.providers.ts', - with: 'apps/web/src/environments/change-detection.providers.zoneless.ts', - }; +// Plan item C6: the renderer runs zoneless. A zone.js polyfill or a zone +// provider would bring back a tick after every macrotask and make the +// change-detection tick counters racy again. +test('the web app bootstraps zoneless without the zone.js polyfill', () => { + const build = webProject.targets['build']; + const configurations = Object.entries(build.configurations ?? {}); - for (const base of ['electron-performance', 'electron-e2e']) { - const baseConfiguration = configurations[base]; - const zoneless = configurations[`${base}-zoneless`]; - assert.ok(zoneless, `web:build must define ${base}-zoneless`); - assert.ok(baseConfiguration, `web:build must define ${base}`); - const { fileReplacements: baseReplacements, ...baseRest } = - baseConfiguration; - const { fileReplacements, ...rest } = zoneless; - assert.deepEqual(rest, baseRest, base); - assert.deepEqual(fileReplacements, [ - ...((baseReplacements as unknown[] | undefined) ?? []), - zonelessReplacement, - ]); + assert.equal(build.options?.['polyfills'], undefined); + for (const [name, configuration] of configurations) { + assert.equal(configuration['polyfills'], undefined, name); } - for (const [name, configuration] of Object.entries(configurations)) { - if (name.endsWith('-zoneless')) continue; - assert.doesNotMatch( - JSON.stringify(configuration['fileReplacements'] ?? []), - /change-detection\.providers/, - name - ); - } - assert.equal( - webProject.targets['build'].defaultConfiguration, - 'production' + const appConfig = readFileSync( + join(workspaceRoot, 'apps/web/src/app/app.config.ts'), + 'utf8' ); + assert.match(appConfig, /provideZonelessChangeDetection\(\)/); + assert.doesNotMatch(appConfig, /provideZoneChangeDetection/); }); test('the resolved web build cache output is the renderer directory', () => { diff --git a/apps/web/project.json b/apps/web/project.json index 6de9984ff..bed6822c7 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -19,7 +19,6 @@ "base": "dist/apps/web", "browser": "" }, - "polyfills": ["zone.js"], "tsConfig": "apps/web/tsconfig.app.json", "inlineStyleLanguage": "scss", "serviceWorker": "ngsw-config.json", @@ -149,31 +148,6 @@ } ] }, - "electron-performance-zoneless": { - "baseHref": "./", - "serviceWorker": false, - "optimization": { - "scripts": true, - "styles": { - "minify": true, - "inlineCritical": false, - "removeSpecialComments": true - }, - "fonts": true - }, - "outputHashing": "all", - "sourceMap": true, - "fileReplacements": [ - { - "replace": "apps/web/src/environments/environment.ts", - "with": "apps/web/src/environments/environment.performance.ts" - }, - { - "replace": "apps/web/src/environments/change-detection.providers.ts", - "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" - } - ] - }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, @@ -186,23 +160,6 @@ "with": "apps/web/src/environments/store-devtools.providers.dev.ts" } ] - }, - "electron-e2e-zoneless": { - "baseHref": "./", - "serviceWorker": false, - "optimization": false, - "extractLicenses": false, - "sourceMap": true, - "fileReplacements": [ - { - "replace": "apps/web/src/environments/store-devtools.providers.ts", - "with": "apps/web/src/environments/store-devtools.providers.dev.ts" - }, - { - "replace": "apps/web/src/environments/change-detection.providers.ts", - "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" - } - ] } }, "defaultConfiguration": "production" diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 6a5b072bb..eb238a5d3 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -8,7 +8,12 @@ import { FullscreenOverlayContainer, OverlayContainer, } from '@angular/cdk/overlay'; -import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core'; +import { + ApplicationConfig, + inject, + importProvidersFrom, + provideZonelessChangeDetection, +} from '@angular/core'; import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withComponentInputBinding } from '@angular/router'; @@ -32,7 +37,6 @@ import { DataService, } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; -import { changeDetectionProviders } from '../environments/change-detection.providers'; import { storeDevtoolsProviders } from '../environments/store-devtools.providers'; import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; @@ -105,7 +109,7 @@ export function DataFactory() { export const appConfig: ApplicationConfig = { providers: [ - ...changeDetectionProviders, + provideZonelessChangeDetection(), provideRouter(routes, withComponentInputBinding()), provideAnimations(), // CDK overlays (menus, tooltips, dialogs) live in a container under diff --git a/apps/web/src/app/settings/settings-unload-guard.service.ts b/apps/web/src/app/settings/settings-unload-guard.service.ts index c487f04a3..847b5a661 100644 --- a/apps/web/src/app/settings/settings-unload-guard.service.ts +++ b/apps/web/src/app/settings/settings-unload-guard.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { AppUpdateInstallService } from '../services/app-update-install.service'; import { SettingsForm } from './settings-form.utils'; @@ -39,7 +39,6 @@ export interface SettingsUnloadGuardHost { */ @Injectable() export class SettingsUnloadGuardService implements OnDestroy { - private readonly zone = inject(NgZone); private readonly installService = inject(AppUpdateInstallService); private host: SettingsUnloadGuardHost | null = null; private unsubscribeCloseRequests: (() => void) | null = null; @@ -78,7 +77,7 @@ export class SettingsUnloadGuardService implements OnDestroy { this.unsubscribeCloseRequests = window.electron?.onWindowCloseRequested?.((requestId) => { this.latestCloseRequestId = requestId; - this.zone.run(() => void this.handleCloseRequest('close')); + void this.handleCloseRequest('close'); }) ?? null; // Whatever surface installs an app update — the settings About @@ -141,9 +140,7 @@ export class SettingsUnloadGuardService implements OnDestroy { this.host = null; } - private readonly beforeUnloadHandler = ( - event: BeforeUnloadEvent - ): void => { + private readonly beforeUnloadHandler = (event: BeforeUnloadEvent): void => { if (!this.host?.form.dirty) { return; } @@ -160,7 +157,7 @@ export class SettingsUnloadGuardService implements OnDestroy { // handler entirely — so this can only be a reload: ask, then // re-trigger it. setTimeout(() => { - this.zone.run(() => void this.handleCloseRequest('reload')); + void this.handleCloseRequest('reload'); }); } }; diff --git a/apps/web/src/environments/change-detection-tick-counter.ts b/apps/web/src/environments/change-detection-tick-counter.ts index 98e759546..071fecec2 100644 --- a/apps/web/src/environments/change-detection-tick-counter.ts +++ b/apps/web/src/environments/change-detection-tick-counter.ts @@ -9,11 +9,11 @@ import { ApplicationRef } from '@angular/core'; * * Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode * `window.ng` global, which the optimized build does not publish. Every - * tick, whether scheduled by zone.js (`onMicrotaskEmpty`), the zoneless - * scheduler or an explicit `ApplicationRef.tick()`, goes through the - * internal `ApplicationRef._tick`, which is also where Angular emits the - * profiler's `ChangeDetectionStart`. Counting its calls therefore matches - * the profiler's count and stays comparable across the zoneless migration. + * tick, whether scheduled by the zoneless scheduler, an `afterNextRender` + * hook or an explicit `ApplicationRef.tick()`, goes through the internal + * `ApplicationRef._tick`, which is also where Angular emits the profiler's + * `ChangeDetectionStart`. Counting its calls therefore matches the + * profiler's count; it did so under zone.js too, before the zoneless flip. * Contract: docs/architecture/performance-journeys.md. */ export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks'; diff --git a/apps/web/src/environments/change-detection.providers.ts b/apps/web/src/environments/change-detection.providers.ts deleted file mode 100644 index d0cf2caa0..000000000 --- a/apps/web/src/environments/change-detection.providers.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - EnvironmentProviders, - provideZoneChangeDetection, -} from '@angular/core'; - -// Change detection for every build: zone.js schedules the ticks. The -// *-zoneless build configurations replace this file with -// change-detection.providers.zoneless.ts while plan item C6 measures -// zoneless change detection; see docs/architecture/zoneless-migration.md. -export const changeDetectionProviders: EnvironmentProviders[] = [ - provideZoneChangeDetection({ eventCoalescing: true }), -]; diff --git a/apps/web/src/environments/change-detection.providers.zoneless.ts b/apps/web/src/environments/change-detection.providers.zoneless.ts deleted file mode 100644 index 30e71ff06..000000000 --- a/apps/web/src/environments/change-detection.providers.zoneless.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { - EnvironmentProviders, - provideZonelessChangeDetection, -} from '@angular/core'; - -// Swapped in for change-detection.providers.ts by the *-zoneless build -// configurations only. zone.js stays in the polyfills until the flip, so -// Angular logs NG0914 in these builds; nothing patches through it. -export const changeDetectionProviders: EnvironmentProviders[] = [ - provideZonelessChangeDetection(), -]; diff --git a/apps/web/src/environments/store-devtools.providers.dev.ts b/apps/web/src/environments/store-devtools.providers.dev.ts index 4776bc9f5..1564cf9e7 100644 --- a/apps/web/src/environments/store-devtools.providers.dev.ts +++ b/apps/web/src/environments/store-devtools.providers.dev.ts @@ -1,8 +1,8 @@ import { EnvironmentProviders } from '@angular/core'; import { provideStoreDevtools } from '@ngrx/store-devtools'; -// Swapped in for store-devtools.providers.ts by the development, -// electron-e2e and electron-e2e-zoneless build configurations only. +// Swapped in for store-devtools.providers.ts by the development and +// electron-e2e build configurations only. export const storeDevtoolsProviders: EnvironmentProviders[] = [ provideStoreDevtools({ maxAge: 25 }), ]; diff --git a/apps/web/src/polyfills-test.ts b/apps/web/src/polyfills-test.ts deleted file mode 100644 index aa09a9ff6..000000000 --- a/apps/web/src/polyfills-test.ts +++ /dev/null @@ -1 +0,0 @@ -import 'zone.js'; diff --git a/apps/web/src/polyfills.ts b/apps/web/src/polyfills.ts deleted file mode 100644 index b40974e69..000000000 --- a/apps/web/src/polyfills.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * This file includes polyfills needed by Angular and is loaded before the app. - * You can add your own extra polyfills to this file. - * - * This file is divided into 2 sections: - * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. - * 2. Application imports. Files imported after ZoneJS that should be loaded before your main - * file. - * - * The current setup is for so-called "evergreen" browsers; the last versions of browsers that - * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), - * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. - * - * Learn more in https://angular.io/guide/browser-support - */ - -/*************************************************************************************************** - * BROWSER POLYFILLS - */ - -/** - * By default, zone.js will patch all possible macroTask and DomEvents - * user can disable parts of macroTask/DomEvents patch by setting following flags - * because those flags need to be set before `zone.js` being loaded, and webpack - * will put import in the top of bundle, so user need to create a separate file - * in this directory (for example: zone-flags.ts), and put the following flags - * into that file, and then add the following code before importing zone.js. - * import './zone-flags.ts'; - * - * The flags allowed in zone-flags.ts are listed here. - * - * The following flags will work for all browsers. - * - * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame - * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick - * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames - * - * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js - * with the following flag, it will bypass `zone.js` patch for IE/Edge - * - * (window as any).__Zone_enable_cross_context_check = true; - * - */ - -/*************************************************************************************************** - * Zone JS is required by default for Angular itself. - */ -import 'zone.js'; // Included with Angular CLI. - -/*************************************************************************************************** - * APPLICATION IMPORTS - */ diff --git a/apps/web/src/setup-jest.ts b/apps/web/src/setup-jest.ts deleted file mode 100644 index 1100b3e8a..000000000 --- a/apps/web/src/setup-jest.ts +++ /dev/null @@ -1 +0,0 @@ -import 'jest-preset-angular/setup-jest'; diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index fac0b08be..44e538282 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -250,8 +250,9 @@ swaps `apps/web/src/environments/environment.ts` for and calls `installChangeDetectionTickCounter()` from `change-detection-tick-counter.ts` while `main.js` is evaluated, before Angular bootstraps. The counter wraps the internal `ApplicationRef._tick`, -the method every tick runs through: the zone scheduler's `onMicrotaskEmpty`, -the zoneless scheduler, `afterNextRender` idle buckets and the public +the method every tick runs through: the zoneless scheduler (and, before the +zoneless flip, the zone scheduler's `onMicrotaskEmpty`), `afterNextRender` +idle buckets and the public `ApplicationRef.tick()` all call it, and it is where Angular emits the profiler's `ChangeDetectionStart`. The count therefore equals the profiler's tick count and stays comparable across the zoneless migration. The running @@ -296,6 +297,16 @@ on another differ by one tick without any different work. Treat a one-tick difference in J1 as that race, and confirm on the CI runner that the counter is deterministic before it becomes a baseline. +Since the zoneless flip (plan item C6) the renderer has no zone.js: a tick +runs only when a signal, a `markForCheck`, an Angular event or an +`afterNextRender` hook schedules one, so the race above is gone. The first +local measurement after the flip (macOS, 2026-10-13, five iterations each) +read `renderer.cdTicksToFirstCard` 5, `renderer.cdTicksIdle30s` 3 and +`renderer.cdTicksToFirstPage` 9 in every iteration, `renderer.cdTicksToPlaying` +7 or 8 and J4 `renderer.cdTicksToResults` 11 or 12; the DOM mutation counts +were identical across iterations. The history of the migration and the +before/after tables are in [zoneless migration](zoneless-migration.md). + #### Main-process counters With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets, @@ -1150,8 +1161,8 @@ runtime flag: `if (!AppConfig.production)` keeps the imported module in `main.js` because the optimizer does not fold the property read. The NgRx store devtools therefore come from `apps/web/src/environments/store-devtools.providers.ts`, an empty list in -every build, which only the `development`, `electron-e2e` and -`electron-e2e-zoneless` configurations replace with +every build, which only the `development` and `electron-e2e` configurations +replace with `store-devtools.providers.dev.ts`; a build-config test in `performance-build-config.spec.ts` keeps it that way. Removing the static import lowered the counter by 12,565 bytes. diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index a5eddb7c6..9b7720526 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -40,11 +40,11 @@ must be ticked here. ## PR sequence -1. [ ] Keep `@ngrx/store-devtools` out of production bundles (#1810, open). Not a +1. [x] Keep `@ngrx/store-devtools` out of production bundles (#1810). Not a zone change; it lowered `renderer.initialBytes` before the migration starts moving it. 2. [x] This inventory and its guard spec. -3. [ ] Per-project PRs, in this order, each converting the project's Eager +3. [x] Per-project PRs (#1818 to #1823), in this order, each converting the project's Eager components and fixing its zone-dependent sites while zone.js stays on: `libs/ui/*`, `libs/workspace/*`, `libs/playlist/*`, `libs/portal/*`, playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`), @@ -54,7 +54,7 @@ must be ticked here. `fileReplacements` flag, off by default; the three implemented journeys (J1-J3; J4 is still planned) and the Electron E2E suite run with it on (see [Zoneless flag](#zoneless-flag)). -5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines +5. [x] Flag on by default, `zone.js` out of `polyfills`, new tick baselines (`renderer.cdTicksIdle30s` and any counter that becomes deterministic once the zone.js race is gone). @@ -185,9 +185,10 @@ the field a signal (or a `computed`), or writes it through one. ## Explicit zone and change-detector calls -They keep working under zoneless (`NgZone` becomes `NoopNgZone`, so `run` -and `runOutsideAngular` just call through). Remove them in the flip PR, not -before: with zone.js on they still matter. +They kept working under zoneless (`NgZone` becomes `NoopNgZone`, so `run` +and `runOutsideAngular` just call through) and were removed in the flip PR, +because with zone.js on they still mattered. The embedded MPV position poll's +spec case that asserted `runOutsideAngular` went with it. - [x] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two `zone.run` calls around the window-close dialog (IPC @@ -196,10 +197,10 @@ before: with zone.js on they still matter. `runOutsideAngular(() => setInterval(...))` for the position poll; `embedded-mpv-session-controller.position.spec.ts` asserts the call and changes with it. -- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`: +- [x] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`: 18 `ngZone.run(() => signal.set(...))` calls, all redundant around signal writes. -- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`: +- [x] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`: one `ngZone.run` around a signal update. - `ChangeDetectorRef` in `stalker-live-stream-layout.component.ts` (4 × `markForCheck`, 1 × `detectChanges` before measuring a row) and @@ -235,46 +236,31 @@ their events arrive over IPC. ## Build, tests and runtime details -- [ ] `provideServiceWorker(..., { registrationStrategy: +- `provideServiceWorker(..., { registrationStrategy: 'registerWhenStable:30000' })`: under zoneless "stable" means no pending - tasks. The 30 s bound still registers the worker; check the PWA build in - the flag PR. -- [ ] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which + tasks; the 30 s bound registers the worker even if a task stays pending. +- [x] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which the zoneless scheduler also calls, so the counters stay comparable. -- [ ] Specs that need zone.js: `fakeAsync` in +- Specs that need zone.js: `fakeAsync` in `playlist-switcher.component.spec.ts` and `stalker-live-navigation.spec.ts`, - `waitForAsync` in 13 files. They keep `zone.js/testing` until rewritten; - removing zone.js from the build polyfills does not affect them. -- [ ] Unreferenced leftovers to delete in the flip PR: + `waitForAsync` in 13 files. They keep `zone.js/testing` (and the `zone.js` + package) until rewritten; the build polyfills do not affect them. + `apps/remote-control-web` still bootstraps with zone.js. +- [x] Unreferenced leftovers deleted in the flip PR: `apps/web/src/polyfills.ts`, `apps/web/src/polyfills-test.ts`, `apps/web/src/setup-jest.ts` (no project, tsconfig or Jest config uses them). ## Zoneless flag -`app.config.ts` takes its change-detection providers from -`apps/web/src/environments/change-detection.providers.ts` -(`provideZoneChangeDetection({ eventCoalescing: true })`). The -`electron-performance-zoneless` and `electron-e2e-zoneless` web -configurations are their base configuration plus one `fileReplacements` -swap to `change-detection.providers.zoneless.ts` -(`provideZonelessChangeDetection()`); a test in -`performance-build-config.spec.ts` pins that and refuses the swap in any -other configuration. zone.js stays in the polyfills, so these builds log -NG0914 in dev mode and nothing schedules through the zone. The Electron app -loads the renderer from `dist/apps/web`, so rebuilding only the web app -switches an existing Electron build: - -```bash -pnpm nx run electron-backend:build-performance # or build-e2e -pnpm nx run web:build:electron-performance-zoneless # or electron-e2e-zoneless -cd apps/electron-backend-e2e -../../node_modules/.bin/playwright test --config=playwright.journeys.config.ts -../../node_modules/.bin/playwright test --grep-invert packaged -``` - -Do not run `pnpm run perf:journeys` or `pnpm nx run electron-backend-e2e:e2e` -afterwards: their build dependencies restore the zone.js renderer. +Between #1824 and the flip, `app.config.ts` took its change-detection +providers from `environments/change-detection.providers.ts`, and two +`*-zoneless` web configurations swapped in `provideZonelessChangeDetection()` +so the journeys and the Electron E2E suite could run zoneless before anything +shipped it. The flip PR removed the flag: `app.config.ts` provides +`provideZonelessChangeDetection()` directly, `apps/web` builds without the +`zone.js` polyfill, and `performance-build-config.spec.ts` fails if either +comes back. First measurement (macOS, 2026-10-04, the six OnPush PRs merged locally on the flag branch; the same integration build measured with the flag off and @@ -307,8 +293,11 @@ passed zoneless: external-player launch states and the MPV/VLC DASH fallbacks in `dash-clearkey`, the MPV double-click gate in `settings`, `remote-control`, `picture-in-picture` and `stream-info`. Their state reaches the renderer over IPC into signals and never ran in the zone. Embedded MPV -playback itself is covered only by the packaged frame-copy E2E, which these -runs left out; run it on a packaged zoneless build before the flip. +playback itself is covered only by the packaged frame-copy E2E: it passed on a +packaged zoneless Linux x64 build from throwaway draft #1832 (see the comment +on #1824), with every packaging job and 8 of 9 cross-platform E2E shards green +(the ninth failed `dashboard-hero-legibility`, which failed the same day on +zone.js PRs too). ## Measuring a PR diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.position.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.position.spec.ts index 9b5ac2e09..cf8effdcc 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.position.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.position.spec.ts @@ -1,4 +1,3 @@ -import { NgZone } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { EmbeddedMpvSession, @@ -132,27 +131,6 @@ describe('EmbeddedMpvSessionController position drift poll', () => { expect(electron.setEmbeddedMpvBounds).not.toHaveBeenCalled(); }); - it('registers the poll outside the Angular zone', async () => { - // With zone change detection, a zone-registered interval would run - // app-wide change detection every 500 ms for the whole stream. - const zone = TestBed.inject(NgZone); - const runOutsideAngular = jest.spyOn(zone, 'runOutsideAngular'); - - const controller = TestBed.inject(EmbeddedMpvSessionController); - const teardown = controller.startSession( - createHost(), - createPlayback(), - 0.5 - ); - await waitFor( - () => controller.sessionId() === 'mpv-1', - 'session to start' - ); - - expect(runOutsideAngular).toHaveBeenCalled(); - teardown(); - }); - it('skips drift measurement for the frame-copy engine', async () => { // Frame-copy paints into a DOM canvas that moves with the layout, so // a position-only shift needs no re-sync (size changes still arrive @@ -162,9 +140,7 @@ describe('EmbeddedMpvSessionController position drift poll', () => { platform: 'linux', engine: 'frame-copy', }); - electron.attachEmbeddedMpvFrameView = jest - .fn() - .mockResolvedValue(true); + electron.attachEmbeddedMpvFrameView = jest.fn().mockResolvedValue(true); electron.detachEmbeddedMpvFrameView = jest.fn(); const rect = { left: 10, top: 20, width: 640, height: 360 }; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index 8986bd828..cf2af1431 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -1,7 +1,6 @@ import { DestroyRef, Injectable, - NgZone, computed, effect, inject, @@ -80,7 +79,6 @@ export class EmbeddedMpvSessionController { ); private readonly destroyRef = inject(DestroyRef); - private readonly zone = inject(NgZone); private readonly translate = inject(TranslateService); private readonly unsubscribeSessionUpdate?: () => void; @@ -192,28 +190,22 @@ export class EmbeddedMpvSessionController { // on drift, so the idle cost is one getBoundingClientRect per tick // with no IPC. // - // The interval runs outside Angular's zone: with zone change - // detection a zone-registered timer would run app-wide change - // detection every tick for the whole stream. It never re-enters the - // zone — the drift path is rAF → setEmbeddedMpvBounds IPC and - // touches no Angular state. Frame-copy paints into a DOM canvas + // The poll touches no Angular state, so with zoneless change + // detection it schedules no render: the drift path is rAF → + // setEmbeddedMpvBounds IPC. Frame-copy paints into a DOM canvas // that moves with the layout, so only native-view can go stale on a // position-only shift; the poll skips the measurement there. - const positionPoll = this.zone.runOutsideAngular(() => - window.setInterval(() => { - if (!activeSessionId || !lastSyncedBounds) { - return; - } - if (untracked(() => this.isFrameCopyEngine())) { - return; - } - if ( - boundsDiffer(this.boundsProvider(host), lastSyncedBounds) - ) { - scheduleBoundsSync(); - } - }, POSITION_POLL_INTERVAL_MS) - ); + const positionPoll = window.setInterval(() => { + if (!activeSessionId || !lastSyncedBounds) { + return; + } + if (untracked(() => this.isFrameCopyEngine())) { + return; + } + if (boundsDiffer(this.boundsProvider(host), lastSyncedBounds)) { + scheduleBoundsSync(); + } + }, POSITION_POLL_INTERVAL_MS); // Page zoom and monitor DPI rescale the CSS→native-pixel mapping the // backend applies to these bounds. Moving the window to a display diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 80f52340d..7256473a2 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -1,11 +1,4 @@ -import { - Injectable, - NgZone, - computed, - effect, - inject, - signal, -} from '@angular/core'; +import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { @@ -91,7 +84,6 @@ export class DashboardDataService { private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); private readonly playlistsService = inject(PlaylistsService); private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly ngZone = inject(NgZone); private readonly translate = inject(TranslateService); private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); private readonly favoritesAutoRefreshEnabled = signal(false); @@ -372,13 +364,11 @@ export class DashboardDataService { if (generation !== this.playbackPositionsLoadGeneration) { return; } - this.ngZone.run(() => { - this.playbackPositionMaps.set(maps); - // A playlist whose load failed counts as loaded: its titles - // show without progress rather than holding the rail back. - this.playbackPositionPlaylistIds.set(playlistIds); - this.playbackPositionsReloads.update((count) => count + 1); - }); + this.playbackPositionMaps.set(maps); + // A playlist whose load failed counts as loaded: its titles + // show without progress rather than holding the rail back. + this.playbackPositionPlaylistIds.set(playlistIds); + this.playbackPositionsReloads.update((count) => count + 1); } readonly stalkerGlobalFavorites = computed(() => { @@ -440,11 +430,9 @@ export class DashboardDataService { return; } - this.ngZone.run(() => { - this.xtreamRecentlyAddedItemsState.set([]); - this.xtreamRecentlyAddedLoadingState.set(false); - this.xtreamRecentlyAddedLoadedState.set(true); - }); + this.xtreamRecentlyAddedItemsState.set([]); + this.xtreamRecentlyAddedLoadingState.set(false); + this.xtreamRecentlyAddedLoadedState.set(true); }); } @@ -490,9 +478,7 @@ export class DashboardDataService { if (!this.hasPortalActivityStorage) { try { const recentItems = await this.loadPwaXtreamGlobalRecentItems(); - this.ngZone.run(() => - this.xtreamGlobalRecentItems.set(recentItems) - ); + this.xtreamGlobalRecentItems.set(recentItems); } catch (err) { console.warn( '[DashboardData] Failed to reload PWA recent items', @@ -510,13 +496,13 @@ export class DashboardDataService { const normalized = recentItems.map((item) => mapDbRecentToItem(item) ); - this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized)); + this.xtreamGlobalRecentItems.set(normalized); } catch (err) { console.warn( '[DashboardData] Failed to reload global recent items', err ); - this.ngZone.run(() => this.xtreamGlobalRecentItems.set([])); + this.xtreamGlobalRecentItems.set([]); } finally { this.globalRecentDbLoadedState.set(true); this.finishInitialGlobalRecentLoadIfReady(); @@ -531,9 +517,7 @@ export class DashboardDataService { } const xtreamReload = this.reloadXtreamGlobalFavorites().finally(() => { - this.ngZone.run(() => - this.xtreamGlobalFavoritesLoadedState.set(true) - ); + this.xtreamGlobalFavoritesLoadedState.set(true); this.finishInitialGlobalFavoritesLoadIfReady(); }); const m3uReload = this.reloadPlaylistBackedGlobalFavorites(); @@ -586,37 +570,31 @@ export class DashboardDataService { } if (!this.hasPortalActivityStorage) { - this.ngZone.run(() => { - this.xtreamRecentlyAddedItemsState.set([]); - this.xtreamRecentlyAddedLoadingState.set(false); - this.xtreamRecentlyAddedLoadedState.set(true); - }); + this.xtreamRecentlyAddedItemsState.set([]); + this.xtreamRecentlyAddedLoadingState.set(false); + this.xtreamRecentlyAddedLoadedState.set(true); return; } try { const items = await this.getXtreamRecentlyAddedItems(limit); - this.ngZone.run(() => - this.xtreamRecentlyAddedItemsState.set(items) - ); + this.xtreamRecentlyAddedItemsState.set(items); } catch (err) { console.warn( '[DashboardData] Failed to reload Xtream recently added items', err ); - this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([])); + this.xtreamRecentlyAddedItemsState.set([]); } finally { - this.ngZone.run(() => { - this.xtreamRecentlyAddedLoadingState.set(false); - this.xtreamRecentlyAddedLoadedState.set(true); - }); + this.xtreamRecentlyAddedLoadingState.set(false); + this.xtreamRecentlyAddedLoadedState.set(true); } } private async reloadXtreamGlobalFavorites(): Promise { if (!this.hasPortalActivityStorage) { const favorites = await this.loadPwaXtreamGlobalFavorites(); - this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites)); + this.xtreamGlobalFavorites.set(favorites); return; } @@ -625,13 +603,13 @@ export class DashboardDataService { const normalized = favorites.map((item) => mapDbFavoriteToItem(item) ); - this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized)); + this.xtreamGlobalFavorites.set(normalized); } catch (err) { console.warn( '[DashboardData] Failed to reload global favorites', err ); - this.ngZone.run(() => this.xtreamGlobalFavorites.set([])); + this.xtreamGlobalFavorites.set([]); } } @@ -730,9 +708,7 @@ export class DashboardDataService { return; } - this.ngZone.run(() => - this.playlistBackedGlobalFavoritesLoadedState.set(true) - ); + this.playlistBackedGlobalFavoritesLoadedState.set(true); this.finishInitialGlobalFavoritesLoadIfReady(); } @@ -762,17 +738,15 @@ export class DashboardDataService { // Drop entries for M3U playlists no longer in the eligible set // (removed playlists or playlists whose favorites were cleared). - this.ngZone.run(() => { - this.m3uPlaylistFavoritesMap.update((prev) => { - let next: Map | null = null; - for (const id of prev.keys()) { - if (!validIds.has(id)) { - next ??= new Map(prev); - next.delete(id); - } + this.m3uPlaylistFavoritesMap.update((prev) => { + let next: Map | null = null; + for (const id of prev.keys()) { + if (!validIds.has(id)) { + next ??= new Map(prev); + next.delete(id); } - return next ?? prev; - }); + } + return next ?? prev; }); for (const id of Array.from(this.m3uFavoritesCache.keys())) { if (!validIds.has(id)) { @@ -801,12 +775,10 @@ export class DashboardDataService { items = []; } - this.ngZone.run(() => { - this.m3uPlaylistFavoritesMap.update((prev) => { - const next = new Map(prev); - next.set(playlist._id, items); - return next; - }); + this.m3uPlaylistFavoritesMap.update((prev) => { + const next = new Map(prev); + next.set(playlist._id, items); + return next; }); }) ); @@ -821,10 +793,8 @@ export class DashboardDataService { return; } - this.ngZone.run(() => { - this.globalFavoritesLoadedState.set(true); - this.globalFavoritesLoadingState.set(false); - }); + this.globalFavoritesLoadedState.set(true); + this.globalFavoritesLoadingState.set(false); } private finishInitialGlobalRecentLoadIfReady(): void { @@ -832,10 +802,8 @@ export class DashboardDataService { return; } - this.ngZone.run(() => { - this.globalRecentLoadedState.set(true); - this.globalRecentLoadingState.set(false); - }); + this.globalRecentLoadedState.set(true); + this.globalRecentLoadingState.set(false); } isTypeInKind( diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts index b7e31f978..a9a493230 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts @@ -1,7 +1,7 @@ import { Injector } from '@angular/core'; import { SourceHealthService } from '@iptvnator/portal/shared/data-access'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { Injectable, NgZone, inject, signal } from '@angular/core'; +import { Injectable, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { normalizeStoredStalkerAccountInfo } from '@iptvnator/portal/stalker/data-access'; import { PlaylistsService, PortalStatusService } from '@iptvnator/services'; @@ -26,7 +26,6 @@ import type { SourceExpiryFacts } from './dashboard-source-expiry.util'; export class DashboardSourceExpiryService { private readonly injector = inject(Injector); private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly ngZone = inject(NgZone); private readonly playlistsService = inject(PlaylistsService); private readonly portalStatusService = inject(PortalStatusService); @@ -182,8 +181,6 @@ export class DashboardSourceExpiryService { if (round !== this.refreshRound) { return; } - this.ngZone.run(() => { - this.factsState.update(update); - }); + this.factsState.update(update); } } diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index ee8841485..4c51fb594 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,11 +3,11 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1595777, + "value": 1545931, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-10-07", - "evidencePr": 1810, + "updatedAt": "2026-10-13", + "evidencePr": null, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" }, "renderer.ipcCallsToFirstCard": {