From 471422791339d0e8a18de711c2c5394cdb810def Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 8 Jun 2026 19:05:13 -0600 Subject: [PATCH] fix(epub): improve canonical window stability and error handling Enhance EPUB TTS canonical windowing by guarding against stale async completions during rapid page turns, preventing outdated segment/highlight state from overwriting the active page. Update SegmentsSidebar to robustly handle errors in canonicalization, ensuring sidebar state is cleared on failure and avoiding residual mappings from previous pages. Refine canonical window documentation to clarify non-overlapping segment partitioning per page. Add a language-aware cache test to verify correct canonical plan isolation across languages. --- src/app/(app)/epub/[id]/useEpubDocument.ts | 16 +++++++ src/components/reader/SegmentsSidebar.tsx | 43 +++++++++++-------- src/lib/client/epub/epub-canonical-window.ts | 19 ++++---- .../unit/epub-canonical-window.vitest.spec.ts | 13 ++++++ 4 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts index 457b987..5cc5427 100644 --- a/src/app/(app)/epub/[id]/useEpubDocument.ts +++ b/src/app/(app)/epub/[id]/useEpubDocument.ts @@ -251,6 +251,17 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const { start, end } = location; if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return ''; + // Guard against stale async completion: this function awaits range + + // canonical-plan resolution, during which rapid page turns can move the + // rendition on. If the live location no longer matches the page we + // captured, bail before writing state so we don't overwrite the active + // page's segments/highlights with a superseded page's. + const capturedStartCfi = start.cfi; + const isStale = (): boolean => { + const live = rendition.location?.start?.cfi; + return Boolean(live) && live !== capturedStartCfi; + }; + const rangeCfi = createRangeCfi(start.cfi, end.cfi); const range = await book.getRange(rangeCfi); @@ -258,6 +269,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { console.warn('Failed to get range from CFI:', rangeCfi); return ''; } + if (isStale()) return ''; const textContent = range.toString().trim(); setRenderedTextMaps(buildRenderedTextMaps( rendition, @@ -281,6 +293,10 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { viewportAnchorSourceKey: normalizeTtsLocationKey(start.cfi), }); + // The plan resolution above can load + segment a whole chapter; re-check + // that we're still on the captured page before committing TTS state. + if (isStale()) return ''; + if (canonicalWindow) { // Stage the next page's canonical segments (the slice immediately after // this window) so a hidden-tab page turn keeps speaking canonical diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 1728ccc..5f955d7 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -220,26 +220,33 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: } let cancelled = false; void (async () => { - const spine = resolveSpineFromCfi(book, currDocPage); - if (!spine) { + try { + const spine = resolveSpineFromCfi(book, currDocPage); + if (!spine) { + if (!cancelled) setSynthRowCanonical([]); + return; + } + const spineText = await getSpineItemPlainText(book, spine.href); + if (cancelled) return; + const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); + const next = canonicalizeEpubSegmentsAgainstSpineText({ + segmentTexts: sentences, + hintCharOffsets: offsets, + spineText, + spineHref: spine.href, + spineIndex: spine.index, + cfi: currDocPage, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + maxBlockLength: ttsSegmentMaxBlockLength, + language: resolvedLanguage, + }); + if (!cancelled) setSynthRowCanonical(next); + } catch (error) { + // Don't leave a previous page's canonical mapping in place if this + // resolution fails — clear it so we fall back to non-canonical rows. + console.warn('Failed to canonicalize EPUB sidebar segments:', error); if (!cancelled) setSynthRowCanonical([]); - return; } - const spineText = await getSpineItemPlainText(book, spine.href); - if (cancelled) return; - const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); - const next = canonicalizeEpubSegmentsAgainstSpineText({ - segmentTexts: sentences, - hintCharOffsets: offsets, - spineText, - spineHref: spine.href, - spineIndex: spine.index, - cfi: currDocPage, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - maxBlockLength: ttsSegmentMaxBlockLength, - language: resolvedLanguage, - }); - if (!cancelled) setSynthRowCanonical(next); })(); return () => { cancelled = true; }; }, [epubBookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength, resolvedLanguage]); diff --git a/src/lib/client/epub/epub-canonical-window.ts b/src/lib/client/epub/epub-canonical-window.ts index 4f5521d..f003c57 100644 --- a/src/lib/client/epub/epub-canonical-window.ts +++ b/src/lib/client/epub/epub-canonical-window.ts @@ -22,14 +22,17 @@ import type { TTSSegmentLocator } from '@/types/client'; * into that one canonical sequence — selected by character offset, identified * by stable ordinal + content key. * - * This is what lets a block that straddles a page break (starts on page A, - * ends on page B) be the *same* canonical segment (same key, same ordinal) on - * both pages, instead of two differently-grouped viewport blocks. Page A and - * page B windows deliberately **overlap** by the straddling segments; the - * playback layer (TTSContext) uses ordinal continuity to assign each straddler - * to exactly the page where it is first heard. See - * `tts-segment-plan.ts` for the planner and `spine-coordinates.ts` for the - * offset helpers this builds on. + * Viewport pages are **contiguous, non-overlapping** windows into that one + * canonical sequence: each segment is owned exclusively by the page where its + * start offset falls (see `selectCanonicalWindow`), exactly like PDF blocks. A + * block that straddles a page break (starts on page A, ends on page B) is the + * *same* canonical segment (same key, same ordinal) wherever it is referenced, + * but it belongs only to page A — page B's window begins at the next segment. + * This clean partition is what makes the sidebar list and manual skip + * deterministic and keeps playback from repeating a straddler on the page turn. + * The playback layer (TTSContext) additionally uses ordinal continuity to hand + * off to ordinal + 1 across the seam. See `tts-segment-plan.ts` for the planner + * and `spine-coordinates.ts` for the offset helpers this builds on. */ export interface SpinePlanParams { diff --git a/tests/unit/epub-canonical-window.vitest.spec.ts b/tests/unit/epub-canonical-window.vitest.spec.ts index c3748a3..54ac89d 100644 --- a/tests/unit/epub-canonical-window.vitest.spec.ts +++ b/tests/unit/epub-canonical-window.vitest.spec.ts @@ -213,6 +213,19 @@ describe('buildSpineCanonicalPlan (cached)', () => { expect(second).toBe(first); // cache hit → identical reference expect(first.map((s) => s.key)).toEqual(plan().map((s) => s.key)); }); + + test('cache is language-aware: a different language is a cache miss', async () => { + const book = makeFakeBook(SPINE_TEXT); // fresh Book → empty plan cache + const base = { + spineHref: SPINE_HREF, spineIndex: SPINE_INDEX, keyPrefix: KEY_PREFIX, maxBlockLength: MAX_BLOCK, + }; + const en = await buildSpineCanonicalPlan(book, { ...base, language: 'en' }); + const ja = await buildSpineCanonicalPlan(book, { ...base, language: 'ja' }); + // Distinct language → distinct cache key → distinct reference (no stale + // reuse across languages). Same language returns the cached reference. + expect(ja).not.toBe(en); + expect(await buildSpineCanonicalPlan(book, { ...base, language: 'en' })).toBe(en); + }); }); describe('buildEpubCanonicalWindowFromChunk', () => {