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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.116.0"
|
version = "0.117.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.116.0"
|
version = "0.117.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.116.0",
|
"version": "0.117.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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 Chip from '$lib/components/Chip.svelte';
|
||||||
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
|
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
|
||||||
import RecommendationShelf from '$lib/components/RecommendationShelf.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 MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||||
import Pager from '$lib/components/Pager.svelte';
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
@@ -48,6 +50,10 @@
|
|||||||
let mangas: MangaCardData[] = $state([]);
|
let mangas: MangaCardData[] = $state([]);
|
||||||
let continueEntries = $state<ReadProgressSummary[]>([]);
|
let continueEntries = $state<ReadProgressSummary[]>([]);
|
||||||
let recommendations = $state<MangaCardData[]>([]);
|
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 search = $state('');
|
||||||
let sort = $state<MangaSort>(DEFAULT_SORT);
|
let sort = $state<MangaSort>(DEFAULT_SORT);
|
||||||
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
|
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
|
||||||
@@ -311,16 +317,15 @@
|
|||||||
// Filter UI still loads with an empty genre list rather than blocking.
|
// Filter UI still loads with an empty genre list rather than blocking.
|
||||||
}
|
}
|
||||||
await hydrateFromUrl();
|
await hydrateFromUrl();
|
||||||
await load();
|
// Fetch the personal shelves concurrently with the catalogue (rather
|
||||||
// Fetch the personal shelves after the catalogue so the public browse
|
// than after it) so their reserved skeleton and the grid resolve
|
||||||
// path stays unauthenticated and unblocked. Both are independent
|
// together instead of the shelves popping in late and shoving the grid
|
||||||
// `/me/*` calls, so fire them concurrently rather than in series.
|
// down. The `/me/*` calls are isolated: a failure (or 401 for guests)
|
||||||
// Each is isolated: a failure (or 401 for guests) hides its own shelf
|
// hides its own shelf via the *OrEmpty wrappers without touching the
|
||||||
// without touching the catalogue or the other shelf.
|
// catalogue or the other shelf.
|
||||||
const [progressResult, recsResult] = await Promise.allSettled([
|
shelvesLoading = true;
|
||||||
listMyReadProgressOrEmpty(),
|
const shelves = Promise.allSettled([listMyReadProgressOrEmpty(), listMyRecommendations()])
|
||||||
listMyRecommendations()
|
.then(([progressResult, recsResult]) => {
|
||||||
]);
|
|
||||||
if (progressResult.status === 'fulfilled') {
|
if (progressResult.status === 'fulfilled') {
|
||||||
// Drop finished series (read to the end, nothing new) — a
|
// Drop finished series (read to the end, nothing new) — a
|
||||||
// "Continue reading" shelf is for what's still in progress.
|
// "Continue reading" shelf is for what's still in progress.
|
||||||
@@ -331,6 +336,12 @@
|
|||||||
// taste signals yet → shelf hidden).
|
// taste signals yet → shelf hidden).
|
||||||
recommendations = recsResult.value;
|
recommendations = recsResult.value;
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
shelvesLoading = false;
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
await shelves;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track viewport in a separate $effect so the listener cleans up on
|
// Track viewport in a separate $effect so the listener cleans up on
|
||||||
@@ -485,12 +496,18 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if continueEntries.length > 0}
|
{#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} />
|
<ContinueReadingShelf entries={continueEntries} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if recommendations.length > 0}
|
{#if recommendations.length > 0}
|
||||||
<RecommendationShelf mangas={recommendations} />
|
<RecommendationShelf mangas={recommendations} />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
|
|||||||
Reference in New Issue
Block a user