diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2e1d268..4196357 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.60.1" +version = "0.60.2" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 71afb92..de7a7d8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.60.1" +version = "0.60.2" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/back-nav-flow.spec.ts b/frontend/e2e/back-nav-flow.spec.ts new file mode 100644 index 0000000..1d44293 --- /dev/null +++ b/frontend/e2e/back-nav-flow.spec.ts @@ -0,0 +1,166 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Regression spec for the reader-back-loop bug: previously the reader's +// back arrow was a plain ``, which PUSHED a new +// history entry every tap, so browser-back kept ping-ponging between +// detail and reader instead of walking out to home. The reader now +// intercepts left-click and does `history.back()` when there's +// same-origin history to pop. + +const MID = 'a1111111-1111-1111-1111-111111111111'; +const CID = 'c1111111-1111-1111-1111-111111111111'; + +const mangaBody = { + id: MID, + title: 'Berserk', + status: 'ongoing', + alt_titles: [], + description: 'Short.', + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [], + genres: [], + tags: [] +}; + +async function mockApis(page: Page) { + await page.route('**/api/v1/auth/config', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (r) => + r.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'x', message: 'x' } }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (r) => + r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }) + ); + await page.route('**/api/v1/me/bookmarks*', (r) => + r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }) + ); + await page.route('**/api/v1/me/read-progress*', (r) => + r.fulfill({ status: 404, contentType: 'application/json', body: '{}' }) + ); + await page.route('**/api/v1/genres*', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) + ); + // Catalog returns a single card whose href points at MID so the + // SPA-click walk lands on the manga we've mocked downstream. + await page.route('**/api/v1/mangas', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [mangaBody], + page: { limit: 50, offset: 0, total: 1 } + }) + }) + ); + await page.route('**/api/v1/mangas/**', (r) => { + const u = new URL(r.request().url()); + const last = u.pathname.split('/').pop(); + if (last === CID) + return r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: CID, + manga_id: MID, + number: 1, + title: 'Ch.1', + page_count: 1, + created_at: '2026-01-01T00:00:00Z' + }) + }); + if (last === 'pages') + return r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + pages: [ + { + id: 'p', + chapter_id: CID, + page_number: 1, + storage_key: 'x.png', + content_type: 'image/png' + } + ] + }) + }); + if (u.pathname.endsWith('/chapters')) + return r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [ + { + id: CID, + manga_id: MID, + number: 1, + title: 'Ch.1', + page_count: 1, + created_at: '2026-01-01T00:00:00Z' + } + ], + page: { limit: 50, offset: 0, total: 1 } + }) + }); + return r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangaBody) + }); + }); + const png = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', + 'hex' + ); + await page.route('**/api/v1/files/**', (r) => + r.fulfill({ status: 200, contentType: 'image/png', body: png }) + ); +} + +test.describe('back-nav flow', () => { + +test('phone viewport: reader back pops history (does not push), then detail back returns to home', async ({ + page +}) => { + await mockApis(page); + await page.setViewportSize({ width: 390, height: 844 }); + + await page.goto('/'); + const initialLen = await page.evaluate(() => window.history.length); + + // Walk: home → detail → reader USING SPA NAVIGATION (link clicks, + // not window.location.href). The bug only manifests on SPA nav + // because `document.referrer` stays empty across pushState — so + // a hard navigation would silently mask the regression. + await page.locator('[data-testid="manga-list"] a').first().click(); + await page.waitForURL(/\/manga\/[^/]+$/); + + await page.locator('[data-testid="chapter-list"] a').first().click(); + await page.waitForURL(/\/chapter\//); + + const lenAtReader = await page.evaluate(() => window.history.length); + expect(lenAtReader).toBe(initialLen + 2); + + // Tap reader back → goes back to detail WITHOUT pushing a new entry. + await page.getByTestId('back-to-manga').click(); + await page.waitForURL(/\/manga\/[^/]+$/); + const lenAtDetail = await page.evaluate(() => window.history.length); + expect(lenAtDetail).toBe(lenAtReader); + + // Tap detail back → walks out to home. + await page.getByTestId('detail-back').click(); + await page.waitForURL((url) => url.pathname === '/'); +}); + +}); diff --git a/frontend/package.json b/frontend/package.json index 6814999..bafdb17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.60.1", + "version": "0.60.2", "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 27bad73..fbbca5a 100644 --- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte @@ -446,6 +446,28 @@ progressTimer = setTimeout(flushProgress, 1500); } + /** + * Reader back behavior — pop browser history instead of pushing a + * fresh entry for `/manga/{id}`. Previously this was a naked + * `` and every tap pushed, so browser-back ping-ponged + * between detail and reader instead of walking out to home. + * + * Just check `history.length > 1` — `document.referrer` does NOT + * update across SvelteKit SPA navigations, so referrer-based + * gating silently fell back to the default href every time. + * Middle-click / cmd-click / right-click are passed through so + * "open in new tab" still works. + */ + function onBackClick(e: MouseEvent) { + if (!browser) return; + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + if (window.history.length > 1) { + e.preventDefault(); + window.history.back(); + } + // else: let the href navigate (deep-link / fresh tab path) + } + // Single-mode: every page change moves the high-water mark. // Intentionally NOT depending on `mode` — toggling layout doesn't // change the read position, and re-running this effect on a mode @@ -552,7 +574,12 @@