news 2026/9/17 2:23:18

Slate History Tranche 4 执行复盘:把撤销/重做捕获锚定到已提交事务接缝(plate 仓库 slate-v2 实践)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Slate History Tranche 4 执行复盘:把撤销/重做捕获锚定到已提交事务接缝(plate 仓库 slate-v2 实践)

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 落地)
直接证明所有者 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:历史行为的直接保持证明

该所有者证明(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-nodehistory-editor-flagsisHistory/*redo-selectionundo/cursor/*undo/delete_backward/*undo/insert_break/basicundo/insert_fragment/basicundo/insert_text/*等全部映射到history-contract.ts,而undo/insert_text/non-contiguousindex.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 标志

withHistoryHistoryApi用三个 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 相同)才合并——这是纯位置/结构判定,不再依赖时间窗口,对应执行文档"不把退役的时序启发式拖回"的承诺;
  • shouldSaveset_selection操作不写入历史,其余保存;
  • 栈上限:undos.length > 100undos.shift()redos在新保存时清空。

注意withHistoryapply拦截仍保留(用于在应用操作前决策 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)

执行文档明确记录了三项被拒绝的策略,理解它们有助于避免重复踩坑:

  1. 拒绝逐个修补失败的撤销 fixture:因为同一事务持有族(transaction-owned family)整体仍红,逐例打补丁只是掩盖共享根因;
  2. 拒绝把"缺 history-contract.ts 文件"当作主要问题:包门禁已经证明存在更深的运行时缺陷,文件缺失只是表象;
  3. 拒绝重开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)记录了收尾决策:

  1. slate-history视为足够定稿,不再阻塞 Tranche 4;
  2. 把显式跳过清单与有界性能读数(bounded-perf read)继续前移;
  3. 按包顺序移交到slate-hyperscript作为下一个包。

而 "Continue Checkpoint" 判定为replan(重规划),并给出"重复 Continue 规则":该执行所有者已完成,在没有新作用域、新反证或新阻塞变化的情况下,对同一所有者重复continue应返回replan。理由是:下一步诚实动作是变更包所有权;在packages/slate-history继续改动就是"发明工作"(invented work)而非进展。这一规则体现了该仓库执行纪律的核心:以证据和所有权边界为准,而不是以持续产出代码为准

十一、总结:这次收口真正沉淀了什么

把 Tranche 4 的核心收获压缩成三条可复用经验:

  1. 历史是提交关注点,不是应用回调关注点。任何事务感知子系统都应在提交订阅者边界派生状态,onChange()只是用户态通知——这是本次执行最重要的架构结论,完整论证见 docs/solutions/logic-errors/2026-04-03-slate-history-capture-must-anchor-to-commit-subscribers-not-onchange-order.md。

  2. "显式跳过"也是契约的一部分。删除的 legacy 行要有明确记录(如 docs/slate-v2/ledgers/slate-history-api.md 的explicit-skip状态),不能靠沉默遗忘,更不能在无新证据时悄悄复活。

  3. 性能要有可比读数与回归下限。任何 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 2:22:41

51单片机交通灯课程设计:从定时器到红外遥控的完整实现

简介:面向51单片机课程设计与期末大作业的完整交通灯设计方案,基于STC/AT89系列等常见51内核,包含源码、实验报告PDF与原理图等。项目覆盖LED数码管倒计时、按键调整、紧急模式等典型功能,代码注释详细,即使新手也能快…

作者头像 李华
网站建设 2026/9/17 2:21:34

前端跨域与实时通信:CORS、SSE、WebSocket 实战指南

先说结论:跨域和实时通信,是前端日常开发里绕不开的两座山。你几乎每天都会遇到“接口跨域了”“推送不实时”“WebSocket 连不上”这类问题。这篇文章我不打算给你念教科书,而是从实际开发场景出发,把跨域方案、SSE、WebSocket 这…

作者头像 李华
网站建设 2026/9/17 2:21:27

RD算法SAR图像仿真:从原理建模到硬件部署全流程

简介:本资源是一套基于MATLAB实现的SAR(合成孔径雷达)成像RD(距离-多普勒)算法仿真代码,面向遥感、雷达信号处理、地球观测等方向的本科生、研究生及工程技术人员,用于深入理解SAR图像形成机理与…

作者头像 李华
网站建设 2026/9/17 2:20:59

2026年广州瓷砖空鼓怎么修?不同情况处理方法不一样

瓷砖空鼓,就是瓷砖和墙面、地面之间“脱了层”,中间有空隙。用手敲一敲,声音发空,严重的踩上去会晃、会响,边缘翘起来。卫生间、厨房、阳台这些贴砖多的地方最常出现。别小看空鼓,它不只是难看,…

作者头像 李华
网站建设 2026/9/17 2:19:39

前缀树(Trie)原理与C++实现详解

1. 前缀树(Trie)基础与LeetCode 208题意解析前缀树是一种高效的树形数据结构,特别适合处理字符串相关问题。在LeetCode 208题中,我们需要实现一个基本的前缀树结构,包含insert、search和startsWith三个核心操作。这个数…

作者头像 李华