From bd6ae86a85b2dab35b44957e96caaf2b8aa39859 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 6 Jul 2026 21:47:53 +0200 Subject: [PATCH] feat: preload the reader from the detail CTA The Continue / Read-first-chapter CTA is the highest-intent link to the heaviest route in the app. An effect now programmatically preloadData()s the target chapter as soon as the detail resolves, so the reader's load and first page images are warm before the tap. Complements the reader's own next-chapter warming. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/detail-cta-preload.spec.ts | 85 +++++++++++++++++++++ frontend/package.json | 2 +- frontend/src/routes/manga/[id]/+page.svelte | 11 ++- 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 frontend/e2e/detail-cta-preload.spec.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e7b0b35..a2bcb53 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.115.2" +version = "0.116.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 15bb0c6..90e7ffb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.115.2" +version = "0.116.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/detail-cta-preload.spec.ts b/frontend/e2e/detail-cta-preload.spec.ts new file mode 100644 index 0000000..cec7654 --- /dev/null +++ b/frontend/e2e/detail-cta-preload.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Page } from './fixtures'; + +// The detail page's Continue/Read CTA is the highest-intent link to the +// heaviest route in the app. It should be programmatically preloaded so the +// reader's data (and first page images) are warm before the tap — proven here +// by the reader-only chapter-pages endpoint being requested on the detail page +// without any click. + +const mangaId = 'c1111111-1111-1111-1111-111111111111'; +const chId = 'cc111111-1111-1111-1111-111111111111'; + +async function mockDetailAndReader(page: Page) { + await page.route('**/api/v1/auth/config', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (r) => + r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }) + ); + await page.route('**/api/v1/auth/me/preferences', (r) => + r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }) + ); + await page.route('**/api/v1/me/bookmarks*', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) }) + ); + await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) => + r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }) + ); + await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) => + r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) }) + ); + // Chapter list (detail + reader both use this). + const chapters = [ + { id: chId, manga_id: mangaId, number: 1, title: null, page_count: 3, created_at: '2026-01-01T00:00:00Z' } + ]; + await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: chapters, page: { limit: 200, offset: 0, total: 1 } }) }) + ); + // Single-chapter fetch (reader only). + await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chId}`, (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(chapters[0]) }) + ); + await page.route(`**/api/v1/mangas/${mangaId}`, (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, + cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 + }) + }) + ); +} + +test('programmatically preloads the reader from the detail CTA', async ({ page }) => { + await mockDetailAndReader(page); + + // The chapter-pages endpoint is fetched only by the reader's load. + let pagesRequested: () => void = () => {}; + const pagesHit = new Promise((resolve) => { + pagesRequested = resolve; + }); + await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chId}/pages`, (route) => { + pagesRequested(); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pages: [{ id: 'p1', number: 1, image_path: 'x', width: null, height: null }] }) + }); + }); + + await page.goto(`/manga/${mangaId}`); + // Detail rendered (so ctaTarget is resolved to the first chapter). + await expect(page.getByTestId('manga-title')).toHaveText('Berserk'); + + // Preload fires the reader load (hence the pages endpoint) without a click. + await expect(pagesHit).resolves.toBeUndefined(); +}); diff --git a/frontend/package.json b/frontend/package.json index 916882a..093b2f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.115.2", + "version": "0.116.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index db83056..87f8569 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -1,6 +1,6 @@