Plate Slate v2 实战:异步 decorate 导致光标漂移的根因定位与浏览器级 Proof 设计
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本篇以 Plate 仓库中的 异步 decorate 光标簇验证计划 为主体,完整还原 Slate 上游问题 #5987(decorate因异步状态更新而改变时,光标会跳回)在 Slate v2 fork 中的复现路径、修复方案与浏览器集成证明(browser proof)设计,并延伸到仓库中的 架构跟进评审 与 解法沉淀文档。读完后你能掌握:如何为"仅重排 DOM 结构、不产生编辑器提交"的渲染变更设计选择权修复(selection repair)机制,以及如何用 Playwright 同时对 Slate 模型选择和浏览器 DOM 光标做双轨断言。
问题背景:decorate的异步更新与光标跳变
该计划文档处理的是 Slate issue ledger 中的 Cluster 10,对应上游 issue#5987:当decorate从一个异步状态更新中改变时,光标会跳到错误位置。上游 PR#6033通过让装饰(decoration)重排与Editable的选择权(selection)恢复保持同步来修复了同一失败模式。
计划文档中记录的复现场景非常具体:
- 编辑器内容为
This is some text here about. there,光标位于行尾; - 用户继续键入
there; - 一个延迟出现的高亮(decoration)随后应用到文本上;
- 此时光标在
about.与there之间向后跳回。
文档同时给出了两条重要的既有状态证据:
- 修复前 ledger 只能声明
Improves #5987,因为当时的证明只覆盖了"投影来源(projection source)稳定性",并没有针对精确的异步Editable.decorate复现路径; - 相关 Slate v2 表面仍然保留
Editable.decorate这一兼容 prop,因此精确证明必须针对这个适配层(adapter)本身,而不只是 v2 一等公民的decorationSources路径。
这正是装饰类 bug 的典型困难:装饰应用会改变 DOM 结构或叶子(leaf)边界,而选择权映射与输入法(IME)合成时序对这种变化极其敏感,外部时序驱动的装饰更新会进一步放大问题——仓库的装饰/注解簇文档 decorations-annotations-cluster 把这一族问题(#3309、#3162、#4712、#5987、#4581 等)归纳为"装饰后的 DOM 与编辑器状态漂移",与本计划同属一个根因家族。
Claim Bar:把Improves升级为Fixes的四条硬标准
计划文档定义了一个可审计的"声明门槛"(Claim Bar):只有当浏览器集成测试同时证明以下四点,才允许把 issue 声明从Improves #5987升级为Fixes #5987:
Editable接收到的decorateprop 是一个函数身份(function identity)在异步状态更新之后才改变的函数;- 用户在延迟装饰应用之前,已经在编辑器末尾键入了匹配文本;
- 延迟装饰可见地重排了渲染后的文本(DOM 结构确实变化了);
- 延迟装饰应用之后,浏览器选择权与 Slate 选择权都保持在键入处的文档末尾。
任何一条不满足,issue 只能停留在Improves #5987。这个门槛的设计价值在于它拒绝了"假绿"(fake green)——后文的解法文档明确指出,只检查 Slate 模型选择权会得到假绿:模型选择权本身是正确的,但真实 DOM 光标是错的。
执行计划与结果
计划文档中的五步执行清单(全部完成):
- 新增一个镜像上游 issue 路径的
decorations-async示例; - 新增一条 Playwright 回归:在末尾键入 → 等待延迟高亮出现 → 断言光标稳定;
- 仅当新测试行失败时才修改
slate-react(本次实际修改了); - 对新测试行执行聚焦的浏览器验证;
- 用精确证明与最终声明更新 Cluster 10 的 ledger。
修复前的浏览器测试行复现了精确的失败,两个关键数字:
- Slate 模型选择权停留在偏移量
41(键入后的文档末尾,正确); - 浏览器 DOM 光标停留在偏移量
35(旧装饰文本的结尾处,错误)。
这两个数字的落差就是根因的精确刻画:文档模型没变(没有编辑器提交),但装饰刷新把文本 DOM 切开并包上了高亮节点,DOM 字符偏移映射随之改变,而 DOM 光标仍钉在旧的装饰边界上。
Changeset 判定
计划文档还给出了 changeset 决策规则:若本次只新增站点示例、测试与 ledger 更新,则不需要 changeset;但如果.tmp/slate-v2/packages下的包运行时代码发生变化,则必须在收尾前判定 changeset 必要性。本次运行时代码确实变更于slate-react,因此 Slate v2 checkout 中包含.changeset/async-decorate-caret.md,而 Plate 侧不需要 changeset。
修复方案:投影刷新后强制一次 repair render
仓库中对应的解法沉淀文档 2026-05-23-async-decorate-refresh-must-export-dom-selection.md 把机制讲透了:
根因:Editable.decorate可以在用户键入之后由一次异步 React 状态更新而改变。装饰刷新会切开并包裹文本 DOM,但不会创建 Slate 编辑器提交(document 本身没变),因此"由提交驱动的 DOM 选择权导出"这条正常路径根本不会执行。
为什么常规思路不成立(该文档的 What Didn't Work 部分):
- 仅保证 projection-source 稳定性不够:文本渲染是正确的,但浏览器光标仍跟随旧 DOM 边界;
- 只断言 Slate 模型选择权是假绿:模型选择权已经正确,真实光标却是错的。
修复:让投影刷新报告"渲染后的投影桶是否发生了变化",editable 运行时只订阅一次这些刷新结果,并在非编辑器来源的投影刷新改变了渲染文本时请求一次渲染修复(repair render):
return projectionStore.subscribeProjectionRefresh((result) => { if (!result.requiresDOMSelectionExport) return requestEditableRepair({ forceRender: true, kind: 'force-render', selectionSourceTransition: { preferModelSelection: true, reason: 'projection-refresh', selectionSource: 'model-owned', }, }) })关键在于preferModelSelection: true:修复渲染完成后,选择权导出以模型选择权(偏移量 41)为准重新落到 DOM 上。由于纯装饰性质的 DOM 变更不产生编辑器提交,这一步为选择权导出提供了"在投影文本 DOM 已重排之后的一个渲染通道",使装饰 DOM 重排与选择权恢复落在同一个 repair 窗口内——这与上游 #6033 的根因结论一致。
浏览器级回归证明:双轨断言写法
回归测试放在浏览器套件而不是模型级单元测试中(解法文档原文:回归住在浏览器套件,不是 model-only unit test)。测试核心是"模型选择权 + DOM 光标"双轨断言:
await page.keyboard.type(' there') await expect(page.locator('[data-cy="async-decoration-highlight"]')).toHaveCount(3) await editor.assert.selection({ anchor: { path: [0, 0], offset: 41 }, focus: { path: [0, 0], offset: 41 }, }) await expect .poll(() => getDOMCaretOffsetInFirstText(editor.root)) .toEqual({ offset: 41, text: 'This is some text here about. there there', })逐行看这段证明在验证 Claim Bar 的哪一条:
page.keyboard.type(' there')对应标准第 2 条:在异步装饰应用前于编辑器末尾键入;- 等待
async-decoration-highlight出现 3 个,对应标准第 3 条:延迟装饰可见地重排了渲染文本; editor.assert.selection断言 Slate 模型选择权锚点与焦点都落在[0, 0]路径偏移41;poll(getDOMCaretOffsetInFirstText)轮询真实 DOM 光标偏移与所在文本,断言其同样到达41且文本为键入后的完整字符串。
只有两个断言同时通过,才说明模型选择权与 DOM 光标在延迟装饰重排后仍保持在键入处的文档末尾(标准第 4 条)。
架构跟进:从"adapter 直接 force render"到投影刷新契约
该仓库没有止步于一个能跑通的补丁。跟进评审文档 2026-05-23-slate-v2-projection-refresh-selection-repair-ralplan.md 对第一版修复给出了明确判定:"当前修复:是好的回归修复,但不是最终最优架构"(当前评分 0.82,目标架构 0.94)。
问题在于第一版让 legacy 适配层直接调用EDITOR_TO_FORCE_RENDER.get(editor)?.(),这证明根因但把Editable.decorate适配层耦合到了 editable repair 渲染器上。评审用源码行级证据定位了耦合点(ledger 记录于.tmp/slate-v2checkout:packages/slate-react/src/components/editable-text-blocks.tsx中创建 legacy 装饰来源、在decorate身份变化时刷新来源并直接触发 force render;projection-store.ts已具备正确的概念性所有者projectionStore.refresh(...);而use-slate-decoration-source.ts的一等装饰来源存在同样的外部刷新形态——所以架构问题不能只在Editable.decorate一处解决)。
目标架构的核心是一个内部契约类型:
type SlateProjectionRefreshResult = { changedRuntimeIds: readonly RuntimeId[] changedSourceId?: string didChange: boolean reason: SlateSourceDirtinessContext['reason'] requiresDOMSelectionExport: boolean }SlateProjectionStore.refresh()返回或发布这个结果,editable 侧只保留一个修复桥(useProjectionDOMRepairBridge/projection-repair-bridge.ts),仅在requiresDOMSelectionExport为真时调度类型化修复:
requestEditableRepair({ reason: 'projection-refresh', selection: 'export-model-to-dom-after-commit', runtimeIds: changedRuntimeIds, })适配器规则随之收紧:适配器不得触碰EDITOR_TO_FORCE_RENDER。允许的是source.refresh({ forceInvalidate: true, reason: 'external' });这条规则适用于 legacyEditable.decorate、useSlateDecorationSource、useSlateRangeDecorationSource、annotation 刷新路径,以及会改变光标周边可选 DOM 的 widget 刷新路径。该桥由 editable 运行时安装,因为只有它能协调 React 提交时序、DOM 修复、IME 状态与选择权导出。
公共 DX 保持不变:Editable decorate={decorate}继续作为适配兼容存在,decorationSources/投影来源是 v2 主路径,forceRender、选择权修复标志、投影修复内部细节一律不对外暴露。
评审文档还记录了该架构落地的 RED/GREEN 过程:在桥接实现之前,一等 hook 来源(useSlateDecorationSource)的浏览器行失败——DOM 光标偏移35对期望41(因为第一版的 force-render 只留在 legacy prop 适配层);桥接落地后,prop 与 hook 两条来源行都把 Slate 选择权与 DOM 光标保持在41。这也解释了为什么 Claim Bar 坚持要覆盖Editable的decorateprop 路径——两条入口共享同一失败模式,架构必须统一收敛。
验证命令与 Ledger 状态
计划文档记录的验证三件套(在.tmp/slate-v2checkout 内执行):
bun lint:fix bun --filter slate-react typecheck PLAYWRIGHT_RETRIES=0 bun playwright playwright/integration/examples/decorations-async.test.ts --project=chromium跟进评审追加了契约级测试bun test ./packages/slate-react/test/projections-and-selection-contract.tsx。最终声明为Fixes #5987,且跟进架构升级不改变该声明——评审原话:"#5987 保持Fixes #5987,本次工作不改变公共 issue 声明,它只是升级该声明背后的架构";相关的装饰失效压力(#4993、#4997、#3383)在各自精确复现通过前不追加修复声明。
仓库 ledger 中该 issue 的最终状态可交叉核对:issue-coverage-matrix 记录 #5987 为Fixes,证明描述为"精确的异步Editable.decorate浏览器证明在延迟装饰回调身份变化与 DOM 重排之后,保持 Slate 选择权与浏览器 DOM 光标于键入处文档末尾";gitcrawl-v2-sync-ledger 将其归入 v2-input-runtime 泳道、状态fixes-claimed;gitcrawl-recluster-map 则把证明文件(示例site/examples/ts/decorations-async.tsx、测试playwright/integration/examples/decorations-async.test.ts)映射到对应簇。注意这些被引用的实现与测试文件位于文档记录的.tmp/slate-v2私有 checkout 路径下,本仓库主树不包含它们,交叉核对应以 ledger 记录为准。
可复用的工程经验
这个案例沉淀出的预防规则(来自解法文档的 Prevention 一节,值得在维护任何"模型 + DOM 投影"型编辑器时借鉴):
- 装饰类 bug 的回归必须双轨断言:同时断言 Slate 模型选择权与浏览器 DOM 光标位置,单轨断言会产生假绿;
- 投影刷新改变渲染文本却不产生编辑器提交时,投影存储应显式报告这一变化,由 editable 运行时负责 DOM 选择权导出;
- 适配器不得直接 force-render:适配器只刷新来源,修复时序归 editable 运行时所有;
- 为"异步 UI 状态改写文本 DOM"的场景保留精确的浏览器测试行——模型级单元测试无法覆盖"React 提交后的 DOM 光标落点"这一失败面。
从更宽的视角看,这条修复线也印证了仓库装饰/注解簇文档的核心判断:装饰应用改变 DOM 结构与叶子边界,选择权映射与合成时序对其极度敏感,decorate这类"外部时序驱动的 prop"是结构性脆弱点;把失效信号归位到投影刷新结果、把修复时序归位到 editable 运行时,正是把这种脆弱性从"回调身份游戏"转化为显式契约的做法。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考