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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:47:53 +02:00
parent 3622dcc02f
commit bd6ae86a85
5 changed files with 98 additions and 4 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.115.2"
version = "0.116.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.115.2"
version = "0.116.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -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<void>((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();
});

View File

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

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { goto, preloadData } from '$app/navigation';
import { fileUrl, ApiError } from '$lib/api/client';
import { createBookmark, deleteBookmark, type Bookmark } from '$lib/api/bookmarks';
import {
@@ -143,6 +143,15 @@
}
}
// Warm the reader (its load + first page images) for the highest-intent
// link on the page — tapping Continue / Read then feels instant. Re-runs
// only when the target chapter changes.
$effect(() => {
const href = ctaTarget?.href;
if (!browser || !href) return;
preloadData(href).catch(() => {});
});
// Tell the root layout to drop its top padding so the hero can sit
// at the viewport top under the page-specific app bar. Scoped to
// <html> so the rule survives sibling re-renders; cleaned up on