fix: lazy-load continuous-reader pages beyond an eager window
Continuous mode rendered every page (up to 2000) with loading="eager", fetching and decoding the whole chapter up front — enough to OOM a mobile tab on a long chapter. Eager-load only a leading window ([0..=eagerThrough], covering the deep-link target + a lead, min 6 pages) and lazy-load the rest; the browser fetches them as the reader scrolls near. Unloaded lazy pages reserve height (page width x ~1.4) so they stay distinct scroll targets for the progress IntersectionObserver, cleared on load. Keeps the DOM dense so the scroll-to-?page=N effect, progress observer, and next-chapter preload trigger are all unchanged. Rewrote reader-preload-window spec to assert the eager/lazy attribute partition + deep-link scroll landing. Bump to 0.124.2. 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]]
|
||||
name = "mangalord"
|
||||
version = "0.124.1"
|
||||
version = "0.124.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.124.1"
|
||||
version = "0.124.2"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.124.1",
|
||||
"version": "0.124.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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}
|
||||
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
|
||||
{#each pages as p, i (p.id)}
|
||||
<!-- The whole current chapter is eager: pages load up front
|
||||
(the browser paces them over its per-origin connection
|
||||
limit, top-to-bottom = reading order) rather than filling
|
||||
in as they're scrolled onto. This also settles every page's
|
||||
real height before the cold-load scroll-to-`?page=N` effect
|
||||
fires — without it, `scrollIntoView(target)` lands while
|
||||
prior pages are still 0×0 placeholders and the target gets
|
||||
pushed past the viewport as they load. -->
|
||||
<!-- Pages in the leading eager window (`i <= eagerThrough`) load
|
||||
up front — this settles every page's real height at/above the
|
||||
cold-load scroll-to-`?page=N` target before that effect fires,
|
||||
so `scrollIntoView(target)` can't land while prior pages are
|
||||
still 0×0 placeholders. Pages beyond the window are lazy: the
|
||||
browser fetches them as the reader scrolls near, so a long
|
||||
chapter doesn't fire every request / decode every image at
|
||||
once. Unloaded lazy pages carry `data-reserve` so CSS gives
|
||||
them a min-height — keeping them distinct scroll targets for
|
||||
the progress observer; `onload` clears it once real height
|
||||
lands. -->
|
||||
<img
|
||||
src={fileUrl(p.storage_key)}
|
||||
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
|
||||
class="page-image"
|
||||
loading="eager"
|
||||
loading={i <= eagerThrough ? 'eager' : 'lazy'}
|
||||
data-reserve={i > 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
|
||||
|
||||
Reference in New Issue
Block a user