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 0ff7caa34..91a8701f4 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,6 +10,7 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe'; interface TargetConfiguration { configurations?: Record>; + defaultConfiguration?: string; dependsOn?: unknown; executor?: unknown; options?: Record; @@ -162,7 +163,12 @@ 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') continue; + if ( + name === 'electron-performance' || + name === 'electron-performance-zoneless' + ) { + continue; + } assert.doesNotMatch( JSON.stringify(configuration['fileReplacements'] ?? []), /environment\.performance/, @@ -171,6 +177,44 @@ test('only the web performance build installs the tick counter the journeys read } }); +// 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', + }; + + 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, + ]); + } + 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' + ); +}); + test('the resolved web build cache output is the renderer directory', () => { const task = readResolvedWebBuildTask(); diff --git a/apps/web/project.json b/apps/web/project.json index 7244c4ac5..74c415311 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -143,12 +143,50 @@ } ] }, + "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, "optimization": false, "extractLicenses": false, "sourceMap": true + }, + "electron-e2e-zoneless": { + "baseHref": "./", + "serviceWorker": false, + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "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 1fc0d8950..edd657996 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -8,12 +8,7 @@ import { FullscreenOverlayContainer, OverlayContainer, } from '@angular/cdk/overlay'; -import { - ApplicationConfig, - inject, - importProvidersFrom, - provideZoneChangeDetection, -} from '@angular/core'; +import { ApplicationConfig, inject, importProvidersFrom } 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'; @@ -39,6 +34,7 @@ import { } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../environments/environment'; +import { changeDetectionProviders } from '../environments/change-detection.providers'; import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; import { ExternalPlaybackService } from './services/external-playback.service'; @@ -110,7 +106,7 @@ export function DataFactory() { export const appConfig: ApplicationConfig = { providers: [ - provideZoneChangeDetection({ eventCoalescing: true }), + ...changeDetectionProviders, provideRouter(routes, withComponentInputBinding()), provideAnimations(), // CDK overlays (menus, tooltips, dialogs) live in a container under diff --git a/apps/web/src/environments/change-detection.providers.ts b/apps/web/src/environments/change-detection.providers.ts new file mode 100644 index 000000000..d0cf2caa0 --- /dev/null +++ b/apps/web/src/environments/change-detection.providers.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..30e71ff06 --- /dev/null +++ b/apps/web/src/environments/change-detection.providers.zoneless.ts @@ -0,0 +1,11 @@ +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/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 2dc07968c..10690f88d 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -176,6 +176,7 @@ the field a signal (or a `computed`), or writes it through one. | [x] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal | | [x] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal | | [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; no fix needed: a value-only `patchValue` re-renders the OnPush sections (`settings.component.form.spec.ts` guards it) | apps/web | +| [x] | `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts` (jump to now, keyboard focus) | `afterNextRender` registered from CDK/RxJS callbacks; zone.js followed them with a tick, zoneless schedules no render, so the guide opened at midnight. It now marks itself when it registers the hook. Found by `epg-guide.e2e.ts` on the zoneless build | flag | | [ ] | `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` | plain `isLoading`/`error`/`status` written after `await` and from a 2 s `setInterval` | only if `apps/remote-control-web` goes zoneless | ## Explicit zone and change-detector calls @@ -245,6 +246,66 @@ their events arrive over IPC. `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. + +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 +on, five iterations each): + +| Counter | flag off | flag on | +| --- | --- | --- | +| `renderer.cdTicksToFirstCard` | 22, 20, 22, 21, 21 | 7, 7, 7, 7, 7 | +| `renderer.cdTicksIdle30s` | 4, 4, 4, 4, 4 | 3, 3, 3, 3, 3 | +| `renderer.cdTicksToFirstPage` | 22 (all) | 8 (all) | +| `renderer.cdTicksToPlaying` | 16, 15, 15, 17, 15 | 6, 8, 10, 10, 7 | +| DOM mutations J1 / J2 / J3 | 553 / 1,603 / 6,182 | 553 / 1,603 / 6,182 (one J3 iteration 6,199, as on master) | +| `spawnToFirstCardMs` p50 | 3,071 | 828 | +| `clickToFirstPageMs` p50 | 82.8 | 80.4 | +| `clickToLoadedMetadataMs` / `clickToPlayingMs` p50 | 94.8 / 268.7 | 158.3 / 409.3 | + +J1 and J2 tick counts become deterministic without the zone.js one-tick +race, and the DOM mutations are unchanged, so nothing renders differently. +J3's tick count still varies with player events and its wall-clock +numbers rose locally; the machine was shared with other runs (load 26 to 58 +during these two runs, master itself read 363.8 ms `clickToPlayingMs` p50 +earlier the same day), so judge J3 on the CI runner before the flip. + +Electron E2E suite on `electron-e2e-zoneless` (all specs except the +packaged frame-copy ones): 203 passed, 7 skipped, 3 failed. `epg-guide` +failed on every run and is fixed above; `playlist-auto-refresh` passed on +`--repeat-each=2`; `dash-clearkey` "reopens from recent and favorites" is the +known local flake (it fails as often on master). The IPC-driven paths +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. + ## Measuring a PR Build `electron-performance` and run the journeys as described in diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index dfed6d379..c6ba666d3 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -6,6 +6,7 @@ import { DatePipe } from '@angular/common'; import { afterNextRender, ChangeDetectionStrategy, + ChangeDetectorRef, Component, computed, DestroyRef, @@ -83,6 +84,7 @@ export class EpgGuideComponent implements OnDestroy { private readonly translate = inject(TranslateService); private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); + private readonly changeDetector = inject(ChangeDetectorRef); private readonly hostElement = inject>(ElementRef); readonly close = output(); @@ -185,8 +187,7 @@ export class EpgGuideComponent implements OnDestroy { activeRow: () => this.activeRowIndex(), ensureLoaded: (channels) => this.programsService.ensureLoaded(channels), setScrollLeft: (left) => this.view.scrollLeft.set(left), - afterRender: (callback) => - afterNextRender(callback, { injector: this.injector }), + afterRender: (callback) => this.afterNextGuideRender(callback), }); private readonly dialogs = new EpgGuideDialogController( @@ -237,15 +238,23 @@ export class EpgGuideComponent implements OnDestroy { this.viewportController.whenRowsRendered( viewport, this.destroyRef, - () => - afterNextRender(() => this.jumpNow(false), { - injector: this.injector, - }) + () => this.afterNextGuideRender(() => this.jumpNow(false)) ); }); }); } + /** + * Run `callback` after the next render. The callers register from CDK + * and RxJS callbacks, outside any template event: zone.js used to follow + * those with a tick, but without it registering a render hook schedules + * no render, so the guide also marks itself for one. + */ + private afterNextGuideRender(callback: () => void): void { + afterNextRender(callback, { injector: this.injector }); + this.changeDetector.markForCheck(); + } + ngOnDestroy(): void { this.view.destroy(); this.search.destroy();