Tolaria ADR 0073:跨编辑器重挂载持久化 linkify 协议注册表——pnpm 补丁与 globalThis 标志协同方案
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
本文基于 Tolaria 的决策记录 docs/adr/0073-persistent-linkify-protocol-registry-across-editor-remounts.md,讲清一个具体问题的完整解法:在"单一编辑器壳长期存活 + 笔记切换 + 富文本/原始模式切换"的生命周期下,如何通过 pnpm 补丁修改@tiptap/extension-link与@blocknote/core,让 linkify 自定义协议在每次应用运行时只注册一次、且注册表在编辑器销毁/重建之间存活,从而消除重复的linkifyjs: already initialized警告。读完后,你将掌握用 pnpmpatchedDependencies治理依赖库生命周期行为的完整方法,以及如何为这类"生命周期特有"的回归编写可验证的冒烟测试。
背景:持久化编辑器壳与上游"一次性注册"模型的冲突
Tolaria 的富文本编辑建立在 BlockNote(底层为 Tiptap/ProseMirror)之上。从 src/components/Editor.tsx 的源码结构看,编辑器壳是常驻的:rawMode作为状态贯穿组件,handleToggleRaw负责在 BlockNote 富文本视图与 CodeMirror 原始 Markdown 视图之间切换,而笔记之间的切换并不销毁编辑器壳。这意味着同一应用运行期内,会反复发生"编辑器实例销毁 → 重建"的循环。
问题在于,上游 BlockNote/Tiptap 的链接栈对 linkify 协议注册的假设是:每个编辑器生命周期内"一次性"完成注册。ADR 原文(见上方 ADR 文件的 Context 一节)指出两个关键事实:
- Tiptap 的 link 扩展在
onDestroy()中会调用 linkifyjs 的reset(),把自定义协议注册表清空; - BlockNote 侧虽然用模块级变量保证"只注册一次",但上游整体模型仍以编辑器实例为单位设计。
在 Tolaria 的持久化生命周期里,这种模型会在"打开笔记"和"编辑器重挂载"流程中反复触发协议再注册,产生重复的linkifyjs: already initialized警告。ADR 强调这是一个横切问题:BlockNote 和 Tiptap 两侧都参与,只有当协议注册在 teardown/remount 循环之间存活、而不是被机会性地反复执行时,故障才会消失。
ADR 同时说明了为什么不能"忍":如果选择容忍或压制这些警告(下文 Option B),编辑器的生命周期正确性就悬在噪声性的重复初始化之上,未来的回归会很难推理。
协议清单:哪些 linkify scheme 需要预注册
从补丁文件 patches/@blocknote__core@0.46.2.patch 中被修改的 dist 代码可以看到,BlockNote 导出的合法链接协议全集为:
VALID_LINK_PROTOCOLS = ["http", "https", "ftp", "ftps", "mailto", "tel", "callto", "sms", "cid", "xmpp"] DEFAULT_LINK_PROTOCOL = "https"其中http、https、ftp、ftps、mailto属于 linkify 自带能识别的协议;真正需要"自定义注册"的是tel、callto、sms、cid、xmpp这 5 个非原生 scheme。linkifyjs 的约束是:自定义协议必须在 linkify 首次被初始化之前注册,若在初始化之后调用registerCustomProtocol,就会输出linkifyjs: already initialized - will not register custom scheme警告。这正是补丁方案的核心约束——把注册时机提前到"模块加载时、linkify 初始化前",而不是"编辑器创建时"。
决策:用 pnpm 补丁改写上游包,让注册表跨重挂载存活
ADR 的 Decision 一节明确了方案:Tolaria 直接补丁上游的 BlockNote 与 Tiptap link 包,使自定义 linkify 协议在每次应用运行时预注册一次,且不在编辑器销毁时被重置;被补丁的包通过globalThis上的标志位协同,Tolaria 用 pnpm patched dependencies 追踪这些修改,而不是在应用代码里做临时性的运行时 monkey-patch。
补丁在依赖体系中的登记位置
pnpm-workspace.yaml 将受影响的上游包登记为补丁依赖:
patchedDependencies: '@blocknote/code-block@0.46.2': patches/@blocknote__code-block@0.46.2.patch '@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch '@blocknote/react@0.46.2': patches/@blocknote__react@0.46.2.patch '@tiptap/extension-code@3.19.0': patches/@tiptap__extension-code@3.19.0.patch '@tiptap/extension-link@3.19.0': patches/@tiptap__extension-link@3.19.0.patch prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch同样的映射也镜像在 package.json 的pnpm.patchedDependencies字段中,与dependencies里声明的@blocknote/core ^0.46.2、@blocknote/react ^0.46.2版本对应。由于 pnpm 的补丁键是"包名@精确版本",从源码结构看,一旦上游升级版本,旧补丁文件将不再匹配,必须在升级时重新制作或有意识地替换这些补丁——这也是 ADR 后果第一条要求的原因。
@tiptap/extension-link补丁:模块加载即预注册,销毁不再 reset
patches/@tiptap__extension-link@3.19.0.patch 对 CJS/ESM 双产物及src/link.ts做了两处对称修改:
- 在模块顶层(即模块加载时,早于任何编辑器实例创建)加入带
globalThis标志守卫的预注册逻辑:
var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"]; var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered"; var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis; if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) { PRE_REGISTERED_PROTOCOLS.forEach((protocol) => { (0, import_linkifyjs.registerCustomProtocol)(protocol); }); linkifyGlobalObject[linkifyProtocolFlag] = true; }- 把 Link 扩展
onDestroy()中的reset()调用删掉,替换为一条解释性注释:
onDestroy() { // Tolaria keeps a single editor shell alive across note swaps, so linkify's // protocol registry must survive editor teardown and remount cycles. },这两处改动分别解决两个问题:预注册把 5 个自定义 scheme 的注册时机从"编辑器创建期"提前到"模块加载期"(此时 linkify 尚未初始化,不会触发警告);删除reset()则保证注册表在编辑器销毁时不被清空,重挂载的编辑器直接复用现有注册表。
@blocknote/core补丁:globalThis 标志取代模块级一次性变量
patches/@blocknote__core@0.46.2.patch 对 BlockNote 默认 Tiptap 扩展装配逻辑(源文件对应src/editor/managers/ExtensionManager/extensions.ts)做了修改。上游原本用模块级布尔量LINKIFY_INITIALIZED判断"是否首次创建编辑器",首次时传入VALID_LINK_PROTOCOLS,之后传空数组。补丁把这一机制替换为:
const bnLinkifyProtocolFlag = "__tolariaBlockNoteLinkifyProtocolsRegistered"; const bnGlobalObject = typeof globalThis > "u" ? void 0 : globalThis; let fe = Boolean(bnGlobalObject && bnGlobalObject[bnLinkifyProtocolFlag]); // ... }).configure({ defaultProtocol: DEFAULT_LINK_PROTOCOL, // Tolaria routes editor link clicks through its guarded native opener. openOnClick: !1, // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes. protocols: [] }), // ... return fe = !0, bnGlobalObject && (bnGlobalObject[bnLinkifyProtocolFlag] = !0), t;即:模块初始状态从globalThis上的标志读取;Link扩展配置永远传空protocols数组(因为 5 个非原生协议已经由 Tiptap 侧补丁在模块加载期注册完毕);装配完成后把标志写回globalThis。
同一补丁中还包含一处相邻修改:openOnClick: false关闭 Tiptap Link 的默认点击打开行为,补丁注释说明 Tolaria 自行把编辑器内链接点击路由到自己的受控原生 opener 上。该行为另有回归测试 src/lib/blockNoteLinkClick.regression.test.ts 覆盖,但它属于链接点击链路而非本协议注册问题,这里仅作溯源提示。
两个补丁如何协同
两个包各自在globalThis上留下标志:
| 标志键 | 写入方 | 语义 |
|---|---|---|
__tolariaTiptapLinkifyProtocolsRegistered | @tiptap/extension-link补丁 | 5 个非原生协议已在 linkify 初始化前注册 |
__tolariaBlockNoteLinkifyProtocolsRegistered | @blocknote/core补丁 | BlockNote 链接栈的"已装配"状态在模块重载间可见 |
协同效果是:无论编辑器实例经历多少次销毁/重建,linkify 注册表只会在模块首次加载时写入一次,且销毁路径不再清理它。由于注册只发生在 linkify 初始化之前,already initialized警告的产生路径被结构性地移除,而不是被日志层面压制。
方案对比:为什么不是压制警告或运行时 monkey-patch
ADR 的 Options considered 一节列出了三个选项,值得完整继承:
- Option A(选定):为受影响的上游包维护显式 pnpm 补丁,预注册所需协议,并让注册表跨重挂载存活。理由:与 Tolaria 的持久编辑器壳模型一致,且在开发模式与打包构建下行为都确定。
- Option B:保留上游行为,在本地容忍或压制警告。维护成本更低,但让编辑器生命周期正确性依赖噪声性的重复初始化,未来回归更难推理。
- Option C:在 Tolaria 应用代码里加运行时 monkey-patch,包住编辑器挂载/卸载。可以避免 vendoring 补丁文件,但把依赖特定于上游包实现的生命周期逻辑扩散进应用代码,在上游升级时更脆弱。
选 A 的本质是把"依赖的生命周期契约"显式地固化在补丁文件与 workspace 配置里:它可被 code review、可被 pnpm 安装流程强制校验、可随上游升级被有意识地重做。
后果:升级成本、所有权边界与再评估条件
ADR 的 Consequences 一节给出四条约束,均可在仓库中对应到具体位置:
- 升级必须保留或替换补丁。pnpm-workspace.yaml 已把
@blocknote/core与@tiptap/extension-link视为补丁依赖;升级@blocknote/core(当前 0.46.2)或 Tiptap 链接扩展时,需要重新审视补丁是否仍然适用,或确认上游行为变化使补丁不再必要。 - 编辑器销毁路径不得假设自己拥有全局 linkify 协议注册表。补丁后的
onDestroy()是刻意的空实现,应用侧代码(如 src/components/Editor.tsx)不应再添加任何"清理 linkify 状态"的逻辑。 - 冒烟覆盖必须保留,因为该故障是生命周期特有的,而不是功能特有的——详见下节。
- 再评估条件:如果上游 BlockNote/Tiptap 未来提供了受支持的、生命周期安全的协议注册模型(例如官方支持跨实例复用注册表),使 Tolaria 的补丁不再必要,应重新评估本 ADR。
回归护栏:用冒烟测试锁定"打开笔记 + 重挂载 + 模式切换"场景
ADR 要求保留的生命周期覆盖落在 tests/smoke/linkify-init-warnings.spec.ts。该测试的结构是:
- 通过
page.on('console')收集页面控制台消息,用正则/linkifyjs: already initialized - will not register custom scheme/i精确匹配目标警告,并记录输出位置以便定位; - 基于 fixture 副本 vault(见 tests/helpers/fixtureVault.ts 的
createFixtureVaultCopy/openFixtureVaultDesktopHarness)打开桌面应用; - 依次执行完整的生命周期序列(测试主体见该文件 第 47–62 行):
- 打开 "Alpha Project" 笔记;
- 切换到 "Note B"(触发编辑器内容/实例重挂载);
- 按
Ctrl+Backslash切入原始模式,断言 CodeMirror 原始编辑器(raw-editor-codemirror)出现; - 再按
Ctrl+Backslash切回富文本模式,断言.bn-editor出现; - 再次打开 "Alpha Project";
- 最终断言收集到的警告数组为空:
expect(linkifyWarnings).toEqual([])。
这个测试的价值在于:它验证的不是"链接能显示/能点击"这类功能,而是"注册表在完整 teardown/remount 循环中保持单次初始化"这一生命周期不变量。若未来有人恢复上游reset()行为或改动预注册时机,该测试会在冒烟层面直接失败。运行方式为pnpm playwright:regression(即playwright test tests/smoke/),该用例属于 smoke 目录下的回归集。
小结:从 ADR 到验证的完整证据链
| 环节 | 说明 | 证据位置 |
|---|---|---|
| 决策记录 | 问题、选项、后果的完整陈述 | docs/adr/0073-persistent-linkify-protocol-registry-across-editor-remounts.md |
| Tiptap 侧实现 | 模块加载期预注册 5 个非原生协议;onDestroy不再reset() | patches/@tiptap__extension-link@3.19.0.patch |
| BlockNote 侧实现 | globalThis标志管理装配状态;protocols: [];openOnClick: false | patches/@blocknote__core@0.46.2.patch |
| 依赖登记 | 补丁键与包版本绑定,升级时强制显式处理 | pnpm-workspace.yaml、package.json |
| 生命周期回归 | 打开笔记/重挂载/原始模式切换全序列零警告断言 | tests/smoke/linkify-init-warnings.spec.ts |
对依赖编辑器持久存活的富文本应用而言,这个 ADR 提供了一个可复用的模式:当上游库的注册/清理假设与你的生命周期模型冲突时,优先用补丁在依赖边界处显式修正契约(注册提前到模块加载、清理从销毁路径移除),用globalThis标志做跨模块协同,并用生命周期维度的冒烟测试把不变量钉死——而不是在应用层做脆弱的运行时打补丁或容忍警告。
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考