mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
22 files changed
+230
-367
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user