Slate v2 多根位置权威治理:把 Root 推理收敛到单一内部模块的架构清理实录
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本篇技术指南基于 Slate v2 架构清理计划文档 2026-05-22-slate-v2-root-location-authority-cleanup-ralplan.md 展开,剖析多根(multi-root)运行时中“位置权威(root location authority)”这一核心正确性问题的完整治理过程。读完后你将掌握:为什么多根编辑器需要单一 root 推理模块、rootless 与显式 root 的公开行为契约如何设计、可重放状态字段的 patch 准入策略如何与 history/collab 持久化语义对齐,以及配套的合同测试矩阵与验证门禁应该怎么搭。
一、背景:多根运行时为什么需要单一位置权威
Plate 仓库中的 Slate v2 是面向多根编辑场景的重写版本,其核心包位于packages/slate(当前主检出树中,点/范围引用相关的编辑器辅助逻辑已组织在 packages/slate/src/internal/editor 目录下)。在这一版中,文档不再是单根结构:同一个 runtime 内可以同时存在main、header等多个命名根,光标、选区、操作重放都必须明确“自己属于哪个根”。
该计划文档给出的核心判断(Current Verdict)是:root-location 清理必须从 Pretext 布局架构计划中独立出来,成为一条独立的 Slate 核心清理轨道(相关背景可见 2026-05-22-slate-v2-pretext-layout-rendering-architecture-ralplan.md)。它明确列出这条轨道要承担的五个能力面:
- 多根
PointRef与RangeRef的行为; - 根感知的操作重放(operation replay)与选区求逆(selection inversion);
- rootless 公开 point/range 形态的保持(对外不暴露根信息);
- 内部 ref 元数据的归属(ownership);
- 可重放状态字段(state-field)的 patch 准入策略。
1.1 驱动这次清理的真实缺陷
为什么 root 元数据散落在各处会出问题?仓库中的解决方案文档 2026-05-23-slate-v2-rootless-explicit-selections-must-not-inherit-sibling-root.md 记录了一个典型事故链,它恰好印证了计划中“脏的 ad hoc 元数据会产生真实的 bug 类”这一判断:
- 先聚焦 Header 编辑器,再点击 Body(主根)中的第二段段落,直接崩溃:
Cannot find a descendant at path [1,0]; - 点击 body 之后的 commit 仍然报告
roots:header——即主根的路径被存到了 header 根名下; - 崩溃栈穿过
runtime-root-engine.ts中的state.marks.get():header 视图的 marks 读取试图在主根路径[1,0]上解析,却按 header 树解析,必然失败。
该事故的根因是“把两种语义压扁成了同一个回退”:
const getLocationMutationRoot = ( editor: Editor, location: Location ): string | undefined => getExplicitLocationRoot(location) ?? getActiveMutationRoot(editor) ?? MAIN_ROOT_KEY- 隐式变更(如
tx.text.insert('x'))应当跟随当前选区所在的根; - 显式 rootless 位置(如在 base runtime 上执行
tx.selection.set({ path: [1, 0], offset: 0 }))应当目标为main,而不是“当前选区恰好所在的根”。
解决方案文档中的结论与本次计划的原则一致:root 解析必须有明确的优先级链——显式根 > 活跃变更根 >MAIN_ROOT_KEY兜底,且这条链只能由一个权威模块维护。这正是后文root-location.ts单一权威模块要固化的东西。
二、决策简报:从散落的 root fallback 到单一权威
计划文档的 Decision Brief 给出了五条设计原则:
- 一个内部模块拥有 root 推理(one internal module owns root inference);
- rootless 的调用方输入,在公开输出上保持 rootless;
- 显式 root 必须穿过 transform 和
unref()存活下来; - undo/replay 恢复的是选区身份,包括根身份;
- 大型可重放状态必须可 patch,否则在进入 history/collab 持久化之前就被拒绝。
Top drivers(决策主驱动)有三条:多根运行时正确性依赖单一 root 权威;__explicit*Root这类散落的 ad hoc 元数据已被证明会产生漂移(drift);状态字段评审发现证明“重放策略必须与实际 history/collab 持久化语义一致”。
文档同时记录了三个候选方案及取舍:
| 方案 | 结论 | 理由 |
|---|---|---|
| 保留在 Pretext/布局计划中继续修 | 否决 | 那个计划讲的是布局与 DOM 物化;root ref 与操作求逆属于 Slate 核心 |
| root 辅助函数继续在各调用点旁复制 | 否决 | 已产生真实 bug 类:path/ref/op 代码会互相漂移 |
| 独立的 root-location authority 计划 + 以活源码收口 | 采用 | 与代码归属一致,且能把剩余评审门禁收窄 |
四条决策边界(decision boundaries)值得单独强调,它们定义了这次清理“做什么、不做什么”:
- root 是位置/操作元数据,不是 path 的一段;
- 公开 point/range 值保持调用方原始形态;
- 内部 transform 可以临时注入 root,但只有内部 helper 有权决定发布前剥掉什么;
- 任何剩余的公开
PointRef/RangeRefroot 表面问题必须由最终评审裁决,不允许被当作风格偏好埋掉。
三、目标架构:root-location 模块的归属与公开行为
计划指定的目标模块是packages/slate/src/internal/root-location.ts(以.tmp/slate-v2工作树为准),并逐项列出其应拥有的职责:
MAIN_ROOT_KEY常量;- 来自 operation、point、range、selection patch 的 root 推理;
- 显式 root 与隐式 root 的可见性区分;
- 隐式 root 的注入(injection)与剥离(stripping);
- ref 的 root 元数据存储;
- 公开/内部 range-ref 的可见性与 draft 状态。
3.1 公开行为契约
计划列出的五条公开行为是验收标准:
- rootless 的 point/range 输入可以在内部绑定到活跃根;
- 公开的
current与unref()返回的根可见性与调用方传入时一致(传 rootless 就得到 rootless,传显式 root 就得到显式 root); - 兄弟根的操作不会 transform 该 ref;
- 选区求逆恢复的是“被恢复选区”的根;
- 默认 history 的状态字段,除非显式声明
history: 'skip',一律视为可重放。
3.2 Hard Cuts(硬性切断项)
计划以 hard cuts 的形式锁死了实现红线:
- 不允许
__explicitRoot; - 不允许
__explicitAnchorRoot; - 不允许
__explicitFocusRoot; - 不允许在 ref/op/transform 调用点重复 root fallback 辅助函数;
- 除非出现浏览器可见的回归,不为这条内部核心清理宣称浏览器证明(browser proof)。
评审收口阶段实际落实的清理是:PointRef与RangeRef不再暴露公开的root?: string字段,root 绑定与根可见性全部迁入root-location.ts内的 WeakMap 元数据。WeakMap 方案同时解决了两个问题:公开 point/range 值保持调用方形态、不需要克隆对象即可携带不可见的绑定关系。
3.3 活源码落点(计划记录的实现证据)
计划文档附了一张“Live Source Grounding”表,记录了.tmp/slate-v2工作树中各表面的落点与结论(均为 keep):
| 表面 | 计划记录的落点 | 结论 |
|---|---|---|
| 内部 root 权威 | packages/slate/src/internal/root-location.ts:定义MAIN_ROOT_KEY;用 WeakMap 存 ref 元数据;拥有 operation/point/range/selection-patch 的 root 推理;拥有隐式 root 注入与剥离 | keep |
PointReftransform | packages/slate/src/interfaces/point-ref.ts:读取 root 元数据;忽略兄弟根操作;注入后再剥离隐式 root | keep |
RangeReftransform | packages/slate/src/interfaces/range-ref.ts:读取 root 元数据;忽略兄弟根操作;transform 并产出公开 ref 草稿 | keep |
| Point ref 创建 | packages/slate/src/editor/point-ref.ts:使用活跃操作根兜底;写入 root 元数据 | keep |
| Range ref 创建/发布 | packages/slate/src/editor/range-ref.ts:活跃操作根兜底;写入元数据;发布/重置公开草稿 | keep |
| Selection inverse | packages/slate/src/interfaces/operation.ts:导入getSelectionPatchRoot;inverse 根从被恢复的 selection patch 推导 | keep |
| 状态字段重放策略 | packages/slate/src/core/public-state.ts:history !== 'skip'或collab === 'shared'时要求 patch hooks;拒绝超大的不可 patch 重放状态 | keep |
| 状态字段回归测试 | packages/slate/test/document-state-patch-contract.ts:拒绝大型 omitted-history 字段缺少 patch hooks 的注册 | keep |
需要说明的证据边界:上述路径是计划文档对其.tmp/slate-v2工作树的记录,当前主检出树中packages/slate/src/internal/下尚不包含该文件(核心实现以计划记录与工作树为准)。但与之同源的问题修复与 root 解析链语义在 2026-05-23 解决方案文档 中有独立佐证,且后续计划 2026-05-23-slate-v2-staged-architecture-cleanup-ralplan.md 也明确以“保留packages/slate/src/internal/root-location.ts为单一权威”为前提,禁止在 text transform 与 history 中再次复制 root-location 辅助函数。
四、可重放状态字段 patch 准入策略
这条轨道还有一个容易被忽视但影响面很大的决策:状态字段的“重放准入”。原则第 5 条——“大型可重放状态必须可 patch 或拒绝”——在计划记录的实现中体现为public-state.ts的策略:当字段history !== 'skip'或collab === 'shared'时,注册必须提供 patch hooks;超大且不可 patch 的重放状态直接拒绝。配套回归测试(document-state-patch-contract.ts)专门验证“大型 omitted-history 字段缺少 patch hooks 会被拒绝”这一行为。
这条策略解决的是计划里点名的问题:状态字段的评审发现证明重放策略必须与实际 history/collab 持久化语义一致。也就是说,凡是可能进入撤销栈或协作同步通道的状态,注册时就必须回答“如何以小 diff 形式重放它”,否则宁可拒绝注册也不允许整块塞进历史——这为多根编辑器中大型根状态(例如某个根视图的整棵子树状态)的撤销/协作能力划清了准入线。
五、测试覆盖目标与验证门禁
5.1 聚焦合同测试矩阵
计划要求在收口前必须存在以下聚焦覆盖(测试文件路径以.tmp/slate-v2工作树为准):
| 文件 | 必须覆盖的行为 |
|---|---|
packages/slate/test/root-location-contract.ts | getOperationRoot、getPointRoot、getRangeRoot、getSelectionPatchRoot、隐式注入/剥离、根不匹配的 range |
packages/slate/test/editor-runtime-view-contract.ts | 在header视图内创建的 rootless point/range ref:随header操作移位、忽略main操作 |
packages/slate/test/editor-runtime-view-contract.ts | 从header视图发起的多块删除只编辑header内容 |
packages/slate/test/rooted-operation-contract.ts | inverseset_selection恢复main -> header、header -> null、null -> header三种根迁移行为 |
packages/slate/test/range-ref-contract.ts | 公开rangeRef.current、草稿发布、unref()保持显式/rootless 输入形态 |
packages/slate/test/range-ref-contract.ts | 公开/内部 range ref 只会被匹配根的操作移除 |
packages/slate/test/transaction-contract.ts | 提交的 root-scopedset_selection操作携带活跃根,而中间件 payload 保持调用方形态 |
packages/slate/test/interfaces-contract.ts | point/range 的 equality、compare、intersection 保持根感知语义 |
packages/slate-history/test/document-state-history-contract.ts | undo/redo 在编辑后恢复多根选区与根作用域 ref |
packages/slate/test/document-state-patch-contract.ts | 大型 omitted-history 状态字段缺少 patch hooks 时被拒绝 |
覆盖红线(coverage rejects)同样明确:不做死代码删除断言;root 语义不允许只靠快照证明;除非发现浏览器可见回归,否则不加浏览器测试。这体现了评审矩阵中tdd视角的落地:每一条行为声明都必须经由公开 editor API 证明,而不只是私有 helper 的形状。
5.2 门禁命令
计划记录的聚焦门禁(Fast Driver Gates)如下,注意其工作目录是.tmp/slate-v2工作树,而非主检出树:
# cwd: /Users/zbeyens/git/plate-2/.tmp/slate-v2 bun test ./packages/slate/test/document-state-patch-contract.ts \ ./packages/slate/test/collab-document-state-contract.ts \ ./packages/slate/test/root-location-contract.ts \ ./packages/slate/test/editor-runtime-view-contract.ts \ ./packages/slate/test/rooted-operation-contract.ts \ ./packages/slate/test/range-ref-contract.ts \ ./packages/slate/test/transaction-contract.ts \ ./packages/slate/test/interfaces-contract.ts \ ./packages/slate-history/test/document-state-history-contract.ts bun typecheck:packages bun lint:fix codex review --uncommitted收口前的宽面门禁:
# cwd: /Users/zbeyens/git/plate-2/.tmp/slate-v2 bun test:bun bun typecheck:packages bun lint以及计划侧门禁(主仓库根目录):
# cwd: /Users/zbeyens/git/plate-2 node tooling/scripts/completion-check.mjs计划记录的验证证据包括:bun lint通过;聚焦合同测试组合bun test ./packages/slate-react/test/runtime-live-state-contract.ts ./packages/slate-history/test/history-contract.ts ./packages/slate-history/test/document-state-history-contract.ts ./packages/slate/test/editor-runtime-view-contract.ts结果为87 pass, 0 fail;slate、slate-history、slate-react三个包的bun --filter ... typecheck全部通过;用户停止评审前的部分 Codex 评审扫描也覆盖了bun lint、bun typecheck:root、git diff HEAD --check、slate-react Vitest 与bun build:packages。
六、评审矩阵与维护者异议处理
评审矩阵(Applicable Review Matrix)记录了每个视角的取舍及理由,这是该计划工程纪律的缩影:
| 视角 | 结论 | 理由 |
|---|---|---|
tdd | 应用 | 每条行为声明必须通过公开 editor API 证明 |
testing | 应用 | 聚焦合同测试是正确层级;纯核心清理不需要浏览器/e2e |
performance-oracle | 按计划应用 | root 检查保持 O(1),WeakMap 元数据避免克隆,只在 root 注入/剥离边界有开销 |
vercel-react-best-practices | 跳过 | 本轨道没有 React 渲染/订阅表面 |
performance/react-useeffect/shadcn | 跳过 | 无生产 RUM 声明 / 无 effect / 无 UI |
维护者异议台账(Maintainer Objection Ledger)中三条最有参考价值的交锋是:
- “为一个小 bug 引入内部模块是不是过度抽象?”——回答:该 bug 横跨 point ref、range ref、operation、transform、history 与状态重放六类调用方,单一 helper 才能阻止跨调用方漂移;
- “WeakMap 隐藏元数据比公开字段更难排查”——回答:它换来的是公开 point/range 值保持调用方形态,避免在公开对象上挂
__explicit*Root这类脏字段;若未来需要ref.root的调试可见性,应由最终评审决定是否把根完全收进 WeakMap 权威(台账 verdict 为 “revise if review accepts”); - “没有浏览器证明是否够?”——回答:被触碰的是包内核心且有直接的 editor/history 合同测试,只有出现浏览器可见回归才追加浏览器证明。
Issue 记账方面,计划明确声明:本清理不携带任何Fixes #.../Improves #...issue 声明,是附着在多根运行时语义上的内部正确性与架构清理;因此 docs/slate-v2/references/pr-description.md、docs/slate-v2/ledgers/issue-coverage-matrix.md、docs/slate-v2/ledgers/fork-issue-dossier.md 三份引用文档均保持不变。
七、收口过程与完成门禁:一个“带停止点的收口”如何诚实记录
这份计划最特别的地方在于它的 Pass-State Ledger 如实记录了一次被用户显式停止的评审循环:
| Pass | 状态 | 证据 |
|---|---|---|
| current-state read | 完成 | 列出了活源码与测试,创建独立 root-location 计划 |
| plan split | 完成 | 本计划接管 root-location;旧布局计划不再拥有该收口 |
| final-codex-review-closeout | 被用户停止 | 跑了部分评审扫描;用户显式停止了宽面评审循环;如实记录“未得到干净的最终评审结论” |
| completion-closeout | 完成 | 作用域状态更新后node tooling/scripts/completion-check.mjs通过,completion 文件指向本计划且status: done |
其完成门禁(Final Completion Gates)的定义值得注意:计划被判定为 done 的条件包括“用户停止了剩余评审循环”“已返回并被接受的评审发现已修复”“聚焦的 root-location/state-field/history 门禁通过”“被触碰包的 lint 与 typecheck 通过”“无需变更 issue/引用台账”“completion 文件指向本计划且状态为 done”。计划明确写道:最终的宽面 Codex review 结论被有意不宣称(“The final broad Codex review verdict is intentionally not claimed”),残留的宽面评审风险作为“用户显式停止”的记录被接受,而不是伪造一个“最终干净评审通过”的结论。文档头部的status: done、score: 0.94(等于target_score)与这条诚实记录是一致的。
八、关键要点
- 单一权威原则:多根编辑器的 root 推理(operation/point/range/selection patch 四类来源)必须收敛到一个内部模块,任何调用点级的 fallback 复制都被视为漂移源;
- 公开形态契约:rootless 输入在公开输出上保持 rootless,显式 root 穿过 transform 与
unref()存活,根可见性由 WeakMap 内部元数据承载,公开接口不出现root?字段; - 求逆语义:undo/选区求逆恢复的是“被恢复选区”的根身份,
main -> header、header -> null、null -> header三类迁移都要有合同测试; - 重放准入:进入 history/collab 的大型状态字段必须可 patch,否则注册期即拒绝,使重放策略与持久化语义一致;
- 验证纪律:聚焦合同测试矩阵 + 聚焦/宽面双层 bun 门禁 + 评审矩阵视角取舍 + 被停止的评审循环如实记录,共同构成一次“可审计收口”的完整样板。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考