资讯动态

Readest 即时高亮删除后 Overlay 残留问题(4773)根因剖析与修复实录

发布时间:2026/9/20 14:59:39 来源:尧图企业网站定制
Readest 即时高亮删除后 Overlay 残留问题#4773根因剖析与修复实录【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest本文基于 Readest 仓库中的缺陷记录文档 instant-highlight-delete-orphan-4773.md结合 annotationIndex.ts、Annotator.tsx、useInstantAnnotation.ts 等源码与回归测试完整还原 Android 端即时高亮创建后立刻删除高亮标记却残留在页面上这一经典状态时序 Bug 的定位思路、根因链条与修复方案。读完你将掌握软删除数据在 memoized 派生索引中的建时过滤 vs 读时过滤陷阱、React effect 调度时序如何放大竞态、以及如何在源码与单测层面验证这类渲染残留问题。问题现象标记消失了却又没有完全消失在 Readest Android 端Tauri WebView用户启用即时高亮instant highlight长按选中即高亮、无弹窗摩擦后执行如下操作序列选中一个词并完成高亮在极短时间内删除这条刚创建的高亮页面上高亮标记仍然存在overlay 仍被绘制只有重新打开书籍才会消失。从数据层看这条书签Booknote确实已软删除——deletedAt时间戳被写入重开书籍后标记不再出现但从渲染层看overlay 在删除动作之后被重新绘制了一次形成一条孤儿 overlayorphan overlay。问题编号 #4773缺陷记录见 instant-highlight-delete-orphan-4773.md。这个现象看似只在 Android 即时高亮下出现但文档明确指出数据层的删除逻辑与普通高亮完全共享即时高亮只是让该竞态更容易被命中而已无弹窗、手势快、连点密集。背景知识Readest 的注解渲染管线要理解这个 Bug先看两条关键链路。软删除模型BookNote 与 deletedAtReadest 的注解记录类型为BookNote见 types/book。删除高亮不是从数组里移除对象而是原地在对象上写入deletedAt Date.now()时间戳软删除。这一设计服务于多端同步场景——删除需要作为一条可同步的操作被记录和传播而不是物理消失。分桶索引为什么每页翻页需要 memoized indexAnnotator.tsx在每次页面 relocate翻页时都要把当前章节的注解重新应用到视图上。若采用朴素实现booknotes.filter(...)全量扫描在注解超过千条的重度用户场景下epubcfi解析会占据大量主线程时间。为此 annotationIndex.ts 提供了buildAnnotationIndex按 CFI spine 前缀章节 id将注解分桶存入bySection: Mapstring, BookNote[]翻页时只需扫描当前章节桶把 O(N) 降到 O(本章注解数)书级global高亮单独存入预过滤数组globals因为其语义是全书范围需要扇出到每个已渲染章节而非匹配当前位置拥有高亮样式style或非空笔记note的注解才会入桶——两者是相互独立的 overlay。索引通过useMemo(() buildAnnotationIndex(config.booknotes ?? []), [config.booknotes])记忆化见 Annotator.tsx只在booknotes引用变化时重算翻页本身不触发重建。重绘 effectrelocate 后重新应用注解翻页进度变化后Annotator 的 effect 执行重绘Annotator.tsxconst { annotations, notes } selectLocationAnnotations(annotationIndex, location); Promise.all(annotations.map((a) view?.addAnnotation(a))); Promise.all(notes.map((n) view?.addAnnotation({ ...n, value: ${NOTE_PREFIX}${n.cfi} }))); for (const annotation of annotationIndex.globals) { if (annotation.deletedAt) continue; // 修复后新增的读端复查 if (view) expandAllRenderedSections(view, annotation); }根因memoized 索引建时过滤读取端却不再复查索引构建时过滤 deletedAtbuildAnnotationIndex在构建索引时过滤已删除项annotationIndex.tsfor (const item of booknotes) { if (item.deletedAt) continue; ... }问题在于索引是 memoized 的。删除操作发生在新索引构建之前时旧索引快照仍然存活。删除是原地打时间戳索引快照被污染删除走handleHighlight(false)Annotator.tsx} else { existing.deletedAt Date.now(); // 原地写入不是替换数组元素 deleted true; }注意这里修改的是config.booknotes数组中同一个对象引用。而 memoizedannotationIndex的桶里装的正是这个对象的引用——索引尚未重算被删除的对象依然躺在旧索引的桶里。此时annotationIndex中的该对象deletedAt已被置位但读取端selectLocationAnnotations在修复前并不复查它。时序竞态popup 打开触发的 effect 在删除之后才 flushselectLocationAnnotations之前的实现信任索引构建时的过滤对桶内对象不再检查deletedAt。于是竞态出现高亮创建 → popup 打开渲染调度了一个重新应用注解的 effect被动 effectpassive effect用户迅速删除高亮极短时间窗口内在 Android WebView 快速连点场景下上述被动 effect 被延迟到删除之后才 flusheffect 从删除前的快照中取出该对象selectLocationAnnotations未复查deletedAt将其返回view.addAnnotation(a)把已删除的高亮重新绘制上去 → 孤儿 overlay。Annotator 不订阅 booknotes 变化memo 保持陈旧文档指出一个关键放大因素Annotator 组件并不订阅 booknotes 数据本身的变化——它只订阅稳定的getConfig函数store 的 selector 保持稳定不会因数据变化触发 re-render。因此config.booknotes引用不变时memoized 索引永远不会重算直到某个无关的 state 变化触发重渲染。索引的陈旧期被无限拉长删除后的残留风险窗口随之扩大。数据层并非即时高亮特有文档明确澄清了一个容易误判的结论该问题在数据层不是即时高亮专属的。useInstantAnnotationuseInstantAnnotation.ts只是通过enableAnnotationQuickActions annotationQuickAction highlight启用无弹窗手势路径让创建→删除之间的窗口被压缩到极短从而显著提高命中率。而删除 重新应用的代码路径即修复所在地与普通高亮完全共享。这也解释了为什么修复要落在公共的索引读取端而不是即时高亮专属代码。两个安全路径为什么它们不受影响文档还排除了两个看似同源的路径onCreateOverlay章节首次渲染时绘制注解它每次都通过getConfig(bookKey)!重新读取新鲜配置并在读取时用booknotes.filter((b) b.type annotation !b.deletedAt)过滤Annotator.tsx不存在陈旧快照问题FoliateViewer 的 onLoad 重绘只在章节section加载时触发快速删除不会触发章节重载。因此修复只需聚焦于读取陈旧索引的两处读点。修复把 deletedAt 复查下沉到读取端修复原则是既要在构建时过滤也要在读取时复查——构建时过滤保证性能与正确性基线读取时复查兜住删除发生在索引构建之后的竞态窗口。修复点 1selectLocationAnnotations 复查 annotations 与 notes 两条列表在 annotationIndex.ts 的桶内循环开头对每个候选对象先复查软删除标记再进入分类逻辑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. 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); }该复查位于分类之前因此同时覆盖高亮列表annotations和笔记列表notes——被删除的带笔记高亮不会残留笔记气泡 overlay纯笔记注解被删除后也不会再出现气泡。修复点 2Annotator 重绘 effect 中的 globals 循环全局高亮走的是另一条路径annotationIndex.globals预过滤数组在重绘 effect 中被遍历并expandAllRenderedSections扇出到每个已渲染章节。全局高亮同样面临陈旧快照风险——若删除发生在索引构建之后扇出会把已删除的全局高亮铺满全书章节产生每章一个孤儿 overlay。修复为在扇出前复查Annotator.tsxfor (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. if (annotation.deletedAt) continue; if (view) expandAllRenderedSections(view, annotation); }两处修复合计只有数行代码却完整封堵了局部高亮残留与全局高亮全书残留两类路径。回归测试用单测钉死陈旧索引竞态修复伴随一个针对性回归测试位于 annotation-index.test.ts核心用例精确复刻了 Bug 的时序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... 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([]); });测试的精妙之处在于故意不重建索引先构建索引再原地篡改deletedAt模拟memo 尚未重算的真实状态然后断言读取端必须返回空列表。修复前该用例会失败返回包含已删除对象的列表修复后通过。文件中的其他用例还覆盖了分桶正确性、纯笔记注解入桶、全局高亮分流、跨章节排除、空 location 等边界共同构成索引层的完整行为契约。端到端验证Android CDP lane 上的 overlay 计数文档记录了修复后的真机验证方式在 Xiaomi 13 ProfuxiWebView上通过CDPChrome DevTools Protocole2e lane执行真实创建→删除→立即 relocate循环连续 4 轮迭代页面 overlay 计数始终保持 6→7→6 的往复创建时 1、删除后回落不再出现残留的孤儿 overlay。同时通过一个流浪 overlay探针stray-overlay sanity probe证明 overlay 计数指标本身不是盲的——即计数稳定并非指标失效而是真实无残留。这一验证链路完整覆盖了单元测试索引层语义→ 真机 e2e渲染层最终效果两层防线也是 Readest 处理渲染类缺陷的典型闭环。经验总结memoized 派生数据的三条铁律派生索引的过滤不能只做一次任何构建时过滤都只对该时刻的快照负责。只要派生数据被 memoized 且数据可被原地修改软删除、原地赋值读取端就必须对已过滤字段做兜底复查——构建时过滤 读取时复查才是完整防线。警惕原地修改 引用共享的组合deletedAt原地写入让旧索引被动获得了删除信息这是幸运的——如果删除是替换数组元素新对象旧索引反而永远看不到删除标记残留会更隐蔽。无论如何memo 的陈旧期都不该被信任。时序 Bug 用最快路径复现用公共路径修复即时高亮只是把竞态窗口压缩到极致真正的缺陷在共享的索引读取逻辑中。定位 Bug 时优先找公共路径修复也落在公共路径才能一劳永逸地覆盖所有触发入口。如果你在自研阅读器或任何渲染层依赖 memoized 派生索引的项目中遇到删除后画面残留、重开才消失的怪异现象不妨先检查读取端是否复查了软删除标记。这一行守卫就是 Readest 从 #4773 中沉淀下来的答案。【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价