From 92b3c9db481e79d9f3c4d8dfc6935050d464427f Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 30 May 2026 17:17:58 +0800 Subject: [PATCH] fix(kosync): resolve progress CFI via its own spine section (#4364) generateKOProgress built its XCFI converter from the paginator's primaryIndex and the rendered primary document, then converted progress.location. Because #primaryIndex can lag behind the viewport during scrolling, the CFI's spine section could differ from the converter's, tripping XCFI's guard ("CFI spine index N does not match converter spine index M") and silently dropping the progress push. Route through getXPointerFromCFI, which keys off the CFI's own spine index and loads the correct section's document from the book when the rendered index doesn't match. Fall back to the cached config.xpointer on failure. Co-authored-by: Claude Opus 4.8 (1M context) --- .../services/xcfi.kosync-primary-lag.test.ts | 48 +++++++++++++++++++ .../src/app/reader/hooks/useKOSync.ts | 29 ++++++----- 2 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/xcfi.kosync-primary-lag.test.ts 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);