Files
readest/apps/readest-app/src/__tests__/utils/annotation-index.test.ts
T
loveheaven 7f57af8f90 perf(cfi): bucket booknotes per chapter and batch-collapse location matcher (#4561)
* perf(cfi): bucket booknotes per chapter and batch-collapse location matcher

When iterating a list of CFIs against the same currentLocation (Annotator
on every page turn, useSearchNav, useBooknotesNav), the standalone
isCfiInLocation collapses the location twice per CFI. With 1000+
booknotes -- which a heavy user reported -- that's 2000 CFI parses
per page turn. The foliate epubcfi.js chunk showed up as ~15% of
self time in Bottom-Up profiles of the release Android build.

Fix:
- createCfiLocationMatcher(location) collapses once and returns a
  matches(cfi) predicate that reuses the cached bounds. O(N) calls
  become 1 collapse + N compares.
- getCfiSpinePrefix(cfi) extracts the spine path via pure string ops
  (no CFI.parse round-trip) for use as a chapter bucket key.
- Annotator builds annotationIndex = { bySection, globals } via
  useMemo([config.booknotes]) once when booknotes change, not per
  page turn. The progress-driven effect then only scans the current
  chapter's bucket -- ~50 CFIs in a typical book instead of all 1000.
  globals are pre-filtered too.
- useSearchNav / useBooknotesNav switch to the batched matcher for
  the same reason.

Includes parity tests covering empty/malformed inputs, equality
shortcut, prefix shortcut, in-range, and out-of-range cases.

* fix(annotator): keep note-only annotations in the per-chapter bucket

The booknote bucketing gated entries on `item.style`, which dropped
note-only annotations (a `note` with no highlight style/color, created
via the Notebook flow) from the per-relocate re-apply path. Their note
bubble was no longer redrawn on relocate or when booknotes changed while
a section stayed rendered.

Restore the original two-list semantics: bucket on style OR note, then
classify per location (annotations need a style, notes need a note).

Extract the logic into a dedicated, unit-tested `annotationIndex` module
(buildAnnotationIndex + selectLocationAnnotations) instead of inlining it
in Annotator, matching the reader/utils domain-named convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:06:13 +02:00

95 lines
3.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
buildAnnotationIndex,
selectLocationAnnotations,
} from '@/app/reader/utils/annotationIndex';
import { uniqueId } from '@/utils/misc';
import { BookNote } from '@/types/book';
describe('buildAnnotationIndex / selectLocationAnnotations', () => {
const note = (overrides: Partial<BookNote>): BookNote => ({
id: uniqueId(),
type: 'annotation',
cfi: 'epubcfi(/6/6!/4/2/2,/1:0,/1:10)',
note: '',
createdAt: 1,
updatedAt: 1,
...overrides,
});
// A relocate always reports a single-section location (foliate joins the
// section base CFI with the in-document range), so the spine prefix keys
// the bucket. `epubcfi(/6/6)` covers everything anchored in section /6/6.
const location = 'epubcfi(/6/6)';
it('buckets a styled annotation under its spine prefix', () => {
const highlight = note({ style: 'highlight', color: 'yellow' });
const { bySection, globals } = buildAnnotationIndex([highlight]);
expect(bySection.get('/6/6')).toEqual([highlight]);
expect(globals).toEqual([]);
});
it('buckets a note-only annotation (no style) so its bubble survives relocate', () => {
// Regression: notes added via the Notebook flow carry a `note` but no
// `style`/`color`. They must still be tracked so the note bubble is
// re-applied on page turns, not just at section first-render.
const noteOnly = note({ note: 'a margin note' });
const { bySection } = buildAnnotationIndex([noteOnly]);
expect(bySection.get('/6/6')).toEqual([noteOnly]);
});
it('splits styled global highlights into their own array', () => {
const global = note({ style: 'highlight', color: 'yellow', text: 'x', global: true });
const { bySection, globals } = buildAnnotationIndex([global]);
expect(globals).toEqual([global]);
expect(bySection.size).toBe(0);
});
it('skips deleted, non-annotation, and empty (no style, no note) entries', () => {
const deleted = note({ style: 'highlight', deletedAt: 9 });
const bookmark = note({ type: 'bookmark' });
const empty = note({ note: ' ' });
const { bySection, globals } = buildAnnotationIndex([deleted, bookmark, empty]);
expect(bySection.size).toBe(0);
expect(globals).toEqual([]);
});
it('classifies a styled annotation into annotations only', () => {
const highlight = note({ style: 'highlight', color: 'yellow' });
const index = buildAnnotationIndex([highlight]);
const { annotations, notes } = selectLocationAnnotations(index, location);
expect(annotations).toEqual([highlight]);
expect(notes).toEqual([]);
});
it('classifies a note-only annotation into notes only', () => {
const noteOnly = note({ note: 'hello' });
const index = buildAnnotationIndex([noteOnly]);
const { annotations, notes } = selectLocationAnnotations(index, location);
expect(annotations).toEqual([]);
expect(notes).toEqual([noteOnly]);
});
it('classifies a styled+noted annotation into both lists', () => {
const both = note({ style: 'highlight', color: 'yellow', note: 'hi' });
const index = buildAnnotationIndex([both]);
const { annotations, notes } = selectLocationAnnotations(index, location);
expect(annotations).toEqual([both]);
expect(notes).toEqual([both]);
});
it('excludes annotations anchored in a different section', () => {
const other = note({ cfi: 'epubcfi(/6/8!/4/2/2,/1:0,/1:10)', style: 'highlight' });
const index = buildAnnotationIndex([other]);
const { annotations, notes } = selectLocationAnnotations(index, location);
expect(annotations).toEqual([]);
expect(notes).toEqual([]);
});
it('returns empty lists for a null/empty location', () => {
const index = buildAnnotationIndex([note({ style: 'highlight' })]);
expect(selectLocationAnnotations(index, null)).toEqual({ annotations: [], notes: [] });
expect(selectLocationAnnotations(index, '')).toEqual({ annotations: [], notes: [] });
});
});