feat: preload the current chapter ahead of the reader

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 19:39:50 +02:00
parent 51ea254dde
commit c69ff502f7
6 changed files with 156 additions and 26 deletions

View File

@@ -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'
);