diff --git a/apps/readest-app/src/__tests__/services/xcfi.kosync-primary-lag.test.ts b/apps/readest-app/src/__tests__/services/xcfi.kosync-primary-lag.test.ts new file mode 100644 index 00000000..d6b6bbf8 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/xcfi.kosync-primary-lag.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { XCFI, getXPointerFromCFI } from '@/utils/xcfi'; +import type { BookDoc } from '@/libs/document'; + +const createDocument = (htmlString: string): Document => { + const parser = new DOMParser(); + return parser.parseFromString(htmlString, 'text/html'); +}; + +// BookDoc stub that respects sparse spine indices so sections[13] can be +// populated without filling 0..12. +const makeBookDoc = (docsByIndex: Record): BookDoc => { + const sections: Array<{ createDocument: () => Promise }> = []; + for (const [index, doc] of Object.entries(docsByIndex)) { + sections[Number(index)] = { createDocument: async () => doc }; + } + return { sections } as unknown as BookDoc; +}; + +// Regression for the KOSync progress-push crash: +// "CFI spine index 13 does not match converter spine index 11". +// +// The paginator's #primaryIndex can lag behind the viewport during scrolling, +// so progress.location can be a CFI in a different spine section than the +// currently-rendered primary view. generateKOProgress used to build its XCFI +// converter from the primary view's document/index and convert the CFI +// directly, which throws whenever the two diverge. +describe('XCFI KOSync primary-index lag', () => { + // Spine step 28 -> 0-based index 13 -> DocFragment[14]. + // Path after '!': /4 = body, /2 = first element child (p), :5 = text offset. + const cfi = 'epubcfi(/6/28!/4/2:5)'; + const primaryDoc = createDocument('

Stale primary section.

'); + const cfiDoc = createDocument('

First paragraph here.

'); + + it('reproduces the bug: converting against the lagging primary document throws', () => { + const converter = new XCFI(primaryDoc, 11); + expect(() => converter.cfiToXPointer(cfi)).toThrow(/spine index 13 does not match/); + }); + + it('resolves the CFI via its own spine section instead of the primary view', async () => { + const bookDoc = makeBookDoc({ 13: cfiDoc }); + + // Mirrors the fixed call: pass the lagging primary doc/index, but the + // helper loads the CFI's actual section (13) from the book. + const xpointer = await getXPointerFromCFI(cfi, primaryDoc, 11, bookDoc); + expect(xpointer.xpointer).toBe('/body/DocFragment[14]/body/p/text().5'); + }); +}); diff --git a/apps/readest-app/src/app/reader/hooks/useKOSync.ts b/apps/readest-app/src/app/reader/hooks/useKOSync.ts index 34fe1d2e..f07cb3df 100644 --- a/apps/readest-app/src/app/reader/hooks/useKOSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useKOSync.ts @@ -9,7 +9,7 @@ import { Book, BookProgress, FIXED_LAYOUT_FORMATS } from '@/types/book'; import { BookDoc } from '@/libs/document'; import { debounce } from '@/utils/debounce'; import { eventDispatcher } from '@/utils/event'; -import { getCFIFromXPointer, XCFI } from '@/utils/xcfi'; +import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi'; import { getLocalProgressPreview, getProgressPercentage } from './kosyncPreview'; import { getRemoteFraction, isXPointerProgress } from './kosyncProgress'; import { useWindowActiveChanged } from './useWindowActiveChanged'; @@ -53,7 +53,7 @@ export const useKOSync = (bookKey: string) => { setKOSyncClient(client); }, [settings]); - const generateKOProgress = useCallback(() => { + const generateKOProgress = useCallback(async () => { const progress = getProgress(bookKey); const bookData = getBookData(bookKey); if (!progress || !bookData) return null; @@ -74,17 +74,22 @@ export const useKOSync = (bookKey: string) => { const koContents = view.renderer.getContents(); const koPrimaryIdx = view.renderer.primaryIndex; const content = koContents.find((x) => x.index === koPrimaryIdx) ?? koContents[0]; - if (content) { - const { doc, index: spineIndex } = content; - const converter = new XCFI(doc, spineIndex || 0); - const xpointerResult = converter.cfiToXPointer(cfi); - koProgress = xpointerResult.xpointer; - setConfig(bookKey, { xpointer: koProgress }); - } else if (config?.xpointer) { - koProgress = config.xpointer; - } + // progress.location may be a CFI in a different spine section than the + // currently-rendered primary view (#primaryIndex can lag behind the + // viewport while scrolling). Resolve against the CFI's own section + // rather than forcing the primary view's document, which throws on a + // spine-index mismatch. + const xpointerResult = await getXPointerFromCFI( + cfi, + content?.doc, + content?.index, + bookData.bookDoc ?? undefined, + ); + koProgress = xpointerResult.xpointer; + setConfig(bookKey, { xpointer: koProgress }); } catch (error) { console.error('Failed to convert CFI to XPointer', error); + if (config?.xpointer) koProgress = config.xpointer; } const page = progress.pageinfo?.current ?? 0; @@ -210,7 +215,7 @@ export const useKOSync = (bookKey: string) => { if (['receive', 'disable'].includes(settings.kosync.strategy)) return; const currentBook = getBookData(bookKey)?.book; - const progress = generateKOProgress(); + const progress = await generateKOProgress(); if (!currentBook || !progress || !progress.koProgress) return; await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);