perf(playlist): make the playlist import and shared UI components OnPush (#1820)

* perf(playlist): make the playlist import and shared UI components OnPush

Plan item C6 step 3 for libs/playlist (import/feature and shared/ui): the
twelve Eager components switch to OnPush. Two of them rendered plain fields
written after an await, outside any template event, which only an Eager
check on the next zone tick picked up:

- playlist-item's portal status dot (PWA, after the async portal check)
  now reads a signal;
- playlist-info's playlist is backed by a signal behind its existing
  getter/setter name, so the EPG source list follows removals and file
  picks that land after awaited cleanup and dialogs.

A regression test for each fails on OnPush with the plain field and passes
with the signal. The Stalker import's post-await patchValue needs no change
(see the zoneless checklist). The m3u feature-player components stay Eager
for the playback PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(playlist): check the OnPush dialogs without forcing a render

Review follow-ups (Greptile, Codex):

- The portal-status and EPG-row tests forced detectChanges() after their
  await, so they passed with plain fields. They now let the fixture render
  on its own; with portalStatus back on a plain field the status test fails.
- New: the playlist info dialog enables Save and shows the path after a
  native EPG file pick, without a forced render. pristine and valid read
  the form's state signals, so the OnPush dialog follows on its own.
- New render spec for the add-playlist dialog with the real URL form: Add
  enables after typing and after a patch from outside the child (as an
  auto-detect prefill does).

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:
authored and GitHub committed 2026-10-07 00:11:16 +02:00
1 parent 335392afa9
commit 0a6f54ca15
17 files changed
+296 -49

No files matched your search

+15 -15
View File
@@ -130,20 +130,20 @@ picker), so each marks itself on the form's `events` through
### libs/playlist (14)
- [ ] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts`
- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
- [x] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts`
- [x] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts`
- [x] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts`
- [x] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts`
- [x] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts`
- [x] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts`
- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
@@ -176,9 +176,9 @@ the field a signal (or a `computed`), or writes it through one.
| --- | --- | --- | --- |
| [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
| [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist |
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist |
| [x] | `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 |
| [x] | `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 |
| [x] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal |
@@ -0,0 +1,86 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { AddPlaylistDialogComponent } from './add-playlist-dialog.component';
/**
* The dialog is OnPush and its Add button reads `form.valid` of the real URL
* import child. The fixture renders on its own (`autoDetectChanges`): a
* forced `detectChanges()` would hide a button that does not follow the
* child form.
*/
describe('AddPlaylistDialogComponent with the real URL form', () => {
let fixture: ComponentFixture<AddPlaylistDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
AddPlaylistDialogComponent,
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
{ provide: Store, useValue: { dispatch: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{ provide: MAT_DIALOG_DATA, useValue: null },
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
],
}).compileComponents();
fixture = TestBed.createComponent(AddPlaylistDialogComponent);
fixture.autoDetectChanges();
await settle();
});
async function settle(): Promise<void> {
await fixture.whenStable();
// The render a form change schedules runs in the next macrotask.
await new Promise((resolve) => setTimeout(resolve));
}
const addButton = () =>
Array.from(
(
fixture.nativeElement as HTMLElement
).querySelectorAll<HTMLButtonElement>('button[mat-flat-button]')
).find((button) =>
button.textContent?.includes('HOME.URL_UPLOAD.ADD_PLAYLIST')
);
const urlInput = () =>
(fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>(
'input[formcontrolname="playlistUrl"]'
);
it('enables Add once the user types a playlist URL', async () => {
expect(addButton()?.disabled).toBe(true);
const input = urlInput();
if (!input) throw new Error('Expected the URL input');
input.value = 'https://example.com/list.m3u';
input.dispatchEvent(new Event('input'));
await settle();
expect(addButton()?.disabled).toBe(false);
});
it('enables Add when the child form is filled without a template event', async () => {
expect(addButton()?.disabled).toBe(true);
// As an auto-detect prefill does: a patch from outside the child.
fixture.componentInstance
.urlUpload()
?.form.patchValue({ playlistUrl: 'https://example.com/list.m3u' });
await settle();
expect(addButton()?.disabled).toBe(false);
});
});
@@ -72,7 +72,7 @@ const METHOD_BY_CANDIDATE_KIND: Record<
selector: 'app-add-playlist',
templateUrl: './add-playlist-dialog.component.html',
styleUrl: './add-playlist-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class AddPlaylistDialogComponent {
@@ -147,7 +147,7 @@ function maskUrlQueryPasswords(url: string): string {
selector: 'app-auto-import',
templateUrl: './auto-import.component.html',
styleUrl: './auto-import.component.scss',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatFormFieldModule,
@@ -18,7 +18,7 @@ const KB = 1024;
imports: [DragDropFileUploadDirective, MatIconModule, TranslatePipe],
selector: 'app-file-upload',
templateUrl: './file-upload.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./file-upload.component.scss'],
})
export class FileUploadComponent {
@@ -75,7 +75,7 @@ interface StalkerSettledIdentity {
],
selector: 'app-stalker-portal-import',
templateUrl: './stalker-portal-import.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
@@ -17,7 +17,7 @@ import { TranslatePipe } from '@ngx-translate/core';
selector: 'app-text-import',
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatInputModule, ReactiveFormsModule, TranslatePipe],
})
export class TextImportComponent {
@@ -19,7 +19,7 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services';
@Component({
selector: 'app-url-upload',
templateUrl: './url-upload.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatFormFieldModule,
MatInputModule,
@@ -60,7 +60,7 @@ function xtreamServerUrlValidator(
],
selector: 'app-xtream-code-import',
templateUrl: './xtream-code-import.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
@@ -103,7 +103,7 @@ const SOURCE_CARDS: readonly SourceCard[] = [
'./empty-state.responsive.scss',
'./empty-state.themes.scss',
],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatIcon, TranslatePipe],
})
export class EmptyStateComponent {
@@ -0,0 +1,126 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistInfoComponent } from './playlist-info.component';
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from './stalker-playlist-connection-editor.token';
/**
* The component is OnPush: the playlist is replaced after the awaited EPG
* cleanup, outside any template event, so the rendered source list has to
* follow without a zone-triggered tick.
*/
describe('PlaylistInfoComponent playlist EPG source rows', () => {
const keepUrl = 'https://playlist.example.com/keep.xml';
const removeUrl = 'https://playlist.example.com/remove.xml';
const playlist = {
id: 'playlist-1',
_id: 'playlist-1',
title: 'My Playlist',
count: 1,
importDate: '2026-04-01T00:00:00.000Z',
autoRefresh: false,
url: 'https://example.com/playlist.m3u',
epgUrls: [keepUrl, removeUrl],
detectedEpgUrls: [keepUrl, removeUrl],
} as Playlist & { id: string };
function render() {
TestBed.configureTestingModule({
imports: [PlaylistInfoComponent, TranslateModule.forRoot()],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: playlist },
{
provide: PlaylistsService,
useValue: { getPlaylistById: jest.fn(() => of(playlist)) },
},
{ provide: DatabaseService, useValue: {} },
{
provide: EpgRuntimeBridgeService,
useValue: {
supportsDataManagement: true,
supportsFilePicker: true,
pickEpgFile: jest
.fn()
.mockResolvedValue('/home/user/epg/guide.xml.gz'),
clearEpgDataForSource: jest
.fn()
.mockResolvedValue({ success: true }),
},
},
{ provide: Store, useValue: { dispatch: jest.fn() } },
{
provide: SettingsStore,
useValue: { getSettings: jest.fn(() => ({ epgUrl: [] })) },
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: MatDialogRef,
useValue: { beforeClosed: () => new Subject<void>() },
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsDesktopFileSave: false },
},
{ provide: STALKER_PLAYLIST_CONNECTION_EDITOR, useValue: {} },
],
});
const fixture = TestBed.createComponent(PlaylistInfoComponent);
fixture.detectChanges();
return fixture;
}
it('drops a removed source row once its cleanup resolves', async () => {
const fixture = render();
const renderedSources = () =>
Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll(
'.playlist-epg-source-row input[readonly]'
),
(input) => (input as HTMLInputElement).value
);
expect(renderedSources()).toEqual([keepUrl, removeUrl]);
// From here the fixture renders on its own: a forced detectChanges()
// after the cleanup would hide a row update that schedules no render.
fixture.autoDetectChanges();
await fixture.componentInstance.removePlaylistEpgSource(removeUrl);
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
expect(renderedSources()).toEqual([keepUrl]);
});
// The native file pick resolves after an await, with no template event.
// The main Save button and the field must follow without a forced render.
it('enables Save after a native EPG file pick without a forced render', async () => {
const fixture = render();
fixture.autoDetectChanges();
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const save = () =>
host.querySelector<HTMLButtonElement>('button[type="submit"]');
const field = () =>
host.querySelector<HTMLInputElement>(
'.playlist-epg-source-form .playlist-epg-source-row input'
);
expect(save()?.disabled).toBe(true);
await fixture.componentInstance.browsePlaylistEpgSourceInput(0);
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
expect(field()?.value).toBe('/home/user/epg/guide.xml.gz');
expect(save()?.disabled).toBe(false);
});
});
@@ -166,7 +166,7 @@ type DesktopFileSaveBridge = Pick<
`,
],
providers: [DatePipe],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
MatButton,
@@ -300,8 +300,20 @@ export class PlaylistInfoComponent {
) as UntypedFormArray;
}
/** Playlist object */
playlist: Playlist & { id: string };
/**
* Playlist object. Signal-backed because it is replaced after awaited
* hydration and EPG source edits, which no template event marks for an
* OnPush check.
*/
private readonly playlistState = signal<Playlist & { id: string }>(
this.playlistData
);
get playlist(): Playlist & { id: string } {
return this.playlistState();
}
set playlist(value: Playlist & { id: string }) {
this.playlistState.set(value);
}
/** Form group with playlist details */
playlistDetails!: UntypedFormGroup;
@@ -311,7 +323,6 @@ export class PlaylistInfoComponent {
this.dialogRef?.beforeClosed().subscribe(() => {
this.dialogClosing = true;
});
this.playlist = this.playlistData;
this.createForm();
this.connectionTest = createXtreamConnectionTestState(
this.playlistDetails
@@ -64,8 +64,8 @@
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
[matTooltip]="'Portal status: ' + portalStatus()"
[attr.aria-label]="'Portal status: ' + portalStatus()"
></span>
} @else if (showsAutoRefresh) {
<mat-icon
@@ -127,9 +127,7 @@ describe('PlaylistItemComponent', () => {
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.refresh-btn'
)
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).toBeNull();
});
@@ -149,9 +147,7 @@ describe('PlaylistItemComponent', () => {
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.refresh-btn'
)
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).not.toBeNull();
});
@@ -173,9 +169,7 @@ describe('PlaylistItemComponent', () => {
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.refresh-btn'
)
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).not.toBeNull();
fixture.destroy();
@@ -195,12 +189,40 @@ describe('PlaylistItemComponent', () => {
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.refresh-btn'
)
(fixture.nativeElement as HTMLElement).querySelector('.refresh-btn')
).toBeNull();
});
// OnPush: the status arrives after an await outside any template event,
// so it must reach the view without a zone-triggered tick.
it('renders the portal status that resolves after the first render', async () => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Xtream Source',
_id: 'xtream-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
serverUrl: 'https://example.com',
username: 'demo',
password: 'secret',
};
// The fixture renders on its own: a forced detectChanges() after the
// await would hide a status that does not schedule a render.
fixture.autoDetectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
const statusDot = (fixture.nativeElement as HTMLElement).querySelector(
'.status-dot'
);
expect(statusDot?.getAttribute('aria-label')).toBe(
'Portal status: active'
);
});
it('renders cancel and progress UI for long-running playlist actions', () => {
fixture.componentRef.setInput('isDeleting', true);
fixture.componentRef.setInput(
@@ -17,6 +17,7 @@ import {
inject,
input,
output,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@@ -39,7 +40,7 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
selector: 'app-playlist-item',
templateUrl: './playlist-item.component.html',
styleUrls: ['./playlist-item.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SourceHealthIndicatorComponent,
DatePipe,
@@ -70,7 +71,7 @@ export class PlaylistItemComponent implements OnInit {
readonly removeClicked = output<string>();
readonly cancelBusyActionClicked = output<void>();
portalStatus: PortalStatus = 'unavailable';
readonly portalStatus = signal<PortalStatus>('unavailable');
private readonly portalStatusService = inject(PortalStatusService);
readonly runtime = inject(RuntimeCapabilitiesService);
readonly sourceIcons = SOURCE_TYPE_ICONS;
@@ -125,21 +126,22 @@ export class PlaylistItemComponent implements OnInit {
private async checkPortalStatus() {
if (this.item.serverUrl && this.item.username && this.item.password) {
this.portalStatus =
this.portalStatus.set(
await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
);
)
);
}
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.portalStatus);
return this.portalStatusService.getStatusClass(this.portalStatus());
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.portalStatus);
return this.portalStatusService.getStatusIcon(this.portalStatus());
}
onPlaylistClick(): void {
@@ -181,7 +181,7 @@ export interface SourceCleanupDialogData extends SourceCleanupContext {
</button>
}
</mat-dialog-actions>`,
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
@@ -29,7 +29,7 @@ import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
role="img"
></span>
}`,
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {