perf(web): run the renderer zoneless and drop the zone.js polyfill (#1929)

* perf(web): run the renderer zoneless and drop the zone.js polyfill

Plan item C6 step 5. app.config.ts provides
provideZonelessChangeDetection() directly and apps/web builds without the
zone.js polyfill, so a tick runs only when a signal, markForCheck, an
Angular event or a render hook asks for one, not after every macrotask.
The flag from #1824 (change-detection.providers*.ts and the *-zoneless
configurations) goes away; a build-config test fails if the polyfill or a
zone provider comes back.

NgZone.run/runOutsideAngular calls that only mattered with zone.js are
removed from the dashboard data services, the settings unload guard and
the embedded MPV position poll (with the spec case asserting it), and
the unused polyfills.ts, polyfills-test.ts and setup-jest.ts are deleted.
Unit tests keep zone.js/testing for fakeAsync and waitForAsync;
apps/remote-control-web still bootstraps with zone.js.

renderer.initialBytes: 1,596,201 -> 1,545,931 bytes (-50,270) on a local
production build of this base; the baseline is lowered to the measured
value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(web): cite #1929 as the initial-bytes baseline evidence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(web): ratchet the change-detection ticks that zoneless made deterministic

On the canonical runner (Performance ratchet run 38359088033, three runs
of this branch, five iterations each) renderer.cdTicksToFirstCard read 5,
renderer.cdTicksIdle30s 3 and renderer.cdTicksToFirstPage 9 in all 15
iterations, against 31, 1 to 4 and 22 with zone.js. They become baselines
with zero slack and the Performance journeys job checks them. J3 and J4
tick counts still vary by one and stay unenforced; J3 wall clock is in
master's range, so the local rise measured on #1824 was machine load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(web): allow J1's one-tick lazy-chunk race in the first-card tick baseline

After merging #1931 (Angular Material off the startup path) the
Performance journeys job read renderer.cdTicksToFirstCard 5, 5, 5, 5, 6:
the lazily loaded chunk resolves on either side of a render. All 15
iterations before it read 5. The baseline stays 5 with one tick of slack
and a note; a return towards zone.js levels (31) still fails, and the
idle and first-page tick baselines stayed exact in the same run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): find a finished programme for the phone dialog after UTC midnight

The Xtream mock lays its 30-minute EPG slots out relative to now, and the
list view shows the programmes that overlap the selected day. In the
first half hour after midnight (the spec pins TZ=UTC) today holds only
the programme on air, so 'stacks the programme dialog actions on a phone'
found no data-when=past row and failed every retry on #1929's Windows
shard (00:18-00:23 UTC). The spec now waits for the list, and steps back
a day when today has no finished programme yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(architecture): record the zoneless renderer in the workspace contract

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-14 21:21:16 +02:00
1 parent 3b2af73baa
commit 37210ead25
22 files changed
+230 -367

No files matched your search

-43
View File
@@ -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"
+7 -3
View File
@@ -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 { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
import { provideEffects } from '@ngrx/effects';
@@ -30,7 +35,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';
@@ -103,7 +107,7 @@ export function DataFactory() {
export const appConfig: ApplicationConfig = {
providers: [
...changeDetectionProviders,
provideZonelessChangeDetection(),
provideRouter(routes, withComponentInputBinding()),
// No provideAnimations(): nothing declares an Angular animation
// trigger, and Material 22 animates with CSS, reading
@@ -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');
});
}
};
@@ -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';
@@ -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 }),
];
@@ -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(),
];
@@ -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 }),
];
-1
View File
@@ -1 +0,0 @@
import 'zone.js';
-52
View File
@@ -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
*/
-1
View File
@@ -1 +0,0 @@
import 'jest-preset-angular/setup-jest';