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:
4grayandClaude Opus 5.5 committed 2026-10-13 08:44:52 +02:00
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', () => {
-43
View File
@@ -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"
+7 -3
View File
@@ -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
View File
@@ -1 +0,0 @@
import 'zone.js';
-52
View File
@@ -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
View File
@@ -1 +0,0 @@
import 'jest-preset-angular/setup-jest';
+15 -4
View File
@@ -250,8 +250,9 @@ swaps `apps/web/src/environments/environment.ts` for
and calls `installChangeDetectionTickCounter()` from
`change-detection-tick-counter.ts` while `main.js` is evaluated, before
Angular bootstraps. The counter wraps the internal `ApplicationRef._tick`,
the method every tick runs through: the zone scheduler's `onMicrotaskEmpty`,
the zoneless scheduler, `afterNextRender` idle buckets and the public
the method every tick runs through: the zoneless scheduler (and, before the
zoneless flip, the zone scheduler's `onMicrotaskEmpty`), `afterNextRender`
idle buckets and the public
`ApplicationRef.tick()` all call it, and it is where Angular emits the
profiler's `ChangeDetectionStart`. The count therefore equals the profiler's
tick count and stays comparable across the zoneless migration. The running
@@ -296,6 +297,16 @@ on another differ by one tick without any different work. Treat a one-tick
difference in J1 as that race, and confirm on the CI runner that the counter
is deterministic before it becomes a baseline.
Since the zoneless flip (plan item C6) the renderer has no zone.js: a tick
runs only when a signal, a `markForCheck`, an Angular event or an
`afterNextRender` hook schedules one, so the race above is gone. The first
local measurement after the flip (macOS, 2026-10-13, five iterations each)
read `renderer.cdTicksToFirstCard` 5, `renderer.cdTicksIdle30s` 3 and
`renderer.cdTicksToFirstPage` 9 in every iteration, `renderer.cdTicksToPlaying`
7 or 8 and J4 `renderer.cdTicksToResults` 11 or 12; the DOM mutation counts
were identical across iterations. The history of the migration and the
before/after tables are in [zoneless migration](zoneless-migration.md).
#### Main-process counters
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
@@ -1150,8 +1161,8 @@ runtime flag: `if (!AppConfig.production)` keeps the imported module in
`main.js` because the optimizer does not fold the property read. The NgRx
store devtools therefore come from
`apps/web/src/environments/store-devtools.providers.ts`, an empty list in
every build, which only the `development`, `electron-e2e` and
`electron-e2e-zoneless` configurations replace with
every build, which only the `development` and `electron-e2e` configurations
replace with
`store-devtools.providers.dev.ts`; a build-config test in
`performance-build-config.spec.ts` keeps it that way. Removing the static
import lowered the counter by 12,565 bytes.
+30 -41
View File
@@ -40,11 +40,11 @@ must be ticked here.
## PR sequence
1. [ ] Keep `@ngrx/store-devtools` out of production bundles (#1810, open). Not a
1. [x] Keep `@ngrx/store-devtools` out of production bundles (#1810). Not a
zone change; it lowered `renderer.initialBytes` before the migration
starts moving it.
2. [x] This inventory and its guard spec.
3. [ ] Per-project PRs, in this order, each converting the project's Eager
3. [x] Per-project PRs (#1818 to #1823), in this order, each converting the project's Eager
components and fixing its zone-dependent sites while zone.js stays on:
`libs/ui/*`, `libs/workspace/*`, `libs/playlist/*`, `libs/portal/*`,
playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`),
@@ -54,7 +54,7 @@ must be ticked here.
`fileReplacements` flag, off by default; the three implemented journeys
(J1-J3; J4 is still planned) and the Electron E2E suite run with it on
(see [Zoneless flag](#zoneless-flag)).
5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
5. [x] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
(`renderer.cdTicksIdle30s` and any counter that becomes deterministic once
the zone.js race is gone).
@@ -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,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 -3
View File
@@ -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": {