diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 3fcce1d..64a8d70 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.1" +version = "0.124.2" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3c7f1d8..f1a00db 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.1" +version = "0.124.2" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/reader-preload-window.spec.ts b/frontend/e2e/reader-preload-window.spec.ts index 787e938..c18fd0c 100644 --- a/frontend/e2e/reader-preload-window.spec.ts +++ b/frontend/e2e/reader-preload-window.spec.ts @@ -1,13 +1,19 @@ 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. +// Continuous mode eager-loads a window of pages ahead of the reading position +// (the deep-linked page + a lead, min 6 pages), so those are fetched and sized +// up front. Pages beyond the window are `loading="lazy"` — the browser fetches +// them natively as the reader scrolls near, so opening a long chapter (up to +// 2000 pages) doesn't fire every request or decode every image at once. Unloaded +// lazy pages reserve height so they stay distinct scroll targets for the +// progress IntersectionObserver. const MANGA_ID = 'm1'; const CH1 = 'c1'; -const PAGE_COUNT = 10; +// Enough pages that the last one sits tens of thousands of px below the fold — +// well beyond any browser's lazy-load distance threshold — so "far pages don't +// prefetch at the top" is unambiguous rather than threshold-brittle. +const PAGE_COUNT = 40; function pages(prefix: string, n: number) { return Array.from({ length: n }, (_, i) => ({ @@ -55,22 +61,59 @@ async function goContinuous(page: Page, path: string) { await expect(page.getByTestId('reader-continuous')).toBeVisible(); } -test('eager-loads the whole current chapter up front, not just the visible pages', async ({ page }) => { +// EAGER_MIN in the reader: opening at page 1 (index 0) eager-loads pages 1..6. +const EAGER_THROUGH = 6; + +test('eager-loads a leading window and leaves far pages lazy', async ({ page }) => { await mockReader(page); await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}`); - // Every page — including ones far below the fold — is eager, so the - // browser fetches the whole chapter up front instead of on scroll. - for (let n = 1; n <= PAGE_COUNT; n++) { + // The leading window is eager — warmed up front so the reader doesn't + // pop-in / reflow as it scrolls onto the next few pages. + for (let n = 1; n <= EAGER_THROUGH; n++) { await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute('loading', 'eager'); } + // Pages beyond the window stay lazy — not fetched until scrolled near, so + // a 2000-page chapter doesn't fire 2000 requests / decodes at once. + for (let n = EAGER_THROUGH + 1; n <= PAGE_COUNT; n++) { + await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute('loading', 'lazy'); + } +}); - // ... and a page well below the fold actually finishes loading without - // ever scrolling to it — the observable payoff of preloading. - const lastPage = page.getByTestId(`reader-page-${PAGE_COUNT}`); +test('a windowed page is warmed up front without scrolling', async ({ page }) => { + await mockReader(page); + await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}`); + + // A page inside the eager window finishes loading without any scroll — the + // preload payoff (no pop-in as the reader advances a few pages) is kept. + // (We can't assert the far lazy pages stay *unfetched* here: headless + // Chromium prefetches `loading="lazy"` images regardless of distance. The + // attribute partition above is the mechanism that defers them in the real + // OOM target — mobile Safari. Playwright can only pin the attributes.) await expect(async () => { - const loaded = await lastPage.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0); + const loaded = await page + .getByTestId(`reader-page-${EAGER_THROUGH}`) + .evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0); expect(loaded).toBe(true); }).toPass(); expect(await page.evaluate(() => window.scrollY)).toBe(0); }); + +test('deep-link ?page=N keeps the target inside the eager window and lands the scroll', async ({ + page +}) => { + await mockReader(page); + // initialIndex = 19 → eagerThrough = 22, so pages 1..23 are eager (the + // target and everything above it settle height before the scroll fires). + await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}?page=20`); + + await expect(page.getByTestId('reader-page-20')).toHaveAttribute('loading', 'eager'); + await expect(page.getByTestId('reader-page-22')).toHaveAttribute('loading', 'eager'); + // A page far past the widened window is still lazy. + await expect(page.getByTestId(`reader-page-${PAGE_COUNT}`)).toHaveAttribute('loading', 'lazy'); + + // The scroll-to-`?page=N` effect lands the target in view (proves the + // eager window settled the heights above it — a short-landing scroll would + // leave page 20 out of the viewport). + await expect(page.getByTestId('reader-page-20')).toBeInViewport(); +}); diff --git a/frontend/package.json b/frontend/package.json index a47750d..ee18e96 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.1", + "version": "0.124.2", "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 b2fbd05..ab9e5b1 100644 --- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte @@ -90,6 +90,26 @@ // svelte-ignore state_referenced_locally let index = $state(initialIndex); let continuousPageEls: HTMLImageElement[] = $state([]); + + // ---- Continuous-mode eager window ---- + // + // Eager-load a leading window of pages, lazy-load the rest. A chapter can + // hold up to 2000 pages; making every one `loading="eager"` (the previous + // behaviour) fired the whole chapter's requests up front and kept every + // decoded image resident — enough to OOM a mobile tab on a long chapter. + // + // The window is `[0 ..= eagerThrough]`. It always covers `0..=initialIndex` + // so the cold-load scroll-to-`?page=N` effect still has settled heights for + // the target and everything above it (the whole reason pages were eager), + // plus a small LEAD so the next pages are warm, and never fewer than + // EAGER_MIN so short chapters stay fully eager. Pages beyond the window are + // `loading="lazy"` and the browser fetches them as the reader scrolls near; + // unloaded lazy pages reserve height via CSS (`.page-image[data-reserve]`) + // so they stay distinct scroll targets for the progress IntersectionObserver. + const EAGER_LEAD = 3; + const EAGER_MIN = 5; + const eagerThrough = $derived(Math.max(initialIndex + EAGER_LEAD, EAGER_MIN)); + let chapterBarEl: HTMLElement | undefined = $state(); let readerNavEl: HTMLElement | undefined = $state(); @@ -1303,19 +1323,24 @@ {:else}
{#each pages as p, i (p.id)} - + {`${manga.title} eagerThrough ? '' : undefined} + onload={(e) => e.currentTarget.removeAttribute('data-reserve')} oncontextmenu={(e) => onPageContextMenu(e, p.id)} onpointerdown={(e) => onPagePointerDown(e, p.id)} onpointermove={onPagePointerMove} @@ -1916,6 +1941,16 @@ -webkit-user-select: none; } + /* A lazy page that hasn't loaded yet has no intrinsic height, so without + a reservation the whole not-yet-loaded tail would collapse to 0 and + pile up at one scroll offset — which both looks broken and gives the + progress IntersectionObserver overlapping 0-height targets. Reserve a + typical page height (page width × ~1.4) so each stays a distinct target; + the `onload` handler drops `data-reserve` once the real height lands. */ + .page-image[data-reserve] { + min-height: calc(var(--reader-page-width) * 1.4); + } + /* Continuous mode inherits the width-driven sizing above: every page is the same width and takes its natural (uncapped) height, so there are no scroll dead-zones inside a single page and widths stay consistent