fix(dashboard): gate rail skeletons per rail, never above visible rails

Addresses the Codex and Greptile reviews on #1738. The component-wide grace
timer started when the dashboard was created, so a rail that begins loading
later (Xtream recently added, TMDB) showed its skeleton at once and could
still flash and collapse; and after the grace period a slow rail's skeleton
could appear above rails that already showed cards, pushing them down and,
if it resolved empty, back up.

createRailSkeletonGates now keeps one gate per rail, in template order: the
grace period counts from that rail's own loading start, a skeleton is never
inserted above a rail that already has cards (the real rail inserts at most
once instead), and a shown skeleton stays until its own rail finishes so the
first arriving rail does not collapse the others in a cascade. Nine specs
cover the fast path, per-rail start, the no-content-below rule, latching,
reloading, destroy and a zero grace period. The seeded-profile timeline is
unchanged at 0.0004 across three renderer reloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 21:51:59 +02:00
1 parent b7a5c36d71
commit 302c3b0eb9
5 files changed
+333 -83

No files matched your search

+17 -7
View File
@@ -548,13 +548,23 @@ The dashboard renders each rail as soon as its own data arrives, and several
rails resolve empty on a normal profile. A per-rail skeleton shown
immediately therefore flashed for a few tens of milliseconds and collapsed,
pulling every rail below it upwards (a layout shift of about 0.23 on each
launch with sources). Rail skeletons wait out a short grace period
(`DASHBOARD_RAIL_SKELETON_GRACE_MS`, 300 ms, in
`libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts`)
and appear only for a rail that is still loading after it. 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 rule for any page that stacks independently loading blocks.
launch with sources). Rail skeletons are gated per rail
(`createRailSkeletonGates` in
`libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts`):
- a skeleton waits out a grace period (`DASHBOARD_RAIL_SKELETON_GRACE_MS`,
300 ms) counted from when *that* rail started loading, since Xtream and
TMDB rails start after the local ones;
- it never appears above a rail that already shows cards: the placeholder
would push visible content down, and back up if the rail resolves empty,
while the real rail inserts at most once;
- once shown, it stays until its own rail finishes, so skeletons do not
vanish in a cascade when the first real rail arrives.
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.
### Reload with content on screen: non-destructive indicator
@@ -1,63 +1,156 @@
import {
DestroyRef,
EnvironmentInjector,
createEnvironmentInjector,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
DASHBOARD_RAIL_SKELETON_GRACE_MS,
createRailSkeletonGrace,
DASHBOARD_RAIL_SKELETON_GRACE_MS as GRACE,
createRailSkeletonGates,
} from './dashboard-skeleton-grace';
describe('createRailSkeletonGrace', () => {
function rail(loading = false, rendered = false) {
const state = { loading: signal(loading), rendered: signal(rendered) };
return {
state,
entry: {
loading: () => state.loading(),
rendered: () => state.rendered(),
},
};
}
describe('createRailSkeletonGates', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
function createInScope(delayMs?: number) {
/** Top rail `upper`, then `lower` below it, like two dashboard rails. */
function setup(graceMs?: number) {
const upper = rail();
const lower = rail();
const injector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);
const grace = injector.runInContext(() =>
createRailSkeletonGrace(delayMs)
const gates = injector.runInContext(() =>
createRailSkeletonGates(
[
['upper', upper.entry],
['lower', lower.entry],
] as const,
graceMs
)
);
return { grace, injector };
const settle = (ms = 0) => {
TestBed.tick();
jest.advanceTimersByTime(ms);
TestBed.tick();
};
return { upper, lower, gates, injector, settle };
}
it('keeps skeletons hidden while rails that load quickly resolve', () => {
const { grace } = createInScope();
it('never shows a skeleton for a rail that resolves within the grace period', () => {
const { upper, gates, settle } = setup();
upper.state.loading.set(true);
settle(GRACE - 1);
upper.state.loading.set(false);
settle(GRACE);
jest.advanceTimersByTime(DASHBOARD_RAIL_SKELETON_GRACE_MS - 1);
expect(grace()).toBe(false);
expect(gates.upper()).toBe(false);
});
it('allows skeletons once a rail keeps loading past the grace period', () => {
const { grace } = createInScope();
it('shows the skeleton for a rail still loading after the grace period, while nothing below has cards', () => {
const { upper, gates, settle } = setup();
upper.state.loading.set(true);
settle(GRACE);
jest.advanceTimersByTime(DASHBOARD_RAIL_SKELETON_GRACE_MS);
expect(gates.upper()).toBe(true);
expect(grace()).toBe(true);
upper.state.loading.set(false);
settle();
expect(gates.upper()).toBe(false);
});
it('clears its timer when the dashboard is destroyed', () => {
const { grace, injector } = createInScope();
it('measures the grace period from each rail’s own loading start', () => {
const { lower, gates, settle } = setup();
// The page has been up for a while before this rail starts loading.
settle(GRACE * 3);
lower.state.loading.set(true);
settle(GRACE - 1);
expect(gates.lower()).toBe(false);
lower.state.loading.set(false);
settle(GRACE);
expect(gates.lower()).toBe(false);
});
it('does not insert a skeleton above a rail that already shows cards', () => {
const { upper, lower, gates, settle } = setup();
lower.state.rendered.set(true);
upper.state.loading.set(true);
settle(GRACE * 2);
expect(gates.upper()).toBe(false);
});
it('keeps a shown skeleton until its own rail finishes, even when a rail below renders', () => {
const { upper, lower, gates, settle } = setup();
upper.state.loading.set(true);
settle(GRACE);
expect(gates.upper()).toBe(true);
lower.state.rendered.set(true);
settle();
expect(gates.upper()).toBe(true);
upper.state.loading.set(false);
settle();
expect(gates.upper()).toBe(false);
});
it('starts a fresh grace period when a rail loads again', () => {
const { upper, gates, settle } = setup();
upper.state.loading.set(true);
settle(GRACE);
upper.state.loading.set(false);
settle();
upper.state.loading.set(true);
settle(GRACE - 1);
expect(gates.upper()).toBe(false);
settle(1);
expect(gates.upper()).toBe(true);
});
it('clears pending timers when the dashboard is destroyed', () => {
const { upper, gates, injector, settle } = setup();
upper.state.loading.set(true);
settle();
expect(jest.getTimerCount()).toBe(1);
injector.destroy();
jest.advanceTimersByTime(DASHBOARD_RAIL_SKELETON_GRACE_MS * 2);
expect(grace()).toBe(false);
expect(jest.getTimerCount()).toBe(0);
jest.advanceTimersByTime(GRACE * 2);
expect(gates.upper()).toBe(false);
});
it('treats a zero delay as elapsed without scheduling a timer', () => {
const { grace } = createInScope(0);
it('shows immediately with a zero grace period, still respecting rails below', () => {
const { upper, lower, gates, settle } = setup(0);
upper.state.loading.set(true);
settle();
expect(gates.upper()).toBe(true);
expect(grace()).toBe(true);
expect(jest.getTimerCount()).toBe(0);
const second = setup(0);
second.lower.state.rendered.set(true);
second.upper.state.loading.set(true);
second.settle();
expect(second.gates.upper()).toBe(false);
expect(lower.state.rendered()).toBe(false);
});
it('needs an injection context', () => {
expect(() => createRailSkeletonGrace()).toThrow();
expect(() => createRailSkeletonGates([])).toThrow();
});
});
@@ -1,31 +1,100 @@
import { DestroyRef, inject, signal, type Signal } from '@angular/core';
import {
DestroyRef,
effect,
inject,
signal,
untracked,
type Signal,
} from '@angular/core';
/**
* How long a dashboard rail may keep loading before its skeleton appears.
* How long a dashboard rail may keep loading, counted from the moment that
* rail started loading, before its skeleton may appear.
*
* On a warm profile the local rails resolve within a few tens of
* milliseconds of the first render, and several of them resolve empty. An
* immediate skeleton for each loading rail therefore flashed and then
* collapsed, pulling every rail below it upwards: a layout shift of about
* 0.23 on every launch with sources (the "good" CLS threshold is 0.1).
* Holding the skeletons back for a short grace period means a fast rail
* appears once, in place, and a genuinely slow one (TMDB, a large portal)
* still gets its placeholder. The hero skeleton is not delayed: it reserves
* the top of the page, where a late insertion would push everything down.
* The rails render as soon as their own data arrives, and on a normal profile
* several resolve empty within a few tens of milliseconds. An immediate
* skeleton per loading rail therefore flashed and collapsed, pulling every
* rail below it upwards: a layout shift of about 0.23 on each launch with
* sources (the "good" CLS threshold is 0.1). The hero keeps its immediate
* skeleton: it reserves the top of the page.
*/
export const DASHBOARD_RAIL_SKELETON_GRACE_MS = 300;
/**
* A signal that turns true once the grace period has elapsed. Must be
* created in an injection context; the timer is cleared on destroy.
*/
export function createRailSkeletonGrace(
delayMs = DASHBOARD_RAIL_SKELETON_GRACE_MS
): Signal<boolean> {
const elapsed = signal(delayMs <= 0);
if (delayMs > 0) {
const timer = setTimeout(() => elapsed.set(true), delayMs);
inject(DestroyRef).onDestroy(() => clearTimeout(timer));
}
return elapsed.asReadonly();
/** One rail, in template order. */
export interface DashboardRailSkeletonEntry {
/** True while this rail's data is loading and a skeleton would help. */
readonly loading: () => boolean;
/** True once this rail renders real cards. */
readonly rendered: () => boolean;
}
/**
* Per-rail skeleton visibility. A rail's skeleton appears only when
*
* 1. the rail has been loading for the grace period, measured from when
* *this* rail started loading (rails such as Xtream or TMDB begin their
* requests after the local ones), and
* 2. no rail below it already shows real cards: inserting a placeholder
* above visible content would push it down, and pull it back up if the
* rail resolves empty, whereas the real rail inserts at most once.
*
* Once shown, a skeleton stays until its rail stops loading, so skeletons do
* not disappear in a cascade when the first real rail arrives.
*
* Must be created in an injection context; timers are cleared on destroy.
*/
export function createRailSkeletonGates<Key extends string>(
entries: readonly (readonly [Key, DashboardRailSkeletonEntry])[],
graceMs = DASHBOARD_RAIL_SKELETON_GRACE_MS
): Record<Key, Signal<boolean>> {
const gates = {} as Record<Key, Signal<boolean>>;
const timers = new Set<ReturnType<typeof setTimeout>>();
inject(DestroyRef).onDestroy(() => {
timers.forEach(clearTimeout);
timers.clear();
});
entries.forEach(([key, entry], index) => {
const below = entries.slice(index + 1).map(([, other]) => other);
const renderedBelow = () => below.some((other) => other.rendered());
const shown = signal(false);
let timer: ReturnType<typeof setTimeout> | null = null;
const stopTimer = () => {
if (timer !== null) {
clearTimeout(timer);
timers.delete(timer);
timer = null;
}
};
effect(() => {
const loading = entry.loading();
untracked(() => {
if (!loading) {
stopTimer();
shown.set(false);
return;
}
if (shown() || timer !== null) {
return;
}
if (graceMs <= 0) {
shown.set(!renderedBelow());
return;
}
timer = setTimeout(() => {
if (timer !== null) timers.delete(timer);
timer = null;
if (entry.loading() && !renderedBelow()) {
shown.set(true);
}
}, graceMs);
timers.add(timer);
});
});
gates[key] = shown.asReadonly();
});
return gates;
}
@@ -238,7 +238,7 @@
liveEpg.setVisibleCards('favorites', $event)
"
/>
} @else if (railSkeletonsVisible() && showLiveFavoritesSkeleton()) {
} @else if (railSkeletons.liveFavorites()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -290,7 +290,7 @@
/>
}
@if (railSkeletonsVisible() && showRecentContentSkeleton()) {
@if (railSkeletons.recentContent()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -316,11 +316,7 @@
[testId]="'dashboard-recent-sources-rail'"
(actionSelected)="onSourceActionSelected($event)"
/>
} @else if (
railSkeletonsVisible() &&
dashboardRails().recentSources &&
!data.playlistsLoaded()
) {
} @else if (railSkeletons.sources()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -348,12 +344,7 @@
[totalCount]="data.xtreamRecentlyAddedItems().length"
[testId]="'dashboard-xtream-recently-added-rail'"
/>
} @else if (
railSkeletonsVisible() &&
dashboardRails().xtreamRecentlyAdded &&
xtreamPlaylistCount() > 0 &&
data.xtreamRecentlyAddedLoading()
) {
} @else if (railSkeletons.xtreamRecentlyAdded()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -379,11 +370,7 @@
[totalCount]="recommendationCards().length"
[testId]="'dashboard-tmdb-recommendations-rail'"
/>
} @else if (
railSkeletonsVisible() &&
dashboardRails().tmdbRecommendations &&
recommendationsService.loading()
) {
} @else if (railSkeletons.tmdbRecommendations()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -406,11 +393,7 @@
[totalCount]="trendingCards().length"
[testId]="'dashboard-tmdb-trending-rail'"
/>
} @else if (
railSkeletonsVisible() &&
dashboardRails().tmdbTrending &&
trendingService.loading()
) {
} @else if (railSkeletons.tmdbTrending()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -55,7 +55,7 @@ import {
resolveSourceExpiryBadge,
SOURCE_EXPIRY_TICK_MS,
} from '@iptvnator/workspace/dashboard/data-access';
import { createRailSkeletonGrace } from './dashboard-skeleton-grace';
import { createRailSkeletonGates } from './dashboard-skeleton-grace';
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
import { DashboardRailComponent } from './dashboard-rail.component';
@@ -142,8 +142,6 @@ export class WorkspaceDashboardRailsComponent {
readonly isElectron = this.runtime.isElectron;
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
/** Rail skeletons wait out a short grace period; see the helper. */
readonly railSkeletonsVisible = createRailSkeletonGrace();
readonly skeletonRails = SKELETON_RAILS;
readonly liveRailTitleKeyForSource = liveRailTitleKeyForSource;
readonly failedHeroImages = signal<Record<string, true>>({});
@@ -377,6 +375,103 @@ export class WorkspaceDashboardRailsComponent {
}));
});
/**
* Skeleton visibility per rail, in template order: a skeleton waits out a
* grace period from its own rail's loading start and never appears above
* a rail that already shows cards (see createRailSkeletonGates).
*/
readonly railSkeletons = createRailSkeletonGates([
[
'continueWatching',
{
loading: () => false,
rendered: () =>
this.dashboardRails().continueWatching &&
this.continueWatchingCards().length > 0,
},
],
[
'liveFavorites',
{
loading: () => this.showLiveFavoritesSkeleton(),
rendered: () =>
this.dashboardRails().liveFavorites &&
!this.showLiveFavoritesSkeleton() &&
this.liveFavoriteCards().length > 0,
},
],
[
'recentLive',
{
loading: () => false,
rendered: () =>
this.dashboardRails().recentlyWatchedLive &&
this.recentLiveCards().length > 0,
},
],
[
'favoriteVod',
{
loading: () => false,
rendered: () =>
this.dashboardRails().favoriteMoviesAndSeries &&
this.favoriteMoviesAndSeriesCards().length > 0,
},
],
[
'recentContent',
{
loading: () => this.showRecentContentSkeleton(),
rendered: () => false,
},
],
[
'sources',
{
loading: () =>
this.dashboardRails().recentSources &&
!this.data.playlistsLoaded(),
rendered: () =>
this.dashboardRails().recentSources &&
this.sourceCards().length > 0,
},
],
[
'xtreamRecentlyAdded',
{
loading: () =>
this.dashboardRails().xtreamRecentlyAdded &&
this.xtreamPlaylistCount() > 0 &&
this.data.xtreamRecentlyAddedLoading(),
rendered: () =>
this.dashboardRails().xtreamRecentlyAdded &&
this.xtreamRecentlyAddedCards().length > 0,
},
],
[
'tmdbRecommendations',
{
loading: () =>
this.dashboardRails().tmdbRecommendations &&
this.recommendationsService.loading(),
rendered: () =>
this.dashboardRails().tmdbRecommendations &&
this.recommendationCards().length > 0,
},
],
[
'tmdbTrending',
{
loading: () =>
this.dashboardRails().tmdbTrending &&
this.trendingService.loading(),
rendered: () =>
this.dashboardRails().tmdbTrending &&
this.trendingCards().length > 0,
},
],
] as const);
constructor() {
// Re-entering the dashboard should pick up any DB-backed recent/favorite
// changes made while viewing details, including newly backfilled