From ec73c6e001d1f2d28f1f246dd7dfc0241379e933 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 6 Jul 2026 21:53:03 +0200 Subject: [PATCH] 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/package.json | 2 +- .../src/lib/components/ShelfSkeleton.svelte | 49 ++++++++++++++ .../components/ShelfSkeleton.svelte.test.ts | 22 +++++++ frontend/src/routes/+page.svelte | 65 ++++++++++++------- 6 files changed, 115 insertions(+), 27 deletions(-) create mode 100644 frontend/src/lib/components/ShelfSkeleton.svelte create mode 100644 frontend/src/lib/components/ShelfSkeleton.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a2bcb53..167b8d7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.116.0" +version = "0.117.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 90e7ffb..9a01ffa 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.116.0" +version = "0.117.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index 093b2f0..4d21d4d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.116.0", + "version": "0.117.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/ShelfSkeleton.svelte b/frontend/src/lib/components/ShelfSkeleton.svelte new file mode 100644 index 0000000..21dae3c --- /dev/null +++ b/frontend/src/lib/components/ShelfSkeleton.svelte @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/lib/components/ShelfSkeleton.svelte.test.ts b/frontend/src/lib/components/ShelfSkeleton.svelte.test.ts new file mode 100644 index 0000000..ad72074 --- /dev/null +++ b/frontend/src/lib/components/ShelfSkeleton.svelte.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 29bf43d..8cfe6c6 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -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([]); let recommendations = $state([]); + // 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(DEFAULT_SORT); let order = $state(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 @@ -{#if continueEntries.length > 0} - -{/if} +{#if shelvesLoading && session.user} + + +{:else} + {#if continueEntries.length > 0} + + {/if} -{#if recommendations.length > 0} - + {#if recommendations.length > 0} + + {/if} {/if}