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.
This commit is contained in:
Richard R 2026-06-08 19:05:13 -06:00
parent cb9ca37500
commit 4714227913
4 changed files with 65 additions and 26 deletions

View file

@ -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

View file

@ -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]);

View file

@ -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 {

View file

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