mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
Merge branch 'perf/zoneless-flag' into ci/zoneless-packaged-smoke
# Conflicts: # docs/architecture/zoneless-migration.md
This commit is contained in:
commit
6ae077c8c6
7 files changed
+185
-14
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(),
|
||||
];
|
||||
@@ -176,6 +176,7 @@ the field a signal (or a `computed`), or writes it through one.
|
||||
| [x] | `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 |
|
||||
| [x] | 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] | `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
|
||||
@@ -245,6 +246,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