From 9c4a93c058ae4cbf103f10ab02bafd4adf68a37a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 4 Jul 2026 21:57:36 +0200 Subject: [PATCH] fix(reader): harden swipe/long-press coexistence and next-chapter prefetch - A long-press now cancels any pending swipe, so a hold-then-drag gesture opens the action sheet without also turning the page (regression-tested). - The next-chapter prefetch no longer retries on every page flip when the endpoint fails (guard clears only when the next chapter changes), and it clears a previous chapter's warmed images on navigation so stale hidden s don't linger. - Tap zones set touch-action: pan-y so vertical panning stays native while horizontal swipes reach our handler. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/reader-next-preload.spec.ts | 21 ++++++++++++- frontend/e2e/reader-swipe.spec.ts | 30 +++++++++++++++++++ frontend/package-lock.json | 4 +-- frontend/package.json | 2 +- frontend/src/lib/components/TapZone.svelte | 7 +++++ .../[id]/chapter/[chapter_id]/+page.svelte | 13 ++++++-- 8 files changed, 72 insertions(+), 9 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c105851..37381d8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.104.0" +version = "0.105.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 74313d0..5392041 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.104.0" +version = "0.105.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/reader-next-preload.spec.ts b/frontend/e2e/reader-next-preload.spec.ts index 7059d41..595a9ad 100644 --- a/frontend/e2e/reader-next-preload.spec.ts +++ b/frontend/e2e/reader-next-preload.spec.ts @@ -7,6 +7,7 @@ import { test, expect, type Page } from './fixtures'; const MANGA_ID = 'm1'; const CH1 = 'c1'; const CH2 = 'c2'; +const CH3 = 'c3'; function pages(prefix: string, n: number) { return Array.from({ length: n }, (_, i) => ({ @@ -36,11 +37,13 @@ async function mockReader(page: Page) { 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, 5) }); if (pathname.endsWith(`/chapters/${CH2}/pages`)) return json(200, { pages: pages(CH2, 4) }); + if (pathname.endsWith(`/chapters/${CH3}/pages`)) return json(200, { pages: pages(CH3, 3) }); if (pathname.endsWith(`/chapters/${CH1}`)) return json(200, chapter(CH1, 1, 5)); if (pathname.endsWith(`/chapters/${CH2}`)) return json(200, chapter(CH2, 2, 4)); + if (pathname.endsWith(`/chapters/${CH3}`)) return json(200, chapter(CH3, 3, 3)); if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) { // Oldest-first, as the reader expects (next = index + 1). - return json(200, { items: [chapter(CH1, 1, 5), chapter(CH2, 2, 4)], page: { limit: 200, offset: 0, total: 2 } }); + return json(200, { items: [chapter(CH1, 1, 5), chapter(CH2, 2, 4), chapter(CH3, 3, 3)], page: { limit: 200, offset: 0, total: 3 } }); } 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' } }); @@ -65,3 +68,19 @@ test('prefetches the next chapter first pages only when near the end', async ({ await expect(preloads).toHaveCount(3); await expect(preloads.first()).toHaveAttribute('src', /c2\/1/); }); + +test('clears stale prefetch when navigating to the next chapter', async ({ page }) => { + await mockReader(page); + await page.goto(`/manga/${MANGA_ID}/chapter/${CH1}`); + await expect(page.getByTestId('reader-page')).toBeVisible(); + + // Warm chapter 2's pages near the end of chapter 1. + await page.keyboard.press('End'); + await expect(page.getByTestId('reader-next-preload')).toHaveCount(3); + + // Navigate to chapter 2. At its start (not near the end) the previous + // prefetch must be cleared, even though chapter 2 has its own next (3). + await page.goto(`/manga/${MANGA_ID}/chapter/${CH2}`); + await expect(page.getByTestId('reader-page')).toBeVisible(); + await expect(page.getByTestId('reader-next-preload')).toHaveCount(0); +}); diff --git a/frontend/e2e/reader-swipe.spec.ts b/frontend/e2e/reader-swipe.spec.ts index efb0586..8da23d1 100644 --- a/frontend/e2e/reader-swipe.spec.ts +++ b/frontend/e2e/reader-swipe.spec.ts @@ -51,6 +51,36 @@ async function swipe(page: Page, dx: number, dy: number) { ); } +// Dispatch pointerdown, hold (so long-press fires), then swipe+lift. +async function holdThenSwipe(page: Page, holdMs: number, dx: number) { + await page.evaluate(() => { + const el = document.querySelector('[data-testid="reader-tap-center"]'); + if (!el) throw new Error('tap zone not found'); + el.dispatchEvent(new PointerEvent('pointerdown', { clientX: 300, clientY: 500, pointerType: 'touch', bubbles: true, cancelable: true })); + }); + await page.waitForTimeout(holdMs); + await page.evaluate(({ dx }) => { + const el = document.querySelector('[data-testid="reader-tap-center"]'); + if (!el) throw new Error('tap zone not found'); + el.dispatchEvent(new PointerEvent('pointermove', { clientX: 300 + dx, clientY: 505, pointerType: 'touch', bubbles: true, cancelable: true })); + el.dispatchEvent(new PointerEvent('pointerup', { clientX: 300 + dx, clientY: 505, pointerType: 'touch', bubbles: true, cancelable: true })); + }, { dx }); +} + +test('a long-press then swipe does not also turn the page', async ({ page }) => { + await mockReader(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`); + const img = page.getByTestId('reader-page'); + await expect(img).toHaveAttribute('src', /k\/1/); + + // Hold past the 450ms long-press threshold, then swipe left before lifting. + await holdThenSwipe(page, 550, -200); + + // The long-press consumed the gesture — the page must not have turned. + await expect(img).toHaveAttribute('src', /k\/1/); +}); + test('horizontal swipe turns the page; vertical drag does not', async ({ page }) => { await mockReader(page); await page.setViewportSize({ width: 390, height: 844 }); // mobile → tap zones render diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d53574e..159c8be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.104.0", + "version": "0.105.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.104.0", + "version": "0.105.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index d88b6ce..e7ea43e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.104.0", + "version": "0.105.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/TapZone.svelte b/frontend/src/lib/components/TapZone.svelte index 310a00b..c7d1457 100644 --- a/frontend/src/lib/components/TapZone.svelte +++ b/frontend/src/lib/components/TapZone.svelte @@ -69,6 +69,10 @@ const startY = e.clientY; pressTimer = setTimeout(() => { pressTimer = null; + // A long-press consumed the gesture — cancel any swipe so a + // subsequent drag-then-lift (finger held, then moved) doesn't + // also turn the page on top of opening the action sheet. + swipeStart = null; suppressNextClick = true; if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer); suppressExpiryTimer = setTimeout(() => { @@ -200,6 +204,9 @@ padding: 0; margin: 0; cursor: pointer; + /* Let vertical panning through to the page but keep horizontal + gestures for our swipe handler rather than browser scroll. */ + touch-action: pan-y; /* No visible style — these are interaction surfaces, not controls. Focus-visible is honored so keyboard users get a ring if they tab into one. */ 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 116e60d..184c0b3 100644 --- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte @@ -973,11 +973,18 @@ $effect(() => { const nc = nextChapter; + // Drop a previous chapter's warmed images once the next chapter + // changes (e.g. after navigating), so stale s don't linger. + if (preloadedForChapterId !== null && (!nc || preloadedForChapterId !== nc.id)) { + preloadedForChapterId = null; + nextPreloadUrls = []; + } if (!nc || pages.length === 0) return; if (pages.length - furthestPage > PRELOAD_WITHIN_PAGES) return; if (preloadedForChapterId === nc.id) return; - // Guard set before the await so overlapping effect runs don't - // double-fetch; cleared on failure so a later pass can retry. + // Marked before the await and NOT reset on failure, so a broken + // endpoint can't re-fetch on every page flip near the end — the + // guard clears only when the next chapter itself changes (above). preloadedForChapterId = nc.id; getChapterPages(manga.id, nc.id) .then((ps) => { @@ -987,7 +994,7 @@ .map((p) => fileUrl(p.storage_key)); }) .catch(() => { - if (preloadedForChapterId === nc.id) preloadedForChapterId = null; + // Best-effort prefetch — swallow and don't retry this chapter. }); });