mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <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:
19 files changed
+210
-49
No files matched your search
@@ -70,9 +70,21 @@ function readEagerChecklist(): { open: string[]; done: string[] } {
|
|||||||
|
|
||||||
const sources = readSources();
|
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', () => {
|
test('the zoneless checklist lists exactly the components that are still Eager', () => {
|
||||||
const eager = [...sources]
|
const eager = [...sources]
|
||||||
.filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager'))
|
.filter(([, text]) => isEagerComponent(text))
|
||||||
.map(([file]) => file)
|
.map(([file]) => file)
|
||||||
.sort();
|
.sort();
|
||||||
const { open } = readEagerChecklist();
|
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', () => {
|
test('ticked checklist entries name files that exist', () => {
|
||||||
for (const file of readEagerChecklist().done) {
|
for (const file of readEagerChecklist().done) {
|
||||||
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
|
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ import { AppUpdateInstallService } from './services/app-update-install.service';
|
|||||||
</section>
|
</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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
.app-update-notification {
|
.app-update-notification {
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ const debugAppComponent = createDevLogger('AppComponent');
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.component.html',
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
imports: [
|
imports: [
|
||||||
AppStartupStatusComponent,
|
AppStartupStatusComponent,
|
||||||
AppUpdateNotificationPanelComponent,
|
AppUpdateNotificationPanelComponent,
|
||||||
|
|||||||
@@ -178,8 +178,7 @@ function decorateReleaseNotesHtml(html: string): string {
|
|||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
`,
|
`,
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
.release-notes-dialog {
|
.release-notes-dialog {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ElectronBridgeAppUpdateStatus,
|
ElectronBridgeAppUpdateStatus,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { UpdateChannelOption } from './settings.models';
|
import { UpdateChannelOption } from './settings.models';
|
||||||
|
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings-about-section',
|
selector: 'app-settings-about-section',
|
||||||
@@ -32,8 +33,7 @@ import { UpdateChannelOption } from './settings.models';
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-about-section.component.html',
|
templateUrl: './settings-about-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [
|
styles: [
|
||||||
':host { display: contents; }',
|
':host { display: contents; }',
|
||||||
'.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }',
|
'.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.
|
* setting. Absent in hosts that only render the version block.
|
||||||
*/
|
*/
|
||||||
readonly form = input<FormGroup | null>(null);
|
readonly form = input<FormGroup | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Parent patches (Discard, backup import) change the form outside
|
||||||
|
// this OnPush section's events.
|
||||||
|
markSectionForCheckOnFormEvents(this.form);
|
||||||
|
}
|
||||||
|
|
||||||
readonly updateChannelOptions = input<UpdateChannelOption[]>([]);
|
readonly updateChannelOptions = input<UpdateChannelOption[]>([]);
|
||||||
|
|
||||||
readonly buildCommitShort = computed(() => {
|
readonly buildCommitShort = computed(() => {
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-backup-section.component.html',
|
templateUrl: './settings-backup-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [':host { display: contents; }'],
|
styles: [':host { display: contents; }'],
|
||||||
})
|
})
|
||||||
export class SettingsBackupSectionComponent {
|
export class SettingsBackupSectionComponent {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
|||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings-dashboard-section',
|
selector: 'app-settings-dashboard-section',
|
||||||
@@ -21,10 +22,15 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-dashboard-section.component.html',
|
templateUrl: './settings-dashboard-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [':host { display: contents; }'],
|
styles: [':host { display: contents; }'],
|
||||||
})
|
})
|
||||||
export class SettingsDashboardSectionComponent {
|
export class SettingsDashboardSectionComponent {
|
||||||
readonly form = input.required<FormGroup>();
|
readonly form = input.required<FormGroup>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Parent patches (Discard, backup import) change the form outside
|
||||||
|
// this OnPush section's events.
|
||||||
|
markSectionForCheckOnFormEvents(this.form);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,8 +31,7 @@ type SettingsDeleteSummaryItem = {
|
|||||||
selector: 'app-settings-delete-all-playlists-dialog',
|
selector: 'app-settings-delete-all-playlists-dialog',
|
||||||
templateUrl: './settings-delete-all-playlists-dialog.component.html',
|
templateUrl: './settings-delete-all-playlists-dialog.component.html',
|
||||||
styleUrls: ['./settings-delete-all-playlists-dialog.component.scss'],
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { EpgViewMode } from '@iptvnator/shared/interfaces';
|
|||||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { EpgViewModeOption } from './settings.models';
|
import { EpgViewModeOption } from './settings.models';
|
||||||
|
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings-epg-section',
|
selector: 'app-settings-epg-section',
|
||||||
@@ -34,12 +35,18 @@ import { EpgViewModeOption } from './settings.models';
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-epg-section.component.html',
|
templateUrl: './settings-epg-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [':host { display: contents; }'],
|
styles: [':host { display: contents; }'],
|
||||||
})
|
})
|
||||||
export class SettingsEpgSectionComponent {
|
export class SettingsEpgSectionComponent {
|
||||||
readonly form = input.required<FormGroup>();
|
readonly form = input.required<FormGroup>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Parent patches (Discard, backup import) change the form outside
|
||||||
|
// this OnPush section's events.
|
||||||
|
markSectionForCheckOnFormEvents(this.form);
|
||||||
|
}
|
||||||
|
|
||||||
readonly epgUrl = input.required<FormArray>();
|
readonly epgUrl = input.required<FormArray>();
|
||||||
readonly isClearingEpgData = input(false);
|
readonly isClearingEpgData = input(false);
|
||||||
readonly canBrowseFiles = input(false);
|
readonly canBrowseFiles = input(false);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
StartupWindowModeOption,
|
StartupWindowModeOption,
|
||||||
ThemeOption,
|
ThemeOption,
|
||||||
} from './settings.models';
|
} from './settings.models';
|
||||||
|
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings-general-section',
|
selector: 'app-settings-general-section',
|
||||||
@@ -33,12 +34,18 @@ import {
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-general-section.component.html',
|
templateUrl: './settings-general-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [':host { display: contents; }'],
|
styles: [':host { display: contents; }'],
|
||||||
})
|
})
|
||||||
export class SettingsGeneralSectionComponent {
|
export class SettingsGeneralSectionComponent {
|
||||||
readonly form = input.required<FormGroup>();
|
readonly form = input.required<FormGroup>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Parent patches (Discard, backup import) change the form outside
|
||||||
|
// this OnPush section's events.
|
||||||
|
markSectionForCheckOnFormEvents(this.form);
|
||||||
|
}
|
||||||
|
|
||||||
readonly languageEnum = input.required<typeof Language>();
|
readonly languageEnum = input.required<typeof Language>();
|
||||||
readonly themeOptions = input.required<ThemeOption[]>();
|
readonly themeOptions = input.required<ThemeOption[]>();
|
||||||
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
|
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
reportsPlaybackFailures,
|
reportsPlaybackFailures,
|
||||||
} from '@iptvnator/shared/interfaces';
|
} from '@iptvnator/shared/interfaces';
|
||||||
import { SettingsPlayerOption } from './settings.models';
|
import { SettingsPlayerOption } from './settings.models';
|
||||||
|
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings-playback-section',
|
selector: 'app-settings-playback-section',
|
||||||
@@ -36,8 +37,7 @@ import { SettingsPlayerOption } from './settings.models';
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-playback-section.component.html',
|
templateUrl: './settings-playback-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [':host { display: contents; }'],
|
styles: [':host { display: contents; }'],
|
||||||
})
|
})
|
||||||
export class SettingsPlaybackSectionComponent {
|
export class SettingsPlaybackSectionComponent {
|
||||||
@@ -53,6 +53,13 @@ export class SettingsPlaybackSectionComponent {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
readonly form = input.required<FormGroup>();
|
readonly form = input.required<FormGroup>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Parent patches (Discard, backup import) change the form outside
|
||||||
|
// this OnPush section's events.
|
||||||
|
markSectionForCheckOnFormEvents(this.form);
|
||||||
|
}
|
||||||
|
|
||||||
readonly players = input.required<SettingsPlayerOption[]>();
|
readonly players = input.required<SettingsPlayerOption[]>();
|
||||||
readonly streamFormatEnum = input.required<typeof StreamFormat>();
|
readonly streamFormatEnum = input.required<typeof StreamFormat>();
|
||||||
readonly isDesktop = input(false);
|
readonly isDesktop = input(false);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { QRCodeComponent } from 'angularx-qrcode';
|
import { QRCodeComponent } from 'angularx-qrcode';
|
||||||
|
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings-remote-control-section',
|
selector: 'app-settings-remote-control-section',
|
||||||
@@ -30,12 +31,18 @@ import { QRCodeComponent } from 'angularx-qrcode';
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-remote-control-section.component.html',
|
templateUrl: './settings-remote-control-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [':host { display: contents; }'],
|
styles: [':host { display: contents; }'],
|
||||||
})
|
})
|
||||||
export class SettingsRemoteControlSectionComponent {
|
export class SettingsRemoteControlSectionComponent {
|
||||||
readonly form = input.required<FormGroup>();
|
readonly form = input.required<FormGroup>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Parent patches (Discard, backup import) change the form outside
|
||||||
|
// this OnPush section's events.
|
||||||
|
markSectionForCheckOnFormEvents(this.form);
|
||||||
|
}
|
||||||
|
|
||||||
readonly localIpAddresses = input.required<string[]>();
|
readonly localIpAddresses = input.required<string[]>();
|
||||||
readonly visibleQrCodeIp = input<string | null>(null);
|
readonly visibleQrCodeIp = input<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ import { SettingsPlaylistDeleteSummary } from './settings.models';
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-reset-section.component.html',
|
templateUrl: './settings-reset-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [':host { display: contents; }'],
|
styles: [':host { display: contents; }'],
|
||||||
})
|
})
|
||||||
export class SettingsResetSectionComponent {
|
export class SettingsResetSectionComponent {
|
||||||
|
|||||||
@@ -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<AbstractControl | null>
|
||||||
|
): void {
|
||||||
|
const changeDetector = inject(ChangeDetectorRef);
|
||||||
|
toObservable(form)
|
||||||
|
.pipe(
|
||||||
|
switchMap((control) => control?.events ?? EMPTY),
|
||||||
|
takeUntilDestroyed()
|
||||||
|
)
|
||||||
|
.subscribe(() => changeDetector.markForCheck());
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
|
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
|
||||||
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
|
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
|
||||||
|
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||||
|
|
||||||
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
|
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
|
||||||
|
|
||||||
@@ -33,8 +34,7 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
|
|||||||
],
|
],
|
||||||
templateUrl: './settings-tmdb-section.component.html',
|
templateUrl: './settings-tmdb-section.component.html',
|
||||||
encapsulation: ViewEncapsulation.None,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
app-settings-tmdb-section {
|
app-settings-tmdb-section {
|
||||||
@@ -92,6 +92,7 @@ export class SettingsTmdbSectionComponent {
|
|||||||
readonly isClearing = signal(false);
|
readonly isClearing = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
markSectionForCheckOnFormEvents(this.form);
|
||||||
// Sizing the cache is a full table scan, but this component only
|
// Sizing the cache is a full table scan, but this component only
|
||||||
// exists while its section page is open, so loading on construction
|
// exists while its section page is open, so loading on construction
|
||||||
// preserves the old "wait until the user is actually looking"
|
// preserves the old "wait until the user is actually looking"
|
||||||
|
|||||||
@@ -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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
template: `
|
template: `
|
||||||
<h2 mat-dialog-title>
|
<h2 mat-dialog-title>
|
||||||
{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }}
|
{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FormArray, FormControl } from '@angular/forms';
|
||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
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', () => {
|
it('hydrates a shared web controls opt-out from the settings store', () => {
|
||||||
settingsStore._setSettings({
|
settingsStore._setSettings({
|
||||||
webPlayerSharedControls: false,
|
webPlayerSharedControls: false,
|
||||||
@@ -185,6 +205,26 @@ describe('SettingsComponent form', () => {
|
|||||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
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', () => {
|
it('stages the EPG view mode without writing to the store until Save', () => {
|
||||||
setSettingsSection('epg');
|
setSettingsSection('epg');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -314,6 +354,29 @@ describe('SettingsComponent form', () => {
|
|||||||
expect(unsavedBar()).toBeNull();
|
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)', () => {
|
it('discard reverts a staged cover size (regression: eager persist made it stick)', () => {
|
||||||
const largeCoverButton = (
|
const largeCoverButton = (
|
||||||
fixture.nativeElement as HTMLElement
|
fixture.nativeElement as HTMLElement
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
|
|||||||
SettingsSearchResultsComponent,
|
SettingsSearchResultsComponent,
|
||||||
SettingsTmdbSectionComponent,
|
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.OnPush,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
|
||||||
providers: [
|
providers: [
|
||||||
SettingsAppUpdateFacade,
|
SettingsAppUpdateFacade,
|
||||||
SettingsBackupFacade,
|
SettingsBackupFacade,
|
||||||
|
|||||||
@@ -63,25 +63,30 @@ must be ticked here.
|
|||||||
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
|
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
|
||||||
(or setting OnPush) once its template state is signals, signal inputs or
|
(or setting OnPush) once its template state is signals, signal inputs or
|
||||||
explicitly marked. The guard spec compares the unticked entries with the
|
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 (15)
|
||||||
|
|
||||||
- [ ] `apps/web/src/app/app.component.ts` (idle audit root)
|
- [x] `apps/web/src/app/app.component.ts` (idle audit root)
|
||||||
- [ ] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root)
|
- [x] `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`
|
- [x] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings.component.ts`
|
- [x] `apps/web/src/app/settings/settings.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-about-section.component.ts`
|
- [x] `apps/web/src/app/settings/settings-about-section.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-backup-section.component.ts`
|
- [x] `apps/web/src/app/settings/settings-backup-section.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-dashboard-section.component.ts`
|
- [x] `apps/web/src/app/settings/settings-dashboard-section.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts`
|
- [x] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-epg-section.component.ts`
|
- [x] `apps/web/src/app/settings/settings-epg-section.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-general-section.component.ts`
|
- [x] `apps/web/src/app/settings/settings-general-section.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-playback-section.component.ts`
|
- [x] `apps/web/src/app/settings/settings-playback-section.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-remote-control-section.component.ts`
|
- [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-reset-section.component.ts`
|
- [x] `apps/web/src/app/settings/settings-reset-section.component.ts`
|
||||||
- [ ] `apps/web/src/app/settings/settings-tmdb-section.component.ts`
|
- [x] `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/settings/settings-unsaved-changes-dialog.component.ts`
|
||||||
|
|
||||||
### libs/ui (20 files, 21 components)
|
### 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/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 |
|
| [ ] | `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 |
|
| [ ] | 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 |
|
| [ ] | `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
|
## 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
|
and `runOutsideAngular` just call through). Remove them in the flip PR, not
|
||||||
before: with zone.js on they still matter.
|
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
|
`zone.run` calls around the window-close dialog (IPC
|
||||||
`onWindowCloseRequested` and `beforeunload`).
|
`onWindowCloseRequested` and `beforeunload`).
|
||||||
- [x] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`:
|
- [x] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`:
|
||||||
|
|||||||
Reference in new issue
Block a user