diff --git a/apps/readest-app/src/__tests__/document/paginator-expand.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-expand.browser.test.ts new file mode 100644 index 00000000..de3f54b8 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-expand.browser.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; + +// repro-3683: cover page with display:table + position:absolute + width:100% on body +const REPRO_3683_URL = new URL('../fixtures/data/repro-3683.epub', import.meta.url).href; +// repro-3583: vertical writing mode with height/width:100% divs and SVG image +const REPRO_3583_URL = new URL('../fixtures/data/repro-3583.epub', import.meta.url).href; +const ALICE_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; + +const loadEPUB = async (url: string) => { + const resp = await fetch(url); + const buffer = await resp.arrayBuffer(); + const name = url.split('/').pop() ?? 'book.epub'; + const file = new File([buffer], name, { type: 'application/epub+zip' }); + const loader = new DocumentLoader(file); + const { book } = await loader.open(); + return book; +}; + +const waitForStabilized = (el: HTMLElement, timeout = 5000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const waitForFillComplete = async (el: Renderer, timeout = 5000) => { + const start = Date.now(); + let lastCount = -1; + let stableFor = 0; + while (Date.now() - start < timeout) { + const count = el.getContents().length; + if (count === lastCount) { + stableFor += 50; + if (stableFor >= 300) return; + } else { + stableFor = 0; + lastCount = count; + } + await new Promise((r) => setTimeout(r, 50)); + } +}; + +let repro3683: BookDoc; +let repro3583: BookDoc; +let aliceBook: BookDoc; + +/** Check all iframes and SVGs in shadow DOM stay within a size bound. */ +const assertBoundedWidths = (paginator: Renderer, maxWidth: number) => { + const shadow = paginator.shadowRoot; + expect(shadow).toBeDefined(); + for (const iframe of shadow!.querySelectorAll('iframe')) { + const w = parseFloat(iframe.style.width) || iframe.getBoundingClientRect().width; + expect(w, `iframe width ${w}px exceeds ${maxWidth}px`).toBeLessThan(maxWidth); + } + for (const svg of shadow!.querySelectorAll('svg')) { + const w = parseFloat(svg.style.width) || svg.getBoundingClientRect().width; + expect(w, `svg width ${w}px exceeds ${maxWidth}px`).toBeLessThan(maxWidth); + } +}; + +const assertBoundedHeights = (paginator: Renderer, maxHeight: number) => { + const shadow = paginator.shadowRoot; + expect(shadow).toBeDefined(); + for (const iframe of shadow!.querySelectorAll('iframe')) { + const h = parseFloat(iframe.style.height) || iframe.getBoundingClientRect().height; + expect(h, `iframe height ${h}px exceeds ${maxHeight}px`).toBeLessThan(maxHeight); + } + for (const svg of shadow!.querySelectorAll('svg')) { + const h = parseFloat(svg.style.height) || svg.getBoundingClientRect().height; + expect(h, `svg height ${h}px exceeds ${maxHeight}px`).toBeLessThan(maxHeight); + } +}; + +describe('Paginator expand loop regression', () => { + let paginator: Renderer; + + const suppressHandler = (e: ErrorEvent) => { + if (e.message?.includes('getComputedStyle')) e.preventDefault(); + }; + + beforeAll(async () => { + window.addEventListener('error', suppressHandler); + [repro3683, repro3583, aliceBook] = await Promise.all([ + loadEPUB(REPRO_3683_URL), + loadEPUB(REPRO_3583_URL), + loadEPUB(ALICE_URL), + ]); + await import('foliate-js/paginator.js'); + }, 30000); + + const createPaginator = () => { + const el = document.createElement('foliate-paginator') as Renderer; + Object.assign(el.style, { + width: '800px', + height: '600px', + position: 'absolute', + left: '0', + top: '0', + }); + document.body.appendChild(el); + return el; + }; + + afterEach(async () => { + if (paginator) { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + window.removeEventListener('error', suppressHandler); + }); + + describe('Table-layout cover page (repro EPUB)', () => { + it('should stabilize without freezing on the problematic cover section', async () => { + paginator = createPaginator(); + paginator.open(repro3683); + + // Section 0 is the cover with display:table + position:absolute + width:100% + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: 0 }); + await stabilized; + + // If we reach here, the paginator didn't loop infinitely + expect(paginator.primaryIndex).toBe(0); + expect(paginator.getContents().length).toBeGreaterThanOrEqual(1); + }); + + it('should render the cover content correctly', async () => { + paginator = createPaginator(); + paginator.open(repro3683); + + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: 0 }); + await stabilized; + + const contents = paginator.getContents(); + const cover = contents.find((c) => c.index === 0); + expect(cover).toBeDefined(); + expect(cover!.doc.body.textContent).toContain('COVER PAGE'); + }); + + it('should navigate from cover to chapter without freezing', async () => { + paginator = createPaginator(); + paginator.open(repro3683); + + // Go to cover first + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: 0 }); + await stabilized; + await waitForFillComplete(paginator); + + // Navigate to chapter 1 — it may already be loaded as an adjacent + // section during fill, so goTo reuses the view without emitting + // 'stabilized'. Just await goTo and verify the result. + await paginator.goTo({ index: 1 }); + // Allow layout to settle + await new Promise((r) => setTimeout(r, 200)); + + expect(paginator.primaryIndex).toBe(1); + const contents = paginator.getContents(); + const ch1 = contents.find((c) => c.index === 1); + expect(ch1).toBeDefined(); + expect(ch1!.doc.body.textContent).toContain('Hello, world.'); + }); + + it('should stabilize the cover in scrolled mode', async () => { + paginator = createPaginator(); + paginator.open(repro3683); + paginator.setAttribute('flow', 'scrolled'); + + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: 0 }); + await stabilized; + + expect(paginator.scrolled).toBe(true); + expect(paginator.primaryIndex).toBe(0); + }); + + it('should keep element sizes bounded in paginated mode', async () => { + // Without the fix, expand() diverges: body (position:absolute, + // width:100%) mirrors the iframe width, each expand computes an + // even larger expandedSize. Iframes/SVGs grow to millions of px. + paginator = createPaginator(); + paginator.open(repro3683); + + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: 0 }); + await stabilized; + await waitForFillComplete(paginator); + + // Let ResizeObserver cycles run — divergence would explode here + await new Promise((r) => setTimeout(r, 500)); + assertBoundedWidths(paginator, 800 * 20); + assertBoundedHeights(paginator, 600 * 20); + }); + }); + + describe('Vertical writing mode with 100% divs in scrolled mode (repro-3583)', () => { + it('should stabilize without freezing', async () => { + paginator = createPaginator(); + paginator.open(repro3583); + paginator.setAttribute('flow', 'scrolled'); + + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: 0 }); + await stabilized; + + expect(paginator.scrolled).toBe(true); + expect(paginator.primaryIndex).toBe(0); + }); + + it('should keep element sizes bounded', async () => { + paginator = createPaginator(); + paginator.open(repro3583); + paginator.setAttribute('flow', 'scrolled'); + + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: 0 }); + await stabilized; + await waitForFillComplete(paginator); + await new Promise((r) => setTimeout(r, 500)); + + assertBoundedWidths(paginator, 800 * 20); + assertBoundedHeights(paginator, 600 * 20); + }); + }); + + describe('Normal EPUB regression check', () => { + it('should stabilize normally on a regular section', async () => { + paginator = createPaginator(); + paginator.open(aliceBook); + + const idx = aliceBook.sections!.findIndex((s) => s.linear !== 'no'); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: idx }); + await stabilized; + + expect(paginator.primaryIndex).toBe(idx); + expect(paginator.getContents().length).toBeGreaterThanOrEqual(1); + }); + + it('should allow legitimate re-expansion after render', async () => { + paginator = createPaginator(); + paginator.open(aliceBook); + + const idx = aliceBook.sections!.findIndex((s) => s.linear !== 'no'); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: idx }); + await stabilized; + await waitForFillComplete(paginator); + + // Re-render should work (expand history resets on layout changes) + const stabilized2 = waitForStabilized(paginator); + paginator.render?.(); + await stabilized2; + + expect(paginator.primaryIndex).toBe(idx); + }); + + it('should handle flow mode switch with convergence detection', async () => { + paginator = createPaginator(); + paginator.open(aliceBook); + + const idx = aliceBook.sections!.findIndex((s) => s.linear !== 'no'); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: idx }); + await stabilized; + await waitForFillComplete(paginator); + + // Switch to scrolled — triggers full re-layout + const stabilized2 = waitForStabilized(paginator); + paginator.setAttribute('flow', 'scrolled'); + await stabilized2; + + expect(paginator.scrolled).toBe(true); + expect(paginator.primaryIndex).toBe(idx); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/paginator-multiview.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-multiview.browser.test.ts index 37a7685e..7e869867 100644 --- a/apps/readest-app/src/__tests__/document/paginator-multiview.browser.test.ts +++ b/apps/readest-app/src/__tests__/document/paginator-multiview.browser.test.ts @@ -1,34 +1,11 @@ import { describe, it, expect, beforeAll, afterEach } from 'vitest'; import { DocumentLoader } from '@/libs/document'; import type { BookDoc } from '@/libs/document'; -import type { FoliateView } from '@/types/view'; +import type { FoliateView, Renderer } from '@/types/view'; // Vite serves fixture files; fetch the EPUB at runtime in the browser. const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; -interface PaginatorElement extends HTMLElement { - open: (book: BookDoc) => void; - goTo: (target: { - index: number; - anchor?: number | (() => number); - select?: boolean; - }) => Promise; - prev: () => Promise; - next: () => Promise; - destroy: () => void; - getContents: () => Array<{ index: number; doc: Document; overlayer: unknown }>; - setStyles: (styles: string | [string, string]) => void; - render: () => void; - primaryIndex: number; - pages: number; - page: number; - size: number; - viewSize: number; - scrolled: boolean; - columnCount: number; - sections: Array<{ linear?: string; load: () => Promise }>; -} - let book: BookDoc; const loadEPUB = async () => { @@ -59,7 +36,7 @@ const waitForStabilized = (el: HTMLElement, timeout = 10000) => }); /** Wait until `getContents().length >= n` or timeout. */ -const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => { +const waitForViews = async (el: Renderer, n: number, timeout = 10000) => { const start = Date.now(); while (Date.now() - start < timeout) { if (el.getContents().length >= n) return; @@ -68,7 +45,7 @@ const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => }; /** Wait for fill to complete by polling until getContents count stabilizes. */ -const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => { +const waitForFillComplete = async (el: Renderer, timeout = 10000) => { const start = Date.now(); let lastCount = -1; let stableFor = 0; @@ -86,7 +63,7 @@ const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => { }; describe('Paginator multi-view architecture (browser)', () => { - let paginator: PaginatorElement; + let paginator: Renderer; beforeAll(async () => { book = await loadEPUB(); @@ -94,7 +71,7 @@ describe('Paginator multi-view architecture (browser)', () => { }, 30000); const createPaginator = () => { - const el = document.createElement('foliate-paginator') as PaginatorElement; + const el = document.createElement('foliate-paginator') as Renderer; // The paginator needs non-zero dimensions for layout calculations Object.assign(el.style, { width: '800px', @@ -210,7 +187,7 @@ describe('Paginator multi-view architecture (browser)', () => { await waitForViews(paginator, 2); const contents = paginator.getContents(); const indices = contents.map((c) => c.index); - expect(indices).toEqual([...indices].sort((a, b) => a - b)); + expect(indices).toEqual([...indices].sort((a, b) => (a ?? 0) - (b ?? 0))); }); it('should include the primary section in getContents', async () => { diff --git a/apps/readest-app/src/__tests__/document/paginator-stabilization.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-stabilization.browser.test.ts index 99190ba0..5bfe9baa 100644 --- a/apps/readest-app/src/__tests__/document/paginator-stabilization.browser.test.ts +++ b/apps/readest-app/src/__tests__/document/paginator-stabilization.browser.test.ts @@ -1,34 +1,11 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { DocumentLoader } from '@/libs/document'; import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; // Vite serves fixture files; fetch the EPUB at runtime in the browser. const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; -interface PaginatorElement extends HTMLElement { - open: (book: BookDoc) => void; - goTo: (target: { - index: number; - anchor?: number | (() => number); - select?: boolean; - }) => Promise; - prev: () => Promise; - next: () => Promise; - destroy: () => void; - getContents: () => Array<{ index: number; doc: Document; overlayer: unknown }>; - setStyles: (styles: string | [string, string]) => void; - render: () => void; - scrollToAnchor: (anchor: number | Range, select?: boolean, smooth?: boolean) => Promise; - primaryIndex: number; - pages: number; - page: number; - size: number; - viewSize: number; - scrolled: boolean; - columnCount: number; - sections: Array<{ linear?: string; load: () => Promise }>; -} - let book: BookDoc; const loadEPUB = async () => { @@ -59,7 +36,7 @@ const waitForStabilized = (el: HTMLElement, timeout = 10000) => }); /** Wait until `getContents().length >= n` or timeout. */ -const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => { +const waitForViews = async (el: Renderer, n: number, timeout = 10000) => { const start = Date.now(); while (Date.now() - start < timeout) { if (el.getContents().length >= n) return; @@ -68,7 +45,7 @@ const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => }; /** Wait for fill to complete by polling until getContents count stabilizes. */ -const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => { +const waitForFillComplete = async (el: Renderer, timeout = 10000) => { const start = Date.now(); let lastCount = -1; let stableFor = 0; @@ -86,7 +63,7 @@ const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => { }; describe('Paginator stabilization (browser)', () => { - let paginator: PaginatorElement; + let paginator: Renderer; // Suppress unhandled errors from paginator's #replaceBackground firing // after views are destroyed (queued iframe loads from rapid navigation). @@ -106,7 +83,7 @@ describe('Paginator stabilization (browser)', () => { }); const createPaginator = () => { - const el = document.createElement('foliate-paginator') as PaginatorElement; + const el = document.createElement('foliate-paginator') as Renderer; Object.assign(el.style, { width: '800px', height: '600px', @@ -255,7 +232,7 @@ describe('Paginator stabilization (browser)', () => { await waitForFillComplete(paginator); const stabilizedFromRender = waitForStabilized(paginator); - paginator.render(); + paginator.render?.(); // render() dispatches stabilized in a RAF await stabilizedFromRender; // If we get here, stabilized was emitted @@ -269,7 +246,7 @@ describe('Paginator stabilization (browser)', () => { const indexBefore = paginator.primaryIndex; const stabilized = waitForStabilized(paginator); - paginator.render(); + paginator.render?.(); await stabilized; expect(paginator.primaryIndex).toBe(indexBefore); }); diff --git a/apps/readest-app/src/__tests__/fixtures/data/repro-3583.epub b/apps/readest-app/src/__tests__/fixtures/data/repro-3583.epub new file mode 100644 index 00000000..12a17e71 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/repro-3583.epub differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/repro-3683.epub b/apps/readest-app/src/__tests__/fixtures/data/repro-3683.epub new file mode 100644 index 00000000..82bf9b42 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/repro-3683.epub differ diff --git a/apps/readest-app/src/types/view.ts b/apps/readest-app/src/types/view.ts index 0e65929a..91a3c5c5 100644 --- a/apps/readest-app/src/types/view.ts +++ b/apps/readest-app/src/types/view.ts @@ -8,6 +8,62 @@ export const NOTE_PREFIX = 'foliate-note:'; type RangeAnchor = (doc: Document) => Range; +export interface Renderer extends HTMLElement { + scrolled?: boolean; + scrollLocked: boolean; + size: number; // current page height + viewSize: number; // whole document view height + start: number; + end: number; + page: number; // section page index (0-based) + pages: number; // section page count + atStart: boolean; + atEnd: boolean; + containerPosition: number; + sideProp: 'width' | 'height'; + pageColors?: { + background: string; + foreground: string; + }; + columnCount?: number; + open: (book: BookDoc) => Promise; + setAttribute: (name: string, value: string | number) => void; + removeAttribute: (name: string) => void; + next: () => Promise; + prev: () => Promise; + nextSection?: () => Promise; + prevSection?: () => Promise; + render?: () => Promise; + goTo: (params: { index: number; anchor?: number | RangeAnchor }) => void; + setStyles?: (css: string) => void; + primaryIndex: number; + getContents: () => { doc: Document; index?: number; overlayer?: unknown }[]; + scrollToAnchor?: (anchor: number | Range, reason?: string, smooth?: boolean) => void; + addEventListener: ( + type: string, + listener: EventListener, + option?: AddEventListenerOptions, + ) => void; + removeEventListener: (type: string, listener: EventListener) => void; + showLoupe?: ( + x: number, + y: number, + options: { + isVertical: boolean; + color: string; + gap: number; + margin: number; + radius: number; + magnification: number; + }, + ) => void; + hideLoupe?: () => void; + destroyLoupe?: () => void; + pinchZoom?: (ratio: number) => void; + pinchEnd?: () => void; + destroy: () => void; +} + export interface FoliateView extends HTMLElement { open: (book: BookDoc) => Promise; close: () => void; @@ -59,57 +115,7 @@ export interface FoliateView extends HTMLElement { forward: () => void; clear: () => void; }; - renderer: { - scrolled?: boolean; - scrollLocked: boolean; - size: number; // current page height - viewSize: number; // whole document view height - start: number; - end: number; - page: number; // section page index (0-based) - pages: number; // section page count - atStart: boolean; - atEnd: boolean; - containerPosition: number; - sideProp: 'width' | 'height'; - pageColors?: { - background: string; - foreground: string; - }; - setAttribute: (name: string, value: string | number) => void; - removeAttribute: (name: string) => void; - next: () => Promise; - prev: () => Promise; - nextSection?: () => Promise; - prevSection?: () => Promise; - goTo?: (params: { index: number; anchor?: number | RangeAnchor }) => void; - setStyles?: (css: string) => void; - primaryIndex: number; - getContents: () => { doc: Document; index?: number; overlayer?: unknown }[]; - scrollToAnchor?: (anchor: number | Range, reason?: string, smooth?: boolean) => void; - addEventListener: ( - type: string, - listener: EventListener, - option?: AddEventListenerOptions, - ) => void; - removeEventListener: (type: string, listener: EventListener) => void; - showLoupe?: ( - x: number, - y: number, - options: { - isVertical: boolean; - color: string; - gap: number; - margin: number; - radius: number; - magnification: number; - }, - ) => void; - hideLoupe?: () => void; - destroyLoupe?: () => void; - pinchZoom?: (ratio: number) => void; - pinchEnd?: () => void; - }; + renderer: Renderer; } export const wrappedFoliateView = (originalView: FoliateView): FoliateView => { diff --git a/packages/foliate-js b/packages/foliate-js index 7de55d4e..2300db0d 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 7de55d4ebfff3264abc638586212e380e934cae1 +Subproject commit 2300db0dee97dd4879e1c50e16464a56c9161669