mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
perf(web): add an opt-in zoneless change-detection build flag (#1824)
* perf(web): add an opt-in zoneless change-detection build flag
Plan item C6 step 4. app.config.ts takes its change-detection providers
from environments/change-detection.providers.ts, which keeps
provideZoneChangeDetection({ eventCoalescing: true }) for every existing
build. The new electron-performance-zoneless and electron-e2e-zoneless
web configurations are their base configuration plus one fileReplacements
swap to provideZonelessChangeDetection(), so the journeys and the Electron
E2E suite can run zoneless while nothing ships it. zone.js stays in the
polyfills until the flip.
A build-config test pins each *-zoneless configuration to its base plus
the swap and refuses the swap anywhere else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(epg): schedule the guide's post-render scroll without zone.js
The programme guide jumps to now once the virtual list first renders rows,
and focuses cells after keyboard scrolls, from afterNextRender hooks
registered in CDK and RxJS callbacks. zone.js followed those callbacks
with a tick; under zoneless change detection a render hook schedules no
render, so the guide opened at midnight (epg-guide.e2e.ts on the zoneless
build). The guide now marks itself when it registers one, which is
harmless with zone.js.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(performance): record the zoneless flag measurements and E2E run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(performance): say the zoneless flag ran with the three implemented journeys
Review follow-up (Greptile): J4 search is still planned, so the flag was
validated with J1-J3 and the Electron E2E suite, not all four journeys.
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:
7 files changed
+190
-18
No files matched your search
@@ -10,6 +10,7 @@ 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>;
|
||||
@@ -162,7 +163,12 @@ 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') continue;
|
||||
if (
|
||||
name === 'electron-performance' ||
|
||||
name === 'electron-performance-zoneless'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(configuration['fileReplacements'] ?? []),
|
||||
/environment\.performance/,
|
||||
@@ -171,6 +177,44 @@ test('only the web performance build installs the tick counter the journeys read
|
||||
}
|
||||
});
|
||||
|
||||
// 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',
|
||||
};
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
test('the resolved web build cache output is the renderer directory', () => {
|
||||
const task = readResolvedWebBuildTask();
|
||||
|
||||
|
||||
@@ -143,12 +143,50 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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,
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"electron-e2e-zoneless": {
|
||||
"baseHref": "./",
|
||||
"serviceWorker": false,
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/web/src/environments/change-detection.providers.ts",
|
||||
"with": "apps/web/src/environments/change-detection.providers.zoneless.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
|
||||
@@ -8,12 +8,7 @@ import {
|
||||
FullscreenOverlayContainer,
|
||||
OverlayContainer,
|
||||
} from '@angular/cdk/overlay';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
inject,
|
||||
importProvidersFrom,
|
||||
provideZoneChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core';
|
||||
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
@@ -39,6 +34,7 @@ import {
|
||||
} from '@iptvnator/services';
|
||||
import { dbConfig } from '@iptvnator/shared/interfaces';
|
||||
import { AppConfig } from '../environments/environment';
|
||||
import { changeDetectionProviders } from '../environments/change-detection.providers';
|
||||
import { routes } from './app.routes';
|
||||
import { ElectronService } from './services/electron.service';
|
||||
import { ExternalPlaybackService } from './services/external-playback.service';
|
||||
@@ -110,7 +106,7 @@ export function DataFactory() {
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
...changeDetectionProviders,
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideAnimations(),
|
||||
// CDK overlays (menus, tooltips, dialogs) live in a container under
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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 }),
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
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(),
|
||||
];
|
||||
@@ -50,9 +50,10 @@ must be ticked here.
|
||||
playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`),
|
||||
`apps/web`. Each reports the tick counters before and after and runs the
|
||||
affected unit and E2E tests.
|
||||
4. [ ] `provideZonelessChangeDetection()` behind a build-time
|
||||
`fileReplacements` flag, off by default; all four journeys and the
|
||||
Electron E2E suite run with it on.
|
||||
4. [x] `provideZonelessChangeDetection()` behind a build-time
|
||||
`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
|
||||
(`renderer.cdTicksIdle30s` and any counter that becomes deterministic once
|
||||
the zone.js race is gone).
|
||||
@@ -181,7 +182,8 @@ the field a signal (or a `computed`), or writes it through one.
|
||||
| [ ] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
|
||||
| [ ] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal |
|
||||
| [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal |
|
||||
| [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; no fix needed: a value-only `patchValue` re-renders the OnPush sections (`settings.component.form.spec.ts` guards it) | apps/web |
|
||||
| [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; each section now marks itself on its form's `events` (`markSectionForCheckOnFormEvents`), which `settings.component.form.spec.ts` guards without a forced render | apps/web |
|
||||
| [x] | `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts` (jump to now, keyboard focus) | `afterNextRender` registered from CDK/RxJS callbacks; zone.js followed them with a tick, zoneless schedules no render, so the guide opened at midnight. It now marks itself when it registers the hook. Found by `epg-guide.e2e.ts` on the zoneless build | flag |
|
||||
| [ ] | `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` | plain `isLoading`/`error`/`status` written after `await` and from a 2 s `setInterval` | only if `apps/remote-control-web` goes zoneless |
|
||||
|
||||
## Explicit zone and change-detector calls
|
||||
@@ -251,6 +253,66 @@ their events arrive over IPC.
|
||||
`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.
|
||||
|
||||
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
|
||||
on, five iterations each):
|
||||
|
||||
| Counter | flag off | flag on |
|
||||
| --- | --- | --- |
|
||||
| `renderer.cdTicksToFirstCard` | 22, 20, 22, 21, 21 | 7, 7, 7, 7, 7 |
|
||||
| `renderer.cdTicksIdle30s` | 4, 4, 4, 4, 4 | 3, 3, 3, 3, 3 |
|
||||
| `renderer.cdTicksToFirstPage` | 22 (all) | 8 (all) |
|
||||
| `renderer.cdTicksToPlaying` | 16, 15, 15, 17, 15 | 6, 8, 10, 10, 7 |
|
||||
| DOM mutations J1 / J2 / J3 | 553 / 1,603 / 6,182 | 553 / 1,603 / 6,182 (one J3 iteration 6,199, as on master) |
|
||||
| `spawnToFirstCardMs` p50 | 3,071 | 828 |
|
||||
| `clickToFirstPageMs` p50 | 82.8 | 80.4 |
|
||||
| `clickToLoadedMetadataMs` / `clickToPlayingMs` p50 | 94.8 / 268.7 | 158.3 / 409.3 |
|
||||
|
||||
J1 and J2 tick counts become deterministic without the zone.js one-tick
|
||||
race, and the DOM mutations are unchanged, so nothing renders differently.
|
||||
J3's tick count still varies with player events and its wall-clock
|
||||
numbers rose locally; the machine was shared with other runs (load 26 to 58
|
||||
during these two runs, master itself read 363.8 ms `clickToPlayingMs` p50
|
||||
earlier the same day), so judge J3 on the CI runner before the flip.
|
||||
|
||||
Electron E2E suite on `electron-e2e-zoneless` (all specs except the
|
||||
packaged frame-copy ones): 203 passed, 7 skipped, 3 failed. `epg-guide`
|
||||
failed on every run and is fixed above; `playlist-auto-refresh` passed on
|
||||
`--repeat-each=2`; `dash-clearkey` "reopens from recent and favorites" is the
|
||||
known local flake (it fails as often on master). The IPC-driven paths
|
||||
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.
|
||||
|
||||
## Measuring a PR
|
||||
|
||||
Build `electron-performance` and run the journeys as described in
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
@@ -83,6 +84,7 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly changeDetector = inject(ChangeDetectorRef);
|
||||
private readonly hostElement = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly close = output<void>();
|
||||
@@ -185,8 +187,7 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
activeRow: () => this.activeRowIndex(),
|
||||
ensureLoaded: (channels) => this.programsService.ensureLoaded(channels),
|
||||
setScrollLeft: (left) => this.view.scrollLeft.set(left),
|
||||
afterRender: (callback) =>
|
||||
afterNextRender(callback, { injector: this.injector }),
|
||||
afterRender: (callback) => this.afterNextGuideRender(callback),
|
||||
});
|
||||
|
||||
private readonly dialogs = new EpgGuideDialogController(
|
||||
@@ -237,15 +238,23 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
this.viewportController.whenRowsRendered(
|
||||
viewport,
|
||||
this.destroyRef,
|
||||
() =>
|
||||
afterNextRender(() => this.jumpNow(false), {
|
||||
injector: this.injector,
|
||||
})
|
||||
() => this.afterNextGuideRender(() => this.jumpNow(false))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `callback` after the next render. The callers register from CDK
|
||||
* and RxJS callbacks, outside any template event: zone.js used to follow
|
||||
* those with a tick, but without it registering a render hook schedules
|
||||
* no render, so the guide also marks itself for one.
|
||||
*/
|
||||
private afterNextGuideRender(callback: () => void): void {
|
||||
afterNextRender(callback, { injector: this.injector });
|
||||
this.changeDetector.markForCheck();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.view.destroy();
|
||||
this.search.destroy();
|
||||
|
||||
Reference in new issue
Block a user