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.md:slatecore 的 API 方向已定稿——事务优先写入(transaction-first writes)、快照/存储优先读取(snapshot/store-first reads),字段式编辑器状态从常规公开形态中移出。core 一旦定稿,Tranche 4 才有条件推进。
二、当前进度快照:证明者已落地、门禁已回绿
执行文档记录了这一批次的"当前读数"(Current Read),是理解整次执行的起点:
| 项 | 状态 |
|---|---|
| core 定稿 | 已完成(absolute API replan 落地) |
| 直接证明所有者 1 | history-contract.ts已落地 |
| 直接证明所有者 2 | integrity-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:历史行为的直接保持证明
该所有者证明(proves):
History.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 undo);
insertBreak()撤销。
4.2 integrity-contract.ts:历史完整性的直接证明
该所有者证明:
- 一个外层事务 = 一个撤销单元(one outer transaction is one undo unit);
withNewBatch(...)只切一次新批次,其余合并进当前作用域;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 与 History
history.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.ts):
SAVING:是否保存操作到历史(isSaving/withoutSaving控制);MERGING:是否合并进上一个批次(isMerging/withMerging/withoutMerging控制);SPLITTING_ONCE:是否只切一次新批次(withNewBatch控制)。
对应 API:
HistoryApi.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栈顶 Batch; withoutSaving+withoutNormalizing包裹下,将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_text(offset 恰好衔接、path 相同)或连续remove_text(offset + text.length 恰好衔接、path 相同)才合并——这是纯位置/结构判定,不再依赖时间窗口,对应执行文档"不把退役的时序启发式拖回"的承诺;shouldSave:set_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 Skip):
- legacy 基于时序的连续/非连续插入启发式(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 undo | +29.35ms |
| typing redo | +20.04ms |
| fragment undo | +25.29ms |
| fragment redo | +31.77ms |
结论性判断(原文口径):仍慢于 legacy,但已不是灾难性的秒级回归形态,回到低数十毫秒量级(low-tens-of-ms band)。该对比目标的元数据见 benchmarks/targets/slate-v2.json(history-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 owner):
bun test ./packages/slate-history/test/index.spec.ts直接证明所有者(direct proof owner):
bun test ./packages/slate-history/test/history-contract.ts bun test ./packages/slate-history/test/integrity-contract.ts包收口门禁(package closeout gates):
bunx turbo build --filter=./packages/slate-history bunx turbo typecheck --filter=./packages/slate-history bun run lint:fix bun run lint性能所有者(perf owner):
bun 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),仅供参考