feat: preload the whole current chapter in continuous mode

Eager-load every page of the current chapter rather than a bounded
rolling window, so pages are fetched up front (the browser paces them
over its per-origin connection limit, top-to-bottom = reading order)
instead of filling in as they're scrolled onto. This drops the
scrollLeadIdx/eagerThrough machinery in favour of a plain
`loading="eager"`.

The first few pages of the *next* chapter are already warmed when the
reader nears the end of the current one (the next-chapter preloader), so
flipping over stays instant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 19:47:48 +02:00
parent c69ff502f7
commit 987d1ba235
6 changed files with 34 additions and 98 deletions

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -170,13 +170,10 @@ test.describe('reader ?page=N deep link', () => {
await expect(page.getByTestId('reader-continuous')).toBeVisible(); await expect(page.getByTestId('reader-continuous')).toBeVisible();
// Pages 1..=4 (1-indexed in the testid, 0-indexed in the // The whole chapter is eager, so the scroll target (page 4) and
// template; initialIndex = 3 means we eager-load at least 0..=3, // every page before it have settled heights before the
// i.e. testids 1..4). Without this guard, page 2+ would be lazy // scroll-to-`?page=N` effect runs — the target can't be pushed
// and their 0×0 placeholders would let the scroll target // past the viewport by later pages loading in.
// appear far above its final position. The rolling preload window
// (PRELOAD_AHEAD) extends the eager set a few pages past the
// target, so on this 6-page chapter every page is eager.
for (const n of [1, 2, 3, 4, 5, 6]) { for (const n of [1, 2, 3, 4, 5, 6]) {
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
'loading', 'loading',
@@ -185,28 +182,21 @@ test.describe('reader ?page=N deep link', () => {
} }
}); });
test('continuous mode: no ?page= eager-loads a leading preload window, not the whole chapter', async ({ test('continuous mode: eager-loads the whole chapter up front', async ({
page page
}) => { }) => {
await mockReader(page, 'continuous'); await mockReader(page, 'continuous');
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await expect(page.getByTestId('reader-continuous')).toBeVisible(); await expect(page.getByTestId('reader-continuous')).toBeVisible();
// initialIndex = 0; the rolling window (PRELOAD_AHEAD = 3) eager-loads // Every page is eager — the whole current chapter is preloaded so
// the first few pages ahead of the reader so they don't pop in on // pages don't pop in / reflow as the reader scrolls onto them.
// scroll ... for (const n of [1, 2, 3, 4, 5, 6]) {
for (const n of [1, 2, 3]) {
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
'loading', 'loading',
'eager' 'eager'
); );
} }
// ... while the tail stays lazy so a long chapter doesn't fetch every
// page at once on open.
await expect(page.getByTestId('reader-page-6')).toHaveAttribute(
'loading',
'lazy'
);
}); });
test('single mode: ?page=N opens at the requested page', async ({ page }) => { test('single mode: ?page=N opens at the requested page', async ({ page }) => {

View File

@@ -55,20 +55,22 @@ async function goContinuous(page: Page, path: string) {
await expect(page.getByTestId('reader-continuous')).toBeVisible(); await expect(page.getByTestId('reader-continuous')).toBeVisible();
} }
test('opening at page 1 eager-loads the leading window but leaves far pages lazy', async ({ page }) => { test('eager-loads the whole current chapter up front, not just the visible pages', async ({ page }) => {
await mockReader(page); await mockReader(page);
await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}`); await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}`);
// The first page is always eager (it's on screen). // Every page — including ones far below the fold — is eager, so the
await expect(page.getByTestId('reader-page-1')).toHaveAttribute('loading', 'eager'); // browser fetches the whole chapter up front instead of on scroll.
// A page far past the leading window is not fetched up front. for (let n = 1; n <= PAGE_COUNT; n++) {
await expect(page.getByTestId('reader-page-10')).toHaveAttribute('loading', 'lazy'); await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute('loading', 'eager');
}); }
test('opening deep in the chapter eager-loads the pages around that position', async ({ page }) => { // ... and a page well below the fold actually finishes loading without
await mockReader(page); // ever scrolling to it — the observable payoff of preloading.
// Deep-link to page 8: the window now covers the tail, so page 10 is eager. const lastPage = page.getByTestId(`reader-page-${PAGE_COUNT}`);
await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}?page=8`); await expect(async () => {
const loaded = await lastPage.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0);
await expect(page.getByTestId('reader-page-10')).toHaveAttribute('loading', 'eager'); expect(loaded).toBe(true);
}).toPass();
expect(await page.evaluate(() => window.scrollY)).toBe(0);
}); });

View File

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

View File

@@ -971,61 +971,6 @@
const furthestPage = $derived(mode === 'single' ? index + 1 : progressPage); const furthestPage = $derived(mode === 'single' ? index + 1 : progressPage);
// ---- Current-chapter rolling preload window (continuous mode) ----
// Eager-load pages up to this index so they're fetched (and sized) a few
// pages ahead of the reading position — they arrive while still below the
// fold instead of popping in / reflowing as the user scrolls onto them.
// Pages past the window stay lazy so opening a long chapter doesn't fire
// every request at once.
//
// The window anchors on `initialIndex` (deterministic at open, and keeps
// the scroll-settle guarantee that pages `0..=initialIndex` are eager) and
// rolls forward by `scrollLeadIdx` — the page at the top of the viewport,
// measured from live geometry below. It deliberately does NOT use the
// read-progress high-water (`furthestPage`): before images load they're
// 0-height and pile into the viewport, which latches that mark to the last
// page and would eager-load the whole chapter on open.
const PRELOAD_AHEAD = 3;
let scrollLeadIdx = $state(0);
const eagerThrough = $derived(
Math.max(1, initialIndex, scrollLeadIdx) + PRELOAD_AHEAD
);
// Track the top-of-viewport page from live rects so the window rolls with
// actual scrolling. Guarded by `scrollY` so the pre-load 0-height pile-up
// (all rects collapsed near the top) can't inflate the lead at open.
$effect(() => {
if (!browser || mode !== 'continuous') return;
const measure = () => {
if (window.scrollY < 4) {
scrollLeadIdx = 0;
return;
}
const els = continuousPageEls;
for (let i = 0; i < els.length; i++) {
if (els[i] && els[i].getBoundingClientRect().bottom > 1) {
scrollLeadIdx = i;
return;
}
}
scrollLeadIdx = Math.max(0, els.length - 1);
};
measure();
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
measure();
});
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
window.removeEventListener('scroll', onScroll);
if (raf) cancelAnimationFrame(raf);
};
});
$effect(() => { $effect(() => {
const nc = nextChapter; const nc = nextChapter;
// Drop a previous chapter's warmed images once the next chapter // Drop a previous chapter's warmed images once the next chapter
@@ -1358,20 +1303,19 @@
{:else} {:else}
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous"> <div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
{#each pages as p, i (p.id)} {#each pages as p, i (p.id)}
<!-- Pages 0..=initialIndex (or at least 0..1) are eager <!-- The whole current chapter is eager: pages load up front
so their real heights are settled before the cold- (the browser paces them over its per-origin connection
load scroll-to-`?page=N` effect fires. Without this, limit, top-to-bottom = reading order) rather than filling
`scrollIntoView(target)` lands while prior pages are in as they're scrolled onto. This also settles every page's
still 0×0 placeholders and the target gets pushed real height before the cold-load scroll-to-`?page=N` effect
past the viewport as they load. `eagerThrough` widens fires — without it, `scrollIntoView(target)` lands while
that set into a rolling window a few pages ahead of the prior pages are still 0×0 placeholders and the target gets
reading position so pages load before they're scrolled pushed past the viewport as they load. -->
onto rather than filling in underneath the viewport. -->
<img <img
src={fileUrl(p.storage_key)} src={fileUrl(p.storage_key)}
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`} alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
class="page-image" class="page-image"
loading={i <= eagerThrough ? 'eager' : 'lazy'} loading="eager"
oncontextmenu={(e) => onPageContextMenu(e, p.id)} oncontextmenu={(e) => onPageContextMenu(e, p.id)}
onpointerdown={(e) => onPagePointerDown(e, p.id)} onpointerdown={(e) => onPagePointerDown(e, p.id)}
onpointermove={onPagePointerMove} onpointermove={onPagePointerMove}