news 2026/9/17 11:32:05

Plate Slate v2 实战:异步 decorate 导致光标漂移的根因定位与浏览器级 Proof 设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Plate Slate v2 实战:异步 decorate 导致光标漂移的根因定位与浏览器级 Proof 设计

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之间向后跳回

文档同时给出了两条重要的既有状态证据:

  1. 修复前 ledger 只能声明Improves #5987,因为当时的证明只覆盖了"投影来源(projection source)稳定性",并没有针对精确的异步Editable.decorate复现路径;
  2. 相关 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

  1. Editable接收到的decorateprop 是一个函数身份(function identity)在异步状态更新之后才改变的函数;
  2. 用户在延迟装饰应用之前,已经在编辑器末尾键入了匹配文本;
  3. 延迟装饰可见地重排了渲染后的文本(DOM 结构确实变化了);
  4. 延迟装饰应用之后,浏览器选择权与 Slate 选择权都保持在键入处的文档末尾

任何一条不满足,issue 只能停留在Improves #5987。这个门槛的设计价值在于它拒绝了"假绿"(fake green)——后文的解法文档明确指出,只检查 Slate 模型选择权会得到假绿:模型选择权本身是正确的,但真实 DOM 光标是错的。

执行计划与结果

计划文档中的五步执行清单(全部完成):

  1. 新增一个镜像上游 issue 路径的decorations-async示例;
  2. 新增一条 Playwright 回归:在末尾键入 → 等待延迟高亮出现 → 断言光标稳定;
  3. 仅当新测试行失败时才修改slate-react(本次实际修改了);
  4. 对新测试行执行聚焦的浏览器验证;
  5. 用精确证明与最终声明更新 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.decorateuseSlateDecorationSourceuseSlateRangeDecorationSource、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 坚持要覆盖Editabledecorateprop 路径——两条入口共享同一失败模式,架构必须统一收敛。

验证命令与 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),仅供参考

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

CFD-Post后处理高效指南:三步法与批处理自动化

简介:ANSYS CFD-Post Users Guide 是 ANSYS 官方发布的 CFD-Post 使用指南,面向使用 Fluent、CFX、OpenFOAM 等求解器完成仿真后需要做结果处理与可视化的工程师、研究人员和学生。文档详细讲解 CFD-Post 的数据导入、切片/等值面/流线等可视化操作、变量…

作者头像 李华
网站建设 2026/9/17 11:28:28

Intel Vortex NPU调用与DCIM配置实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

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

用Dify搭建多语言PDF原格式翻译流水线:拆译合查全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华