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

@@ -331,6 +331,7 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as
) as {
WORKSPACE: { SHELL: { RAIL_LIVE: string } };
EPG: {
PREVIOUS_DAY: string;
PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string };
TIMELINE: { WATCH_FROM_START: string };
};
@@ -365,6 +366,15 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as
.first()
.click();
const guide = app.mainWindow.locator('app-epg-list-view');
await expect(guide.locator('[data-when="now"]').first()).toBeVisible();
// The mock's 30-minute slots run relative to now, so in the first half
// hour after UTC midnight today holds only the programme on air: step
// back a day for a finished one.
if ((await guide.locator('[data-when="past"]').count()) === 0) {
await guide
.getByRole('button', { name: fr.EPG.PREVIOUS_DAY })
.click();
}
await expect(guide.locator('[data-when="past"]').first()).toBeVisible();
// The window cannot shrink below its desktop minimum, so emulate a
@@ -10,7 +10,6 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe';
interface TargetConfiguration {
configurations?: Record<string, Record<string, unknown>>;
defaultConfiguration?: string;
dependsOn?: unknown;
executor?: unknown;
options?: Record<string, unknown>;
@@ -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', () => {