From 2e321cb1bd772d80e9c8b1c6089a1c634bff1e6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:20:13 +0200 Subject: [PATCH 1/2] chore(deps): bump mpegts.js from 1.8.1 to 1.8.2 (#1835) * chore(deps): bump mpegts.js from 1.8.1 to 1.8.2 Bumps [mpegts.js](https://github.com/xqq/mpegts.js) from 1.8.1 to 1.8.2. - [Release notes](https://github.com/xqq/mpegts.js/releases) - [Commits](https://github.com/xqq/mpegts.js/compare/v1.8.1...v1.8.2) --- updated-dependencies: - dependency-name: mpegts.js dependency-version: 1.8.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * test(playback): accept the mpegts.js 1.8.2 error contract 1.8.2 keeps the public ErrorTypes and ErrorDetails exports unchanged, so the version lock moves to 1.8.2 with the same accepted contract. Co-Authored-By: Claude Opus 5.5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- docs/architecture/embedded-inline-playback.md | 2 +- .../diagnostics/mpegts-playback-evidence.util.spec.ts | 2 +- .../lib/diagnostics/mpegts-playback-evidence.util.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 11 +++++------ 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 3c19e5a58..8f98857af 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -749,7 +749,7 @@ allowlisted display name. `network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding. -mpegts.js `1.8.1` errors cross one shared structured boundary before the HTML5, +mpegts.js `1.8.2` errors cross one shared structured boundary before the HTML5, Video.js, or ArtPlayer owner emits a diagnostic. Version-locked tests compare the installed public `ErrorTypes` and `ErrorDetails` exports with the accepted contract. Evidence retains only an exact type/detail pair, terminal diff --git a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts index 4c9613bc7..d225076ed 100644 --- a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts @@ -21,7 +21,7 @@ const METADATA = createPlaybackSourceMetadata({ }); describe('mpegts.js playback evidence', () => { - it('locks the accepted public contract to mpegts.js 1.8.1', () => { + it('locks the accepted public contract to mpegts.js 1.8.2', () => { expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION); expect(mpegts.ErrorTypes).toEqual({ NETWORK_ERROR: MpegTsPlaybackEngineType.Network, diff --git a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts index e09a80088..dbae7fe68 100644 --- a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts +++ b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts @@ -11,7 +11,7 @@ import { MpegTsPlaybackStage, } from './mpegts-playback-evidence.model'; -export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.1'; +export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.2'; interface MpegTsPlaybackCause { readonly stage: MpegTsPlaybackStageValue; diff --git a/package.json b/package.json index 6a3fe916a..406ec03df 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "hls.js": "1.7.1", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2", "marked": "18.0.11", - "mpegts.js": "1.8.1", + "mpegts.js": "1.8.2", "ms": "2.1.3", "ngx-indexed-db": "22.0.0", "ngx-skeleton-loader": "11.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93e5f04d4..244eb6e6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,8 +155,8 @@ importers: specifier: 18.0.11 version: 18.0.11 mpegts.js: - specifier: 1.8.1 - version: 1.8.1 + specifier: 1.8.2 + version: 1.8.2 ms: specifier: 2.1.3 version: 2.1.3 @@ -7701,8 +7701,8 @@ packages: resolution: {integrity: sha512-blbA7XpAaOdC/PR0Tu8GiUxlx6CpBuRowQPYV7lhi9Yr9G9+dkPXIUjqjzc/NCn/uZZPopluaJlIYntIEI/VLw==} hasBin: true - mpegts.js@1.8.1: - resolution: {integrity: sha512-mQ2daxqBaS+QmBYTmJMDUGwAOw08534b08KSyqUGkjkhuBODNod15j8pnRfVEIQVWulKNe201/6UqILe4m4YRQ==} + mpegts.js@1.8.2: + resolution: {integrity: sha512-cZYMa5muASH55wrS6JK5IGkUqnCA96c0f+WdnW91jIK9s/N6NzjmTQDJNYg9A5u/G2Q5nj2b3w1Zwzze/9guxA==} engines: {node: '>=20.9.0'} mrmime@2.0.1: @@ -8854,7 +8854,6 @@ packages: shaka-player@5.2.12: resolution: {integrity: sha512-vEsuVDI3+oLh+yqpjXexQZlg2XMUBI0VrqatDuKWacPaxAud6Uph5uCowUPLhUgNG5XKWCpoM4l1r8qYzWH+zg==} - engines: {node: '>=18'} shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} @@ -18064,7 +18063,7 @@ snapshots: '@xmldom/xmldom': 0.8.15 global: 4.4.0 - mpegts.js@1.8.1: + mpegts.js@1.8.2: dependencies: es6-promise: 4.2.8 events: 3.3.0 From 77458b393194258e37340ff5e111eaa51affcdf3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:58:28 +0200 Subject: [PATCH 2/2] perf(web): make the app root and settings components OnPush (#1823) * perf(web): make the app root and settings components OnPush Plan item C6 step 3 for apps/web: the fifteen Eager components switch to OnPush, among them the app root and the update notification panel that the idle audit found re-rendering on every idle tick. Their template state is signals from the settings facades, signal inputs and the shared reactive settings form. The checklist flagged the backup import, which patches the form from a detached file input with no template event. A new spec patches only a value, which changes no form status, and confirms the OnPush general section still shows the new theme; it guards that path for the zoneless flag. Co-Authored-By: Claude Opus 5.5 * fix(settings): re-render OnPush sections when the form changes outside them Review follow-ups (Greptile, Codex): - The settings sections read form values in their templates (selected theme and cover size, epgField.value, form().value.player), and the parent changes the form outside their events: Discard and backup import patch it, the store hydrates it, the EPG file picker sets a control after an await. Under OnPush the section kept the old selection or EPG status. Each section now marks itself on its form's events (markSectionForCheckOnFormEvents). - The value-only patch test no longer forces detectChanges(); with the fixture rendering on its own it fails without the marking, and so does a new test for a control set outside the EPG section. - The zoneless guard counts only changeDetection metadata outside comments, so a comment naming the strategy is not an Eager component. Co-Authored-By: Claude Opus 5.5 * test(settings): guard the unsaved-changes bar after a save off the sections Review follow-up (Codex): Save marks the form pristine after an async store write, also on Backup, Reset or search, where no form section is rendered. The OnPush page re-renders anyway because pristine and valid read the form's state signals; the new test checks that on the Backup page without forcing a render (it waits for the scheduled one). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../performance/zoneless-migration.spec.ts | 31 ++++++++- ...app-update-notification-panel.component.ts | 3 +- apps/web/src/app/app.component.ts | 3 +- ...p-update-release-notes-dialog.component.ts | 3 +- .../settings-about-section.component.ts | 11 +++- .../settings-backup-section.component.ts | 3 +- .../settings-dashboard-section.component.ts | 10 ++- ...s-delete-all-playlists-dialog.component.ts | 3 +- .../settings-epg-section.component.ts | 11 +++- .../settings-general-section.component.ts | 11 +++- .../settings-playback-section.component.ts | 11 +++- ...ttings-remote-control-section.component.ts | 11 +++- .../settings-reset-section.component.ts | 3 +- .../settings/settings-section-form-render.ts | 30 +++++++++ .../settings-tmdb-section.component.ts | 5 +- ...ttings-unsaved-changes-dialog.component.ts | 3 +- .../settings/settings.component.form.spec.ts | 63 +++++++++++++++++++ .../src/app/settings/settings.component.ts | 3 +- docs/architecture/zoneless-migration.md | 41 ++++++------ 19 files changed, 210 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/app/settings/settings-section-form-render.ts diff --git a/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts b/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts index f9a67c9d7..3137c0f1f 100644 --- a/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts +++ b/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts @@ -70,9 +70,21 @@ function readEagerChecklist(): { open: string[]; done: string[] } { const sources = readSources(); +// Component metadata only: a comment or string that names the strategy is +// not an Eager component. +const eagerMetadata = /changeDetection\s*:\s*ChangeDetectionStrategy\.Eager\b/; + +function withoutComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); +} + +function isEagerComponent(text: string): boolean { + return eagerMetadata.test(withoutComments(text)); +} + test('the zoneless checklist lists exactly the components that are still Eager', () => { const eager = [...sources] - .filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager')) + .filter(([, text]) => isEagerComponent(text)) .map(([file]) => file) .sort(); const { open } = readEagerChecklist(); @@ -106,6 +118,23 @@ test('the guard skips test-only file names and keeps production ones', () => { } }); +test('a comment that names the Eager strategy is not an Eager component', () => { + assert.equal( + isEagerComponent( + '// was ChangeDetectionStrategy.Eager before C6\n' + + '/* changeDetection: ChangeDetectionStrategy.Eager */\n' + + '@Component({ changeDetection: ChangeDetectionStrategy.OnPush })' + ), + false + ); + assert.equal( + isEagerComponent( + '@Component({\n changeDetection: ChangeDetectionStrategy.Eager,\n})' + ), + true + ); +}); + 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`); diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts index a99e814ad..91afea93b 100644 --- a/apps/web/src/app/app-update-notification-panel.component.ts +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -101,8 +101,7 @@ import { AppUpdateInstallService } from './services/app-update-install.service'; } `, - // 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: [ ` .app-update-notification { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 15c3df9c3..804899aa4 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -55,8 +55,7 @@ const debugAppComponent = createDevLogger('AppComponent'); @Component({ selector: 'app-root', templateUrl: './app.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, imports: [ AppStartupStatusComponent, AppUpdateNotificationPanelComponent, diff --git a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts index 870e1c142..afacf4eff 100644 --- a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts +++ b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts @@ -178,8 +178,7 @@ function decorateReleaseNotesHtml(html: string): string { `, - // 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: [ ` .release-notes-dialog { diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 9d6f1f6a0..028522a27 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -19,6 +19,7 @@ import { ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; import { UpdateChannelOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-about-section', @@ -32,8 +33,7 @@ import { UpdateChannelOption } from './settings.models'; ], templateUrl: './settings-about-section.component.html', encapsulation: ViewEncapsulation.None, - // 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 { display: contents; }', '.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }', @@ -58,6 +58,13 @@ export class SettingsAboutSectionComponent { * setting. Absent in hosts that only render the version block. */ readonly form = input(null); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly updateChannelOptions = input([]); readonly buildCommitShort = computed(() => { diff --git a/apps/web/src/app/settings/settings-backup-section.component.ts b/apps/web/src/app/settings/settings-backup-section.component.ts index f383c6163..0af8aefba 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.ts +++ b/apps/web/src/app/settings/settings-backup-section.component.ts @@ -20,8 +20,7 @@ import { TranslateModule } from '@ngx-translate/core'; ], templateUrl: './settings-backup-section.component.html', encapsulation: ViewEncapsulation.None, - // 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 { display: contents; }'], }) export class SettingsBackupSectionComponent { diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.ts b/apps/web/src/app/settings/settings-dashboard-section.component.ts index 3cb1739b6..c39bdbca5 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.ts +++ b/apps/web/src/app/settings/settings-dashboard-section.component.ts @@ -9,6 +9,7 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-dashboard-section', @@ -21,10 +22,15 @@ import { TranslateModule } from '@ngx-translate/core'; ], templateUrl: './settings-dashboard-section.component.html', encapsulation: ViewEncapsulation.None, - // 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 { display: contents; }'], }) export class SettingsDashboardSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } } diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts index 87dd57aa2..9f4a0fea2 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts @@ -31,8 +31,7 @@ type SettingsDeleteSummaryItem = { selector: 'app-settings-delete-all-playlists-dialog', templateUrl: './settings-delete-all-playlists-dialog.component.html', styleUrls: ['./settings-delete-all-playlists-dialog.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, imports: [ CommonModule, MatButtonModule, diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index 8f07d4124..8152b24da 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -17,6 +17,7 @@ import { EpgViewMode } from '@iptvnator/shared/interfaces'; import { EpgSourceStatusComponent } from '@iptvnator/ui/epg'; import { TranslateModule } from '@ngx-translate/core'; import { EpgViewModeOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-epg-section', @@ -34,12 +35,18 @@ import { EpgViewModeOption } from './settings.models'; ], templateUrl: './settings-epg-section.component.html', encapsulation: ViewEncapsulation.None, - // 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 { display: contents; }'], }) export class SettingsEpgSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly epgUrl = input.required(); readonly isClearingEpgData = input(false); readonly canBrowseFiles = input(false); diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index b24d2afbc..b44971bf0 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -19,6 +19,7 @@ import { StartupWindowModeOption, ThemeOption, } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-general-section', @@ -33,12 +34,18 @@ import { ], templateUrl: './settings-general-section.component.html', encapsulation: ViewEncapsulation.None, - // 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 { display: contents; }'], }) export class SettingsGeneralSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly languageEnum = input.required(); readonly themeOptions = input.required(); readonly coverSizeOptions = input.required(); diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 6b0afd01f..5a3eee85d 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -20,6 +20,7 @@ import { reportsPlaybackFailures, } from '@iptvnator/shared/interfaces'; import { SettingsPlayerOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-playback-section', @@ -36,8 +37,7 @@ import { SettingsPlayerOption } from './settings.models'; ], templateUrl: './settings-playback-section.component.html', encapsulation: ViewEncapsulation.None, - // 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 { display: contents; }'], }) export class SettingsPlaybackSectionComponent { @@ -53,6 +53,13 @@ export class SettingsPlaybackSectionComponent { ].join('\n'); readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly players = input.required(); readonly streamFormatEnum = input.required(); readonly isDesktop = input(false); diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 37b6408ef..7d667f46a 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -14,6 +14,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; import { QRCodeComponent } from 'angularx-qrcode'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-remote-control-section', @@ -30,12 +31,18 @@ import { QRCodeComponent } from 'angularx-qrcode'; ], templateUrl: './settings-remote-control-section.component.html', encapsulation: ViewEncapsulation.None, - // 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 { display: contents; }'], }) export class SettingsRemoteControlSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly localIpAddresses = input.required(); readonly visibleQrCodeIp = input(null); diff --git a/apps/web/src/app/settings/settings-reset-section.component.ts b/apps/web/src/app/settings/settings-reset-section.component.ts index ae6d5226f..8396ee5c4 100644 --- a/apps/web/src/app/settings/settings-reset-section.component.ts +++ b/apps/web/src/app/settings/settings-reset-section.component.ts @@ -22,8 +22,7 @@ import { SettingsPlaylistDeleteSummary } from './settings.models'; ], templateUrl: './settings-reset-section.component.html', encapsulation: ViewEncapsulation.None, - // 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 { display: contents; }'], }) export class SettingsResetSectionComponent { diff --git a/apps/web/src/app/settings/settings-section-form-render.ts b/apps/web/src/app/settings/settings-section-form-render.ts new file mode 100644 index 000000000..f0c19729b --- /dev/null +++ b/apps/web/src/app/settings/settings-section-form-render.ts @@ -0,0 +1,30 @@ +import { ChangeDetectorRef, inject, type Signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import type { AbstractControl } from '@angular/forms'; +import { EMPTY, switchMap } from 'rxjs'; + +/** + * Marks an OnPush settings section for check on every event of its form. + * + * The sections read form values and states in their templates (selected + * theme, `epgField.value`, `form().value.player`), which are not signals. + * The parent changes the form outside the section's template events: Discard + * and backup import patch it, the store hydrates it, and the EPG file picker + * sets a control after an `await`. Without this the section keeps showing + * the previous value until some unrelated event marks it. `events` covers + * value, status, touched and pristine changes, including those of child + * controls, which bubble up to the group. + * + * Call it from a field initializer or the constructor. + */ +export function markSectionForCheckOnFormEvents( + form: Signal +): void { + const changeDetector = inject(ChangeDetectorRef); + toObservable(form) + .pipe( + switchMap((control) => control?.events ?? EMPTY), + takeUntilDestroyed() + ) + .subscribe(() => changeDetector.markForCheck()); +} diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 4b57acfba..0959308c7 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -16,6 +16,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslateModule } from '@ngx-translate/core'; import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; @@ -33,8 +34,7 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; ], templateUrl: './settings-tmdb-section.component.html', encapsulation: ViewEncapsulation.None, - // 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: [ ` app-settings-tmdb-section { @@ -92,6 +92,7 @@ export class SettingsTmdbSectionComponent { readonly isClearing = signal(false); constructor() { + markSectionForCheckOnFormEvents(this.form); // Sizing the cache is a full table scan, but this component only // exists while its section page is open, so loading on construction // preserves the old "wait until the user is actually looking" diff --git a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts index c556f7d1b..c709a9e06 100644 --- a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts +++ b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts @@ -54,8 +54,7 @@ export interface SettingsUnsavedChangesDialogData { } `, ], - // 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: `

{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }} diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index 5cd4b2d3d..bf9b9b1d2 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -1,3 +1,4 @@ +import { FormArray, FormControl } from '@angular/forms'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; @@ -143,6 +144,25 @@ describe('SettingsComponent form', () => { }); }); + // The sections are OnPush and a Discard or backup import patches the + // form outside their template events, so the section must mark + // itself on the form's events. The fixture renders on its own here: + // a forced detectChanges() would hide a section that is not marked. + it('re-renders section selections after a value-only form patch', async () => { + const darkTheme = () => + (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="DARK_THEME"]' + ); + fixture.autoDetectChanges(); + await fixture.whenStable(); + expect(darkTheme()?.getAttribute('aria-checked')).toBe('false'); + + component.settingsForm.patchValue({ theme: Theme.DarkTheme }); + await fixture.whenStable(); + + expect(darkTheme()?.getAttribute('aria-checked')).toBe('true'); + }); + it('hydrates a shared web controls opt-out from the settings store', () => { settingsStore._setSettings({ webPlayerSharedControls: false, @@ -185,6 +205,26 @@ describe('SettingsComponent form', () => { expect(settingsStore.updateSettings).not.toHaveBeenCalled(); }); + // The native file picker sets the EPG control after an await, with + // no template event in the OnPush section; its status must follow. + it('shows the source status after a control is set outside the section', async () => { + setSettingsSection('epg'); + fixture.autoDetectChanges(); + const epgUrls = component.settingsForm.get('epgUrl') as FormArray; + epgUrls.push(new FormControl('')); + await fixture.whenStable(); + const status = () => + (fixture.nativeElement as HTMLElement).querySelector( + 'app-epg-source-status' + ); + expect(status()).toBeNull(); + + epgUrls.at(epgUrls.length - 1).setValue('/tmp/guide.xml'); + await fixture.whenStable(); + + expect(status()).not.toBeNull(); + }); + it('stages the EPG view mode without writing to the store until Save', () => { setSettingsSection('epg'); fixture.detectChanges(); @@ -314,6 +354,29 @@ describe('SettingsComponent form', () => { expect(unsavedBar()).toBeNull(); }); + // The page owns the bar and is OnPush, and Save marks the form + // pristine after an async store write, also on a page without a form + // section. `pristine` and `valid` read the form's state signals, so + // the page re-renders without a form subscription; no forced render + // here, so a regression shows. + it('hides after a save on a page without a form section', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + setSettingsSection('backup'); + fixture.autoDetectChanges(); + component.settingsForm.get('theme')?.setValue(Theme.DarkTheme); + component.settingsForm.markAsDirty(); + await fixture.whenStable(); + expect(unsavedBar()).not.toBeNull(); + + component.onSubmit(); + await fixture.whenStable(); + // The render the form event scheduled runs in the next macrotask. + await new Promise((resolve) => setTimeout(resolve)); + + expect(component.settingsForm.pristine).toBe(true); + expect(unsavedBar()).toBeNull(); + }); + it('discard reverts a staged cover size (regression: eager persist made it stick)', () => { const largeCoverButton = ( fixture.nativeElement as HTMLElement diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5f95e8b07..f43b3eb30 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -102,8 +102,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsSearchResultsComponent, SettingsTmdbSectionComponent, ], - // 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, providers: [ SettingsAppUpdateFacade, SettingsBackupFacade, diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 63e70d481..4d7e804f7 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -63,25 +63,30 @@ must be ticked here. 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`. +files whose component metadata still sets +`changeDetection: ChangeDetectionStrategy.Eager` (comments do not count). +The settings sections read form values in their templates and the parent +patches the form outside their events (Discard, backup import, the EPG file +picker), so each marks itself on the form's `events` through +`markSectionForCheckOnFormEvents` (`apps/web/src/app/settings`). ### 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` +- [x] `apps/web/src/app/app.component.ts` (idle audit root) +- [x] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root) +- [x] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts` +- [x] `apps/web/src/app/settings/settings.component.ts` +- [x] `apps/web/src/app/settings/settings-about-section.component.ts` +- [x] `apps/web/src/app/settings/settings-backup-section.component.ts` +- [x] `apps/web/src/app/settings/settings-dashboard-section.component.ts` +- [x] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts` +- [x] `apps/web/src/app/settings/settings-epg-section.component.ts` +- [x] `apps/web/src/app/settings/settings-general-section.component.ts` +- [x] `apps/web/src/app/settings/settings-playback-section.component.ts` +- [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts` +- [x] `apps/web/src/app/settings/settings-reset-section.component.ts` +- [x] `apps/web/src/app/settings/settings-tmdb-section.component.ts` +- [x] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts` ### libs/ui (20 files, 21 components) @@ -176,7 +181,7 @@ the field a signal (or a `computed`), or writes it through one. | [ ] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal | | [ ] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal | | [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal | -| [ ] | `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 | +| [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; no fix needed: a value-only `patchValue` re-renders the OnPush sections (`settings.component.form.spec.ts` guards it) | apps/web | | [ ] | `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 @@ -185,7 +190,7 @@ 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 +- [x] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two `zone.run` calls around the window-close dialog (IPC `onWindowCloseRequested` and `beforeunload`). - [x] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`: