From c69ff502f70863c806cb346865d88c5bc90c5178 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 7 Jul 2026 19:39:50 +0200 Subject: [PATCH] feat: preload the current chapter ahead of the reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/reader-page-deep-link.spec.ts | 40 +++++----- frontend/e2e/reader-preload-window.spec.ts | 74 +++++++++++++++++++ frontend/package.json | 2 +- .../[id]/chapter/[chapter_id]/+page.svelte | 62 +++++++++++++++- 6 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 frontend/e2e/reader-preload-window.spec.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7942035..46c328b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.122.1" +version = "0.123.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 10c49f5..b26d63f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.122.1" +version = "0.123.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/reader-page-deep-link.spec.ts b/frontend/e2e/reader-page-deep-link.spec.ts index bd82292..458d3c5 100644 --- a/frontend/e2e/reader-page-deep-link.spec.ts +++ b/frontend/e2e/reader-page-deep-link.spec.ts @@ -171,41 +171,39 @@ test.describe('reader ?page=N deep link', () => { await expect(page.getByTestId('reader-continuous')).toBeVisible(); // Pages 1..=4 (1-indexed in the testid, 0-indexed in the - // template; initialIndex = 3 means we eager-load 0..=3, i.e. - // testids 1..4). Without this guard, page 2+ would be lazy + // template; initialIndex = 3 means we eager-load at least 0..=3, + // i.e. testids 1..4). Without this guard, page 2+ would be lazy // and their 0×0 placeholders would let the scroll target - // appear far above its final position. - for (const n of [1, 2, 3, 4]) { + // 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]) { await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( 'loading', '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 }) => { await mockReader(page, 'continuous'); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await expect(page.getByTestId('reader-continuous')).toBeVisible(); - await expect(page.getByTestId('reader-page-1')).toHaveAttribute( - 'loading', - 'eager' - ); - await expect(page.getByTestId('reader-page-2')).toHaveAttribute( - 'loading', - 'eager' - ); - await expect(page.getByTestId('reader-page-3')).toHaveAttribute( + // initialIndex = 0; the rolling window (PRELOAD_AHEAD = 3) eager-loads + // the first few pages ahead of the reader so they don't pop in on + // scroll ... + for (const n of [1, 2, 3]) { + await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( + 'loading', + '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' ); diff --git a/frontend/e2e/reader-preload-window.spec.ts b/frontend/e2e/reader-preload-window.spec.ts new file mode 100644 index 0000000..68fe8bf --- /dev/null +++ b/frontend/e2e/reader-preload-window.spec.ts @@ -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 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: '' }); + } + 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'); +}); diff --git a/frontend/package.json b/frontend/package.json index 0d9e7e4..a362776 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.122.1", + "version": "0.123.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte index 184c0b3..9f02fc0 100644 --- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte @@ -971,6 +971,61 @@ 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(() => { const nc = nextChapter; // Drop a previous chapter's warmed images once the next chapter @@ -1308,12 +1363,15 @@ load scroll-to-`?page=N` effect fires. Without this, `scrollIntoView(target)` lands while prior pages are 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. --> {`${manga.title} onPageContextMenu(e, p.id)} onpointerdown={(e) => onPagePointerDown(e, p.id)} onpointermove={onPagePointerMove}