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`: