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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 06:44:12 +02:00
1 parent adb4889b0f
commit 9128a90cfd
8 files changed
+170 -41

No files matched your search

+8
View File
@@ -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.
+3 -2
View File
@@ -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
+16 -6
View File
@@ -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
+4 -1
View File
@@ -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.
@@ -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();
@@ -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;
}
@@ -92,6 +92,9 @@ describe('DashboardHeroSlidesPresenter', () => {
let tmdbEnabled: ReturnType<typeof signal<boolean>>;
let tmdbLanguage: ReturnType<typeof signal<string>>;
let getExtras: jest.Mock;
let recentLoading: ReturnType<typeof signal<boolean>>;
let favoritesLoading: ReturnType<typeof signal<boolean>>;
let addedLoading: ReturnType<typeof signal<boolean>>;
const positions = new Map<string | number, PlaybackPositionData>([
[
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);
});
});
@@ -80,9 +80,19 @@ export class DashboardHeroSlidesPresenter {
>(new Map());
private readonly requestedTmdbKeys = new Set<string>();
/** 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. */