From 0631a3dec1ae154e74b6fc16638e1a8c959040f9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 12:16:14 +0200 Subject: [PATCH] perf(web): add an opt-in zoneless change-detection build flag Plan item C6 step 4. app.config.ts takes its change-detection providers from environments/change-detection.providers.ts, which keeps provideZoneChangeDetection({ eventCoalescing: true }) for every existing build. The new electron-performance-zoneless and electron-e2e-zoneless web configurations are their base configuration plus one fileReplacements swap to provideZonelessChangeDetection(), so the journeys and the Electron E2E suite can run zoneless while nothing ships it. zone.js stays in the polyfills until the flip. A build-config test pins each *-zoneless configuration to its base plus the swap and refuses the swap anywhere else. Co-Authored-By: Claude Opus 5.5 --- .../performance-build-config.spec.ts | 46 ++++++++++++++++++- apps/web/project.json | 38 +++++++++++++++ apps/web/src/app/app.config.ts | 10 ++-- .../change-detection.providers.ts | 12 +++++ .../change-detection.providers.zoneless.ts | 11 +++++ 5 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/environments/change-detection.providers.ts create mode 100644 apps/web/src/environments/change-detection.providers.zoneless.ts 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(), +];