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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.116.0"
|
||||
version = "0.117.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.116.0"
|
||||
version = "0.117.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.116.0",
|
||||
"version": "0.117.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
49
frontend/src/lib/components/ShelfSkeleton.svelte
Normal file
49
frontend/src/lib/components/ShelfSkeleton.svelte
Normal 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>
|
||||
22
frontend/src/lib/components/ShelfSkeleton.svelte.test.ts
Normal file
22
frontend/src/lib/components/ShelfSkeleton.svelte.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user