Files
mnote/bugs/05-editor-mainline/process/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md
T
2026-05-13 22:43:16 +08:00

194 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 5-11 [process][bug] Mindmap 幽灵附件增长与树命令卡顿 v1
> 更新时间:2026-05-13
>
> 分类归属:
> - `05-editor-mainline/process`
> - 涉及边界:`04-tree-domain/tree command + file_tree projection`、`06-mindmap/runtime save + resource relation`
>
> 用户证据:
> - `/mnt/Data1T/mnote/tmp/image copy 94.png`
> - `/mnt/Data1T/mnote/tmp/image copy 93.png`
## 1. 问题定义
用户在页面中只是修改了一下 mindmap,文件树/资源树下却陆续出现多个 `mindmap-mindmap...` 附件行。截图显示同一页面 `新页面3` 下有 `index.md`,并且 mindmap 附件从 2 条增长到 4 条。
同一轮反馈还指出:删除页面和新建页面都很慢,表现为树操作后明显卡顿。
这不是单纯的图标显示问题。当前症状同时暴露两条链路风险:
1. mindmap 编辑/初始化/保存链路会多次向资产层广播同一个资源存在,且缺少“页面正文 block 与 mindmap asset 关系唯一”的硬约束。
2. tree shell 的页面新建、删除、重命名、移动仍在命令成功后强制整页刷新,和当前 live projection/SSE 机制重复,导致用户感知卡顿。
## 2. 真实现象
已观察到的用户现象:
1. 新页面下初始只有 `index.md` 和少量 mindmap 附件。
2. 用户只是编辑 mindmap,不是主动新建 mindmap。
3. 等一会儿或再次修改后,同一页面下又出现新的 `mindmap-mindmap...` 行。
4. 页面新建和删除动作响应慢,像是页面/树整体重新加载。
期望结果:
1. 一个页面内的一个 mindmap block 只对应一个稳定 `mindmapId` 和一个 file tree asset row。
2. mindmap 保存只能更新既有资源,不应创建新的 mindmap 资产行。
3. file tree projection 应按 `{documentId, blockId, assetId}` 或明确 object identity 去重。
4. 页面新建/删除成功后应优先消费 command result / tree delta 更新局部投影,不应默认整页 reload。
## 3. 初步调查证据
### 3.1 mindmap 资产广播存在多入口
`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` 中同一个 mindmap 资源至少有三处会触发资产刷新广播:
- 保存成功后广播 `emitAssetsChanged(docId, { id: mindmapId, asset_type: "mindmap", ... })``MindmapBlock.tsx:1499``:1534`
- 初始同步 `createOnly: true` 成功后广播:`MindmapBlock.tsx:1591``:1621`
- mindmap 实例就绪后立即广播:`MindmapBlock.tsx:1636``:1647`
另一个 legacy/compat block wrapper 也会在 mount 时执行 `createOnly` 并广播资产:`MindmapBlock.tsx:3573``:3612`
这些广播本身用 `id = mindmapId`,理论上同 ID 会被 sidebar 本地 state 去重;但只要保存/转换链路让同一视觉 mindmap 换了新的 `mindmapId`,就会生成新的资产行。
### 3.2 mindmapId 仍可能由时间戳生成
当前 `leptos-tiptap` 插入 mindmap 时使用:
- `rust/spikes/leptos-tiptap-spike/src/lib.rs:5681``:5689`
这里 `next_mindmap_id()` 生成 `mindmap_{Date.now()}`,并写入 paragraph attrs 的 `mindmapId`。如果后续转换、保存、重新挂载中丢失原 attrs,fallback 会用新的 block identity / 新插入节点创建新的 `mindmapId`,资产层就会认为这是另一个 mindmap。
相关转换锚点:
- `rust/crates/mnote-web/src/routes/web_shell.rs:818``:838`legacy block 转 TipTap 时写入 `mindmapId`
- `rust/crates/mnote-web/src/routes/web_shell.rs:983``:1009`TipTap 节点转 editor block 时若 attrs 缺失则用 blockId fallback
- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:188``:213``mindmapReferenceProps` 会在缺少 `mindmapId` 时 fallback 到 blockId
- `wolai-frontend/src/lib/documents/tiptap-content-converter.ts:410``:418`editor block 转 TipTap 时把 mindmap props 写回 paragraph attrs
当前缺少一条回归断言:连续编辑同一个 mindmap 后,保存前后 `mindmapId` 必须保持不变,且 file tree 下同一页面 mindmap asset 数量不增长。
### 3.3 Convex mindmaps 表允许同页多 mindmap,但缺少 block 关系唯一约束
`wolai-frontend/convex/schema.ts:164``:183` 定义 mindmaps 表,并以 `(document_id, mindmap_id)` 做查询索引。注意这里是普通索引,不是唯一约束。
`wolai-frontend/convex/mindmaps.ts``put` 对同一 `(document_id, mindmap_id)` 是幂等 patch/insert,但它并不知道页面正文中的哪个 block 才是唯一来源。也就是说:
- 同一个 `mindmapId` 重复保存不会多插入。
- 如果历史或并发路径已经写出同一 `(document_id, mindmap_id)` 的多行,`put` 当前用 `.first()` 只会 patch 第一行,剩余重复行仍会被后续 list/projection 展示。
- 但如果前端生成了新的 `mindmapId`,后端会按合法新 mindmap 插入。
- file tree 会把同一 document 下所有 active mindmaps 映射为资产行。
对应映射:
- `wolai-frontend/convex/mindmaps.ts:206``:245``put` 查询 `by_doc_mindmap``.first()`,不存在则 insert
- `wolai-frontend/convex/sidebar.ts:79``:100``mindmap_id` 被映射为 `id``mindmap-{mindmap_id}.json`
- `wolai-frontend/convex/sidebar.ts:193``:220`:所有 active mindmaps 都进入 `mindmap_assets`
- `rust/crates/bridge-runtime/src/lib.rs:7569``:7647`file tree projection 从 `mindmap_assets` 构建资源行,并只按 asset id 去重
因此,当前有两个需要实测区分的分支:
1. 同一视觉对象被保存成多个不同 `mindmapId`,每个都被合法展示为一个资源。
2. 数据表中已经存在同一 `(document_id, mindmap_id)` 多行,`.first()` 更新掩盖重复行,sidebar list 把重复行全部暴露出来。
两者都会在截图中表现为同一页面下多个 `mindmap-mindmap...` 行。
### 3.4 Rust / Next API 都会把 mindmap 保存转成 tree resync
保存路径还会触发树刷新:
- Next API `POST /api/mindmap/[docId]/[mindmapId]` 构造 `mindmaps.put``mindmap.command.apply``wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts:229``:254`
- Rust API 普通保存也包装为 `mindmaps.put` 并携带 `createOnly``rust/crates/mnote-web/src/routes/mindmap_api.rs:177``:200`
- Bridge runtime 给 `mindmaps.put` 生成 `resync_required` 树事件:`rust/crates/bridge-runtime/src/lib.rs:9273``:9326`
这意味着 mindmap 每次保存都会推动资源树重新读 projection;如果底层 mindmaps 数据已经重复,保存/刷新会把重复行显性化。
## 4. 页面新建/删除慢的证据
tree shell 在多处命令成功后调用 `scheduleRefresh()`,而 `scheduleRefresh()` 的实现是 80ms 后整页 reload 或带 `renameRowId` 重新 assign
- `rust/crates/mnote-web/src/routes/tree.rs:2976``:2986`
调用点包括:
- 文件树删除:`rust/crates/mnote-web/src/routes/tree.rs:2791``:2824`
- 新建页面:`rust/crates/mnote-web/src/routes/tree.rs:4309``:4340`
- 重命名:`rust/crates/mnote-web/src/routes/tree.rs:4373``:4406`
- 移动:`rust/crates/mnote-web/src/routes/tree.rs:4448``:4502`
与此同时,主页面已经有 tree live controller 接收 `snapshot` / `delta` / `resync`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881``:3917`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939``:4050`
这会造成两种低效叠加:
1. 命令结果本身已经返回 `tree.node.created` / `tree.node.archived` 等 delta hint。
2. 前端仍然整页刷新,重新加载 sidebar、file tree、workspace shell、编辑器 runtime。
这解释了“新建页面和删除页面都很慢”的用户感知。
## 5. 当前判断
当前根因尚需实测最终确认,但静态代码已经能支持以下判断:
1. mindmap ghost asset 增长的高概率根因是 `mindmapId` 稳定性没有被端到端锁死。只要编辑/保存/重挂载过程中 attrs 丢失或重新创建 block,就会插入新 `mindmap_{timestamp}`,后端会合法保存,file tree 会合法展示。
2. 另一个可疑根因是 `mindmaps` 表没有唯一约束,`put` 只 patch `.first()`,无法清除或阻止同键重复行。
3. 资产广播入口过多会放大问题。它们让新 mindmapId 或重复数据几乎立即进入 sidebar 本地 state 和 Convex projection,用户就会看到“自己又新增了一个”。
4. file tree projection 只按 `asset.id` 去重,无法识别“同一 document + 同一正文 block 的多个 mindmapId 其实是幽灵副本”,也无法处理同 id 多行投影的上游异常。
5. 页面新建/删除慢不是 Convex 单点问题,当前 tree shell 命令成功后仍强制 reload,是明确的性能和体验缺陷。该慢操作应单独归入 `04-tree-domain` 跟踪,本文只保留关联证据。
## 6. 建议复现与验证
建议补一条失败优先 smoke,先不要直接修代码:
1. 使用真实测试账号登录 `http://localhost:3000/auth`
2. 新建测试页面,记录 `documentId`
3. 插入一个 mindmap,记录首个 `mindmapId`
4. 连续修改 mindmap 中心主题或新增节点 3 次,每次等待保存完成。
5. 切到文件树,统计该页面下 `asset_type=mindmap` 的行数和 `data-mnote-object-identity`
6. 刷新页面后再次统计。
7. 断言同一页面下 mindmap asset 数量仍为 1,且 `mindmapId` 未变化。
8. 同一脚本计时新建页面、删除页面从点击到 DOM 稳定的耗时,并记录是否触发 `window.location.reload()` / navigation。
建议同时检查 Convex 数据:
- `mindmaps` 中同一 `document_id` 下是否出现多个 `mindmap_id`
- 新增出来的 `mindmap_id` 是否都形如 `mindmap_<timestamp>`
- 页面正文 TipTap JSON 中是否只有一个 mindmap paragraph,且 attrs.mindmapId 是否随保存变化。
## 7. 建议修复方向
### 阶段一:防止继续增长
1. mindmap block 创建时生成一次稳定 `blockId``mindmapId`,后续保存、转换、重挂载必须保留。
2. `documents.save` / TipTap converter 对 `mnoteBlockType=mindmap` 增加 contract:缺少 `mindmapId` 时不得静默新建时间戳 ID,应先从 block identity / object identity 恢复,恢复不了则报可观测错误。
3. `mindmaps.put` 或上层 command 增加可选 `blockId` / `blockAssetRelation`,对同一 `{documentId, blockId}` 已有关联 mindmap 时拒绝插入第二个 mindmapId。
4. asset 广播入口收口:保存成功、initial createOnly、实例就绪不应都伪造 asset;前端应优先消费 kernel/file tree projection 返回的 object identity。
### 阶段二:清理幽灵副本
1. 提供只读诊断脚本:列出同一页面下多个 mindmapId、对应 updated_at、正文引用的 mindmapId。
2. 只有在用户确认后,才可清理未被页面正文引用的 mindmap 行。
3. 清理必须进入 bug 修复 checklist,不能在本缺陷说明阶段直接删除数据。
### 阶段三:树命令性能收口
1. `tree.node.create` 成功后用 command result / delta 局部插入节点,而不是 reload。
2. `tree.node.archive` 成功后用 `remove_document` delta 局部移除节点。
3. 只有 projection 丢失、SSE 断线或 reducer 无法应用时才走 resync/reload fallback。
4. smoke 增加“无整页 reload”断言和耗时阈值。
## 8. 流转条件
当前状态:`process`
只有满足以下条件后才能移动到 `bugs/05-editor-mainline/done/`
1. [ ] 已有 smoke 能稳定复现当前 mindmap 资产增长问题,或能证明 Convex 数据中存在同页多 mindmap 幽灵副本。
2. [ ] 同一 mindmap 连续编辑保存后,`mindmapId` 和 file tree object identity 保持稳定。
3. [ ] 同一页面下未主动新建多个 mindmap 时,file tree 只出现一个 mindmap asset row。
4. [ ] 新建页面和删除页面不再默认整页 reload,或至少有明确 fallback 条件。
5. [ ] 浏览器实测记录新建/删除耗时,并明显低于当前整页 reload 体验。
6. [ ] 若清理历史幽灵副本,必须经用户确认,并保留清理前后证据。