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:
MechaCat02
2026-07-07 20:28:41 +02:00
parent 1c955458d6
commit 379224bee4
5 changed files with 103 additions and 25 deletions

View File

@@ -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();
});