diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 7942035..46c328b 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.122.1"
+version = "0.123.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 10c49f5..b26d63f 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.122.1"
+version = "0.123.0"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/reader-page-deep-link.spec.ts b/frontend/e2e/reader-page-deep-link.spec.ts
index bd82292..458d3c5 100644
--- a/frontend/e2e/reader-page-deep-link.spec.ts
+++ b/frontend/e2e/reader-page-deep-link.spec.ts
@@ -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'
);
diff --git a/frontend/e2e/reader-preload-window.spec.ts b/frontend/e2e/reader-preload-window.spec.ts
new file mode 100644
index 0000000..68fe8bf
--- /dev/null
+++ b/frontend/e2e/reader-preload-window.spec.ts
@@ -0,0 +1,74 @@
+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.
+
+const MANGA_ID = 'm1';
+const CH1 = 'c1';
+const PAGE_COUNT = 10;
+
+function pages(prefix: string, n: number) {
+ return Array.from({ length: n }, (_, i) => ({
+ id: `${prefix}p${i + 1}`,
+ chapter_id: prefix,
+ page_number: i + 1,
+ storage_key: `${prefix}/${i + 1}`,
+ content_type: 'image/svg+xml'
+ }));
+}
+
+function chapter(id: string, number: number, count: number) {
+ return { id, manga_id: MANGA_ID, number, title: null, page_count: count, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 };
+}
+
+async function mockReader(page: Page) {
+ await page.route('**/api/v1/**', async (route) => {
+ const { pathname } = new URL(route.request().url());
+ const json = (status: number, body: unknown) =>
+ route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
+
+ if (pathname.includes('/files/')) {
+ // A viewBox + rect gives the a real intrinsic size so pages
+ // get real (tall) heights — offscreen pages then stay below the
+ // fold instead of collapsing to 0 and piling into the viewport.
+ return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '' });
+ }
+ if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
+ if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ if (pathname.endsWith(`/chapters/${CH1}/pages`)) return json(200, { pages: pages(CH1, PAGE_COUNT) });
+ if (pathname.endsWith(`/chapters/${CH1}`)) return json(200, chapter(CH1, 1, PAGE_COUNT));
+ if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
+ return json(200, { items: [chapter(CH1, 1, PAGE_COUNT)], page: { limit: 200, offset: 0, total: 1 } });
+ }
+ if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 });
+ if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
+ });
+}
+
+async function goContinuous(page: Page, path: string) {
+ await page.addInitScript(() => localStorage.setItem('mangalord-reader-mode', 'continuous'));
+ await page.goto(path);
+ await expect(page.getByTestId('reader-continuous')).toBeVisible();
+}
+
+test('opening at page 1 eager-loads the leading window but leaves far pages lazy', async ({ page }) => {
+ await mockReader(page);
+ await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}`);
+
+ // The first page is always eager (it's on screen).
+ await expect(page.getByTestId('reader-page-1')).toHaveAttribute('loading', 'eager');
+ // A page far past the leading window is not fetched up front.
+ await expect(page.getByTestId('reader-page-10')).toHaveAttribute('loading', 'lazy');
+});
+
+test('opening deep in the chapter eager-loads the pages around that position', async ({ page }) => {
+ await mockReader(page);
+ // Deep-link to page 8: the window now covers the tail, so page 10 is eager.
+ await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}?page=8`);
+
+ await expect(page.getByTestId('reader-page-10')).toHaveAttribute('loading', 'eager');
+});
diff --git a/frontend/package.json b/frontend/package.json
index 0d9e7e4..a362776 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.122.1",
+ "version": "0.123.0",
"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 184c0b3..9f02fc0 100644
--- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
+++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
@@ -971,6 +971,61 @@
const furthestPage = $derived(mode === 'single' ? index + 1 : progressPage);
+ // ---- Current-chapter rolling preload window (continuous mode) ----
+ // Eager-load pages up to this index so they're fetched (and sized) a few
+ // pages ahead of the reading position — they arrive while still below the
+ // fold instead of popping in / reflowing as the user scrolls onto them.
+ // Pages past the window stay lazy so opening a long chapter doesn't fire
+ // every request at once.
+ //
+ // The window anchors on `initialIndex` (deterministic at open, and keeps
+ // the scroll-settle guarantee that pages `0..=initialIndex` are eager) and
+ // rolls forward by `scrollLeadIdx` — the page at the top of the viewport,
+ // measured from live geometry below. It deliberately does NOT use the
+ // read-progress high-water (`furthestPage`): before images load they're
+ // 0-height and pile into the viewport, which latches that mark to the last
+ // page and would eager-load the whole chapter on open.
+ const PRELOAD_AHEAD = 3;
+ let scrollLeadIdx = $state(0);
+ const eagerThrough = $derived(
+ Math.max(1, initialIndex, scrollLeadIdx) + PRELOAD_AHEAD
+ );
+
+ // Track the top-of-viewport page from live rects so the window rolls with
+ // actual scrolling. Guarded by `scrollY` so the pre-load 0-height pile-up
+ // (all rects collapsed near the top) can't inflate the lead at open.
+ $effect(() => {
+ if (!browser || mode !== 'continuous') return;
+ const measure = () => {
+ if (window.scrollY < 4) {
+ scrollLeadIdx = 0;
+ return;
+ }
+ const els = continuousPageEls;
+ for (let i = 0; i < els.length; i++) {
+ if (els[i] && els[i].getBoundingClientRect().bottom > 1) {
+ scrollLeadIdx = i;
+ return;
+ }
+ }
+ scrollLeadIdx = Math.max(0, els.length - 1);
+ };
+ measure();
+ let raf = 0;
+ const onScroll = () => {
+ if (raf) return;
+ raf = requestAnimationFrame(() => {
+ raf = 0;
+ measure();
+ });
+ };
+ window.addEventListener('scroll', onScroll, { passive: true });
+ return () => {
+ window.removeEventListener('scroll', onScroll);
+ if (raf) cancelAnimationFrame(raf);
+ };
+ });
+
$effect(() => {
const nc = nextChapter;
// Drop a previous chapter's warmed images once the next chapter
@@ -1308,12 +1363,15 @@
load scroll-to-`?page=N` effect fires. Without this,
`scrollIntoView(target)` lands while prior pages are
still 0×0 placeholders and the target gets pushed
- past the viewport as they load. -->
+ past the viewport as they load. `eagerThrough` widens
+ that set into a rolling window a few pages ahead of the
+ reading position so pages load before they're scrolled
+ onto rather than filling in underneath the viewport. -->
onPageContextMenu(e, p.id)}
onpointerdown={(e) => onPagePointerDown(e, p.id)}
onpointermove={onPagePointerMove}