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

+12 -8
View File
@@ -64,12 +64,14 @@ in both dependency sections and all official Nx packages on one exact version.
Use `.nvmrc` for development/CI; supported LTS Node ranges are
`^22.22.3 || ^24.15.0`. Docker uses the supported Node 24 line.
The Angular 22 migration explicitly preserves the old default change detection
with `ChangeDetectionStrategy.Eager`. Existing OnPush components and
`provideZoneChangeDetection` remain in their existing modes.
Existing eager components under Angular ESLint carry a line-scoped explanation
for the newly recommended OnPush rule; new components keep that rule enabled.
Adopting OnPush in those components requires a separate behavior review.
The Angular 22 migration explicitly preserved the old default change detection
with `ChangeDetectionStrategy.Eager`. Plan item C6 since converted every component
the renderer uses to OnPush and switched it to
`provideZonelessChangeDetection()` without the `zone.js` polyfill; see the
[zoneless migration](zoneless-migration.md). `apps/remote-control-web` and its
remote-control component still use zone.js and `ChangeDetectionStrategy.Eager`,
with a line-scoped explanation for the OnPush lint rule; new components keep
that rule enabled.
TypeScript 6 migration settings preserve compiler defaults and per-project output roots;
`ignoreDeprecations: "6.0"` temporarily supports legacy Node test configs and
`baseUrl`, rather than changing the whole workspace to browser resolution.
@@ -105,8 +107,10 @@ imports `zone.js/testing` for existing `fakeAsync` helpers. This preserves the
Angular 21 scheduler used by jest-preset-angular 15: its `setupZoneTestEnv` loaded
Zone but did not provide zone-based change detection on Angular 20+. Preset 17
does provide it, which disables automatic fixture rendering and changes
`whenStable()` behavior. Production applications continue to use their existing
`provideZoneChangeDetection`; browser and Electron E2E validate that runtime.
`whenStable()` behavior. The production renderer (`apps/web`) runs
`provideZonelessChangeDetection()` like the tests; `apps/remote-control-web`
keeps `provideZoneChangeDetection`. Browser and Electron E2E validate both
runtimes.
## Vite Dev-Server Patch
+24 -6
View File
@@ -250,8 +250,9 @@ swaps `apps/web/src/environments/environment.ts` for
and calls `installChangeDetectionTickCounter()` from
`change-detection-tick-counter.ts` while `main.js` is evaluated, before
Angular bootstraps. The counter wraps the internal `ApplicationRef._tick`,
the method every tick runs through: the zone scheduler's `onMicrotaskEmpty`,
the zoneless scheduler, `afterNextRender` idle buckets and the public
the method every tick runs through: the zoneless scheduler (and, before the
zoneless flip, the zone scheduler's `onMicrotaskEmpty`), `afterNextRender`
idle buckets and the public
`ApplicationRef.tick()` all call it, and it is where Angular emits the
profiler's `ChangeDetectionStart`. The count therefore equals the profiler's
tick count and stays comparable across the zoneless migration. The running
@@ -296,6 +297,16 @@ on another differ by one tick without any different work. Treat a one-tick
difference in J1 as that race, and confirm on the CI runner that the counter
is deterministic before it becomes a baseline.
Since the zoneless flip (plan item C6) the renderer has no zone.js: a tick
runs only when a signal, a `markForCheck`, an Angular event or an
`afterNextRender` hook schedules one, so the race above is gone. The first
local measurement after the flip (macOS, 2026-10-13, five iterations each)
read `renderer.cdTicksToFirstCard` 5, `renderer.cdTicksIdle30s` 3 and
`renderer.cdTicksToFirstPage` 9 in every iteration, `renderer.cdTicksToPlaying`
7 or 8 and J4 `renderer.cdTicksToResults` 11 or 12; the DOM mutation counts
were identical across iterations. The history of the migration and the
before/after tables are in [zoneless migration](zoneless-migration.md).
#### Main-process counters
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
@@ -1150,8 +1161,8 @@ runtime flag: `if (!AppConfig.production)` keeps the imported module in
`main.js` because the optimizer does not fold the property read. The NgRx
store devtools therefore come from
`apps/web/src/environments/store-devtools.providers.ts`, an empty list in
every build, which only the `development`, `electron-e2e` and
`electron-e2e-zoneless` configurations replace with
every build, which only the `development` and `electron-e2e` configurations
replace with
`store-devtools.providers.dev.ts`; a build-config test in
`performance-build-config.spec.ts` keeps it that way. Removing the static
import lowered the counter by 12,565 bytes.
@@ -1431,8 +1442,15 @@ Not enforced, with the reason:
- Every `renderer.longTasks` (J1 2 or 1, J2 0 with one 1 earlier in the
week, J3 1 or 2 until #1817 and 0 since): a long task is a task over
50 ms, so the count follows runner speed, not work.
- Every `cdTicks` counter: the zoneless migration (plan item C6) changes
them.
- J3 `renderer.cdTicksToPlaying` (7 or 8) and J4
`renderer.cdTicksToResults` (10 or 11): not identical even zoneless; the
player's and the search debounce's events land in one or two ticks. J1
`renderer.cdTicksIdle30s` (3) and J2 `renderer.cdTicksToFirstPage` (9) are
enforced exactly since the zoneless flip (#1929), identical in all 15
iterations of its three dispatched runs. J1 `renderer.cdTicksToFirstCard`
read 5 in those 15 too, but after #1931 moved Angular Material off the
startup path one iteration in six read 6: the lazy chunk resolves on either
side of a render. It is enforced at 5 with `slack: 1`.
- J2 `renderer.domMutationsToFirstPage`: 1,602 or 1,603 between runs of
recent commits.
- J3 `renderer.ipcCallsToPlaying` (4 or 5) and
+52 -44
View File
@@ -40,11 +40,11 @@ must be ticked here.
## PR sequence
1. [ ] Keep `@ngrx/store-devtools` out of production bundles (#1810, open). Not a
1. [x] Keep `@ngrx/store-devtools` out of production bundles (#1810). Not a
zone change; it lowered `renderer.initialBytes` before the migration
starts moving it.
2. [x] This inventory and its guard spec.
3. [ ] Per-project PRs, in this order, each converting the project's Eager
3. [x] Per-project PRs (#1818 to #1823), in this order, each converting the project's Eager
components and fixing its zone-dependent sites while zone.js stays on:
`libs/ui/*`, `libs/workspace/*`, `libs/playlist/*`, `libs/portal/*`,
playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`),
@@ -54,7 +54,7 @@ must be ticked here.
`fileReplacements` flag, off by default; the three implemented journeys
(J1-J3; J4 is still planned) and the Electron E2E suite run with it on
(see [Zoneless flag](#zoneless-flag)).
5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
5. [x] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
(`renderer.cdTicksIdle30s` and any counter that becomes deterministic once
the zone.js race is gone).
@@ -183,9 +183,10 @@ the field a signal (or a `computed`), or writes it through one.
## Explicit zone and change-detector calls
They keep working under zoneless (`NgZone` becomes `NoopNgZone`, so `run`
and `runOutsideAngular` just call through). Remove them in the flip PR, not
before: with zone.js on they still matter.
They kept working under zoneless (`NgZone` becomes `NoopNgZone`, so `run`
and `runOutsideAngular` just call through) and were removed in the flip PR,
because with zone.js on they still mattered. The embedded MPV position poll's
spec case that asserted `runOutsideAngular` went with it.
- [x] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two
`zone.run` calls around the window-close dialog (IPC
@@ -194,10 +195,10 @@ before: with zone.js on they still matter.
`runOutsideAngular(() => setInterval(...))` for the position poll;
`embedded-mpv-session-controller.position.spec.ts` asserts the call and
changes with it.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`:
- [x] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`:
18 `ngZone.run(() => signal.set(...))` calls, all redundant around signal
writes.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`:
- [x] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`:
one `ngZone.run` around a signal update.
- `ChangeDetectorRef` in `stalker-live-stream-layout.component.ts`
(4 × `markForCheck`, 1 × `detectChanges` before measuring a row) and
@@ -233,46 +234,31 @@ their events arrive over IPC.
## Build, tests and runtime details
- [ ] `provideServiceWorker(..., { registrationStrategy:
- `provideServiceWorker(..., { registrationStrategy:
'registerWhenStable:30000' })`: under zoneless "stable" means no pending
tasks. The 30 s bound still registers the worker; check the PWA build in
the flag PR.
- [ ] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which
tasks; the 30 s bound registers the worker even if a task stays pending.
- [x] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which
the zoneless scheduler also calls, so the counters stay comparable.
- [ ] Specs that need zone.js: `fakeAsync` in
- Specs that need zone.js: `fakeAsync` in
`playlist-switcher.component.spec.ts` and `stalker-live-navigation.spec.ts`,
`waitForAsync` in 13 files. They keep `zone.js/testing` until rewritten;
removing zone.js from the build polyfills does not affect them.
- [ ] Unreferenced leftovers to delete in the flip PR:
`waitForAsync` in 13 files. They keep `zone.js/testing` (and the `zone.js`
package) until rewritten; the build polyfills do not affect them.
`apps/remote-control-web` still bootstraps with zone.js.
- [x] Unreferenced leftovers deleted in the flip PR:
`apps/web/src/polyfills.ts`, `apps/web/src/polyfills-test.ts`,
`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.
Between #1824 and the flip, `app.config.ts` took its change-detection
providers from `environments/change-detection.providers.ts`, and two
`*-zoneless` web configurations swapped in `provideZonelessChangeDetection()`
so the journeys and the Electron E2E suite could run zoneless before anything
shipped it. The flip PR removed the flag: `app.config.ts` provides
`provideZonelessChangeDetection()` directly, `apps/web` builds without the
`zone.js` polyfill, and `performance-build-config.spec.ts` fails if either
comes back.
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
@@ -305,8 +291,11 @@ 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.
playback itself is covered only by the packaged frame-copy E2E: it passed on a
packaged zoneless Linux x64 build from throwaway draft #1832 (see the comment
on #1824), with every packaging job and 8 of 9 cross-platform E2E shards green
(the ninth failed `dashboard-hero-legibility`, which failed the same day on
zone.js PRs too).
## Measuring a PR
@@ -314,6 +303,25 @@ Build `electron-performance` and run the journeys as described in
[performance journeys](performance-journeys.md), then paste
`renderer.cdTicksToFirstCard`, `renderer.cdTicksIdle30s`,
`renderer.cdTicksToFirstPage` and `renderer.cdTicksToPlaying` before and
after. While zone.js is on, removing Eager does not change the number of
ticks, only the work per tick; expect the counters to stay put until the flag
PR and the template work (DOM mutations, profile time) to drop.
after. Until the flip, while zone.js was on, removing Eager did not change
the number of ticks, only the work per tick. Since the flip (#1929)
`renderer.cdTicksToFirstCard` (5, with one tick of slack for the lazy
Material chunk since #1931), `renderer.cdTicksIdle30s` (3) and
`renderer.cdTicksToFirstPage` (9) are ratchet baselines: a change that adds a
tick to launch, to the idle dashboard or to opening a source fails the
journeys job.
Canonical runner (`Performance ratchet` dispatched on #1929, three runs, five
iterations each) against `master` the same day with zone.js:
| Counter | `master` (zone.js) | #1929 (zoneless) |
| --- | --- | --- |
| `renderer.cdTicksToFirstCard` | 31 (2026-10-04) | 5 |
| `renderer.cdTicksIdle30s` | 1 to 4 | 3 |
| `renderer.cdTicksToFirstPage` | 22 | 9 |
| `renderer.cdTicksToPlaying` | 15 to 17 | 7 or 8 |
| `renderer.initialBytes` | 1,596,201 | 1,545,931 |
| J3 `clickToPlayingMs` p50 | 151.6, 159.2, 185.7 | 128.0, 178.6, 189.5 |
J3 wall clock is in the same range, so the local rise measured on the flag
PR was machine load.