From 335392afa9f37746d314ef990068860a69564a50 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:48:19 +0200 Subject: [PATCH] perf(portal): make the portal Eager components OnPush (#1821) * 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 * test(portal): let the favorites handler's markForCheck render the heart Review follow-up (Greptile): the test forced detectChanges() after the favorites arrived, so it passed without the handler's markForCheck(). It now lets the fixture render on its own; removing the call fails it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray 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 | 50 +++++++++++++++++++ .../portal-channels-list.component.ts | 2 + .../serial-details.component.ts | 2 +- 13 files changed, 75 insertions(+), 21 deletions(-) diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 08dddfcec..95bfd0670 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -149,15 +149,15 @@ picker), so each marks itself on the form's `events` through ### 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-only files that set Eager are not listed; they do not ship. The guard skips every `*.spec.ts` / `*.test.ts` file with or without a suffix of one @@ -179,9 +179,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 | | [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`; each section now marks itself on its form's `events` (`markSectionForCheckOnFormEvents`), which `settings.component.form.spec.ts` guards without a forced render | apps/web | | [x] | `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts` (jump to now, keyboard focus) | `afterNextRender` registered from CDK/RxJS callbacks; zone.js followed them with a tick, zoneless schedules no render, so the guide opened at midnight. It now marks itself when it registers the hook. Found by `epg-guide.e2e.ts` on the zoneless build | flag | | [ ] | `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..a05a42348 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,56 @@ 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); + + // From here the fixture renders on its own: a forced detectChanges() + // would hide a favorites handler that does not mark the view. + fixture.autoDetectChanges(); + favorites$.next([ + { + content_id: 1, + playlist_id: 'playlist-1', + type: 'live', + title: 'Cartoon Network', + category_id: 7, + xtream_id: 50, + }, + ] satisfies FavoriteItem[]); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + 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,