fix(reader): harden swipe/long-press coexistence and next-chapter prefetch
All checks were successful
deploy / test-backend (push) Successful in 32m21s
deploy / test-frontend (push) Successful in 10m33s
deploy / build-and-push (push) Successful in 11m40s
deploy / deploy (push) Successful in 18s

- 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
  <img>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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-04 21:57:36 +02:00
parent bfaa166e0a
commit 9c4a93c058
8 changed files with 72 additions and 9 deletions

View File

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

View File

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