Merge branch 'perf/onpush-libs-playlist' into ci/zoneless-packaged-smoke

This commit is contained in:
4gray committed 2026-10-04 21:52:01 +02:00
commit 4c03850bb8
16 files changed
+177 -49

No files matched your search

+15 -15
View File
@@ -124,20 +124,20 @@ files that still contain `ChangeDetectionStrategy.Eager`.
### 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`
- [ ] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts`
- [ ] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
- [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.
@@ -169,9 +169,9 @@ the field a signal (or a `computed`), or writes it through one.
| --- | --- | --- | --- |
| [ ] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
| [ ] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist |
| [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 |
| [ ] | `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 |
@@ -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,95 @@
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,
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]);
await fixture.componentInstance.removePlaylistEpgSource(removeUrl);
fixture.detectChanges();
expect(renderedSources()).toEqual([keepUrl]);
});
});
@@ -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,38 @@ 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',
};
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
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 {