feat: reserve home shelf space while it loads (fixes CLS)

The Continue-reading / Recommended shelves were fetched after the catalogue
and rendered only once populated, so they popped in late and shoved the grid
down on every authenticated home visit. Now the shelf fetch fires
concurrently with the catalogue and a reserved ShelfSkeleton holds the space
for logged-in users until it resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:53:03 +02:00
parent bd6ae86a85
commit ec73c6e001
6 changed files with 115 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.116.0",
"version": "0.117.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import Skeleton from './Skeleton.svelte';
/**
* Placeholder for a horizontal home shelf (Continue reading / Recommended)
* while it loads. Reserves the shelf's vertical space so the catalog below
* doesn't get shoved down when the real shelf pops in after its late fetch.
* Dimensions mirror ContinueReadingShelf's 108×162 cards.
*/
let { count = 6, testid = 'shelf-skeleton' }: { count?: number; testid?: string } = $props();
</script>
<section class="shelf" data-testid={testid} aria-hidden="true">
<Skeleton variant="text" width="10rem" height="1.1rem" radius="sm" />
<ul class="track">
{#each Array(count) as _}
<li class="card">
<Skeleton width="108px" height="162px" radius="sm" />
<Skeleton variant="text" width="80%" radius="sm" />
</li>
{/each}
</ul>
</section>
<style>
.shelf {
margin: 0 0 var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.track {
list-style: none;
display: flex;
gap: var(--space-3);
padding: 0 0 var(--space-2);
margin: 0;
overflow: hidden;
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-1);
width: 108px;
flex: 0 0 auto;
}
</style>

View File

@@ -0,0 +1,22 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import ShelfSkeleton from './ShelfSkeleton.svelte';
afterEach(() => cleanup());
describe('ShelfSkeleton', () => {
it('renders the requested number of card placeholders', () => {
render(ShelfSkeleton, { props: { count: 4, testid: 'sk' } });
expect(screen.getByTestId('sk').querySelectorAll('.card').length).toBe(4);
});
it('defaults to 6 cards', () => {
render(ShelfSkeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').querySelectorAll('.card').length).toBe(6);
});
it('is decorative (aria-hidden)', () => {
render(ShelfSkeleton, { props: { testid: 'sk' } });
expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
});
});

View File

@@ -31,6 +31,8 @@
import Chip from '$lib/components/Chip.svelte';
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
import RecommendationShelf from '$lib/components/RecommendationShelf.svelte';
import ShelfSkeleton from '$lib/components/ShelfSkeleton.svelte';
import { session } from '$lib/session.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
import Pager from '$lib/components/Pager.svelte';
@@ -48,6 +50,10 @@
let mangas: MangaCardData[] = $state([]);
let continueEntries = $state<ReadProgressSummary[]>([]);
let recommendations = $state<MangaCardData[]>([]);
// True while the personal shelves are being fetched. Drives a reserved
// ShelfSkeleton (for logged-in users) so the catalog below isn't shoved
// down when the shelves resolve.
let shelvesLoading = $state(false);
let search = $state('');
let sort = $state<MangaSort>(DEFAULT_SORT);
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
@@ -311,26 +317,31 @@
// Filter UI still loads with an empty genre list rather than blocking.
}
await hydrateFromUrl();
// Fetch the personal shelves concurrently with the catalogue (rather
// than after it) so their reserved skeleton and the grid resolve
// together instead of the shelves popping in late and shoving the grid
// down. The `/me/*` calls are isolated: a failure (or 401 for guests)
// hides its own shelf via the *OrEmpty wrappers without touching the
// catalogue or the other shelf.
shelvesLoading = true;
const shelves = Promise.allSettled([listMyReadProgressOrEmpty(), listMyRecommendations()])
.then(([progressResult, recsResult]) => {
if (progressResult.status === 'fulfilled') {
// Drop finished series (read to the end, nothing new) — a
// "Continue reading" shelf is for what's still in progress.
continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
}
if (recsResult.status === 'fulfilled') {
// Personal "Recommended for you" feed (empty for guests / no
// taste signals yet → shelf hidden).
recommendations = recsResult.value;
}
})
.finally(() => {
shelvesLoading = false;
});
await load();
// Fetch the personal shelves after the catalogue so the public browse
// path stays unauthenticated and unblocked. Both are independent
// `/me/*` calls, so fire them concurrently rather than in series.
// Each is isolated: a failure (or 401 for guests) hides its own shelf
// without touching the catalogue or the other shelf.
const [progressResult, recsResult] = await Promise.allSettled([
listMyReadProgressOrEmpty(),
listMyRecommendations()
]);
if (progressResult.status === 'fulfilled') {
// Drop finished series (read to the end, nothing new) — a
// "Continue reading" shelf is for what's still in progress.
continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
}
if (recsResult.status === 'fulfilled') {
// Personal "Recommended for you" feed (empty for guests / no
// taste signals yet → shelf hidden).
recommendations = recsResult.value;
}
await shelves;
});
// Track viewport in a separate $effect so the listener cleans up on
@@ -485,12 +496,18 @@
</a>
</div>
{#if continueEntries.length > 0}
<ContinueReadingShelf entries={continueEntries} />
{/if}
{#if shelvesLoading && session.user}
<!-- Reserve the shelf area for logged-in users so the grid below doesn't
jump when the real shelves resolve. -->
<ShelfSkeleton testid="shelf-skeleton" />
{:else}
{#if continueEntries.length > 0}
<ContinueReadingShelf entries={continueEntries} />
{/if}
{#if recommendations.length > 0}
<RecommendationShelf mangas={recommendations} />
{#if recommendations.length > 0}
<RecommendationShelf mangas={recommendations} />
{/if}
{/if}
<form