diff --git a/.changes/dashboard-no-rail-jump.md b/.changes/dashboard-no-rail-jump.md new file mode 100644 index 000000000..95223a4df --- /dev/null +++ b/.changes/dashboard-no-rail-jump.md @@ -0,0 +1,9 @@ +--- +type: perf +area: dashboard +--- + +The dashboard no longer jumps when it opens: placeholder rails that turned out +to be empty used to flash and collapse, pushing the rails below them up the +page. Rails now appear once, in place, and a placeholder shows only when a rail +takes noticeably long to load. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9ed67dbe5..995de6879 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -542,6 +542,20 @@ mirrors the row/card geometry it precedes (see Channel List Item and Cover Grids). The unified Favorites/Recent page gates this on `isLoading`, set only while its item list is empty. +### Pages of independently loading blocks: delayed skeletons + +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. + ### Reload with content on screen: non-destructive indicator A reload of a list that is already rendered (the collection page's 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 new file mode 100644 index 000000000..d45194438 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts @@ -0,0 +1,63 @@ +import { + DestroyRef, + EnvironmentInjector, + createEnvironmentInjector, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + DASHBOARD_RAIL_SKELETON_GRACE_MS, + createRailSkeletonGrace, +} from './dashboard-skeleton-grace'; + +describe('createRailSkeletonGrace', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function createInScope(delayMs?: number) { + const injector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const grace = injector.runInContext(() => + createRailSkeletonGrace(delayMs) + ); + return { grace, injector }; + } + + it('keeps skeletons hidden while rails that load quickly resolve', () => { + const { grace } = createInScope(); + + jest.advanceTimersByTime(DASHBOARD_RAIL_SKELETON_GRACE_MS - 1); + + expect(grace()).toBe(false); + }); + + it('allows skeletons once a rail keeps loading past the grace period', () => { + const { grace } = createInScope(); + + jest.advanceTimersByTime(DASHBOARD_RAIL_SKELETON_GRACE_MS); + + expect(grace()).toBe(true); + }); + + it('clears its timer when the dashboard is destroyed', () => { + const { grace, injector } = createInScope(); + + injector.destroy(); + jest.advanceTimersByTime(DASHBOARD_RAIL_SKELETON_GRACE_MS * 2); + + expect(grace()).toBe(false); + expect(jest.getTimerCount()).toBe(0); + }); + + it('treats a zero delay as elapsed without scheduling a timer', () => { + const { grace } = createInScope(0); + + expect(grace()).toBe(true); + expect(jest.getTimerCount()).toBe(0); + }); + + it('needs an injection context', () => { + expect(() => createRailSkeletonGrace()).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 new file mode 100644 index 000000000..1ef0226d5 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts @@ -0,0 +1,31 @@ +import { DestroyRef, inject, signal, type Signal } from '@angular/core'; + +/** + * How long a dashboard rail may keep loading before its skeleton appears. + * + * 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. + */ +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(); +} 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 34db676c1..8683452bf 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 (showLiveFavoritesSkeleton()) { + } @else if (railSkeletonsVisible() && showLiveFavoritesSkeleton()) {