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

@@ -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 };
@@ -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