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

@@ -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