From 92032cba28ff85e748a7696438213ea730be1ec4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 10:37:22 +0200 Subject: [PATCH] perf(portal): make the portal Eager components OnPush Plan item C6 step 3 for libs/portal: the nine Eager components in portal/shared/ui, portal/stalker/feature and portal/xtream/feature switch to OnPush. Their templates read signals, signal inputs, async pipes and template-event state; the plain fields they write outside events (playback request ids, save throttles) are not rendered. The already-OnPush live channel lists filled their favorites Maps in a subscription and the Xtream list dropped programme previews after the EPG mapping dialog, all without marking the view. They now call markForCheck like the neighbouring handlers do, so a late favorites answer shows its hearts without waiting for an unrelated check. A regression test for the Xtream list fails without the call. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/zoneless-migration.md | 24 +++++----- .../favorites-layout.component.ts | 2 +- .../playlist-error-view.component.ts | 2 +- .../search-form/search-form.component.ts | 2 +- .../navigation/portal-rail-links.component.ts | 2 +- .../stalker-catalog-detail.component.ts | 2 +- .../stalker-favorites-button.component.ts | 2 +- .../stalker-live-stream-layout.component.ts | 2 + .../stalker-series-view.component.ts | 2 +- .../global-search-results.component.ts | 2 +- .../portal-channels-list.component.spec.ts | 46 +++++++++++++++++++ .../portal-channels-list.component.ts | 2 + .../serial-details.component.ts | 2 +- 13 files changed, 71 insertions(+), 21 deletions(-) diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 630d51ff2..9f232be48 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -143,15 +143,15 @@ files that still contain `ChangeDetectionStrategy.Eager`. ### libs/portal (9) -- [ ] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts` -- [ ] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts` -- [ ] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts` -- [ ] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts` -- [ ] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` -- [ ] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts` -- [ ] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` -- [ ] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts` -- [ ] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` +- [x] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts` +- [x] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts` +- [x] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts` +- [x] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts` +- [x] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` +- [x] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts` +- [x] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- [x] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts` +- [x] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` Test stubs that set Eager (`*.spec.ts`, `*.spec-stubs.ts`, `*.spec-data.ts`, `*.test-helpers.ts`) are not listed; they do not ship. @@ -170,9 +170,9 @@ the field a signal (or a `computed`), or writes it through one. | [ ] | `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 | -| [ ] | `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 | +| [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 | | [ ] | `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 | | [ ] | `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 | diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts index 52c4e285d..ea7a5b251 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts @@ -56,7 +56,7 @@ export interface FavoriteLayoutItem { './favorites-layout.component.scss', '../../styles/portal-sidebar.scss', ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CategoryViewComponent, ContentCardComponent, diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts index cae2b8f39..161840ee5 100644 --- a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts @@ -25,7 +25,7 @@ import { DialogService } from '@iptvnator/ui/components'; selector: 'app-playlist-error-view', templateUrl: './playlist-error-view.component.html', styleUrls: ['./playlist-error-view.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIconModule, RouterLink, TranslateModule], }) export class PlaylistErrorViewComponent { diff --git a/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts b/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts index 5028d424d..30bffdd1f 100644 --- a/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts +++ b/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts @@ -39,7 +39,7 @@ export interface SearchFilter { TranslatePipe, ], templateUrl: './search-form.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` .search-container { diff --git a/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts b/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts index 2976a0cee..610770e80 100644 --- a/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts +++ b/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts @@ -22,7 +22,7 @@ import { selector: 'app-portal-rail-links', imports: [MatIcon, MatListModule, MatTooltip, RouterLink, RouterLinkActive], templateUrl: './portal-rail-links.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './portal-rail-links.component.scss', }) export class PortalRailLinksComponent { diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 488abdeeb..156cdf6ab 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -60,7 +60,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util'; selector: 'app-stalker-catalog-detail', imports: [StalkerSeriesViewComponent, VodDetailsComponent], templateUrl: './stalker-catalog-detail.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts index efdc2bb9d..5d7ccccc6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts @@ -27,7 +27,7 @@ type FavoriteButtonItem = StalkerSelectedVodItem & { selector: 'app-favorites-button', templateUrl: './stalker-favorites-button.component.html', styleUrls: ['./stalker-favorites-button.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [AsyncPipe, DetailIconButtonComponent, MatIconModule, TranslateModule], }) export class FavoritesButtonComponent { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index f38b6b715..5005aa1c1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -679,6 +679,8 @@ export class StalkerLiveStreamLayoutComponent ); } }); + // OnPush: the Map changed outside any template event. + this.cdr.markForCheck(); }); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index a4fd531f4..2d8ab7b36 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -155,7 +155,7 @@ interface StalkerSeriesPlaybackRequestContext { TranslatePipe, SeasonContainerComponent, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ StalkerSeriesTmdbSeasonsService, StalkerSeriesHeroPresenter, diff --git a/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts b/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts index 1b0185345..72d6f17a5 100644 --- a/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts @@ -6,7 +6,7 @@ import { SearchResultsComponent } from '../search-results/search-results.compone selector: 'app-global-search-results', imports: [SearchResultsComponent], providers: [XtreamStore], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, template: '', }) export class GlobalSearchResultsComponent {} diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 7afa25423..c964079b2 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -900,6 +900,52 @@ describe('PortalChannelsListComponent', () => { ).toBe('live:290'); }); + // OnPush: favorites resolve asynchronously (IPC or IndexedDB), outside + // any template event, so the heart must follow without a zone tick. + it('renders favorites that load after the first render', async () => { + const favorites$ = new Subject(); + favoritesService.getFavorites.mockReturnValue(favorites$); + selectedTypeContentLoading.set(false); + selectedChannels.set([{ title: 'Cartoon Network', xtream_id: 50 }]); + currentPlaylist.set({ + id: 'playlist-1', + password: 'secret', + serverUrl: 'http://demo.example', + username: 'demo', + }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const viewport = fixture.componentInstance.viewport(); + Object.defineProperty( + viewport?.elementRef.nativeElement, + 'clientHeight', + { configurable: true, value: 520 } + ); + viewport?.checkViewportSize(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const row = () => + fixture.debugElement.query(By.directive(ChannelListItemComponent)) + .componentInstance as ChannelListItemComponent; + expect(row().isFavorite()).toBe(false); + + favorites$.next([ + { + content_id: 1, + playlist_id: 'playlist-1', + type: 'live', + title: 'Cartoon Network', + category_id: 7, + xtream_id: 50, + }, + ] satisfies FavoriteItem[]); + fixture.detectChanges(); + + expect(row().isFavorite()).toBe(true); + }); + it('scrolls the virtual list to the selected live channel', () => { const channels = Array.from({ length: 20 }, (_, index) => ({ title: `Channel ${index + 1}`, diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index a8723a295..623fcb345 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -334,6 +334,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { true ); }); + this.cdr.markForCheck(); }); // A toggle in another list instance (the sidebar and the // fullscreen channel panel render this component side by side) @@ -771,6 +772,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { this.epgQueueService.invalidate(streamId); this.epgPrograms.delete(streamId); this.currentProgramsProgress.delete(streamId); + this.cdr.markForCheck(); this.loadEpgForVisibleChannels(this.channelsToPreview()); }); } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index c29a4e087..c35ead6ed 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -87,7 +87,7 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a SerialDetailsMenuService, SerialDetailsDownloadAdapterService, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CastCrewRowComponent, DetailActionButtonComponent,