mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
perf(ui): make the libs/ui Eager components OnPush (#1818)
* docs(performance): inventory the zoneless change-detection migration Plan item C6 step 2. docs/architecture/zoneless-migration.md lists the 66 production files (67 components) that still set ChangeDetectionStrategy.Eager, the ten places where a template-read plain field is written outside an Angular event, the NgZone and ChangeDetectorRef calls to remove at the flip, and the IPC, player, observer, timer and dialog paths checked as signal-safe. On Angular 22 an unset changeDetection already means OnPush, so only the explicit Eager components re-render on every tick. zoneless-migration.spec.ts in the performance harness compares the checklist with the code: a new Eager component, or a converted one left unticked, fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(ui): make the libs/ui Eager components OnPush Plan item C6 step 3 for libs/ui/components and libs/ui/epg: eleven components (twelve with the EPG trust dialog) set ChangeDetectionStrategy.Eager and were checked on every tick, among them the always-mounted EPG progress panel the idle audit found re-rendering on every idle tick. Their template state is already signals, signal inputs, immutable dialog data or fields written from template events, so they switch to OnPush without other changes. The epg-item-description spec mutated dialog data after creation and marked only the fixture's host view; it now marks the component's own view, which OnPush requires. The libs/ui playback and remote-control components stay Eager for their own PRs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
16 files changed
+367
-24
No files matched your search
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable playwright/expect-expect -- These are Node assertion-based repository contract tests. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// docs/architecture/zoneless-migration.md lists every component that still
|
||||
// opts out of OnPush. This keeps the checklist and the code in step: a new
|
||||
// Eager component fails here, and so does a converted one left unticked.
|
||||
|
||||
const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url));
|
||||
const checklistPath = 'docs/architecture/zoneless-migration.md';
|
||||
const sourceRoots = ['apps', 'libs'];
|
||||
const skippedDirectories = new Set(['node_modules', 'dist', 'coverage']);
|
||||
const testOnlyFile =
|
||||
/(\.spec|\.spec-stubs|\.spec-data|\.test-helpers|test-setup)\.ts$/;
|
||||
|
||||
function listProductionSources(directory: string): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (skippedDirectories.has(entry.name)) continue;
|
||||
if (entry.name.endsWith('-e2e')) continue;
|
||||
files.push(...listProductionSources(path));
|
||||
} else if (
|
||||
entry.name.endsWith('.ts') &&
|
||||
!entry.name.endsWith('.d.ts') &&
|
||||
!testOnlyFile.test(entry.name)
|
||||
) {
|
||||
files.push(relative(workspaceRoot, path).split(sep).join('/'));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function readSources(): Map<string, string> {
|
||||
const sources = new Map<string, string>();
|
||||
for (const root of sourceRoots) {
|
||||
for (const file of listProductionSources(join(workspaceRoot, root))) {
|
||||
sources.set(file, readFileSync(join(workspaceRoot, file), 'utf8'));
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function readEagerChecklist(): { open: string[]; done: string[] } {
|
||||
const markdown = readFileSync(join(workspaceRoot, checklistPath), 'utf8');
|
||||
const section = markdown.split(/^## Eager components$/m)[1];
|
||||
assert.ok(
|
||||
section,
|
||||
`${checklistPath} must have an "Eager components" section`
|
||||
);
|
||||
const body = section.split(/^## /m)[0];
|
||||
const open: string[] = [];
|
||||
const done: string[] = [];
|
||||
for (const match of body.matchAll(/^- \[( |x)\] `([^`]+\.ts)`/gm)) {
|
||||
(match[1] === 'x' ? done : open).push(match[2]);
|
||||
}
|
||||
return { open: open.sort(), done: done.sort() };
|
||||
}
|
||||
|
||||
const sources = readSources();
|
||||
|
||||
test('the zoneless checklist lists exactly the components that are still Eager', () => {
|
||||
const eager = [...sources]
|
||||
.filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager'))
|
||||
.map(([file]) => file)
|
||||
.sort();
|
||||
const { open } = readEagerChecklist();
|
||||
|
||||
assert.deepEqual(
|
||||
eager,
|
||||
open,
|
||||
`Production files with ChangeDetectionStrategy.Eager must match the unticked entries in ${checklistPath}. ` +
|
||||
'Do not add Eager components; tick an entry when its component is converted.'
|
||||
);
|
||||
});
|
||||
|
||||
test('ticked checklist entries name files that exist', () => {
|
||||
for (const file of readEagerChecklist().done) {
|
||||
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no production component uses the deprecated Default strategy alias', () => {
|
||||
for (const [file, text] of sources) {
|
||||
assert.doesNotMatch(text, /ChangeDetectionStrategy\.Default\b/, file);
|
||||
}
|
||||
});
|
||||
@@ -208,7 +208,8 @@ window, and `evidence.idle.domMutations` the mutation records in the whole
|
||||
document. The [idle work audit](idle-work-audit-2026-09.md) found Eager
|
||||
components re-rendering on every such tick in a dev build; this counter
|
||||
measures the ticks in the optimized build, so plan item C6 can show what
|
||||
zoneless change detection removes.
|
||||
zoneless change detection removes; its checklist is the
|
||||
[zoneless migration](zoneless-migration.md).
|
||||
|
||||
The window opens when the settle window closes, so startup data still
|
||||
landing is not idle work, and it is timed by a renderer `setTimeout`. The
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
# Zoneless change-detection migration
|
||||
|
||||
Working checklist for plan item C6 of the performance journeys plan: move the
|
||||
renderer (`apps/web`) from zone.js to `provideZonelessChangeDetection()`.
|
||||
The win is measured with the change-detection tick counters described in
|
||||
[performance journeys](performance-journeys.md#change-detection-ticks); the
|
||||
[idle work audit](idle-work-audit-2026-09.md) found the Eager roots that
|
||||
re-render on every tick. Update this file in the same PR that converts an item.
|
||||
|
||||
The inventory was taken on `e8b181fce` (2026-10-04, Angular 22.1.6). Run
|
||||
`pnpm nx run electron-backend-e2e:test-performance-harness` after editing the
|
||||
Eager list: `zoneless-migration.spec.ts` fails when the list and the code
|
||||
disagree, so a new Eager component cannot land unnoticed and a converted one
|
||||
must be ticked here.
|
||||
|
||||
## Starting point
|
||||
|
||||
- **OnPush is already the default.** Since Angular 22 an unset
|
||||
`changeDetection` means OnPush, and the old `Default` strategy is spelled
|
||||
`ChangeDetectionStrategy.Eager`. Only components that set `Eager` are
|
||||
checked on every tick. `ChangeDetectionStrategy.Default` is not used.
|
||||
- **Renderer bootstrap.** `apps/web/src/app/app.config.ts` provides
|
||||
`provideZoneChangeDetection({ eventCoalescing: true })` and
|
||||
`apps/web/project.json` builds with `"polyfills": ["zone.js"]`.
|
||||
`apps/remote-control-web` does the same; it is a separate app and outside
|
||||
this migration unless a step says otherwise.
|
||||
- **Unit tests already run zoneless.** Every `src/test-setup.ts` (apps/web,
|
||||
apps/remote-control-web and 24 libs) calls `setupZonelessTestEnv` and loads
|
||||
`zone.js`/`zone.js/testing` only for `fakeAsync` and `waitForAsync`. A
|
||||
component that passes its specs is therefore not proof of zone-free
|
||||
production behavior when the spec calls `fixture.detectChanges()` itself.
|
||||
- **IPC callbacks never ran in the Angular zone.** `window.electron.on*`
|
||||
listeners arrive through `contextBridge` and are not zone-patched, so every
|
||||
one that works today already writes signals or calls `NgZone.run`.
|
||||
- **Counters before the migration** (macOS, from
|
||||
[performance journeys](performance-journeys.md#change-detection-ticks)):
|
||||
`renderer.cdTicksToFirstCard` 20–21 (the one-tick zone.js race),
|
||||
`renderer.cdTicksIdle30s` 3, `renderer.cdTicksToFirstPage` 22;
|
||||
`renderer.cdTicksToPlaying` has no recorded run yet.
|
||||
|
||||
## PR sequence
|
||||
|
||||
1. [ ] Keep `@ngrx/store-devtools` out of production bundles (#1810, open). 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
|
||||
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`),
|
||||
`apps/web`. Each reports the tick counters before and after and runs the
|
||||
affected unit and E2E tests.
|
||||
4. [ ] `provideZonelessChangeDetection()` behind a build-time
|
||||
`fileReplacements` flag, off by default; all four journeys and the
|
||||
Electron E2E suite run with it on.
|
||||
5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
|
||||
(`renderer.cdTicksIdle30s` and any counter that becomes deterministic once
|
||||
the zone.js race is gone).
|
||||
|
||||
## Eager components
|
||||
|
||||
66 production files, 67 components (`epg-progress-panel.component.ts` holds
|
||||
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
|
||||
(or setting OnPush) once its template state is signals, signal inputs or
|
||||
explicitly marked. The guard spec compares the unticked entries with the
|
||||
files that still contain `ChangeDetectionStrategy.Eager`.
|
||||
|
||||
### apps/web (15)
|
||||
|
||||
- [ ] `apps/web/src/app/app.component.ts` (idle audit root)
|
||||
- [ ] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root)
|
||||
- [ ] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-about-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-backup-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-dashboard-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-epg-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-general-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-playback-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-remote-control-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-reset-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-tmdb-section.component.ts`
|
||||
- [ ] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts`
|
||||
|
||||
### libs/ui (20 files, 21 components)
|
||||
|
||||
- [x] `libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts`
|
||||
- [x] `libs/ui/components/src/lib/content-hero/content-hero.component.ts`
|
||||
- [x] `libs/ui/components/src/lib/expandable-text/expandable-text.component.ts`
|
||||
- [x] `libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts`
|
||||
- [x] `libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`
|
||||
- [x] `libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts`
|
||||
- [x] `libs/ui/components/src/lib/season-container/episode-info-dialog.component.ts`
|
||||
- [x] `libs/ui/components/src/lib/watched-badge/watched-badge.component.ts`
|
||||
- [x] `libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts`
|
||||
- [x] `libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts` (idle audit root; also `EpgTrustConfirmDialogComponent`)
|
||||
- [x] `libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts`
|
||||
- [ ] `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` (`apps/remote-control-web` only)
|
||||
- [ ] `libs/ui/playback/src/lib/art-player/art-player.component.ts`
|
||||
- [ ] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
|
||||
- [ ] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts`
|
||||
- [ ] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts`
|
||||
- [ ] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts`
|
||||
- [ ] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts`
|
||||
- [ ] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts`
|
||||
- [ ] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
|
||||
|
||||
`libs/ui/playback` (8) goes with the playback PR, not the `libs/ui` one.
|
||||
|
||||
### apps/remote-control-web (1)
|
||||
|
||||
- [ ] `apps/remote-control-web/src/app/app.ts` (separate app; converts with `remote-control.component.ts`)
|
||||
|
||||
### libs/workspace (7)
|
||||
|
||||
- [ ] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts`
|
||||
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts`
|
||||
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts`
|
||||
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts`
|
||||
- [ ] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts`
|
||||
- [ ] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
|
||||
- [ ] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts`
|
||||
|
||||
### libs/playlist (14)
|
||||
|
||||
- [ ] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
|
||||
- [ ] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
|
||||
- [ ] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts`
|
||||
- [ ] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts`
|
||||
- [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts`
|
||||
- [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts`
|
||||
- [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts`
|
||||
- [ ] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts`
|
||||
- [ ] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
|
||||
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
|
||||
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
|
||||
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
|
||||
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
|
||||
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
|
||||
|
||||
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
|
||||
|
||||
### libs/portal (9)
|
||||
|
||||
- [ ] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts`
|
||||
- [ ] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts`
|
||||
- [ ] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts`
|
||||
- [ ] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
|
||||
- [ ] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
|
||||
- [ ] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
|
||||
- [ ] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
|
||||
- [ ] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts`
|
||||
- [ ] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts`
|
||||
|
||||
Test stubs that set Eager (`*.spec.ts`, `*.spec-stubs.ts`, `*.spec-data.ts`,
|
||||
`*.test-helpers.ts`) are not listed; they do not ship.
|
||||
|
||||
## Zone-dependent sites
|
||||
|
||||
Plain (non-signal) fields read by a template and written from a callback
|
||||
that is not an Angular template event. Under zone.js the next tick happens to
|
||||
refresh an Eager view; under zoneless nothing schedules one. Each fix makes
|
||||
the field a signal (or a `computed`), or writes it through one.
|
||||
|
||||
| Done | Site | What depends on the zone | Owning PR |
|
||||
| --- | --- | --- | --- |
|
||||
| [ ] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
|
||||
| [ ] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
|
||||
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
|
||||
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
|
||||
| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist |
|
||||
| [ ] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
|
||||
| [ ] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal |
|
||||
| [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal |
|
||||
| [ ] | `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` | apps/web |
|
||||
| [ ] | `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
|
||||
|
||||
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.
|
||||
|
||||
- [ ] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two
|
||||
`zone.run` calls around the window-close dialog (IPC
|
||||
`onWindowCloseRequested` and `beforeunload`).
|
||||
- [ ] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`:
|
||||
`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`:
|
||||
18 `ngZone.run(() => signal.set(...))` calls, all redundant around signal
|
||||
writes.
|
||||
- [ ] `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
|
||||
`portal-channels-list.component.ts` (3 × `markForCheck`, 2 ×
|
||||
`detectChanges`): correct under zoneless; replace the Maps with signals in
|
||||
the portal PR if it stays small.
|
||||
|
||||
## Checked and signal-safe
|
||||
|
||||
No change needed; recorded so the flag PR knows where to look if a journey
|
||||
regresses. Embedded MPV and external players are the riskiest paths because
|
||||
their events arrive over IPC.
|
||||
|
||||
- **IPC listeners** (17 registrations): app update status, external player
|
||||
sessions, window close and window state, player errors, playlist open
|
||||
requests, embedded MPV sessions, playback history gate, downloads,
|
||||
recordings, playlist refresh, DB operation and save progress, EPG progress,
|
||||
playback position updates, channel change and remote-control commands. All
|
||||
write signals, signal stores or NgRx, or have no UI state.
|
||||
- **Player libraries** (video.js, mpegts.js, hls.js, artplayer, shaka, native
|
||||
`<video>`): callbacks bump signals in the control adapters or emit outputs
|
||||
whose parent handlers write signals.
|
||||
- **Observers** (13 Intersection/Resize/Mutation observers) and **document
|
||||
and window listeners** (~40): signals or DOM only. `@HostListener`
|
||||
bindings are Angular listeners and mark their view.
|
||||
- **Timers** (~130 `setTimeout`/`setInterval`/rAF/`queueMicrotask`): all
|
||||
write signals, touch the DOM or focus, or have no UI state, apart from the
|
||||
two in the table above.
|
||||
- **Dialogs and snackbars** (20 `afterClosed`/`onAction` sites): signals,
|
||||
stores, outputs or navigation, apart from the one in the table above.
|
||||
- No production code uses `NgZone.onStable`, `onMicrotaskEmpty`, `isStable`,
|
||||
`ApplicationRef.tick()`, `Zone.current` or `ngDoCheck`.
|
||||
|
||||
## Build, tests and runtime details
|
||||
|
||||
- [ ] `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
|
||||
the zoneless scheduler also calls, so the counters stay comparable.
|
||||
- [ ] 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:
|
||||
`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).
|
||||
|
||||
## Measuring a PR
|
||||
|
||||
Build `electron-performance` and run the journeys as described in
|
||||
[performance journeys](performance-journeys.md), then paste
|
||||
`renderer.cdTicksToFirstCard`, `renderer.cdTicksIdle30s`,
|
||||
`renderer.cdTicksToFirstPage` and `renderer.cdTicksToPlaying` before and
|
||||
after. While zone.js is on, removing Eager does not change the number of
|
||||
ticks, only the work per tick; expect the counters to stay put until the flag
|
||||
PR and the template work (DOM mutations, profile time) to drop.
|
||||
@@ -15,6 +15,7 @@ are not prerequisites for reading repository contracts.
|
||||
| Angular conventions; docs and skills maintenance; local review before a pull request | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
|
||||
| Unit, E2E, lint and coverage; `tools/coverage`, `tools/typecheck` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
|
||||
| Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
|
||||
| Zoneless change detection, `ChangeDetectionStrategy.Eager` components, `NgZone` usage | [Zoneless migration](../architecture/zoneless-migration.md) | Read the checklist directly |
|
||||
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
|
||||
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
|
||||
|
||||
|
||||
@@ -29,8 +29,7 @@ export interface ConfirmDialogData {
|
||||
|
||||
@Component({
|
||||
imports: [MatButtonModule, MatDialogModule, TranslateModule],
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ dialogData.title }}
|
||||
|
||||
@@ -34,8 +34,7 @@ export type ContentHeroLayout = 'stage' | 'compact';
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './content-hero.component.html',
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./content-hero.component.scss'],
|
||||
})
|
||||
export class ContentHeroComponent {
|
||||
|
||||
@@ -47,8 +47,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
|
||||
@@ -56,8 +56,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./content-about.component.scss'],
|
||||
})
|
||||
export class ContentAboutComponent {
|
||||
|
||||
@@ -43,8 +43,7 @@ import {
|
||||
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
|
||||
templateUrl: './portal-detail-shell.component.html',
|
||||
styleUrls: ['./portal-detail-shell.component.scss'],
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
tabindex: '0',
|
||||
role: 'region',
|
||||
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
.progress-capsule {
|
||||
|
||||
@@ -105,8 +105,7 @@ export const EPISODE_INFO_PLAY = 'play' as const;
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
|
||||
@@ -12,8 +12,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
.watched-badge {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ChangeDetectorRef, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { By } from '@angular/platform-browser';
|
||||
@@ -72,8 +72,12 @@ describe('EpgItemDescriptionComponent', () => {
|
||||
});
|
||||
|
||||
it('offers archive URL copying only with explicit capability and explains credentials', () => {
|
||||
// The component is OnPush and dialog data is fixed once the dialog
|
||||
// opens, so the test marks the component's own view after mutating it.
|
||||
const componentView =
|
||||
fixture.debugElement.injector.get(ChangeDetectorRef);
|
||||
Object.assign(component.dialogData, { archiveUrlAvailable: true });
|
||||
fixture.changeDetectorRef.markForCheck();
|
||||
componentView.markForCheck();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
@@ -84,7 +88,7 @@ describe('EpgItemDescriptionComponent', () => {
|
||||
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE'
|
||||
);
|
||||
Object.assign(component.dialogData, { archiveUrlAvailable: false });
|
||||
fixture.changeDetectorRef.markForCheck();
|
||||
componentView.markForCheck();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
|
||||
@@ -38,7 +38,7 @@ export type EpgItemDialogData = EpgProgram & {
|
||||
selector: 'app-epg-item-description',
|
||||
templateUrl: './epg-item-description.component.html',
|
||||
styleUrls: ['./epg-item-description.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DatePipe, MatDialogModule, MatIcon, TranslatePipe],
|
||||
})
|
||||
export class EpgItemDescriptionComponent {
|
||||
|
||||
@@ -32,7 +32,7 @@ interface EpgTrustConfirmDialogData {
|
||||
@Component({
|
||||
selector: 'app-epg-trust-confirm-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
@@ -64,7 +64,7 @@ class EpgTrustConfirmDialogComponent {
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-progress-panel.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './epg-progress-panel.component.scss',
|
||||
})
|
||||
export class EpgProgressPanelComponent {
|
||||
|
||||
@@ -30,7 +30,7 @@ type BadgeStatus =
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-source-status.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './epg-source-status.component.scss',
|
||||
})
|
||||
export class EpgSourceStatusComponent implements OnInit {
|
||||
|
||||
Reference in new issue
Block a user