)
Slate History Tranche 4 执行复盘把撤销/重做捕获锚定到已提交事务接缝plate 仓库 slate-v2 实践【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate导读本文基于 plate 仓库内 docs/plans/2026-04-19-slate-history-tranche-4-execution.md 展开完整复盘 slate-v2 项目收尾slate-history支持包的第 4 批次Tranche 4执行。核心命题只有一个撤销/重做与选区恢复必须锚定在已提交事务这条真值线上而不是锚定在应用层的 onChange 回调或旧版逐操作 apply 拦截上。读完本文你将掌握这次收尾的目标设定、提交接缝commit seam捕获修复的前因后果、两类直接证明契约history-contract / integrity-contract覆盖的行为面、完整门禁命令栈以及哪些旧策略被显式拒绝、哪些行为被显式跳过的取舍逻辑并能在本地仓库用对应测试与基准命令复现验证。一、Tranche 4 的目标在已定稿的 slate core 之上诚实收口该执行文档开门见山给出目标在已定稿settled的slatecore 之上对packages/slate-history做一次诚实收口close honestly三条硬约束是撤销/重做与选区恢复锚定在已提交事务的真值上anchored to committed transaction truth保留仍然有价值的 legacy 与 draft 历史行为preserve kept legacy draft history behavior where it still earns its keep不把已退役的时序启发式timing heuristics或旧包装器时代的假设拖回 core。换句话说这次收口不是简单把测试跑绿而是要回答一个架构级问题定稿后的 core 提交/事务模型在真实支撑包slate-history接触时是否经得起考验。该文档将slate-history定位为第一个真正的证明者the first real proof验证 settled core 的 commit / transaction 模型能与支撑包共存而不崩塌。前置条件core 已定稿执行前最重要的解锁条件来自同日完成的 docs/plans/2026-04-19-slate-absolute-api-replan.mdslatecore 的 API 方向已定稿——事务优先写入transaction-first writes、快照/存储优先读取snapshot/store-first reads字段式编辑器状态从常规公开形态中移出。core 一旦定稿Tranche 4 才有条件推进。二、当前进度快照证明者已落地、门禁已回绿执行文档记录了这一批次的当前读数Current Read是理解整次执行的起点项状态core 定稿已完成absolute API replan 落地直接证明所有者 1history-contract.ts已落地直接证明所有者 2integrity-contract.ts已落地包所有者门禁bun test ./packages/slate-history/test/index.spec.ts回绿包收口门禁栈build / typecheck / lint 全绿缺失的历史对比所有者bun run bench:history:compare:local已恢复首个红色集群事务持有的 delete / join / insert-break 撤销行缺历史捕获根因是捕获仍坐在旧写入接缝上修复后历史捕获锚定到已提交的 publish 接缝而非旧版逐操作editor.apply(...)拦截其中首个红色集群是本次执行最有价值的部分一批失败的撤销 fixture 共享同一个根因——事务持有的删除、块合并join、插入换行insert-break撤销行没有被记录进历史因为捕获逻辑还挂在旧写入接缝上。修复动作只有一个把历史捕获从逐操作拦截迁移到已提交发布接缝见下一节。三、核心修复历史捕获必须锚定提交订阅者而非 onChange 顺序这次修复不是临时补丁而是有完整 root-cause 记录的架构决策其完整论证见仓库内解决方案文档 docs/solutions/logic-errors/2026-04-03-slate-history-capture-must-anchor-to-commit-subscribers-not-onchange-order.md。3.1 问题历史批次在 onChange 之后派生起初历史批次是在一个subscribe(...)监听器中派生的而该监听器运行在editor.onChange()之后。这意味着如果应用层的onChange()重入编辑器reentrant edit历史单元就会被抹脏或错误归属——尽管栈本应锚定在已提交事务上。当时的核心发布顺序是state.snapshot snapshot state.transaction null ;(editor as MutableEditor).operations transaction.operations.slice() syncPublicEditor(editor, snapshot) editor.onChange() // 应用回调 for (const listener of state.listeners) { listener(snapshot) // 历史在此派生 → 已晚于 onChange }3.2 修复把订阅通知挪到 onChange 之前修复方案是把Editor.subscribe(...)通知提前到editor.onChange()之前state.snapshot snapshot state.transaction null ;(editor as MutableEditor).operations transaction.operations.slice() syncPublicEditor(editor, snapshot) for (const listener of state.listeners) { listener(snapshot) // 提交接缝历史在此捕获 } editor.onChange() // 用户态通知延后这样slate-history可以在提交订阅者边界拿到三样东西精确的已提交快照、精确的已提交操作列表、尚未被用户态重入编辑污染的状态。而editor.onChange()被移出历史捕获的权威链——它只是用户态通知不再是提交元数据的可靠来源。3.3 预防规则文档沉淀的工程原则该解决方案文档同时沉淀了可复用的预防规则对任何事务感知子系统都适用子系统若声称事务感知其状态必须在提交边界派生而不是从应用回调派生把editor.onChange()视为用户态通知而不是提交元数据的来源添加提交时子系统前先问一句如果 onChange() 重入编辑器这个子系统还能正确捕获原始提交吗凡是依赖回调大概不会在这里重入的设计本身就是腐烂的设计。这与执行文档中commit-before-onChange history capture这一证明覆盖点完全对应——即证明历史捕获在onChange()重入破坏之前就看到已提交操作。四、直接证明所有者两层契约覆盖的行为面执行文档强调直接 proof-owner 覆盖已上线对应两个契约文件其行为面在 docs/slate-v2/ledgers/slate-history-api.md 中有逐行审计矩阵4.1 history-contract.ts历史行为的直接保持证明该所有者证明provesHistory.isHistory(...)生命周期真值普通 insert-text 撤销连续 insert-text 合并为一个撤销单元contiguous insert-text merge-as-one-undo-unit删除片段后先 deselect / 再 refocus 的选区恢复delete-fragment selection restore反向块 / 嵌套块 / 同文本删除的撤销reverse block / nested-block / same-text delete undoinsertBreak()撤销。4.2 integrity-contract.ts历史完整性的直接证明该所有者证明一个外层事务 一个撤销单元one outer transaction is one undo unitwithNewBatch(...)只切一次新批次其余合并进当前作用域withoutMerging(...)强制开启新批次withoutSaving(...)抑制历史记录writeHistory(...)仍是栈写入接缝stack-write seam历史捕获在 onChange() 重入抹脏之前看到已提交操作。从审计矩阵docs/slate-v2/ledgers/slate-history-api.md可以看到 legacy 测试行与证明者的映射关系apply-batch-exact-set-node、history-editor-flags、isHistory/*、redo-selection、undo/cursor/*、undo/delete_backward/*、undo/insert_break/basic、undo/insert_fragment/basic、undo/insert_text/*等全部映射到history-contract.ts而undo/insert_text/non-contiguous与index.js旧 fixture 入口被标记为explicit-skip——这正是显式跳过保持显式策略的落点。五、源码侧印证withHistory 与 HistoryApi 的实现语义执行文档的结论可以直接在仓库源码中印证。当前仓库中历史功能实现在 packages/slate/src/slate-history/with-history.ts 与 packages/slate/src/slate-history/history.ts。5.1 数据结构Batch 与 Historyhistory.ts 定义了历史对象结构export type History { redos: Batch[]; undos: Batch[]; }; type Batch { operations: Operation[]; selectionAfter?: TRange | null; // 重做后恢复的选区 selectionBefore: TRange | null; // 撤销前恢复的选区 };关键语义每个 Batch 同时携带 operations 与选区快照这是撤销/重做与选区恢复锚定在已提交事务真值上的数据基础。HistoryApi.isHistory()通过isPlainObject 双栈数组 操作列表校验来识别历史对象。5.2 编辑器级状态三个 WeakMap 标志withHistory与HistoryApi用三个 WeakMap 维护编辑器级历史状态见 history.tsSAVING是否保存操作到历史isSaving/withoutSaving控制MERGING是否合并进上一个批次isMerging/withMerging/withoutMerging控制SPLITTING_ONCE是否只切一次新批次withNewBatch控制。对应 APIHistoryApi.withMerging(editor, fn) // fn 内的操作合并进上一个历史单元 HistoryApi.withNewBatch(editor, fn) // 第一个操作开新批次后续照常合并 HistoryApi.withoutMerging(editor, fn) // 强制新批次不与上一个保存点合并 HistoryApi.withoutSaving(editor, fn) // 完全不入历史这四个 API 分别对应 integrity-contract 证明的四种行为也对应执行文档中merge / save / split flags的覆盖点。5.3 undo / redo 的实现路径with-history.ts 中的 undo 实现取undos栈顶 BatchwithoutSavingwithoutNormalizing包裹下将batch.operations逐操作求逆并反向应用OperationApi.inverse(...).reverse()恢复batch.selectionBefore把{ ...batch, selectionAfter: 当前选区 }写入redos栈并弹出 undos。redo 实现则直接重放batch.operations恢复batch.selectionAfter ?? batch.selectionBefore然后writeHistory(undos, batch)。这套逆操作重放 双向选区快照的机制正是 delete / join / insert-break 等结构性操作能正确还原选区的底层保证。5.4 合并与保存的判定shouldMerge / shouldSave历史单元合并判定在with-history.ts尾部shouldMerge仅当连续insert_textoffset 恰好衔接、path 相同或连续remove_textoffset text.length 恰好衔接、path 相同才合并——这是纯位置/结构判定不再依赖时间窗口对应执行文档不把退役的时序启发式拖回的承诺shouldSaveset_selection操作不写入历史其余保存栈上限undos.length 100时undos.shift()redos在新保存时清空。注意withHistory中apply拦截仍保留用于在应用操作前决策 save/merge但执行文档明确指出历史捕获的权威接缝已从旧版逐操作 apply 拦截迁移到已提交 publish 接缝——拦截点保留用于分批决策捕获真值则来自提交边界。另外e.writeHistory是公开的栈写入覆盖点stack-write override seam这正是 history-contract 证明的stack-write override seam。六、测试用例实证行为即契约仓库内 packages/slate/src/slate-history/with-history.spec.tsx 可以直接佐证执行文档声称的行为面纯选区操作不入历史editor.select(...)后history.undos/history.redos长度均为 0基础 insertText 可撤销插入text后undo()回到one且光标恢复到 offset 3连续 insertText 合并为一个撤销步骤三次insertText(t)(w)(o)后undos长度仅为 1、该批次含 3 个操作一次 undo 全部回退。这些用例与执行文档kept undo / redo parity rows和transaction-owned undo-unit capture的覆盖声明一一对应属于可在本仓库直接运行验证的正面证据。七、被拒绝的策略与显式跳过Rejected Tactics执行文档明确记录了三项被拒绝的策略理解它们有助于避免重复踩坑拒绝逐个修补失败的撤销 fixture因为同一事务持有族transaction-owned family整体仍红逐例打补丁只是掩盖共享根因拒绝把缺 history-contract.ts 文件当作主要问题包门禁已经证明存在更深的运行时缺陷文件缺失只是表象拒绝重开packages/slate/**除非某个被保持的历史行能证明 bug 真的在 core否则不动 core。同时保留了显式跳过清单Explicit Skiplegacy 基于时序的连续/非连续插入启发式timing-based contiguous / non-contiguous insert heuristics——保持显式跳过除非后续证据证明它们值得保留更宽的已删除 delete / fragment 行——同样保持显式跳过除非证据证明它们属于被保持的活跃声明kept live claim。这是不把退役的时序启发式或包装器时代假设拖回 core目标的具体落点。八、性能读数回到数十毫秒量级但仍有差距执行文档保留了完整的性能所有者状态Perf Owner Status对比命令已恢复bun run bench:history:compare:local最近一次对比读数current-vs-legacy操作增量typing undo29.35mstyping redo20.04msfragment undo25.29msfragment redo31.77ms结论性判断原文口径仍慢于 legacy但已不是灾难性的秒级回归形态回到低数十毫秒量级low-tens-of-ms band。该对比目标的元数据见 benchmarks/targets/slate-v2.jsonhistory-compare目标指标为history_compare_worst_p95_ratio方向 lower对比产物归档在 benchmarks/targets/history/slate-v2-latest.json。此外执行文档还设定了共享 core 回归下限一旦packages/slate/**有任何改动必须跑以下四条基准以证明没有把 core 拖慢bun run bench:slate:6038:local bun run bench:core:normalization:compare:local bun run bench:core:observation:compare:local bun run bench:core:huge-document:compare:local九、完整门禁体系从正确性到性能的验证栈执行文档把门禁分为四层可直接在本地复现正确性所有者correctness ownerbun test ./packages/slate-history/test/index.spec.ts直接证明所有者direct proof ownerbun test ./packages/slate-history/test/history-contract.ts bun test ./packages/slate-history/test/integrity-contract.ts包收口门禁package closeout gatesbunx turbo build --filter./packages/slate-history bunx turbo typecheck --filter./packages/slate-history bun run lint:fix bun run lint性能所有者perf ownerbun run bench:history:compare:local这套正确性 → 直接证明 → 收口 → 性能的四层门禁栈是该仓库所有包收口的标准验证模式也是 Tranche 4 判定可以停止阻塞的依据。十、收尾决策replan 不是失败是所有权移交执行文档的最后两个章节Next Move 与 Continue Checkpoint / Repeated Continue Rule记录了收尾决策将slate-history视为足够定稿不再阻塞 Tranche 4把显式跳过清单与有界性能读数bounded-perf read继续前移按包顺序移交到slate-hyperscript作为下一个包。而 Continue Checkpoint 判定为replan重规划并给出重复 Continue 规则该执行所有者已完成在没有新作用域、新反证或新阻塞变化的情况下对同一所有者重复continue应返回replan。理由是下一步诚实动作是变更包所有权在packages/slate-history继续改动就是发明工作invented work而非进展。这一规则体现了该仓库执行纪律的核心以证据和所有权边界为准而不是以持续产出代码为准。十一、总结这次收口真正沉淀了什么把 Tranche 4 的核心收获压缩成三条可复用经验历史是提交关注点不是应用回调关注点。任何事务感知子系统都应在提交订阅者边界派生状态onChange()只是用户态通知——这是本次执行最重要的架构结论完整论证见 docs/solutions/logic-errors/2026-04-03-slate-history-capture-must-anchor-to-commit-subscribers-not-onchange-order.md。显式跳过也是契约的一部分。删除的 legacy 行要有明确记录如 docs/slate-v2/ledgers/slate-history-api.md 的explicit-skip状态不能靠沉默遗忘更不能在无新证据时悄悄复活。性能要有可比读数与回归下限。任何 core 变更都要过bench:slate:6038等共享下限历史对比目标bench:history:compare:local的读数要持续留档benchmarks/targets/history/slate-v2-latest.json。对需要在 plate / slate-v2 生态中实现或评审撤销/重做 选区恢复类功能的开发者而言这份执行文档连同源码实现with-history.ts、history.ts与测试with-history.spec.tsx构成了一组可直接对照落地的完整样本。【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考