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

@@ -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. */