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;
}