diff --git a/apps/readest-app/src/__tests__/utils/annotation-index.test.ts b/apps/readest-app/src/__tests__/utils/annotation-index.test.ts index b51f7012..7d357a0e 100644 --- a/apps/readest-app/src/__tests__/utils/annotation-index.test.ts +++ b/apps/readest-app/src/__tests__/utils/annotation-index.test.ts @@ -54,6 +54,22 @@ describe('buildAnnotationIndex / selectLocationAnnotations', () => { expect(globals).toEqual([]); }); + it('skips an annotation deleted in place after the index was built (stale index)', () => { + // #4773: the re-apply effect holds a memoized index. A quick delete stamps + // `deletedAt` on the SAME object that is still sitting in the bucket (the + // memo has not recomputed yet). If the read trusts the build-time filter, + // the just-deleted highlight gets re-drawn and its overlay is orphaned — + // visible on the page until the book is reopened. The read must re-check. + const highlight = note({ style: 'highlight', color: 'yellow', note: 'hi' }); + const index = buildAnnotationIndex([highlight]); + // The user deletes the highlight: `deletedAt` is stamped in place on the + // booknote object that the stale index still references. + highlight.deletedAt = 123; + const { annotations, notes } = selectLocationAnnotations(index, location); + expect(annotations).toEqual([]); + expect(notes).toEqual([]); + }); + it('classifies a styled annotation into annotations only', () => { const highlight = note({ style: 'highlight', color: 'yellow' }); const index = buildAnnotationIndex([highlight]); diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 69e700f1..b69baaac 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -985,6 +985,10 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ // covered by `onCreateOverlay`. Using the pre-built `globals` // array avoids re-walking booknotes per page turn. for (const annotation of annotationIndex.globals) { + // Same stale-index guard as selectLocationAnnotations: a global deleted + // in place after the memoized index was built must not be re-fanned out + // across sections, which would orphan its overlays (#4773). + if (annotation.deletedAt) continue; if (view) expandAllRenderedSections(view, annotation); } } catch (e) { diff --git a/apps/readest-app/src/app/reader/utils/annotationIndex.ts b/apps/readest-app/src/app/reader/utils/annotationIndex.ts index 227d0c1d..4cbcafe2 100644 --- a/apps/readest-app/src/app/reader/utils/annotationIndex.ts +++ b/apps/readest-app/src/app/reader/utils/annotationIndex.ts @@ -70,6 +70,10 @@ export function selectLocationAnnotations( const candidates = index.bySection.get(sectionKey) ?? []; const matchesLocation = createCfiLocationMatcher(location); for (const item of candidates) { + // Re-check `deletedAt` at read time: the index is memoized, so a highlight + // deleted in place after the index was built still sits in this bucket. Re- + // drawing it here would orphan its overlay (visible until reopen) — #4773. + if (item.deletedAt) continue; if (!matchesLocation(item.cfi)) continue; if (item.style) annotations.push(item); if (item.note && item.note.trim().length > 0) notes.push(item);