mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
perf(dashboard): hold rail skeletons back so empty rails stop shifting the page
On every launch with sources the dashboard shifted by about 0.23 (the "good" CLS threshold is 0.1). The rails render as soon as their own data arrives, and each loading rail showed a 328 px skeleton immediately. On a normal profile the live-favorites and recent-content sources resolve empty 15-20 ms later, so their skeletons flashed and collapsed and every rail below jumped up by about 360 px. J1 never saw it: its layout-shift window ends at the first card, which is painted just before the collapse. Rail skeletons now wait out a 300 ms grace period (createRailSkeletonGrace) and appear only for a rail still loading after it; the hero keeps its immediate skeleton because it reserves the top of the page. Recorded over three renderer reloads of a seeded profile, the dashboard's layout shift drops from 0.219-0.234 to 0.0004, with the real rails painted at the same time as before. Plan thread C5, journey J1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d9a563d1a
commit
b7a5c36d71
6 files changed
+132
-4
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
const elapsed = signal(delayMs <= 0);
|
||||
if (delayMs > 0) {
|
||||
const timer = setTimeout(() => elapsed.set(true), delayMs);
|
||||
inject(DestroyRef).onDestroy(() => clearTimeout(timer));
|
||||
}
|
||||
return elapsed.asReadonly();
|
||||
}
|
||||
+12
-4
@@ -238,7 +238,7 @@
|
||||
liveEpg.setVisibleCards('favorites', $event)
|
||||
"
|
||||
/>
|
||||
} @else if (showLiveFavoritesSkeleton()) {
|
||||
} @else if (railSkeletonsVisible() && showLiveFavoritesSkeleton()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -290,7 +290,7 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (showRecentContentSkeleton()) {
|
||||
@if (railSkeletonsVisible() && showRecentContentSkeleton()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -316,7 +316,11 @@
|
||||
[testId]="'dashboard-recent-sources-rail'"
|
||||
(actionSelected)="onSourceActionSelected($event)"
|
||||
/>
|
||||
} @else if (dashboardRails().recentSources && !data.playlistsLoaded()) {
|
||||
} @else if (
|
||||
railSkeletonsVisible() &&
|
||||
dashboardRails().recentSources &&
|
||||
!data.playlistsLoaded()
|
||||
) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -345,6 +349,7 @@
|
||||
[testId]="'dashboard-xtream-recently-added-rail'"
|
||||
/>
|
||||
} @else if (
|
||||
railSkeletonsVisible() &&
|
||||
dashboardRails().xtreamRecentlyAdded &&
|
||||
xtreamPlaylistCount() > 0 &&
|
||||
data.xtreamRecentlyAddedLoading()
|
||||
@@ -375,6 +380,7 @@
|
||||
[testId]="'dashboard-tmdb-recommendations-rail'"
|
||||
/>
|
||||
} @else if (
|
||||
railSkeletonsVisible() &&
|
||||
dashboardRails().tmdbRecommendations &&
|
||||
recommendationsService.loading()
|
||||
) {
|
||||
@@ -401,7 +407,9 @@
|
||||
[testId]="'dashboard-tmdb-trending-rail'"
|
||||
/>
|
||||
} @else if (
|
||||
dashboardRails().tmdbTrending && trendingService.loading()
|
||||
railSkeletonsVisible() &&
|
||||
dashboardRails().tmdbTrending &&
|
||||
trendingService.loading()
|
||||
) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
resolveSourceExpiryBadge,
|
||||
SOURCE_EXPIRY_TICK_MS,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { createRailSkeletonGrace } 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';
|
||||
@@ -141,6 +142,8 @@ 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>>({});
|
||||
|
||||
Reference in new issue
Block a user