diff --git a/client/public/assets/foliate/paginator.js b/client/public/assets/foliate/paginator.js index 427e4b0fc..747bb7905 100644 --- a/client/public/assets/foliate/paginator.js +++ b/client/public/assets/foliate/paginator.js @@ -1010,11 +1010,14 @@ export class Paginator extends HTMLElement { } await this.#display( Promise.resolve(this.sections[index].load()) - .then((src) => ({ index, src, anchor, onLoad, select })) + .then((src) => { + if (!src) throw new Error(`Failed to load section ${index}`) + return { index, src, anchor, onLoad, select } + }) .catch((e) => { console.warn(e) console.warn(new Error(`Failed to load section ${index}`)) - return {} + throw e }), ) } diff --git a/client/public/assets/foliate/view.js b/client/public/assets/foliate/view.js index cc250f9fc..a86346d43 100644 --- a/client/public/assets/foliate/view.js +++ b/client/public/assets/foliate/view.js @@ -133,7 +133,8 @@ export const makeBook = async (file) => { book = await new EPUB(loader).init() } } else if (await isPDF(file)) { - const { makePDF } = await import('./pdf.js') + const pdfModule = './pdf.js' + const { makePDF } = await import(pdfModule) book = await makePDF(file) } else { const { isMOBI, MOBI } = await import('./mobi.js') @@ -536,11 +537,16 @@ export class View extends HTMLElement { async goTo(target) { const resolved = this.resolveNavigation(target) - const hasContent = () => (this.renderer.getContents?.()?.length ?? 0) > 0 + const hasContentFor = (nextResolved) => { + const contents = this.renderer.getContents?.() ?? [] + if (!contents.length) return false + if (typeof nextResolved?.index !== 'number') return true + return contents.some((content) => content?.index === nextResolved.index) + } const tryGoTo = async (nextResolved) => { try { await this.renderer.goTo(nextResolved) - return hasContent() + return hasContentFor(nextResolved) } catch (e) { console.warn(e) return false diff --git a/client/src/features/reader/epub/__tests__/foliate-navigation.spec.ts b/client/src/features/reader/epub/__tests__/foliate-navigation.spec.ts new file mode 100644 index 000000000..5d835a29b --- /dev/null +++ b/client/src/features/reader/epub/__tests__/foliate-navigation.spec.ts @@ -0,0 +1,102 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' + +function installBrowserGlobals() { + Object.assign(globalThis, { + EventTarget: window.EventTarget, + CustomEvent: window.CustomEvent, + Event: window.Event, + Node: window.Node, + NodeFilter: window.NodeFilter, + Range: window.Range, + DOMRect: window.DOMRect, + CSS: window.CSS, + }) + + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } + + globalThis.requestAnimationFrame ??= (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0) + globalThis.matchMedia ??= () => + ({ + matches: false, + addEventListener: vi.fn<() => void>(), + removeEventListener: vi.fn<() => void>(), + }) as unknown as MediaQueryList +} + +describe('Foliate navigation', () => { + let View: new () => { + book: { sections: unknown[]; resolveHref: (href: string) => { index: number } } + renderer: { + goTo: (resolved: { index: number }) => Promise + getContents: () => { index: number; text?: string }[] + } + goTo: (target: string) => Promise<{ index: number } | undefined> + } + let Paginator: new () => { + sections: { load: () => Promise }[] + goTo: (target: { index: number }) => Promise + } + + beforeAll(async () => { + installBrowserGlobals() + + const viewModulePath = '../../../../../public/assets/foliate/view.js' + const paginatorModulePath = '../../../../../public/assets/foliate/paginator.js' + ;({ View } = (await import(viewModulePath)) as { View: typeof View }) + ;({ Paginator } = (await import(paginatorModulePath)) as { Paginator: typeof Paginator }) + }) + + it('does not treat stale rendered contents as successful navigation', async () => { + const view = new View() + let renderedIndex = 0 + const goToCalls: { index: number }[] = [] + + view.book = { + sections: [{}, {}, {}], + resolveHref: (href: string) => ({ index: Number(href.replace('chapter-', '')) }), + } + view.renderer = { + async goTo(resolved: { index: number }) { + goToCalls.push(resolved) + if (resolved.index === 2) renderedIndex = 2 + }, + getContents: () => [{ index: renderedIndex, text: `chapter ${renderedIndex}` }], + } + + await expect(view.goTo('chapter-1')).resolves.toEqual({ index: 2 }) + + expect(goToCalls).toEqual([{ index: 1 }, { index: 2 }]) + expect(view.renderer.getContents()).toEqual([{ index: 2, text: 'chapter 2' }]) + }) + + it('keeps the requested target when the rendered contents match', async () => { + const view = new View() + let renderedIndex = 0 + + view.book = { + sections: [{}, {}, {}], + resolveHref: (href: string) => ({ index: Number(href.replace('chapter-', '')) }), + } + view.renderer = { + async goTo(resolved: { index: number }) { + renderedIndex = resolved.index + }, + getContents: () => [{ index: renderedIndex }], + } + + await expect(view.goTo('chapter-1')).resolves.toEqual({ index: 1 }) + + expect(view.renderer.getContents()).toEqual([{ index: 1 }]) + }) + + it('rejects paginator navigation when a section load does not produce a source', async () => { + const paginator = new Paginator() + paginator.sections = [{ load: vi.fn<() => Promise>().mockResolvedValue(null) }] + + await expect(paginator.goTo({ index: 0 })).rejects.toThrow('Failed to load section 0') + }) +})