fix(reader): load correct chapter in web reader

Navigation sometimes treated stale rendered contents as successful navigation or failed silently when a section did not load properly. Ensure the target index matches the rendered content and throw on section load failure to prevent incorrect chapter display.

Closes #203
This commit is contained in:
neon
2026-06-02 21:29:29 +00:00
parent 45f252a7e6
commit 0208ba0b9e
3 changed files with 116 additions and 5 deletions
+5 -2
View File
@@ -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
}),
)
}
+9 -3
View File
@@ -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
@@ -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<void>
getContents: () => { index: number; text?: string }[]
}
goTo: (target: string) => Promise<{ index: number } | undefined>
}
let Paginator: new () => {
sections: { load: () => Promise<string | null> }[]
goTo: (target: { index: number }) => Promise<void>
}
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<string | null>>().mockResolvedValue(null) }]
await expect(paginator.goTo({ index: 0 })).rejects.toThrow('Failed to load section 0')
})
})