feat: preload the current chapter ahead of the reader
Continuous mode eager-loaded only pages 0..=initialIndex; everything below stayed `loading="lazy"`, so pages fetched and reflowed in as they scrolled into view. Now a rolling window eager-loads a few pages ahead of the reading position (PRELOAD_AHEAD) so they arrive — fetched and sized — while still below the fold, then swap in without pop-in. The window rolls forward off `scrollLeadIdx`, the top-of-viewport page measured from live rects and guarded by `scrollY`. It deliberately does not use the read-progress high-water mark: before images load they are 0-height and pile into the viewport, which would latch that mark to the last page and eager-load the whole chapter on open. Pages past the window stay lazy so opening a long chapter doesn't fetch every page at once. 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.122.1"
|
version = "0.123.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.122.1"
|
version = "0.123.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -171,41 +171,39 @@ 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
|
// Pages 1..=4 (1-indexed in the testid, 0-indexed in the
|
||||||
// template; initialIndex = 3 means we eager-load 0..=3, i.e.
|
// template; initialIndex = 3 means we eager-load at least 0..=3,
|
||||||
// testids 1..4). Without this guard, page 2+ would be lazy
|
// i.e. testids 1..4). Without this guard, page 2+ would be lazy
|
||||||
// and their 0×0 placeholders would let the scroll target
|
// and their 0×0 placeholders would let the scroll target
|
||||||
// appear far above its final position.
|
// appear far above its final position. The rolling preload window
|
||||||
for (const n of [1, 2, 3, 4]) {
|
// (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]) {
|
||||||
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
|
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
|
||||||
'loading',
|
'loading',
|
||||||
'eager'
|
'eager'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Pages beyond the target stay lazy.
|
|
||||||
for (const n of [5, 6]) {
|
|
||||||
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
|
|
||||||
'loading',
|
|
||||||
'lazy'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('continuous mode: no ?page= eager-loads only the first two', async ({
|
test('continuous mode: no ?page= eager-loads a leading preload window, not the whole chapter', 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();
|
||||||
await expect(page.getByTestId('reader-page-1')).toHaveAttribute(
|
// initialIndex = 0; the rolling window (PRELOAD_AHEAD = 3) eager-loads
|
||||||
'loading',
|
// the first few pages ahead of the reader so they don't pop in on
|
||||||
'eager'
|
// scroll ...
|
||||||
);
|
for (const n of [1, 2, 3]) {
|
||||||
await expect(page.getByTestId('reader-page-2')).toHaveAttribute(
|
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
|
||||||
'loading',
|
'loading',
|
||||||
'eager'
|
'eager'
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId('reader-page-3')).toHaveAttribute(
|
}
|
||||||
|
// ... 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',
|
'loading',
|
||||||
'lazy'
|
'lazy'
|
||||||
);
|
);
|
||||||
|
|||||||
74
frontend/e2e/reader-preload-window.spec.ts
Normal file
74
frontend/e2e/reader-preload-window.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Continuous mode eager-loads a rolling window of pages ahead of the reading
|
||||||
|
// position, so pages are fetched (and sized) before they scroll into view
|
||||||
|
// instead of popping in / reflowing on scroll. Pages far beyond the window
|
||||||
|
// stay lazy so opening a long chapter doesn't fire every request at once.
|
||||||
|
|
||||||
|
const MANGA_ID = 'm1';
|
||||||
|
const CH1 = 'c1';
|
||||||
|
const PAGE_COUNT = 10;
|
||||||
|
|
||||||
|
function pages(prefix: string, n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: `${prefix}p${i + 1}`,
|
||||||
|
chapter_id: prefix,
|
||||||
|
page_number: i + 1,
|
||||||
|
storage_key: `${prefix}/${i + 1}`,
|
||||||
|
content_type: 'image/svg+xml'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function chapter(id: string, number: number, count: number) {
|
||||||
|
return { id, manga_id: MANGA_ID, number, title: null, page_count: count, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockReader(page: Page) {
|
||||||
|
await page.route('**/api/v1/**', async (route) => {
|
||||||
|
const { pathname } = new URL(route.request().url());
|
||||||
|
const json = (status: number, body: unknown) =>
|
||||||
|
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
if (pathname.includes('/files/')) {
|
||||||
|
// A viewBox + rect gives the <img> a real intrinsic size so pages
|
||||||
|
// get real (tall) heights — offscreen pages then stay below the
|
||||||
|
// fold instead of collapsing to 0 and piling into the viewport.
|
||||||
|
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200" viewBox="0 0 800 1200"><rect width="800" height="1200" fill="#888"/></svg>' });
|
||||||
|
}
|
||||||
|
if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
|
||||||
|
if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
|
||||||
|
if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
|
||||||
|
if (pathname.endsWith(`/chapters/${CH1}/pages`)) return json(200, { pages: pages(CH1, PAGE_COUNT) });
|
||||||
|
if (pathname.endsWith(`/chapters/${CH1}`)) return json(200, chapter(CH1, 1, PAGE_COUNT));
|
||||||
|
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
|
||||||
|
return json(200, { items: [chapter(CH1, 1, PAGE_COUNT)], page: { limit: 200, offset: 0, total: 1 } });
|
||||||
|
}
|
||||||
|
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, 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 });
|
||||||
|
if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
|
||||||
|
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goContinuous(page: Page, path: string) {
|
||||||
|
await page.addInitScript(() => localStorage.setItem('mangalord-reader-mode', 'continuous'));
|
||||||
|
await page.goto(path);
|
||||||
|
await expect(page.getByTestId('reader-continuous')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('opening at page 1 eager-loads the leading window but leaves far pages lazy', async ({ page }) => {
|
||||||
|
await mockReader(page);
|
||||||
|
await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}`);
|
||||||
|
|
||||||
|
// The first page is always eager (it's on screen).
|
||||||
|
await expect(page.getByTestId('reader-page-1')).toHaveAttribute('loading', 'eager');
|
||||||
|
// A page far past the leading window is not fetched up front.
|
||||||
|
await expect(page.getByTestId('reader-page-10')).toHaveAttribute('loading', 'lazy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening deep in the chapter eager-loads the pages around that position', async ({ page }) => {
|
||||||
|
await mockReader(page);
|
||||||
|
// Deep-link to page 8: the window now covers the tail, so page 10 is eager.
|
||||||
|
await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}?page=8`);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('reader-page-10')).toHaveAttribute('loading', 'eager');
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.122.1",
|
"version": "0.123.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -971,6 +971,61 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -1308,12 +1363,15 @@
|
|||||||
load scroll-to-`?page=N` effect fires. Without this,
|
load scroll-to-`?page=N` effect fires. Without this,
|
||||||
`scrollIntoView(target)` lands while prior pages are
|
`scrollIntoView(target)` lands while prior pages are
|
||||||
still 0×0 placeholders and the target gets pushed
|
still 0×0 placeholders and the target gets pushed
|
||||||
past the viewport as they load. -->
|
past the viewport as they load. `eagerThrough` widens
|
||||||
|
that set into a rolling window a few pages ahead of the
|
||||||
|
reading position so pages load before they're scrolled
|
||||||
|
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 <= Math.max(1, initialIndex) ? 'eager' : 'lazy'}
|
loading={i <= eagerThrough ? 'eager' : 'lazy'}
|
||||||
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user