diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 995de6879..cefd96ac7 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts index d45194438..69c9bcc86 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts @@ -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(); }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts index 1ef0226d5..264cf652d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts @@ -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 { - 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( + entries: readonly (readonly [Key, DashboardRailSkeletonEntry])[], + graceMs = DASHBOARD_RAIL_SKELETON_GRACE_MS +): Record> { + const gates = {} as Record>; + const timers = new Set>(); + 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 | 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; } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 8683452bf..353fd4148 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -238,7 +238,7 @@ liveEpg.setVisibleCards('favorites', $event) " /> - } @else if (railSkeletonsVisible() && showLiveFavoritesSkeleton()) { + } @else if (railSkeletons.liveFavorites()) {