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', () => {