mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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>
This commit is contained in:
1 parent
ebb1ab3467
commit
d88d005062
19 files changed
+147
-355
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: performance
|
||||
---
|
||||
|
||||
The app does less background work and starts with less code: screens now
|
||||
update only when something they show changes, instead of after every timer,
|
||||
network reply or click anywhere in the app.
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"base": "dist/apps/web",
|
||||
"browser": ""
|
||||
},
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "apps/web/tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"serviceWorker": "ngsw-config.json",
|
||||
@@ -149,31 +148,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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,
|
||||
@@ -186,23 +160,6 @@
|
||||
"with": "apps/web/src/environments/store-devtools.providers.dev.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"electron-e2e-zoneless": {
|
||||
"baseHref": "./",
|
||||
"serviceWorker": false,
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/web/src/environments/store-devtools.providers.ts",
|
||||
"with": "apps/web/src/environments/store-devtools.providers.dev.ts"
|
||||
},
|
||||
{
|
||||
"replace": "apps/web/src/environments/change-detection.providers.ts",
|
||||
"with": "apps/web/src/environments/change-detection.providers.zoneless.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
FullscreenOverlayContainer,
|
||||
OverlayContainer,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
inject,
|
||||
importProvidersFrom,
|
||||
provideZonelessChangeDetection,
|
||||
} 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';
|
||||
@@ -32,7 +37,6 @@ import {
|
||||
DataService,
|
||||
} from '@iptvnator/services';
|
||||
import { dbConfig } from '@iptvnator/shared/interfaces';
|
||||
import { changeDetectionProviders } from '../environments/change-detection.providers';
|
||||
import { storeDevtoolsProviders } from '../environments/store-devtools.providers';
|
||||
import { routes } from './app.routes';
|
||||
import { ElectronService } from './services/electron.service';
|
||||
@@ -105,7 +109,7 @@ export function DataFactory() {
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
...changeDetectionProviders,
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideAnimations(),
|
||||
// CDK overlays (menus, tooltips, dialogs) live in a container under
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { inject, Injectable, NgZone, OnDestroy } from '@angular/core';
|
||||
import { inject, Injectable, OnDestroy } from '@angular/core';
|
||||
import { AppUpdateInstallService } from '../services/app-update-install.service';
|
||||
import { SettingsForm } from './settings-form.utils';
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface SettingsUnloadGuardHost {
|
||||
*/
|
||||
@Injectable()
|
||||
export class SettingsUnloadGuardService implements OnDestroy {
|
||||
private readonly zone = inject(NgZone);
|
||||
private readonly installService = inject(AppUpdateInstallService);
|
||||
private host: SettingsUnloadGuardHost | null = null;
|
||||
private unsubscribeCloseRequests: (() => void) | null = null;
|
||||
@@ -78,7 +77,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
|
||||
this.unsubscribeCloseRequests =
|
||||
window.electron?.onWindowCloseRequested?.((requestId) => {
|
||||
this.latestCloseRequestId = requestId;
|
||||
this.zone.run(() => void this.handleCloseRequest('close'));
|
||||
void this.handleCloseRequest('close');
|
||||
}) ?? null;
|
||||
|
||||
// Whatever surface installs an app update — the settings About
|
||||
@@ -141,9 +140,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
|
||||
this.host = null;
|
||||
}
|
||||
|
||||
private readonly beforeUnloadHandler = (
|
||||
event: BeforeUnloadEvent
|
||||
): void => {
|
||||
private readonly beforeUnloadHandler = (event: BeforeUnloadEvent): void => {
|
||||
if (!this.host?.form.dirty) {
|
||||
return;
|
||||
}
|
||||
@@ -160,7 +157,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
|
||||
// handler entirely — so this can only be a reload: ask, then
|
||||
// re-trigger it.
|
||||
setTimeout(() => {
|
||||
this.zone.run(() => void this.handleCloseRequest('reload'));
|
||||
void this.handleCloseRequest('reload');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,11 +9,11 @@ import { ApplicationRef } from '@angular/core';
|
||||
*
|
||||
* Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode
|
||||
* `window.ng` global, which the optimized build does not publish. Every
|
||||
* tick, whether scheduled by zone.js (`onMicrotaskEmpty`), the zoneless
|
||||
* scheduler or an explicit `ApplicationRef.tick()`, goes through the
|
||||
* internal `ApplicationRef._tick`, which is also where Angular emits the
|
||||
* profiler's `ChangeDetectionStart`. Counting its calls therefore matches
|
||||
* the profiler's count and stays comparable across the zoneless migration.
|
||||
* tick, whether scheduled by the zoneless scheduler, an `afterNextRender`
|
||||
* hook or an explicit `ApplicationRef.tick()`, goes through the internal
|
||||
* `ApplicationRef._tick`, which is also where Angular emits the profiler's
|
||||
* `ChangeDetectionStart`. Counting its calls therefore matches the
|
||||
* profiler's count; it did so under zone.js too, before the zoneless flip.
|
||||
* Contract: docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks';
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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 }),
|
||||
];
|
||||
@@ -1,11 +0,0 @@
|
||||
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(),
|
||||
];
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EnvironmentProviders } from '@angular/core';
|
||||
import { provideStoreDevtools } from '@ngrx/store-devtools';
|
||||
|
||||
// Swapped in for store-devtools.providers.ts by the development,
|
||||
// electron-e2e and electron-e2e-zoneless build configurations only.
|
||||
// Swapped in for store-devtools.providers.ts by the development and
|
||||
// electron-e2e build configurations only.
|
||||
export const storeDevtoolsProviders: EnvironmentProviders[] = [
|
||||
provideStoreDevtools({ maxAge: 25 }),
|
||||
];
|
||||
@@ -1 +0,0 @@
|
||||
import 'zone.js';
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
|
||||
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/guide/browser-support
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
||||
* will put import in the top of bundle, so user need to create a separate file
|
||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
||||
* into that file, and then add the following code before importing zone.js.
|
||||
* import './zone-flags.ts';
|
||||
*
|
||||
* The flags allowed in zone-flags.ts are listed here.
|
||||
*
|
||||
* The following flags will work for all browsers.
|
||||
*
|
||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*
|
||||
* (window as any).__Zone_enable_cross_context_check = true;
|
||||
*
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js'; // Included with Angular CLI.
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
@@ -1 +0,0 @@
|
||||
import 'jest-preset-angular/setup-jest';
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -185,9 +185,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
|
||||
@@ -196,10 +197,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
|
||||
@@ -235,46 +236,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
|
||||
@@ -307,8 +293,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
|
||||
|
||||
|
||||
+1
-25
@@ -1,4 +1,3 @@
|
||||
import { NgZone } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
EmbeddedMpvSession,
|
||||
@@ -132,27 +131,6 @@ describe('EmbeddedMpvSessionController position drift poll', () => {
|
||||
expect(electron.setEmbeddedMpvBounds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers the poll outside the Angular zone', async () => {
|
||||
// With zone change detection, a zone-registered interval would run
|
||||
// app-wide change detection every 500 ms for the whole stream.
|
||||
const zone = TestBed.inject(NgZone);
|
||||
const runOutsideAngular = jest.spyOn(zone, 'runOutsideAngular');
|
||||
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
const teardown = controller.startSession(
|
||||
createHost(),
|
||||
createPlayback(),
|
||||
0.5
|
||||
);
|
||||
await waitFor(
|
||||
() => controller.sessionId() === 'mpv-1',
|
||||
'session to start'
|
||||
);
|
||||
|
||||
expect(runOutsideAngular).toHaveBeenCalled();
|
||||
teardown();
|
||||
});
|
||||
|
||||
it('skips drift measurement for the frame-copy engine', async () => {
|
||||
// Frame-copy paints into a DOM canvas that moves with the layout, so
|
||||
// a position-only shift needs no re-sync (size changes still arrive
|
||||
@@ -162,9 +140,7 @@ describe('EmbeddedMpvSessionController position drift poll', () => {
|
||||
platform: 'linux',
|
||||
engine: 'frame-copy',
|
||||
});
|
||||
electron.attachEmbeddedMpvFrameView = jest
|
||||
.fn()
|
||||
.mockResolvedValue(true);
|
||||
electron.attachEmbeddedMpvFrameView = jest.fn().mockResolvedValue(true);
|
||||
electron.detachEmbeddedMpvFrameView = jest.fn();
|
||||
|
||||
const rect = { left: 10, top: 20, width: 640, height: 360 };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
Injectable,
|
||||
NgZone,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
@@ -80,7 +79,6 @@ export class EmbeddedMpvSessionController {
|
||||
);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly zone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly unsubscribeSessionUpdate?: () => void;
|
||||
|
||||
@@ -192,28 +190,22 @@ export class EmbeddedMpvSessionController {
|
||||
// on drift, so the idle cost is one getBoundingClientRect per tick
|
||||
// with no IPC.
|
||||
//
|
||||
// The interval runs outside Angular's zone: with zone change
|
||||
// detection a zone-registered timer would run app-wide change
|
||||
// detection every tick for the whole stream. It never re-enters the
|
||||
// zone — the drift path is rAF → setEmbeddedMpvBounds IPC and
|
||||
// touches no Angular state. Frame-copy paints into a DOM canvas
|
||||
// The poll touches no Angular state, so with zoneless change
|
||||
// detection it schedules no render: the drift path is rAF →
|
||||
// setEmbeddedMpvBounds IPC. Frame-copy paints into a DOM canvas
|
||||
// that moves with the layout, so only native-view can go stale on a
|
||||
// position-only shift; the poll skips the measurement there.
|
||||
const positionPoll = this.zone.runOutsideAngular(() =>
|
||||
window.setInterval(() => {
|
||||
if (!activeSessionId || !lastSyncedBounds) {
|
||||
return;
|
||||
}
|
||||
if (untracked(() => this.isFrameCopyEngine())) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
boundsDiffer(this.boundsProvider(host), lastSyncedBounds)
|
||||
) {
|
||||
scheduleBoundsSync();
|
||||
}
|
||||
}, POSITION_POLL_INTERVAL_MS)
|
||||
);
|
||||
const positionPoll = window.setInterval(() => {
|
||||
if (!activeSessionId || !lastSyncedBounds) {
|
||||
return;
|
||||
}
|
||||
if (untracked(() => this.isFrameCopyEngine())) {
|
||||
return;
|
||||
}
|
||||
if (boundsDiffer(this.boundsProvider(host), lastSyncedBounds)) {
|
||||
scheduleBoundsSync();
|
||||
}
|
||||
}, POSITION_POLL_INTERVAL_MS);
|
||||
|
||||
// Page zoom and monitor DPI rescale the CSS→native-pixel mapping the
|
||||
// backend applies to these bounds. Moving the window to a display
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NgZone,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
@@ -91,7 +84,6 @@ export class DashboardDataService {
|
||||
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly favoritesAutoRefreshEnabled = signal(false);
|
||||
@@ -372,13 +364,11 @@ export class DashboardDataService {
|
||||
if (generation !== this.playbackPositionsLoadGeneration) {
|
||||
return;
|
||||
}
|
||||
this.ngZone.run(() => {
|
||||
this.playbackPositionMaps.set(maps);
|
||||
// A playlist whose load failed counts as loaded: its titles
|
||||
// show without progress rather than holding the rail back.
|
||||
this.playbackPositionPlaylistIds.set(playlistIds);
|
||||
this.playbackPositionsReloads.update((count) => count + 1);
|
||||
});
|
||||
this.playbackPositionMaps.set(maps);
|
||||
// A playlist whose load failed counts as loaded: its titles
|
||||
// show without progress rather than holding the rail back.
|
||||
this.playbackPositionPlaylistIds.set(playlistIds);
|
||||
this.playbackPositionsReloads.update((count) => count + 1);
|
||||
}
|
||||
|
||||
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
|
||||
@@ -440,11 +430,9 @@ export class DashboardDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -490,9 +478,7 @@ export class DashboardDataService {
|
||||
if (!this.hasPortalActivityStorage) {
|
||||
try {
|
||||
const recentItems = await this.loadPwaXtreamGlobalRecentItems();
|
||||
this.ngZone.run(() =>
|
||||
this.xtreamGlobalRecentItems.set(recentItems)
|
||||
);
|
||||
this.xtreamGlobalRecentItems.set(recentItems);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload PWA recent items',
|
||||
@@ -510,13 +496,13 @@ export class DashboardDataService {
|
||||
const normalized = recentItems.map((item) =>
|
||||
mapDbRecentToItem(item)
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized));
|
||||
this.xtreamGlobalRecentItems.set(normalized);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload global recent items',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalRecentItems.set([]));
|
||||
this.xtreamGlobalRecentItems.set([]);
|
||||
} finally {
|
||||
this.globalRecentDbLoadedState.set(true);
|
||||
this.finishInitialGlobalRecentLoadIfReady();
|
||||
@@ -531,9 +517,7 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
const xtreamReload = this.reloadXtreamGlobalFavorites().finally(() => {
|
||||
this.ngZone.run(() =>
|
||||
this.xtreamGlobalFavoritesLoadedState.set(true)
|
||||
);
|
||||
this.xtreamGlobalFavoritesLoadedState.set(true);
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
});
|
||||
const m3uReload = this.reloadPlaylistBackedGlobalFavorites();
|
||||
@@ -586,37 +570,31 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
if (!this.hasPortalActivityStorage) {
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await this.getXtreamRecentlyAddedItems(limit);
|
||||
this.ngZone.run(() =>
|
||||
this.xtreamRecentlyAddedItemsState.set(items)
|
||||
);
|
||||
this.xtreamRecentlyAddedItemsState.set(items);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload Xtream recently added items',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([]));
|
||||
this.xtreamRecentlyAddedItemsState.set([]);
|
||||
} finally {
|
||||
this.ngZone.run(() => {
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
});
|
||||
this.xtreamRecentlyAddedLoadingState.set(false);
|
||||
this.xtreamRecentlyAddedLoadedState.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadXtreamGlobalFavorites(): Promise<void> {
|
||||
if (!this.hasPortalActivityStorage) {
|
||||
const favorites = await this.loadPwaXtreamGlobalFavorites();
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites));
|
||||
this.xtreamGlobalFavorites.set(favorites);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -625,13 +603,13 @@ export class DashboardDataService {
|
||||
const normalized = favorites.map((item) =>
|
||||
mapDbFavoriteToItem(item)
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized));
|
||||
this.xtreamGlobalFavorites.set(normalized);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload global favorites',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set([]));
|
||||
this.xtreamGlobalFavorites.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,9 +708,7 @@ export class DashboardDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.run(() =>
|
||||
this.playlistBackedGlobalFavoritesLoadedState.set(true)
|
||||
);
|
||||
this.playlistBackedGlobalFavoritesLoadedState.set(true);
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
}
|
||||
|
||||
@@ -762,17 +738,15 @@ export class DashboardDataService {
|
||||
|
||||
// Drop entries for M3U playlists no longer in the eligible set
|
||||
// (removed playlists or playlists whose favorites were cleared).
|
||||
this.ngZone.run(() => {
|
||||
this.m3uPlaylistFavoritesMap.update((prev) => {
|
||||
let next: Map<string, DashboardFavoriteItem[]> | null = null;
|
||||
for (const id of prev.keys()) {
|
||||
if (!validIds.has(id)) {
|
||||
next ??= new Map(prev);
|
||||
next.delete(id);
|
||||
}
|
||||
this.m3uPlaylistFavoritesMap.update((prev) => {
|
||||
let next: Map<string, DashboardFavoriteItem[]> | null = null;
|
||||
for (const id of prev.keys()) {
|
||||
if (!validIds.has(id)) {
|
||||
next ??= new Map(prev);
|
||||
next.delete(id);
|
||||
}
|
||||
return next ?? prev;
|
||||
});
|
||||
}
|
||||
return next ?? prev;
|
||||
});
|
||||
for (const id of Array.from(this.m3uFavoritesCache.keys())) {
|
||||
if (!validIds.has(id)) {
|
||||
@@ -801,12 +775,10 @@ export class DashboardDataService {
|
||||
items = [];
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.m3uPlaylistFavoritesMap.update((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(playlist._id, items);
|
||||
return next;
|
||||
});
|
||||
this.m3uPlaylistFavoritesMap.update((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(playlist._id, items);
|
||||
return next;
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -821,10 +793,8 @@ export class DashboardDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.globalFavoritesLoadedState.set(true);
|
||||
this.globalFavoritesLoadingState.set(false);
|
||||
});
|
||||
this.globalFavoritesLoadedState.set(true);
|
||||
this.globalFavoritesLoadingState.set(false);
|
||||
}
|
||||
|
||||
private finishInitialGlobalRecentLoadIfReady(): void {
|
||||
@@ -832,10 +802,8 @@ export class DashboardDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.run(() => {
|
||||
this.globalRecentLoadedState.set(true);
|
||||
this.globalRecentLoadingState.set(false);
|
||||
});
|
||||
this.globalRecentLoadedState.set(true);
|
||||
this.globalRecentLoadingState.set(false);
|
||||
}
|
||||
|
||||
isTypeInKind(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { Injectable, NgZone, inject, signal } from '@angular/core';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { normalizeStoredStalkerAccountInfo } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService, PortalStatusService } from '@iptvnator/services';
|
||||
@@ -26,7 +26,6 @@ import type { SourceExpiryFacts } from './dashboard-source-expiry.util';
|
||||
export class DashboardSourceExpiryService {
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
|
||||
@@ -182,8 +181,6 @@ export class DashboardSourceExpiryService {
|
||||
if (round !== this.refreshRound) {
|
||||
return;
|
||||
}
|
||||
this.ngZone.run(() => {
|
||||
this.factsState.update(update);
|
||||
});
|
||||
this.factsState.update(update);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 1595777,
|
||||
"value": 1545931,
|
||||
"unit": "bytes",
|
||||
"slack": 4096,
|
||||
"updatedAt": "2026-10-07",
|
||||
"evidencePr": 1810,
|
||||
"updatedAt": "2026-10-13",
|
||||
"evidencePr": null,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
},
|
||||
"renderer.ipcCallsToFirstCard": {
|
||||
|
||||
Reference in new issue
Block a user