chore: 收口 review 执行清单与 runtime 验证

- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录

- 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目

- 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑

- 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
This commit is contained in:
lix-2026
2026-05-14 05:52:08 +08:00
parent b4a452a8b7
commit 96e03645f7
69 changed files with 4780 additions and 979 deletions
@@ -0,0 +1,183 @@
# 4-25 [done][bug] 树命令新建/删除后强制刷新导致卡顿 v1
> 更新时间:2026-05-14
>
> 分类归属:
> - `04-tree-domain/done`
> - 关联缺陷:`bugs/05-editor-mainline/done/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md`
>
> 用户反馈:
> - “删除页面和新建页面都很慢,不知道为什么这么卡。”
## 1. 问题定义
当前页面树/文件树的新建页面、删除页面等命令,在服务端命令成功后仍会触发整页刷新或完整树刷新。用户感知是:新建和删除并不是局部更新,而是卡一下、等整套页面壳或树壳重新加载。
这属于 `tree command -> projection update -> UI apply` 链路问题,应归到 `04-tree-domain`,而不是只作为编辑器 UI 问题处理。
## 2. 真实现象
用户反馈:
1. 新建页面慢。
2. 删除页面慢。
3. 卡顿与同轮 mindmap 文件树重复增长一起出现,怀疑树刷新链路被频繁触发。
期望结果:
1. `tree.node.create` 成功后,树 UI 局部插入新节点并进入重命名/导航状态。
2. `tree.node.archive` 成功后,树 UI 局部移除节点或移动到回收站 projection。
3. 只有 delta 无法应用、projection 缺失、SSE 断线或数据不一致时,才进入 resync/reload fallback。
4. 普通新建/删除不应默认整页 reload。
## 3. 证据
### 3.1 Rust tree shell 有硬刷新路径
`rust/crates/mnote-web/src/routes/tree.rs:2976``:2986`
- `scheduleRefresh()` 在 80ms 后执行 `window.location.reload()`
- 如果带 `renameRowId`,则通过 `window.location.assign(url.toString())` 重新加载。
调用点:
- 文件树删除:`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 shell 命令体验明显慢于纯 delta / reducer 更新。
### 3.2 主页面已有 tree live controller,但命令后仍刷新
主页面已经监听 tree live
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881``:3917` 处理 `tree:snapshot` / `tree:delta` / `tree:resync`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939``:4050` 启动 `/api/tree/events` EventSource
也就是说,命令完成后理论上可以由 delta/resync 更新 projection;当前硬 reload 与 live projection 机制重复。
### 3.3 命令本身已有 delta hint
Bridge runtime 对树命令生成 stream delta hint
- `tree.node.create``rust/crates/bridge-runtime/src/lib.rs:9545``:9592`
- `tree.node.archive``rust/crates/bridge-runtime/src/lib.rs:9655``:9702`
当前前端没有把这些结果作为默认局部更新来源,而是在 tree shell 中继续 reload。
### 3.4 React Sidebar 路径也存在重刷链
子代理只读调查发现 React Sidebar 路径也存在重刷:
- 新建页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:1799``:1847` 附近在 command 后 `await refreshTree()` 再跳转。
- 删除页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:2148` 附近删除后等待 command、`refreshTree()`、广播文档变化/跳转。
- Next API 的 `api/tree/commands` create/delete 还会执行 workspace/default scaffold、bridge mutation、artifact 记录等多段流程。
这说明卡顿不只来自单个 reload,而是命令后刷新链路偏重。
### 3.5 tree stream overview 查询曾经全量扫描 workspace 日志
`/api/tree/events` 的 live 轮询依赖 `bridgeLogs:listWorkspaceOverview` 读取最新 command/domain event 窗口。2026-05-13 复核发现,该查询原先对 `command_logs``domain_events` 都是按 workspace 全量 `.collect()` 后再在内存中过滤、排序和分页;日志量增长后,这会让每轮 SSE 轮询成本随历史日志线性增长。
本轮已完成代码侧收口:
- `command_logs` 增加 `workspace_id + created_at + id` 及 status/page/block 常用过滤索引。
- `domain_events` 增加 `workspace_id + created_at + id` 及 status/aggregate 常用过滤索引。
- `listWorkspaceOverview` 改为按索引读取有界窗口,不再全量 collect 后分页。
这只能降低 tree live polling 的背景成本,并不等于新建/删除的强刷新问题已经修完。React Sidebar 的 `refreshTree()` 和 Rust tree shell 的 `scheduleRefresh()` 仍需单独收口。
### 3.6 2026-05-14 tree live 局部 apply 进展
本轮已完成主页面 Rust 3000 下的部分局部 apply 收口:
- `rust/crates/mnote-web/src/transport/convex.rs`tree create / rename / archive / restore / move 及 compat documents mutation 发往 legacy Convex mutation 前剥离 artifact-only 字段,修复 `documents:softDelete``streamDeltaHint` / `domainEventPlan` 等额外字段触发 validator 502 的问题。
- `rust/crates/mnote-web/src/ssr/pages/layout.rs``tree:delta``move_document` 会局部更新页面树与 File Tree 行的 `data-parent-id``remove_document` 会局部移除页面树与 File Tree 行。
- 已通过:`cargo test -p mnote-web convex_command_args_strips -- --nocapture`
- 已通过:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`
- 已通过:`node scripts/task177-tree-move-archive-live-smoke.js`,覆盖 move 后页面树 / File Tree 行父节点更新、archive 后两棵树局部移除,以及文档页头保持稳定。
这说明 move / archive 的 Rust 3000 主页面 live reducer 已有可验证进展;当时普通新建/删除、React Sidebar `refreshTree()` 与耗时阈值仍待后续章节继续收口。后续 3.7-3.9 已补齐这些流转条件。
### 3.7 2026-05-14 Rust tree shell create / delete no-reload 验收
本轮已完成 Rust `/tree` debug shell 的普通新建 / 删除强刷新收口:
- `rust/crates/mnote-web/src/routes/tree.rs`:新增 `addTreeItemLocally()` / `applyCreatedDocumentLocally()``convex_workspace` create 成功后直接插入本地树项并进入 inline rename;只有本地 apply 失败时才 fallback 到 `scheduleRefresh()`
- `rust/crates/mnote-web/src/routes/tree.rs`:新增 `removeTreeItemEverywhere()` / `applyRemovedDocumentLocally()``convex_workspace` delete 成功后从本地 projection 移除对应页面及子项;只有删除后仍能在本地 row map 中看到目标行时才 fallback `scheduleRefresh()`
- `scripts/task179-tree-create-delete-no-reload-smoke.js`:真实浏览器覆盖 `/tree?mode=filetree` 的 create + delete,以及 `/tree?mode=page` 的 create;断言操作后 URL 不变、对应 mode 内无新增顶层 navigation。
- 已通过:`cargo test -p mnote-web tree_shell_returns_interactive_html_document -- --nocapture`
- 已通过:`cargo test -p mnote-web tree_shell -- --nocapture`
- 已通过:`node scripts/task179-tree-create-delete-no-reload-smoke.js`,最新结果 `ok=true``filetree.createMs=120``filetree.deleteMs=178``page.createMs=120`、两个 mode 的 `navigationEvents.length=0`
### 3.8 2026-05-14 React Sidebar 直接 create / delete 收口进展
React Sidebar 的直接新建 / 删除路径已完成一层去阻塞:
- `wolai-frontend/src/components/sidebar/sidebar.tsx``handleCreate()` 继续使用 `createDocumentCommand()` 结果本地 `setTree()` 插入节点并导航,但不再同步 `await refreshTree()`
- `wolai-frontend/src/components/sidebar/sidebar.tsx`:新增 `removeDocumentsFromTree()``handleDelete()``handleDeleteResourceSelection()` 在 delete command 成功后本地移除页面节点,不再同步等待整树 refetch;附件删除仍保留 asset 专用刷新链。
- `wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts`:新增源级回归断言,防止页面 create/delete 成功路径重新出现同步 `await refreshTree()`
- 已按 TDD 验证:新增测试先失败,修改后通过。
- 已通过:`pnpm test -- src/components/sidebar/sidebar-delete-preflight-source.test.ts`Vitest 实际执行 112 个测试文件、459 个测试)。
- 已通过:`pnpm exec eslint src/components/sidebar/sidebar.tsx src/components/sidebar/sidebar-delete-preflight-source.test.ts`,无 error`sidebar.tsx` 保留既有 warning。
### 3.9 2026-05-14 Rust-family React shell mutation 本地 apply 收口
Rust-family React shell 的 host mutation 回调已从“命令成功后默认整树刷新”改为可局部 apply:
- `wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx``tree.node.created` / `tree.node.renamed` / `tree.subtree.moved` 成功后把 command result 中的 `documentId``parentId``title``sortOrder``workspaceId``execution` 传回 Sidebar。
- `wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx`legacy iframe bridge 同步转发 mutation payload 中的局部 apply 字段,保持兼容。
- `wolai-frontend/src/components/sidebar/sidebar.tsx``handleTreeShellMutation` 对 create / rename / move 分别本地插入、重命名、移动页面树;只有未知事件或缺少必要字段时保留 `refreshTree()` 兜底。
- `wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts`:新增 RED -> GREEN 源级回归断言,防止 `onTreeMutation -> refreshTree()` 重新退化成默认整树 refetch。
- 已通过:`pnpm test -- src/components/sidebar/sidebar-delete-preflight-source.test.ts`Vitest 实际执行 112 个测试文件、460 个测试)。
- 已通过:`pnpm exec eslint src/components/sidebar/sidebar.tsx src/components/sidebar/sidebar-delete-preflight-source.test.ts src/components/sidebar/tree-shell-host.tsx src/components/sidebar/tree-shell-surface.tsx src/components/sidebar/tree-shell-dom-host.tsx src/components/sidebar/tree-shell-iframe-host.tsx`,无 error;保留既有 warning。
## 4. 当前判断
当前慢的核心不是“Convex 一定慢”,而是树命令执行后缺少轻量本地 apply:
1. 命令 result / delta hint 已经具备局部更新信息。
2. UI 仍经常走 `refreshTree()``window.location.reload()` 或 full snapshot。
3. `resync_required` 类事件会触发完整 workspace snapshot,结构性变化越多,越容易造成卡顿。
4. tree stream overview 全量日志扫描已在代码侧改为有界窗口查询,并已有 `task123` / `task120` / `task177` 证明 Rust 3000 下可输出并消费相关 live 事件。
5. move / archive 已具备局部 DOM reducer 证据。
6. Rust `/tree` debug shell 的 `convex_workspace` 普通新建 / 删除已经通过无 reload 与耗时 smoke。
7. React Sidebar 直接 create/delete 成功路径已改成本地更新,不再同步等待 `refreshTree()`
8. Rust-family React shell 的 create / rename / move host mutation 回调已改成本地 apply,不再默认触发整树 refetch;未知事件或缺字段才保留刷新兜底。
## 5. 建议验证
修复前先补一条计时 smoke
1. 登录真实测试账号。
2. 新建 `TEST-TREE-LATENCY-<timestamp>` 页面。
3. 记录 `POST /api/tree/commands` 响应耗时。
4. 记录下一次 `tree:delta` / `tree:resync` 到达耗时。
5. 断言过程中是否发生 `window.location.reload()` 或顶层 navigation。
6. 删除该测试页面,重复同样计时。
7. 输出新建/删除从点击到 DOM 稳定的总耗时。
## 6. 建议修复方向
1. `tree.node.create` 成功后直接用 command result 插入本地节点,并只在后台等待 live delta 校准。
2. `tree.node.archive` 成功后直接从当前 projection 移除节点,并只在后台等待 live delta 校准。
3. `scheduleRefresh()` 改成显式 fallback,只有 reducer 无法应用时才调用。
4. React Sidebar 的 `refreshTree()` 应去重、节流,并避免与 SSE/full snapshot 同时触发。
5. smoke 增加无 reload 断言和耗时阈值。
6. tree stream 查询模型已完成第一步优化;后续若 polling-backed SSE 继续作为长期实现,还需要补连接数、日志量、延迟边界测试。
## 7. 流转条件
当前状态:`done`
Rust tree shell 的 create / delete 强刷新子项已关闭;React Sidebar 直接 create/delete 同步刷新等待已关闭;Rust-family React shell 的 create / rename / move host mutation 回调已改为本地 apply,不再默认整树 refetch。
只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`
1. [x] 新建页面普通路径不再默认整页 reload。Rust `/tree` `convex_workspace` 的 File Tree 与 Page Tree create 已由 `task179` 验证。
2. [x] 删除页面普通路径不再默认整页 reload。Rust `/tree` `convex_workspace` 的 File Tree delete 已由 `task179` 验证。
3. [x] command result / tree delta 能局部更新页面树与文件树 projection。create 走 `applyCreatedDocumentLocally()`delete 走 `applyRemovedDocumentLocally()`move/archive 由 `task177` 验证主页面与 File Tree DOM reducer。
4. [x] fallback reload 只在明确异常条件下触发,并有可观测标记。当前 create/delete 仅在本地 apply 失败或目标行未移除时 fallback `scheduleRefresh()`
5. [x] smoke 记录新建/删除耗时,并通过无 reload 断言。`task179` 最新记录 `filetree.createMs=120``filetree.deleteMs=178``page.createMs=120`,对应 mode 内 navigation 为 0。
6. [x] Rust-family React shell 的 `onTreeMutation -> refreshTree()` 不再在 create / rename / move 成功后默认整树 refetch。`handleTreeShellMutation` 已按 create / rename / move 本地 apply,未知事件或缺字段才刷新兜底,并由 `sidebar-delete-preflight-source.test.ts` 防回归。
@@ -1,117 +0,0 @@
# 4-25 [process][bug] 树命令新建/删除后强制刷新导致卡顿 v1
> 更新时间:2026-05-13
>
> 分类归属:
> - `04-tree-domain/process`
> - 关联缺陷:`bugs/05-editor-mainline/process/5-11-mindmap-ghost-assets-and-tree-command-latency-v1.md`
>
> 用户反馈:
> - “删除页面和新建页面都很慢,不知道为什么这么卡。”
## 1. 问题定义
当前页面树/文件树的新建页面、删除页面等命令,在服务端命令成功后仍会触发整页刷新或完整树刷新。用户感知是:新建和删除并不是局部更新,而是卡一下、等整套页面壳或树壳重新加载。
这属于 `tree command -> projection update -> UI apply` 链路问题,应归到 `04-tree-domain`,而不是只作为编辑器 UI 问题处理。
## 2. 真实现象
用户反馈:
1. 新建页面慢。
2. 删除页面慢。
3. 卡顿与同轮 mindmap 文件树重复增长一起出现,怀疑树刷新链路被频繁触发。
期望结果:
1. `tree.node.create` 成功后,树 UI 局部插入新节点并进入重命名/导航状态。
2. `tree.node.archive` 成功后,树 UI 局部移除节点或移动到回收站 projection。
3. 只有 delta 无法应用、projection 缺失、SSE 断线或数据不一致时,才进入 resync/reload fallback。
4. 普通新建/删除不应默认整页 reload。
## 3. 证据
### 3.1 Rust tree shell 有硬刷新路径
`rust/crates/mnote-web/src/routes/tree.rs:2976``:2986`
- `scheduleRefresh()` 在 80ms 后执行 `window.location.reload()`
- 如果带 `renameRowId`,则通过 `window.location.assign(url.toString())` 重新加载。
调用点:
- 文件树删除:`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 shell 命令体验明显慢于纯 delta / reducer 更新。
### 3.2 主页面已有 tree live controller,但命令后仍刷新
主页面已经监听 tree live
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3881``:3917` 处理 `tree:snapshot` / `tree:delta` / `tree:resync`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs:3939``:4050` 启动 `/api/tree/events` EventSource
也就是说,命令完成后理论上可以由 delta/resync 更新 projection;当前硬 reload 与 live projection 机制重复。
### 3.3 命令本身已有 delta hint
Bridge runtime 对树命令生成 stream delta hint
- `tree.node.create``rust/crates/bridge-runtime/src/lib.rs:9545``:9592`
- `tree.node.archive``rust/crates/bridge-runtime/src/lib.rs:9655``:9702`
当前前端没有把这些结果作为默认局部更新来源,而是在 tree shell 中继续 reload。
### 3.4 React Sidebar 路径也存在重刷链
子代理只读调查发现 React Sidebar 路径也存在重刷:
- 新建页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:1799``:1847` 附近在 command 后 `await refreshTree()` 再跳转。
- 删除页面:`wolai-frontend/src/components/sidebar/sidebar.tsx:2148` 附近删除后等待 command、`refreshTree()`、广播文档变化/跳转。
- Next API 的 `api/tree/commands` create/delete 还会执行 workspace/default scaffold、bridge mutation、artifact 记录等多段流程。
这说明卡顿不只来自单个 reload,而是命令后刷新链路偏重。
## 4. 当前判断
当前慢的核心不是“Convex 一定慢”,而是树命令执行后缺少轻量本地 apply:
1. 命令 result / delta hint 已经具备局部更新信息。
2. UI 仍经常走 `refreshTree()``window.location.reload()` 或 full snapshot。
3. `resync_required` 类事件会触发完整 workspace snapshot,结构性变化越多,越容易造成卡顿。
## 5. 建议验证
修复前先补一条计时 smoke
1. 登录真实测试账号。
2. 新建 `TEST-TREE-LATENCY-<timestamp>` 页面。
3. 记录 `POST /api/tree/commands` 响应耗时。
4. 记录下一次 `tree:delta` / `tree:resync` 到达耗时。
5. 断言过程中是否发生 `window.location.reload()` 或顶层 navigation。
6. 删除该测试页面,重复同样计时。
7. 输出新建/删除从点击到 DOM 稳定的总耗时。
## 6. 建议修复方向
1. `tree.node.create` 成功后直接用 command result 插入本地节点,并只在后台等待 live delta 校准。
2. `tree.node.archive` 成功后直接从当前 projection 移除节点,并只在后台等待 live delta 校准。
3. `scheduleRefresh()` 改成显式 fallback,只有 reducer 无法应用时才调用。
4. React Sidebar 的 `refreshTree()` 应去重、节流,并避免与 SSE/full snapshot 同时触发。
5. smoke 增加无 reload 断言和耗时阈值。
## 7. 流转条件
当前状态:`process`
只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`
1. [ ] 新建页面普通路径不再默认整页 reload。
2. [ ] 删除页面普通路径不再默认整页 reload。
3. [ ] command result / tree delta 能局部更新页面树与文件树 projection。
4. [ ] fallback reload 只在明确异常条件下触发,并有可观测标记。
5. [ ] smoke 记录新建/删除耗时,并通过无 reload 断言。
@@ -0,0 +1,293 @@
# 5-11 [done][bug] Mindmap 幽灵附件增长与树命令卡顿 v1
> 更新时间:2026-05-14
>
> 分类归属:
> - `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 数据已经重复,保存/刷新会把重复行显性化。
### 3.5 mindmap 节点编辑 artifact 语义过宽
当前 `mindmaps.put` / `mindmap.command.apply` 已经会进入 artifact 链路,但普通导图节点编辑生成的是 tree resource 语义:
- `tree.resource.mindmap.put`
- `tree.resource.mindmap.updated`
- `streamDelta: resync_required`
这会把“导图 object 内部内容变化”提升成“资源树需要重新同步”。从 `design/10-review/05-tree.md` 的对象边界看,mindmap 是页面内 block 关联的独立 object / asset editor,不是 `index.md` 正文替身;普通节点文字、结构、样式变化应属于 mindmap object 内容事件,而不是 file tree 资源关系事件。
因此,artifact 本身仍然需要,但语义需要分层:
1. 修改 mindmap 节点、文字、样式、折叠状态:应记录 `mindmap.content.updated` / `mindmap.node.updated` 这类 object 内容事件,不应默认触发 file tree resource resync。
2. 创建、挂载、解绑、删除、移动、重命名 mindmap 资源:才应记录 `tree.resource.*` / `tree.asset.*` 事件,并影响 file tree projection。
3. `resync_required` 只能作为无法应用精确 delta 时的保守 fallback,不应成为普通节点编辑的默认输出。
当前过宽 artifact 会放大本 bug:只要底层存在 ghost mindmap 行,或同一视觉 mindmap 被重新分配了新的 `mindmapId`,普通编辑触发的 tree resync 就会把重复资源更快暴露到文件树,并带来额外刷新成本。
### 3.6 次级入口收口进展
2026-05-14 已完成 `design/10-review` 第 9 项的入口一致性收口:
- Mindmap toolbar `export` 不再映射到未受控的 `EXPORT` runtime commandUI state 继续禁用该入口。
-`/api/mindmap-ai/expand-node` Next route 从“有效请求稳定 501”改为显式 410 退役边界,并指向 AI Agent 内置 `mindmap_expand_node` 工具。
- legacy `MindmapSidebar` 中直接调用旧 route 的补完入口已从渲染层关闭。
- `mindmap.command.apply` 保存 facade 已通过 Rust 单测确认仍存在,未回退到 Page Aggregate body 保存链。
这组收口只解决“能力入口与实现不一致”的次级问题,不代表 ghost mindmap 资产增长、普通节点编辑 artifact 语义过宽、file tree 重复资源行这几个核心缺陷已经完成。
### 3.7 2026-05-14 ghost asset 止血进展
本轮已确认并修复一条会制造幽灵 mindmap asset 的高风险链路:
- 根因一:`resolveCurrentDocumentId()` 原先优先读取 `window.location.pathname`。切页或打开文件树后,旧 mindmap NodeView 的保存 / 刷新 URL 可能被当前页面路径覆盖成其他 document,导致同一个视觉 mindmap 在错误页面下创建 asset。
- 根因二:TipTap NodeView 构造时会立即 `fetchScene("initial")`。刚从其他页面切回原页面时,NodeView root 可能尚未插入带 `data-document-id` 的 DOM;旧逻辑会退回 URL document id,进而向错误页面创建同名 asset。
- `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts``resolveCurrentDocumentId()` 改为优先使用 NodeView anchor 所属的 `[data-document-id]`;当 anchor 存在但尚未连接到 DOM 时返回 `null`,交给已有 retry 机制等待 DOM 归位,不再退回旧 URL。
- `design/05-editor-mainline/reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.test.ts`:补充 NodeView 所属文档优先、无 anchor 时回退 URL、未连接 anchor 不回退旧 URL三类用例。
- `wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts`:补充 mindmapId 多轮转换稳定性回归用例。
- `scripts/task169-mindmap-realtime-smoke.js`:增强 File Tree mindmap row 去重与 object identity 断言,等待 runtime view settle 后再做 live 对比。
已通过验证:
- `node --import tsx --test src/extensions/tiptap_paragraph.test.ts`4 个测试通过。
- `npm run typecheck`,目录:`design/05-editor-mainline/reference-code/leptos-tiptap/tiptap`
- `npm run build`,目录:`design/05-editor-mainline/reference-code/leptos-tiptap/tiptap`,并同步生成物到 leptos-tiptap reference / spike generated JS。
- `pnpm test -- src/lib/documents/tiptap-content-converter.test.ts`Vitest 实际执行 112 个测试文件、458 个测试,通过。
- `node scripts/task169-mindmap-realtime-smoke.js`,结果 `ok=true``failures=[]`;同一页面下最终 File Tree mindmap row 数量为 1`mindmapId=mindmap_1778695090474`object identity 仍指向同一 `objectKind:"mindmap"`
### 3.8 2026-05-14 普通 mindmap 编辑 artifact 语义收窄
本轮已完成普通 mindmap 内容编辑的 artifact 语义收窄:
- `rust/crates/bridge-runtime/src/lib.rs``mindmap.command.apply` 改为产出 `mindmap.content.updated` domain event 与 `noop` stream delta,不再默认产出 `tree.resource.mindmap.updated + resync_required`
- `rust/crates/bridge-runtime/src/lib.rs``mindmaps.put``createOnly` 分流;`createOnly=true` 仍表示资源创建 / 挂载,保留 `tree.resource.mindmap.put + resync_required``createOnly=false` 表示既有 mindmap blob 内容更新,改为 `mindmap.content.updated + noop`
- `rust/crates/mnote-web/src/routes/mindmap_api.rs`:新增 route 单测覆盖 `mindmap.command.apply` 响应中的 object event 与 noop delta。
- `scripts/task169-mindmap-realtime-smoke.js`:新增真实浏览器 artifact 断言,拦截普通 `mindmap.command.apply` 响应,禁止 `tree.resource.*` event 与 `resync_required` delta。
已通过验证:
- RED`cargo test -p bridge-runtime mindmaps_put_existing_content_update_uses_object_event_without_tree_resync -- --nocapture` 修改前失败,旧行为为 `resync_required`
- RED`cargo test -p mnote-web mindmap_command_apply_returns_object_artifacts_without_tree_resync -- --nocapture` 修改前失败,旧行为为 `tree.resource.mindmap.put`
- GREEN`cargo test -p bridge-runtime mindmap -- --nocapture`17 个 mindmap 相关测试通过。
- GREEN`cargo test -p mnote-web mindmap -- --nocapture`,6 个 mindmap 相关测试通过。
- 已通过:`node --check scripts/task169-mindmap-realtime-smoke.js`
- 已通过:`node scripts/task169-mindmap-realtime-smoke.js`,最新结果 `ok=true``failures=[]`6 条可解析普通 `mindmap.command.apply` 响应均为 `eventType=mindmap.content.updated``streamOp=noop`,另有 1 条 Playwright response body 读取失败被记录为 `skippedReadFailures=1`;同一页面最终仍只有 1 条 mindmap asset row`mindmapId=mindmap_1778698542703`
仍未完成:
- 本轮未执行历史幽灵副本清理;若后续需要清理 Convex 中未被正文引用的旧 mindmap 行,必须先经用户确认并保留清理前后证据。
### 3.9 2026-05-14 历史幽灵副本只读审计入口
本轮新增只读审计脚本,用于在清理前生成候选证据,但不删除任何用户数据:
- `scripts/task180-mindmap-ghost-candidate-audit.js`:支持从 `/api/sidebar`、导出的 sidebar JSON、`/api/tree/projections/file?workspaceId=<workspaceId>` 或导出的 file projection JSON 读取数据,报告同页多 active mindmap、重复 active asset id、缺失 document 的 active mindmap asset、以及 file tree 中同一 mindmap object 的重复 row。
- `scripts/task180-mindmap-ghost-candidate-audit.test.js`:覆盖候选识别逻辑,确认脚本只输出 candidate summary;同时覆盖 Rust file projection 的 `{ ok, result: { items } }` 包装形态和直接 `items` 形态。
- 已通过:`node scripts/task180-mindmap-ghost-candidate-audit.test.js`
- 已通过:`node --check scripts/task180-mindmap-ghost-candidate-audit.js``node --check scripts/task180-mindmap-ghost-candidate-audit.test.js`
- 已通过真实只读审计:`node scripts/task180-mindmap-ghost-candidate-audit.js --url 'http://127.0.0.1:3000/api/tree/projections/file?workspaceId=tree_1777430834634_3' --output tmp/task180-mindmap-ghost-candidate-audit/runtime-file-projection-result.json`。结果 `ok=true``readOnly=true``fileTreeMindmapRows=10``duplicateFileTreeMindmapRows=0``candidateCount=0`
- 已确认 endpoint 可用:`/api/tree/projections/file?workspaceId=tree_1777430834634_3` 返回 `200 OK`projection item 数量为 90。
该脚本只解决“清理前证据如何生成”的问题,不代表历史数据已经清理。真正删除 Convex 历史幽灵副本仍需用户明确授权,并应另行保留清理前后审计结果。
需要注意:file projection 输入只包含投影行,能审计 File Tree 内同一 mindmap object 的重复 row;它不包含 `mindmapAssets``documents` 全量数据,因此不能单独判断同页多 active mindmap、重复 active asset id 或 orphan asset,这三类仍需 `/api/sidebar` 或等价导出的完整数据。
## 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。
这解释了“新建页面和删除页面都很慢”的用户感知。
2026-05-14 已有部分 tree command live apply 进展:
- `rust/crates/mnote-web/src/transport/convex.rs` 已剥离发往 legacy Convex mutation 的 artifact-only 字段,修复 archive 命令因 validator 额外字段触发 502 的问题。
- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 已补 `move_document` / `remove_document` 的主页面 DOM reducer。
- 已通过:`node scripts/task177-tree-move-archive-live-smoke.js`,覆盖 move 后页面树 / File Tree 行父节点更新、archive 后两棵树局部移除,以及文档页头稳定。
这只降低了 tree command 卡顿链路的一部分风险,不代表 mindmap ghost asset 增长已经修复,也不代表普通新建/删除的无 reload 与耗时阈值已经完成。
## 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. 普通 mindmap 节点编辑当前仍会走 artifact,但语义已从 `tree.resource.* + resync_required` 收窄为 `mindmap.content.updated + noop`,不再默认推动 file tree projection resync。
6. 页面新建/删除慢不是 Convex 单点问题,曾由 tree shell 命令成功后强制 reload 与 Sidebar 整树刷新链路放大。该慢操作已单独归入 `04-tree-domain` 跟踪,并在 `bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md` 完成 create / delete no-reload、React Sidebar 直接 create/delete 本地更新,以及 Rust-family host mutation 本地 apply;本文只保留关联证据。
7. 2026-05-14 已修复 NodeView document id 误归属导致的 ghost asset 增长高风险链路;真实 `task169` 已证明连续编辑后同一页面下只保留 1 条 mindmap asset row。
8. 2026-05-14 已收窄普通 mindmap 内容编辑 artifact 语义;剩余边界主要是历史幽灵副本清理必须显式授权,不能在本 bug 修复中擅自删除用户数据。
## 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. 检查 `bridgeLogs:listWorkspaceOverview` 中连续 mindmap 节点编辑产生的 artifact,确认普通内容编辑不应默认产生 `tree.resource.* + resync_required`
9. 同一脚本计时新建页面、删除页面从点击到 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。
5. 收窄普通 mindmap 节点编辑的 artifact:内容编辑记录 `mindmap.content.updated` / `mindmap.node.updated`,只有资源关系变化才记录 `tree.resource.*`
6. 普通 mindmap 节点编辑不应默认输出 `resync_required`;只有缺少精确 delta、projection 丢失或 reducer 无法应用时才触发保守 resync。
### 阶段二:清理幽灵副本
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. 流转条件
当前状态:`done`
只有满足以下条件后才能移动到 `bugs/05-editor-mainline/done/`
1. [x] 已有 smoke 能稳定覆盖 ghost asset 防回归。`task169` 增强后断言同一页面最终只保留 1 条 mindmap asset row,并检查 object identity。
2. [x] 同一 mindmap 连续编辑保存后,`mindmapId` 和 file tree object identity 保持稳定。`task169` 最新结果中 `mindmapId=mindmap_1778698542703`,最终 File Tree mindmap row 只剩同一 object identity。
3. [x] 同一页面下未主动新建多个 mindmap 时,file tree 只出现一个 mindmap asset row。`task169` 结果 `ok=true``failures=[]`
4. [x] 普通 mindmap 节点编辑产生 object 内容 artifact,不默认产生 `tree.resource.* + resync_required``task169` 最新结果已验证 6 条可解析普通 `mindmap.command.apply` 均为 `mindmap.content.updated + noop`1 条 Playwright response body 读取失败只计入采样跳过,不作为应用语义失败。
5. [x] 只有 mindmap 创建、挂载、解绑、删除、移动、重命名这类资源关系变化才影响 file tree projection。`mindmaps.put createOnly=true` 保留资源事件;`createOnly=false``mindmap.command.apply` 改为 object 内容事件。
6. [x] 新建页面和删除页面不再默认整页 reload,或至少有明确 fallback 条件。`task179` 已验证 Rust `/tree` `convex_workspace` File Tree create/delete 与 Page Tree create 对应 mode 内 navigation 为 0。
7. [x] 浏览器实测记录新建/删除耗时,并明显低于当前整页 reload 体验。`task179` 记录 `filetree.createMs=142``filetree.deleteMs=180``page.createMs=126`
8. [x] 历史幽灵副本的只读候选审计已完成,且当前真实 File Tree projection 未发现 duplicate row。本轮已新增 `task180` 只读候选审计脚本,并补齐 `/api/tree/projections/file` 只读投影输入支持;已完成真实只读审计,candidateCount=0,因此本轮不需要执行历史数据清理,也不会删除用户数据。
@@ -1,193 +0,0 @@
# 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. [ ] 若清理历史幽灵副本,必须经用户确认,并保留清理前后证据。