跳转至

前端响应性:持久化写入放大修复计划

字段 决策
状态 历史性合并写入器方案;存储与水合所有权已被 PR #1636 取代
目标 一个聚焦的前端 PR
优先级 P1 响应性与数据安全加固
风险 中:持久化时机变化,持久化格式不变
依赖 无
回滚 还原该 PR;现有键和模式仍可读取

后续修订(2026-08-24,PR #1636): 下文作为历史 PR #1541 合并写入器设计保留。其中对“current”、{version: 7} 和同步水合的用法描述的是 2026-08-13 基线,而非当前运行时。PR #1636 为有界浏览器状态保留合并写入器,将无界长文本数据迁移到 IndexedDB,并在窗口所有权确定后显式地异步执行 store 水合。

持久化版本所有权(PR #1636 之后)

版本/时期 所有者 数据与迁移约定
历史 Zustand v4 → v5 frontend/src/store/index.ts → migrateAppStore 保留的 version < 5 迁移会将旧版 v4 项目记录规范化为统一的长文本形态。这是旧封装迁移,不是当前存储版本。
历史合并写入器基线 v7 PR #1541 与本文档 完整的持久化 Zustand 投影以 { state, version: 7 } 形式存在于同步 localStorage 中。下文对 v7 和同步 getItem 的引用是历史验收标准。
当前 Zustand 封装 v9 frontend/src/store/index.ts 加 frontend/src/utils/longformPersistence.ts omnivoice.app 仍然是 Zustand 的键,但其常规 localStorage 副本是一个有界 v9 封装。升级期间,拆分适配器会先提交任何旧的完整长文本负载,再压缩该封装。
当前 IndexedDB 模式 1 frontend/src/utils/indexedDbLongformStore.ts omnivoice.longform 拥有无界工作区负载及其写入者修订号。引导过程使用 skipHydration,解析主窗口/小部件所有权,并在渲染前等待异步拆分存储重新水合。

Zustand 封装版本(9)和 IndexedDB 数据库模式(1)是互相独立的计数器,由不同所有者维护;不得用“模式 v9”来指代 IndexedDB 格式。

执行决策

第一个优化 PR 应从高频交互路径中移除同步 JSON 序列化和 localStorage 写入。它应保留现有 omnivoice.app 和 omni_ui 契约,将每次突发写入合并为最新值,在有界时间窗口内刷新,并防止延迟写入撤销 Factory Reset。

这是最佳的首要改动,因为它解决了一个已测量的跨工作区瓶颈,且没有同时引入存储迁移、后端改动或 App.jsx 重写。增量配音调度、事务性撤销和工作区拆分仍作为独立后续项,各自带有证据和回滚边界。

证据与诊断

静态路径

有两条独立的持久化路径运行在浏览器主线程上:

  1. 每次 Zustand set 都会调用持久化中间件。中间件运行 partialize,序列化完整的持久化投影,并调用同步的 localStorage.setItem('omnivoice.app', ...),即使该变更只改动了瞬态状态。
  2. useAppData 有一个宽泛的 effect,每当文本、配音片段、转录文本、轨道、历史记录或相关偏好发生变化时,它都会序列化并写入 omni_ui。

由此产生的热路径是:

input -> store update -> render/effects -> full projection -> JSON.stringify -> localStorage.setItem

其成本随文档大小扩展,而不是随用户修改的小字段大小扩展。localStorage 是同步的,因此序列化和物理写入都会与下一帧竞争。

本地运行时基线

以下测量值是来自提交 3e3189d04d2d6dba69b4dd07fefc8725b9c94af6 的诊断基线,不是可移植的 CI 阈值。每个场景执行 20 次 UI 规模交互;原始存储耗时不包括 JSON.stringify,因此是下界。两次不相关的 contact-key 写入已从目标键计数中排除,但包含在聚合原始耗时中。

测试场景 对目标键的写入次数 输入到下一帧 原始 setItem 耗时
小型本地状态 40 次 omnivoice.app + 20 次 omni_ui 平均 13.9 ms,最大 18.9 ms 1.9 ms
1,800 个配音片段 + 400 个故事轨道 40 + 20 平均 23.6 ms,最大 39.0 ms 50.7 ms
3,000 个配音片段 + 3,000 个故事轨道 40 + 20 重复出现 56-114 ms 的长任务 809 ms

代表性的序列化大小约为:omnivoice.app 156 KB,omni_ui 1.5 MB。直接文本编辑探针也会在每次更改时对每个键各产生一次写入。

基线验证

  • 基线提交:3e3189d04d2d6dba69b4dd07fefc8725b9c94af6。
  • bun run test -- src/utils/prefKeys.test.js src/test/omniUiSchema.test.js src/test/dubStepRestoreClamp.test.js src/store/uiScaleMigration.test.ts src/test/dubPerLangTranslations.test.jsx src/test/dubVoiceMatchRequest.test.jsx 通过:6 个文件,35 个测试。
  • 生产构建通过。主应用 chunk 约 381.82 KB(压缩)/ 116.27 KB(gzip)。
  • backend/api/routers/mcp_bindings.py 不涉及此问题:其列表处理器是薄委托,绑定面板已经并发加载绑定和配置文件。
  • 大型 Settings/OpenAPI chunk 是懒加载的,不是此处针对的交互时瓶颈。

目标

在 PR #1541 基线中,在发起交互的任务中不执行 JSON 序列化或物理存储写入,并且在突发后仅持久化最新值,同时保留该基线的同步水合和恢复格式。上方的后续矩阵记录了后来的水合和格式变化。

范围

范围内

  • 一个共享的、带类型的、合并写入的浏览器 localStorage JSON 写入器。
  • 一个与 Zustand 兼容的结构化存储适配器,由它自身延迟序列化。
  • 延迟的 omni_ui 持久化,字段集与当前完全一致。
  • 尾部刷新、最大等待时间刷新和页面生命周期刷新。
  • 针对独立 Tauri 采集小部件的单写入者保护。
  • Factory Reset 取消机制,使挂起值无法重新创建已删除的键。
  • 确定性的单元/集成测试、前后浏览器跟踪,以及一条 Unreleased 更新日志条目。

明确不在范围内

  • IndexedDB、worker、新的存储键、schema 变更或 Zustand 版本升级。
  • 从 omni_ui 中移除重复字段,或更改恢复优先级。
  • 后端/API/数据库变更,包括 MCP 绑定。
  • 在本 PR 中对 /tools/incremental 做防抖。
  • 更改撤销/重做语义或快照表示。
  • 拆分 store、分解 App.jsx,或移动 workspace 导入。
  • 新的依赖、用户可见字符串、locale 文件或应用版本升级。
  • CI 中对硬件敏感的时序断言。

历史 PR #1541 兼容性与安全不变量

实现必须保留以下所有内容:

契约 必需的不变量
Zustand 键 omnivoice.app
#1541 基线下的 Zustand 封装结构 { state, version: 7 },使用常规 JSON.stringify 语义序列化
Zustand 投影 现有 partialize 字段和瞬态字段剥离在语义上保持不变
#1541 基线下的 Zustand 迁移 现有 v1-v7 迁移行为保持不变
遗留恢复键 omni_ui
遗留恢复结构 确切的当前字段名、省略行为以及 sanitizeOmniUi 恢复路径
#1541 基线下的水合 同步;不引入加载门或异步竞态
持久性 当序列化/存储成功且浏览器运行定时器时,某个脏键会在其首次未刷新的变更后 1,000 ms 内被尝试写入
生命周期 pagehide 和文档隐藏事件会尝试写入待处理值;两者共同作用下,每个未变更的代次最多产生一次物理写入
重置 已移除的偏好键不能在重置重新加载之前被旧的或新排队的任务重新创建
桌面窗口 持久化从未知/只读角色开始;解析得到的主 webview 被激活为唯一写入者,独立 widget 保持只读
隐私 日志可包含键和错误名称,绝不包含持久化的用户内容
平台一致性 在 macOS、Windows、Linux、浏览器和 Docker 上具有相同的默认行为

对于 PR #1541,直接消费者(如 utils/donationMoments.js、E2E 状态播种、长格式恢复以及偏好键注册表)必须继续无变更地解析当时存在的封装结构。捐赠退出选项的主要 omnivoice.donate.optOut 标志仍然是一次即时、独立的写入;其即时行为以及刷新后的遗留封装回退都需要兼容性覆盖。

并发浏览器/Docker 标签页在本 PR 中明确不会被提升为协调的多写入者系统。它们继续保留不受支持的“最后物理写入者胜出”行为。PR 描述必须说明该边界;添加跨标签页修订或 BroadcastChannel 仲裁将是独立的数据一致性设计。

拟议设计

1. 共享合并写入器

创建 frontend/src/utils/coalescedJsonStorage.ts,包含可注入核心与一个应用单例。公共契约应保持精简:

API 契约
queueJsonWrite(key, readLatestValue) 将 key 标记为脏并替换其惰性提供器;返回一个按代次绑定的释放器,该释放器只能取消本次注册
createZustandJsonStorage() 返回一个 PersistStorage 适配器,其 getItem 是同步的,setItem 会排队处理结构化的 StorageValue
flushPendingWrites() 同步序列化并尝试每个待写入项;返回供测试/诊断使用的摘要
discardPendingWrites(predicate?) 取消匹配键谓词的定时器和待处理值
suspendJsonWrites(predicate) 丢弃匹配的工作,并拒绝后续匹配的队列,直到使用返回的恢复回调
configurePersistenceRole(role) 将单例从初始 unknown 解析为 main 或 readonly;激活已暂存的主窗口工作,或丢弃所有已暂存的 widget 工作
适配器 removeItem(key) 在原始移除之前取消/暂存移除该键;传播主窗口移除错误;在只读 widget 中保持无操作
installPersistenceLifecycleFlush() 为主引导所有者安装一次单例监听器对;清理是幂等的,仅供测试/HMR 拆除使用

必需的调度语义:

  • 静默延迟:某个键的最新值之后 250 ms。
  • 硬性上限:从该键首次未刷新的值起 1,000 ms;持续输入不得饿死持久化。
  • 最后调度的值胜出。
  • 排队的提供器仅在刷新时于 JavaScript 线程上求值,因此序列化的值是在刷新时最新的应用值,而非深拷贝的事件时对象。
  • 静默定时器在替换时重置;最大定时器不会重置。
  • 一次成功的最大时限刷新会为后续更新开启新窗口。
  • 使用标准定时器。不要将 requestIdleCallback 作为正确性路径的一部分;不同受支持 webview 中的可用性不同。
  • 不要包装 createJSONStorage。它会在调用适配器之前先进行字符串化,从而把主要成本留在交互路径内。

刷新行为:

  1. 在刷新时读取最新提供器并仅此时序列化。
  2. 将序列化后的值与当前已持久化的原始值比较,若相同则跳过物理写入。
  3. 在本次刷新中,每个脏键最多调用一次 setItem。
  4. 仅在成功写入或确认值相同之后,才将该条目标记为干净。
  5. 确保旧定时器不会在更新值、取消或移除之后提交。

当存在待处理的结构化值时,getItem 必须求值并返回该最新值;否则必须同步解析已持久化的原始值。这使显式调用 Zustand rehydrate() 时保持内部一致,同时不改变冷启动水合。

惰性提供器契约避免了在每次输入时复制一份 1.5 MB 的文档。任务 0 必须审计每个已持久化的嵌套容器,排查原地变更。React/Zustand 的 setter 预期会发布替换值;任何孤立违规都必须在接入此调度器前修复,或显式转换为安全的值提供器。如果审计暴露出广泛的可变数据约定,请停止并重新设计本 PR,而不是在其中隐藏一次状态模型重构。还必须有一个确定性测试固定“刷新时取当前值”的语义:在不序列化的情况下变更/替换提供器的源,然后刷新并验证写入的是当前值。

2. 失败语义

  • JSON.stringify 或存储失败不得通过 Zustand setter、React effect 或生命周期事件逃逸。
  • 序列化失败会在发出警告后丢弃该无效值;后续有效更新可继续进行。
  • 每次 flush 尝试都先清除两个计时器。
  • 配额/安全/写入失败会使之前的持久化 blob 保持不变,并让最新值保持 dirty,但会解除自动重试。之后的队列会开启全新的 250/1,000 ms 窗口;显式/生命周期 flush 只尝试一次。仅推进计时器不得形成重试循环。
  • 多键 flush 按 key 隔离:成功的 key 变为 clean;失败的 key 保持 dirty;重试失败的 key 不得重写其他成功的兄弟键。
  • 每个 key/操作/错误类别只警告一次,避免控制台刷屏。
  • 绝不记录 value、文本、片段数据或序列化后的 payload。
  • 适配器 removeItem 和 Factory Reset 保持真实:先取消待处理工作,然后将主窗口原始移除失败传递给调用方。
  • 1,000 ms 持久性声明仅在浏览器调度计时器且存储成功时成立。计时器节流、配额拒绝、进程崩溃或生命周期写入失败都无法承诺持久性;这些情况是可观察的且不会导致崩溃。

3. 主窗口所有权

Tauri 组件在单独的 webview 中导入同一个 Zustand store,并调用 setter 来设置运行时听写状态。目前,这些瞬时 setter 可能会将较旧的投影持久化到主窗口的当前偏好之上。

不要在存储工具内部重复实现组件检测。detectIsWidget() 已经解析了初始化标记、Tauri getCurrentWindow().label 和旧版开发 URL。bootstrapApp() 必须在 React 渲染之前将该精确的解析结果传递给 configurePersistenceRole()。

该单例从 unknown 开始:hydration 读取可用,但写入/移除只能暂存,且不得运行任何计时器、序列化或原始变更。解析为 main 时,仅重放每个 key 的最新暂存操作,并在激活时启动其 250/1,000 ms 时钟;等待角色检测所花的时间不计入禁止写入的窗口。解析为 readonly 时,丢弃暂存工作,并使 setItem 和 removeItem 都变为无效操作。这是必要的,因为 store 是在异步窗口检测完成之前静态导入的。页面内浏览器捕获 pill 共享主文档,并且保持可写。

在未调用 bootstrapApp() 的情况下导入 store 的测试,必须使用隔离的 writer,或在 setup 中显式配置 main,并在 teardown 中重置 role、暂存工作、挂起状态、计时器和监听器。现有迁移测试必须在播种原始 fixtures 之前清除调度器状态;否则暂存的待处理值可能在 persist.rehydrate() 期间掩盖 fixture。

4. 生命周期所有权

在 detectIsWidget() 解析完成后,bootstrapApp() 应在仅针对主窗口渲染之前配置角色并安装生命周期 flush。Bootstrap 是生产环境中的唯一所有者;测试中使用隔离的 writer 实例或显式 teardown 来重置监听器。

  • 在 pagehide 时执行 flush。
  • 仅在 document.visibilityState === 'hidden' 时,在 visibilitychange 上执行 flush。
  • 不要添加 beforeunload;它没有必要,并且可能干扰后退/前进缓存。
  • 生命周期 flush 使用与计时器 flush 相同的 generation/cancellation 检查。如果隐藏可见性和 pagehide 同时触发,第二次调用会观察到干净的 generation,并且不会执行第二次序列化/写入。
  • 仅将页面生命周期事件视为异步 IndexedDB 工作的尽力而为。在桌面上,先阻止第一次原生退出请求,要求主 webview 等待长格式提交完成,然后清空紧凑的本地 envelope,之后再确认退出;如果 webview 无法响应,三秒原生超时仍必须关闭或重新启动。有意的前端重载/重启操作在导航前等待同一个有序 helper。

5. Zustand 集成

在 frontend/src/store/index.ts 中:

  • 将 createJSONStorage(() => localStorage) 替换为结构化合并(coalescing)适配器。
  • 在语义上原样保留 name、partialize、version: 7 和 migrate。
  • 保持长格式投影以及移除 generating/audioUrl 的逻辑不变。
  • 不要向该 key 添加 text、配音片段或其他旧版恢复字段。

此 PR 有意让 partialize 保持同步。如果变更后的性能分析显示其 storyTracks.map(...) 仍然影响显著,请在单独的变更中优化投影调度,而不是在此处替换 hydration 和迁移机制。

6. omni_ui 集成

在 frontend/src/hooks/useAppData.js 中:

  • 使用相同的属性顺序和值构建相同的恢复对象。
  • 用惰性的 queueJsonWrite('omni_ui', readLatestOmniUi) provider 替换直接的 JSON.stringify + localStorage.setItem。
  • 保持同步解析、sanitizeOmniUi、旧版 clone/design 处理以及 dub-step 钳位逻辑不变。
  • 添加显式的 omniUiRestoreComplete 就绪状态。初始持久化 effect 不得排队任何内容;恢复 effect 在同一批次中设置所有恢复值并翻转就绪状态,随后的渲染提供第一个可写值。
  • 证明初始 effects 与恢复后渲染之间的即时生命周期事件无法持久化默认值。
  • 向 writer 提供惰性的最新值 provider,并在 effect 清理中调用其绑定 generation 的 disposer。过期的 StrictMode/已卸载 effect 只能取消其自身的注册,绝不能取消新挂载的 provider。不要在排队时深拷贝;上述不可变性审计和 flush 时取最新值的契约定义了所有权。

7. Factory Reset 集成

在 clearLocalPreferences 中:

  1. 暂停并丢弃所有满足 isPrefKey(key) 为 true 的待处理 key。
  2. 与现在完全一样地枚举并移除持久化偏好 key。
  3. 与现在完全一样地保留连接凭据和用户数据 key。

暂停状态会在成功重置会话的剩余时间内持续生效,因为在重新加载前的 400 ms 内仍可能发生后台存储活动。请将整个枚举并移除事务(包括 length、key() 以及键的筛选/访问)包裹起来,以便任何失败在重新抛出之前先恢复写入。这样可以防止现有的重置错误路径让持久化在静默状态下被禁用。这一顺序是强制性的:否则,过期的定时器、重置后的新存储更新或稍后的 pagehide 事件可能会在删除后重新复活 omnivoice.app 或 omni_ui。模拟成功重置但没有真正重新加载的测试,必须在事后显式重置隔离的写入器。

文件级变更预算

文件 变更
frontend/src/utils/coalescedJsonStorage.ts 新增惰性调度器、Zustand 适配器、角色配置、暂停机制与生命周期所有权
frontend/src/utils/coalescedJsonStorage.test.ts 新增确定性调度器/失败/生命周期/微件测试
frontend/src/store/index.ts 仅替换存储适配器;保留投影与迁移逻辑
frontend/src/store/persistenceScheduling.test.ts 新增 Zustand 封装、合并写入、水合及长文本投影测试
frontend/src/hooks/useAppData.js 门控恢复就绪状态,并将现有的 omni_ui 值提供器排队
frontend/src/hooks/useAppData.persistence.test.jsx 新增恢复与突发写入集成测试
frontend/src/main-app.jsx 配置解析后的窗口角色,然后安装仅主窗口的生命周期刷新
frontend/src/main-app.test.jsx 扩展标签/标记/URL 角色顺序的覆盖范围
frontend/src/utils/prefKeys.js 在成功重置期间暂停待处理及未来的偏好设置写入
frontend/src/utils/prefKeys.test.js 新增防复活覆盖测试
frontend/src/utils/donationMoments.test.js 保留立即的主要退出行为与已刷新的旧版回退行为
frontend/e2e-perf/responsiveness.spec.ts 新增可选的生产包夹具、路由模拟、埋点与 JSON 产物;不含基于墙钟时间的 CI 断言
frontend/playwright.perf.config.ts 新增从现有生产冒烟配置派生的跨平台生产预览基准配置
CHANGELOG.md PR 编号确定后,在 Unreleased 中增加一行性能/修复说明

后端、locale、包清单、lockfile 或持久化模式文件都不应改动。

实施顺序

任务 0:冻结当前契约

  • [ ] 记录父提交的 SHA,并使用相同的夹具重新运行浏览器基线测试。
  • [ ] 为精确的 Zustand 封装、版本、旧版快照键、直接读取器及重置键注册表添加特征断言;这些断言必须在生产变更之前通过。
  • [ ] 为突发写入次数与初始默认值覆盖行为添加集成断言;这些断言必须因预期原因而在当前即时写入器上失败。
  • [ ] 对照冻结的夹具,确认现有的直接读取器(donationMoments、E2E 辅助工具)。
  • [ ] 审计持久化的 Zustand 投影及每个 omni_ui 嵌套值是否发生就地变更。在 PR 中记录搜索路径;在采用惰性提供器之前解决任何命中。
  • [ ] 在添加 fail-before 用例的同时,保持当前 35 个定向测试为绿色通过状态。

退出条件:特征测试通过;行为集成测试仅因写入是即时/重复的或启动持久化未加门控而失败。调度器专用的单元测试随新工具一起引入,而不是在其接缝尚不存在时假装失败。

任务 1:实现存储原语

  • [ ] 实现基于注入的 clock/storage/serializer 依赖的每键静默定时器与最大定时器。
  • [ ] 让值的物化与序列化变为惰性,并针对持久化的原始字符串进行去重。
  • [ ] 实现同步的待处理/持久化读取。
  • [ ] 实现受代际约束的提供器释放器,以及刷新、丢弃和移除守卫。
  • [ ] 为破坏性重置窗口实现基于谓词的暂停机制。
  • [ ] 将失败的尝试定义为定时器已解除;后续队列将开启一个新的最大时间窗口。
  • [ ] 在多个脏键之间隔离部分失败。
  • [ ] 从抛异常的提供器、持久化读取、格式错误的 JSON 及原始写入中恢复,且不影响后续有效操作。
  • [ ] 对警告进行去重,并证明不会记录任何包含用户内容的值、序列化负载或错误消息。
  • [ ] 在不记录负载的前提下,对错误进行收容与去重。
  • [ ] 添加 unknown -> main|readonly 角色配置;未知工作无法触及原始存储。
  • [ ] 使适配器移除遵循取消、角色及错误传播契约。
  • [ ] 添加单所有者生命周期安装及幂等拆除。
  • [ ] 为测试添加完整的隔离写入器重置钩子:角色、暂存操作、暂停、定时器、监听器及警告注册表。

退出条件:所有工具测试均通过,且不导入 React 或应用存储。

任务 2:在不改变契约的前提下接入 Zustand

  • [ ] 用结构化适配器替换 createJSONStorage。
  • [ ] 保持 partialize、version 与 migrate 不变,除非测试需要机械地提取键常量。
  • [ ] 证明 100 次快速临时更新不会导致任何同步序列化/写入,且最多只有一次尾部写入。
  • [ ] 证明最终 JSON 包含最新的持久化更新以及 { version: 7 }。
  • [ ] 证明 persist.clearStorage() 无法被定时器/生命周期撤销,并且在微件角色下处于惰性状态。
  • [ ] 更新原始种子迁移测试,以便在 rehydrate() 之前重置待处理/暂存的写入器状态。
  • [ ] 证明长文本字段可往返读写,同时 generating 与 audioUrl 仍被排除在外。
  • [ ] 证明 v6 到 v7 以及更早版本所接受的夹具仍可同步水合。

退出条件:现有的存储迁移测试及新的调度测试套件全部通过。

任务 3:接入 omni_ui

  • [ ] 仅在精确形状测试需要时才提取快照构造逻辑;不要重新设计所有权。
  • [ ] 添加恢复完成状态门控,然后将最新值提供器排队,而不是在 effect 中序列化。
  • [ ] 添加种子恢复测试,证明初始默认值绝不会成为持久化的最终赢家。
  • [ ] 在恢复后的渲染之前派发生命周期刷新,并证明它不会写入任何默认值。
  • [ ] 添加突发测试,证明在一次写入后,最新的文本与配音片段数据胜出。
  • [ ] 覆盖 StrictMode 的双重 effect 以及静默定时器之前的卸载/重新挂载场景;任何过期的提供器都不得胜出。
  • [ ] 在保持不变的条件下重新运行 schema、旧版模式及恢复后的配音步骤测试。

退出条件:显式刷写后的重新加载会通过 sanitizeOmniUi 恢复一个深度相等的最新快照。

任务 4:处理生命周期与重置竞态

  • [ ] 在渲染前配置精确的 detectIsWidget() 结果,然后安装主窗口生命周期刷写机制。
  • [ ] 验证 marker、仅 Tauri 标签以及旧版 URL 的检测;角色确定前的 setter 无法从 widget 泄漏。
  • [ ] 验证未知角色的 set→remove 最终保持已移除,remove→set 会激活 set,并且 1 秒时钟从 main 角色激活时开始计时。
  • [ ] 验证重复安装不会导致监听器重复,并且 teardown 会移除完全相同的回调。
  • [ ] 验证隐藏可见性加上 pagehide 对未更改的待处理 generation 最多产生一次序列化/写入。
  • [ ] 在恢复出厂设置移除之前,挂起待处理的和将来的偏好值。
  • [ ] 重置后,排入另一个 store 更新,推进所有 fake timer,并派发生命周期事件;两个目标键必须始终保持不存在。
  • [ ] 验证直接移除以及枚举/访问失败会在传播错误之前恢复正常持久化。
  • [ ] 验证保留的 connection/data 键未被改动。
  • [ ] 验证独立 widget 的 setter 和 persist.clearStorage() 无法变更持久化状态,而主窗口操作仍然正常。

退出条件:过时的 timer、生命周期事件、StrictMode 或 widget 都不能覆盖更新的或有意删除的持久化状态。

任务 5:验证与记录

  • [ ] 迭代期间运行有针对性的测试。
  • [ ] 运行前端 typecheck、lint、格式检查、完整 Vitest、构建以及生产包 smoke 测试。
  • [ ] 在合入前离线运行仓库的后端测试套件,尽管没有后端改动,因为它们是合并门禁。
  • [ ] 提交 opt-in 的 Playwright 基准测试,包含确定性 fixture 生成和 JSON 输出。
  • [ ] 运行交替的 parent/implementation/parent(A/B/A)基准序列,每个 leg 重复五次;如果同一提交的方差超过 5%,则重跑。
  • [ ] 将计数、payload 大小、p50/p95/max 交互延迟和 long-task 证据附加到 PR。
  • [ ] 打开 draft PR 以获取其编号,然后在请求评审之前添加/修改 Unreleased changelog 行。

退出条件:确定性验收标准通过;构建/合并门禁为绿色;浏览器计时作为可复现的决策证据附加,而不是作为对硬件敏感的 CI 门禁。

必需的确定性测试

场景 必需结果
单次突发中的 100 次替换 0 次同步 provider/序列化/写入调用;1 次尾随写入,值为 100
惰性 provider 源在刷写前发生变化 写入的是刷写时当前值;入队期间没有发生深克隆或序列化
过时的 provider 释放器 仅取消其自身的 generation;不能取消同一键的新 provider
超过 1 秒的持续更新 发生一次最大等待的刷写;后续更新开始新的窗口
持久化值相同 刷写时可能发生序列化;但实际的 setItem 会被跳过
刷写前显式调用 getItem 同步返回最新的待处理结构化值
隐藏文档后紧接 pagehide 对未更改的待处理 generation 最多一次序列化/写入
取消/移除后触发所有 timer 被删除的键始终保持不存在
Zustand 的 persist.clearStorage() 挂起/暂存的键被取消;timer/生命周期不能使其复活
成功重置后紧接着新的 store 更新 匹配的写入保持挂起状态,被删除的键在重新加载之前一直不存在
重置移除失败 错误被传播,写入挂起被释放
重置枚举/访问失败 错误被传播,写入挂起被释放
序列化错误 调用方不会抛出;无效条目不会污染后续的有效更新
provider 抛错 调用方/生命周期不会崩溃;无效条目被丢弃,后续有效 provider 成功
持久化 getItem 抛错 水合在不崩溃的情况下回退到默认值;后续有效队列可以持久化
格式错误的持久化 JSON 水合遵循当前的安全回退/迁移行为,后续持久化会修复它
配额/安全错误 调用方不会抛出;旧的持久化值保留;timer 不重试;后续一个队列启动一个新窗口
双键部分失败 成功的键保持干净;只有失败的键稍后重试
未知的已暂存 set→remove / remove→set 只有最终操作会在 main 上激活;其时钟从激活时开始
未知/独立 widget 的更新和移除 读取正常;在角色解析之前或只读解析之后,不会发生原始变更
重复的生命周期安装/teardown 只有一套监听器;完全相同的回调恰好被移除一次
Zustand 瞬时突发 最多一次 omnivoice.app 写入,且 v7 envelope 保持不变
旧版恢复突发 最多一次 omni_ui 写入,包含最新的 text/segments
有种子数据的初始恢复 默认值绝不会覆盖已恢复的状态,包括即时生命周期以及 StrictMode/unmount 竞态
恢复出厂设置竞态 在 timer 和生命周期事件之后,两个待处理目标键都保持不存在
捐赠选择退出兼容性 主要的选择退出状态立即保持可见;已刷写的 v7 旧版回退保持可读
重复警告 每个键/操作/错误类别只产生一条警告;不出现任何值、序列化 payload 或包含实际内容的错误消息

不要使用已耗毫秒数作为 Vitest 通过/失败的断言。CI 中使用 fake timer 和调用计数;浏览器 trace 用于性能证据。

验证命令

迭代期间运行有针对性的测试:

cd frontend
bun run test -- src/utils/coalescedJsonStorage.test.ts src/store/persistenceScheduling.test.ts src/hooks/useAppData.persistence.test.jsx src/main-app.test.jsx src/utils/prefKeys.test.js src/utils/donationMoments.test.js src/test/omniUiSchema.test.js src/test/dubStepRestoreClamp.test.js src/store/uiScaleMigration.test.ts

运行前端合入门禁:

cd frontend
bun run typecheck:ci
bun run lint
bun run format:check
bun run test
bun run test:prod-bundle
bun run test:legacy

test:prod-bundle 已在冒烟测试前执行生产构建,因此单独的 bun run build 只会重复工作。仅在迭代期间需要构建产物时才单独运行它。

在仓库根目录运行后端 CI 等效套件,并使用真正空的 Hugging Face 缓存:

$previousOffline = $env:HF_HUB_OFFLINE
$previousCache = $env:HF_HUB_CACHE
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$emptyHfCache = [IO.Path]::GetFullPath((Join-Path $tempRoot ("omnivoice-hf-empty-" + [guid]::NewGuid())))
if (-not $emptyHfCache.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe cache path' }
New-Item -ItemType Directory -Path $emptyHfCache | Out-Null
try {
  if (@(Get-ChildItem -LiteralPath $emptyHfCache -Force).Count -ne 0) { throw 'HF cache is not empty' }
  $env:HF_HUB_OFFLINE = '1'
  $env:HF_HUB_CACHE = $emptyHfCache
  uv run --no-sync pytest tests/ -q --tb=short
  if ($LASTEXITCODE -ne 0) { throw "tests/ failed with exit code $LASTEXITCODE" }
  uv run --no-sync pytest backend/tests/ -q --tb=short
  if ($LASTEXITCODE -ne 0) { throw "backend/tests/ failed with exit code $LASTEXITCODE" }
} finally {
  $env:HF_HUB_OFFLINE = $previousOffline
  $env:HF_HUB_CACHE = $previousCache
  Remove-Item -LiteralPath $emptyHfCache -Recurse -Force
}

这些是仓库准入门槛,而非前端优化本身有效的证据。独特的缓存和已恢复的环境可防止已填充的开发缓存或泄漏的 Shell 状态掩盖失败。

浏览器验证协议

在 frontend/e2e-perf/responsiveness.spec.ts 和 frontend/playwright.perf.config.ts 中提交一个非 CI 的生产包基准测试工具。将其置于 e2e-prod/ 之外可确保现有生产冒烟 CI 命令不会发现此手动基准测试。该配置必须镜像 playwright.prod.config.ts:构建真实的 dist/,使用 vite preview 在专用严格端口上提供服务,尊重 PLAYWRIGHT_CHROMIUM,仅当 /usr/bin/chromium 存在时使用它,否则回退到 Playwright 自带的浏览器。它不得使用开发服务器 E2E 配置。

该 spec 必须从固定种子生成 fixture,在导航前安装所有必需的 API/WebSocket 路由 mock 或确定性引导绕过方案,并且不得假设后端运行在端口 3900 上。它必须在应用代码之前使用 page.addInitScript 包装目标键存储写入和 PerformanceObserver,驱动选择器而非任意睡眠,并在 Playwright 的 test-results 目录下输出机器可读的 JSON。它断言最终状态和可观察的确定性写入计数,但不断言经过的毫秒数,也不声称观察序列化器任务身份。注入的 Vitest 调度器测试拥有更强的断言:"在发起任务中不执行 provider/序列化器"。

运行它:

cd frontend
node ./node_modules/@playwright/test/cli.js test --config=playwright.perf.config.ts responsiveness.spec.ts --repeat-each=5 --reporter=line

先运行 bun install --frozen-lockfile。上述命令从 frontend/ 验证,通过精确包路径解析已安装的 Playwright 1.61.0 CLI;不要将其替换为 bun x playwright 或全局 bun run shim,这可能会选择另一个 Playwright 版本、获取包,甚至解析到过时的 Windows shim。如果 PLAYWRIGHT_CHROMIUM 未设置且不存在受支持的系统 Chromium,请使用 node ./node_modules/@playwright/test/cli.js install chromium 安装一次固定浏览器。这不会增加项目依赖,且专用配置提供了跨平台的可执行回退方案。该配置拥有端口 4174,绝不复用现有监听器,因此过时的预览会响亮地失败,每次成功运行都会精确拆除其启动的服务器。

在两个提交上使用相同的浏览器版本、构建模式、机器电源状态和 fixture。

  1. 在应用加载前检测目标键 setItem 计数、序列化字节长度和调用时长。
  2. 观察长任务和事件到下一次 requestAnimationFrame 的延迟。
  3. 使用当前 v7/legacy 格式播种 1,800 个配音段和 400 个故事轨道。
  4. 以 25 ms 间隔运行 20 次 UI 规模更新,保持完整突发低于硬上限。
  5. 以相同节奏运行 20 次 Studio 文本更新。
  6. 每次突发结束后等待 1,250 ms,并通过解析两个键验证持久化的最新值。
  7. 运行 A/B/A(父版本、实现版本、父版本),每条腿重复五次;比较中位数 p95 并保留每件 JSON 产物。
  8. 将 3,000/3,000 fixture 作为诊断压力案例运行一次,而非产品上限。

确定性合并门槛:

  • 小于 1 秒的 20 事件突发在突发后每个目标键产生不超过一次物理写入:相比测得的 60 次目标写入,至少减少 96%。
  • 注入的工具/集成测试证明在发起输入任务中不执行目标键 provider、序列化或写入;浏览器工具独立验证可观察的物理写入。
  • 两个解析后的持久值都包含最终交互的状态。

手动决策阈值,而非 CI 合并门槛:

  • 在代表性 fixture 上,目标至少降低 20% 的中位数 p95 输入到帧延迟。
  • 在小 fixture 上,目标中位数 p95 回退不超过 5%。
  • 在持久化工作位于其跟踪栈中的交互突发期间,预期没有超过 50 ms 的任务。
  • 如果任一目标未达成,或同一提交 A/A 方差超过 5%,则将计时结果视为不确定,附上原始产物并重新分析。不要为了制造有利数字而扩大此 PR 的范围。

验收标准

仅当以下所有条件均为真时,PR 才可进行审查:

  • [ ] 现有键、字段集、JSON 信封、版本、迁移和恢复行为均未更改。
  • [ ] 一次突发每个脏键至多产生一次尾随写入,且最新值胜出。
  • [ ] 正常的连续输入在 1 秒内调度一次尝试;失败和定时器节流限制已准确记录。
  • [ ] 存储健康时,有序隐藏/导航同步刷新,硬进程终止最多丢失计划的未刷新窗口;失败/节流异常已记录。
  • [ ] 恢复出厂设置不能被待处理工作撤销。
  • [ ] 未知角色工作无法到达原始存储,且独立小部件无法写入或删除主窗口偏好设置。
  • [ ] 存储失败不能使输入处理崩溃,且永远不会将用户内容泄漏到日志中。
  • [ ] 确定性测试满足合并门槛;已检入的 A/B/A 基准测试和原始计时产物作为非 CI 决策证据附上。
  • [ ] 前端和后端合并门槛均通过。
  • [ ] 不存在依赖、lockfile、区域设置、后端、持久化版本或包版本更改。
  • [ ] PR 保持为单一持久化关注点的可审查范围;不包含机会主义重构。

风险与缓解措施

风险 缓解措施
硬性进程终止时,最多会丢失计划窗口内的编辑内容 250 毫秒静默刷新、最长 1,000 毫秒尝试、hidden/pagehide 刷新;披露计时器/存储限制
待处理或新排队的写入会重新创建重置数据 在原始移除之前通过 isPrefKey 挂起;重置后更新 + 计时器 + 生命周期回归测试
小部件刷新过时的主窗口状态 在渲染前解析现有检测器;未知状态无法写入;小部件的设置/移除操作保持只读
较早的计时器覆盖较新的值 每键生成令牌和最后值优先测试
可变数据在延迟序列化之前发生变化 惰性当前值提供程序加上有文档记录的变更审计;绝不声称具有事件时间快照语义
配额或禁用存储导致 UI 中断 包含写入错误,保留先前的持久 blob,解除计时器,仅在后来的活动/显式刷新时重试
并发浏览器标签页相互覆盖 明确保留不支持的最后一个物理写入者边界;不要在此添加不完整的冲突协议
对于病态文档,尾部刷新仍然代价高昂 测量它;不要隐藏它。如果代表性刷新超出预算,请在单独的 PR 中升级到文档存储/工作线程设计
中间件契约意外更改 精确的 envelope/fixture 测试,加上现有的迁移和直接读取器套件
生命周期监听器在开发/测试中重复 一个生产环境所有者、隔离的测试实例、幂等拆除,以及重复安装测试
计时基准测试在 CI 中不稳定 将墙钟证据保持为信息性/手动;以确定性操作计数作为门禁

回滚计划

这是 PR #1541 的回滚计划:当时不需要数据迁移,因为两个构建版本都读取相同的 v7 envelope。这不是 PR #1636 之后当前的回滚说明;回滚拆分后的 v9/IndexedDB 存储需要其迁移和降级保证,而不是假定仅 v7 的 localStorage 布局。

后续队列

以下内容有意不纳入第一个 PR:

  1. 增量配音调度。 添加 300 毫秒防抖,通过 apiPost 传递 AbortController.signal,使用单调递增的请求版本号,在 Dub 之外取消,并证明每个突发仅一个请求且拒绝过期响应。
  2. 事务性配音撤销。 分析 pushUndo,它目前每次编辑都会将完整的片段数组字符串化,并保留最多 50 个快照。如果影响显著,则按片段/字段对编辑进行分组,并在焦点或空闲边界处处理,同时保留单步撤销行为。
  3. 工作区隔离。 在持久化修复后分析 React 提交;然后一次提取一个工作区,将重量级 hooks/imports 移到惰性边界之后。仅靠源码长度和选择器数量不是成功指标。
  4. 文档存储迁移——由后续 PR #1636 完成。 Zustand envelope 现在为 v9,而 IndexedDB schema 1 存储无限制的长文文档,覆盖迁移、降级、重置、配额和异步水合。此历史计划中没有待处理的第二次迁移。

每项后续工作都必须从全新的追踪开始。任何后续项都不应仅仅因为邻近就被拉入此 PR。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio