From 9128a90cfd7fc902c4e23370de2228843d4f2c5c Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 1 Oct 2026 06:44:12 +0200 Subject: [PATCH] fix(dashboard): keep the hero skeleton until every hero source has loaded The hero dropped its skeleton as soon as the history load resolved empty and came back moments later with an Xtream recently-added slide (that query waits for the favorites), moving every rail below by 316 px twice. On J1 this read renderer.layoutShiftScoreSettled 0.235 on every macOS iteration and was a race on the Linux runner. The hero now stays in its skeleton while it has no slide and any of its sources (history, favorites, Xtream recently added) is on its first load. DashboardDataService no longer settles xtreamRecentlyAddedLoading before the playlist inventory has loaded, where "no Xtream playlists" was decided on a still empty inventory. Co-Authored-By: Claude Opus 5.5 --- .changes/dashboard-hero-no-flicker.md | 8 ++ docs/architecture/iptvnator-ui-guidelines.md | 5 +- docs/architecture/performance-journeys.md | 22 +++-- docs/architecture/workspace-dashboard.md | 5 +- .../src/lib/dashboard-data.service.spec.ts | 95 +++++++++++++------ .../src/lib/dashboard-data.service.ts | 4 +- .../dashboard-hero-slides.presenter.spec.ts | 58 ++++++++++- .../rails/dashboard-hero-slides.presenter.ts | 14 ++- 8 files changed, 170 insertions(+), 41 deletions(-) create mode 100644 .changes/dashboard-hero-no-flicker.md diff --git a/.changes/dashboard-hero-no-flicker.md b/.changes/dashboard-hero-no-flicker.md new file mode 100644 index 000000000..11520174e --- /dev/null +++ b/.changes/dashboard-hero-no-flicker.md @@ -0,0 +1,8 @@ +--- +type: fix +area: dashboard +--- + +The dashboard hero no longer disappears and comes back while the dashboard +opens. On a profile with no watch history, the banner vanished for a moment +before a new arrival appeared in it, and the rails below jumped up and down. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 20ebe310c..d5a883a13 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -568,8 +568,9 @@ launch with sources). Rail skeletons are gated per rail The top block (the dashboard hero) keeps its immediate skeleton: it reserves the space above everything else, where a late insertion would push the whole -page down. Use the same rules for any page that stacks independently loading -blocks. +page down. For the same reason it stays until every source that can fill it +has loaded, not only the first one. Use the same rules for any page that +stacks independently loading blocks. ### Reload with content on screen: non-destructive indicator diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 66fc6c15d..74a7a659e 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -176,8 +176,7 @@ stable in the other two). Every iteration shows the same two shifts of 0.118: about 12 ms after the first card the `dashboard-recent-sources-rail`, which holds the first card, moves up by 316 px, and 12-65 ms later it moves back down. Something 316 px tall above it is removed and inserted again during -startup, a flicker #1738 did not cover. The counter is working as intended; -the flicker is a separate fix. +startup, a flicker #1738 did not cover. On the Linux CI runner (`Performance journeys` job of #1756, run 36618062068) the same flicker is a race: the measured iterations read @@ -185,6 +184,17 @@ On the Linux CI runner (`Performance journeys` job of #1756, run after the first card), and the one hit shows the same two 316 px moves of the recent-sources rail. +The 316 px element was the dashboard hero. The J1 profile has no history +and no favorites; its only slide is an Xtream recently-added title, and that +query waits for the favorites. The hero dropped its skeleton as soon as the +history resolved empty and came back with that slide moments later. It now +keeps the skeleton until every source that can feature a title has loaded +(`DashboardHeroSlidesPresenter.loading`), and +`DashboardDataService.xtreamRecentlyAddedLoading` no longer settles before +the playlist inventory has loaded. After the fix (macOS, 2026-09-30): both +counters were 0 in all 12 iterations of two runs, every window closed on +`quiet` and `lateShifts` was empty. + #### Main-process counters With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets, @@ -658,10 +668,10 @@ in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from 1,401 to 1,674 ms. All four stay evidence for now. Runner counters also differ from a Mac (12 and 571 there, the fast path without the Linux-only `getWindowState` call), so take J1 baseline values from the runner only. -`renderer.layoutShiftScoreSettled` has no baseline either: the runner reads -it as `stable: false` because the dashboard flicker it reports is a race -there (see [Settle window](#settle-window)). Add the runner's number once -that flicker is fixed and the counter is deterministic. +`renderer.layoutShiftScoreSettled` has no baseline either: the runner read +it as `stable: false` because the dashboard hero flicker it reported was a +race there (see [Settle window](#settle-window)). That flicker is fixed; add +the runner's number once runner runs read it as `stable` too. ### Weekly tightening diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 76c2f6256..d81025839 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -75,7 +75,10 @@ Render rules: full-bleed. All rails and the hero are skipped. 3. The hero (`lib-dashboard-hero`) renders when it has at least one slide; see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while - the first history load runs. An item enters recent history only after + it has no slide and any of its sources (history, favorites, Xtream + recently added) is still on its first load; dropping it earlier removed + the hero and inserted it again when a later source featured a title, + moving every rail below twice. An item enters recent history only after its stream has really played (see "Recently Viewed Confirmation" in `embedded-inline-playback.md`), so a channel that failed at once never becomes a hero slide. diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 51dc5212c..ccec0eff5 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -167,6 +167,35 @@ describe('DashboardDataService', () => { clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), }; + const createTestingModuleProviders = () => ({ + providers: [ + DashboardDataService, + { provide: Store, useValue: storeMock }, + { provide: DatabaseService, useValue: dbServiceMock }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSourceMock, + }, + { + provide: PlaylistsService, + useValue: playlistsServiceMock, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + onLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: playbackPositionsMock, + }, + ], + }); + beforeEach(() => { Object.defineProperty(window, 'electron', { value: { @@ -225,34 +254,7 @@ describe('DashboardDataService', () => { playbackPositionsMock.getAllPlaybackPositions.mockClear(); playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]); - TestBed.configureTestingModule({ - providers: [ - DashboardDataService, - { provide: Store, useValue: storeMock }, - { provide: DatabaseService, useValue: dbServiceMock }, - { - provide: XTREAM_DATA_SOURCE, - useValue: xtreamDataSourceMock, - }, - { - provide: PlaylistsService, - useValue: playlistsServiceMock, - }, - { - provide: TranslateService, - useValue: { - instant: (key: string) => key, - onLangChange: of(null), - currentLang: 'en', - defaultLang: 'en', - }, - }, - { - provide: PORTAL_PLAYBACK_POSITIONS, - useValue: playbackPositionsMock, - }, - ], - }); + TestBed.configureTestingModule(createTestingModuleProviders()); service = TestBed.inject(DashboardDataService); }); @@ -303,6 +305,43 @@ describe('DashboardDataService', () => { expect(service.dashboardReady()).toBe(true); }); + it('keeps xtream recently added loading until the playlist inventory has loaded', async () => { + // Startup: the dashboard exists before the inventory, which is empty + // until it loads, so "no Xtream playlists" is not known yet. + TestBed.resetTestingModule(); + playlistsLoadedSignal.set(false); + playlistsSignal.set([]); + TestBed.configureTestingModule(createTestingModuleProviders()); + service = TestBed.inject(DashboardDataService); + TestBed.tick(); + expect(service.xtreamRecentlyAddedLoading()).toBe(true); + expect(service.xtreamRecentlyAddedLoaded()).toBe(false); + + playlistsSignal.set(createDefaultPlaylists()); + playlistsLoadedSignal.set(true); + TestBed.tick(); + expect(service.xtreamRecentlyAddedLoading()).toBe(true); + + await service.reloadXtreamRecentlyAddedItems(); + expect(service.xtreamRecentlyAddedLoading()).toBe(false); + expect(service.xtreamRecentlyAddedLoaded()).toBe(true); + }); + + it('settles xtream recently added once the loaded inventory has no xtream playlists', () => { + TestBed.resetTestingModule(); + playlistsLoadedSignal.set(false); + playlistsSignal.set([]); + TestBed.configureTestingModule(createTestingModuleProviders()); + service = TestBed.inject(DashboardDataService); + TestBed.tick(); + expect(service.xtreamRecentlyAddedLoading()).toBe(true); + + playlistsLoadedSignal.set(true); + TestBed.tick(); + expect(service.xtreamRecentlyAddedLoading()).toBe(false); + expect(service.xtreamRecentlyAddedLoaded()).toBe(true); + }); + it('includes M3U favorites in global favorite items', async () => { await service.reloadGlobalFavorites(); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 4471d1e2b..24b616591 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -452,8 +452,10 @@ export class DashboardDataService { this.finishInitialGlobalFavoritesLoadIfReady(); }); + // Before the inventory has loaded "no Xtream playlists" is not known + // yet; settling here would end the first load before it started. effect(() => { - if (this.hasXtreamPlaylists()) { + if (!this.playlistsLoaded() || this.hasXtreamPlaylists()) { return; } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts index ebb72ab5d..6ee2a2ebf 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -92,6 +92,9 @@ describe('DashboardHeroSlidesPresenter', () => { let tmdbEnabled: ReturnType>; let tmdbLanguage: ReturnType>; let getExtras: jest.Mock; + let recentLoading: ReturnType>; + let favoritesLoading: ReturnType>; + let addedLoading: ReturnType>; const positions = new Map([ [ 1, @@ -123,7 +126,9 @@ describe('DashboardHeroSlidesPresenter', () => { { provide: DashboardDataService, useValue: { - globalRecentLoading: signal(false), + globalRecentLoading: () => recentLoading(), + globalFavoritesLoading: () => favoritesLoading(), + xtreamRecentlyAddedLoading: () => addedLoading(), globalRecentItems: recentItems, globalRecentVodItems: () => recentItems().filter((i) => i.type !== 'live'), @@ -195,6 +200,9 @@ describe('DashboardHeroSlidesPresenter', () => { tmdbEnabled = signal(false); tmdbLanguage = signal('en-US'); getExtras = jest.fn().mockResolvedValue(null); + recentLoading = signal(false); + favoritesLoading = signal(false); + addedLoading = signal(false); }); it('builds the rotation from resume, live, favourite and import slides', () => { @@ -380,4 +388,52 @@ describe('DashboardHeroSlidesPresenter', () => { backdropSource: 'fallback', }); }); + + it('keeps the skeleton until every source that can feature a title has loaded', () => { + // J1 profile: no history, no favourites; the Xtream recently-added + // query runs after the favourites and features the only slide. + recentItems.set([]); + favorites.set([]); + candidates.set([]); + addedItems.set([]); + recentLoading.set(true); + favoritesLoading.set(true); + addedLoading.set(true); + const presenter = create(); + expect(presenter.loading()).toBe(true); + + recentLoading.set(false); + expect(presenter.loading()).toBe(true); + + favoritesLoading.set(false); + expect(presenter.loading()).toBe(true); + + addedItems.set([import1]); + addedLoading.set(false); + expect(presenter.slides().map((slide) => slide.kind)).toEqual([ + 'added', + ]); + expect(presenter.loading()).toBe(false); + }); + + it('drops the skeleton once every source has loaded with nothing to feature', () => { + recentItems.set([]); + favorites.set([]); + candidates.set([]); + addedItems.set([]); + addedLoading.set(true); + const presenter = create(); + expect(presenter.loading()).toBe(true); + + addedLoading.set(false); + expect(presenter.loading()).toBe(false); + expect(presenter.slides()).toEqual([]); + }); + + it('shows a slide without waiting for the slower sources', () => { + favoritesLoading.set(true); + addedLoading.set(true); + + expect(create().loading()).toBe(false); + }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts index 9321cac0a..3404a568c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -80,9 +80,19 @@ export class DashboardHeroSlidesPresenter { >(new Map()); private readonly requestedTmdbKeys = new Set(); - /** First history load still running and nothing to feature yet. */ + /** + * Nothing to feature yet and a source that can feature a title is still + * on its first load. Every such source counts, not only the history: + * the Xtream recently-added query waits for the favorites, so dropping + * the skeleton when the history resolved empty removed the hero and + * inserted it again moments later, moving every rail below twice. + */ readonly loading = computed( - () => this.data.globalRecentLoading() && this.slides().length === 0 + () => + this.slides().length === 0 && + (this.data.globalRecentLoading() || + this.data.globalFavoritesLoading() || + this.data.xtreamRecentlyAddedLoading()) ); /** The first candidate channel with a programme on air right now. */