From ca9366232e017a78b705a9f0c0c7f4471af43641 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Thu, 14 May 2026 09:05:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(mnote-web):=20=E5=8D=B3=E6=97=B6=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=A1=B5=E9=9D=A2=E5=86=85=E6=96=B0=E5=BB=BA=E6=80=9D?= =?UTF-8?q?=E7=BB=B4=E5=AF=BC=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...h-stalled-after-tree-asset-mutations-v1.md | 16 ++- rust/crates/mnote-web/src/ssr/pages/layout.rs | 124 ++++++++++++++++++ .../task426-mnote-web-main-no-reload-smoke.js | 56 +++++++- 3 files changed, 191 insertions(+), 5 deletions(-) diff --git a/bugs/04-tree-domain/done/4-26-sidebar-filetree-projection-refresh-stalled-after-tree-asset-mutations-v1.md b/bugs/04-tree-domain/done/4-26-sidebar-filetree-projection-refresh-stalled-after-tree-asset-mutations-v1.md index 84cca82b..7cea2be7 100644 --- a/bugs/04-tree-domain/done/4-26-sidebar-filetree-projection-refresh-stalled-after-tree-asset-mutations-v1.md +++ b/bugs/04-tree-domain/done/4-26-sidebar-filetree-projection-refresh-stalled-after-tree-asset-mutations-v1.md @@ -145,6 +145,10 @@ - `create` 成功后立即插入 Page Tree 行,以及 File Tree 的 `doc:` / `index:` 行。 - `purge/delete/archive` 成功后立即从 Page Tree 与 File Tree 移除目标页面行。 - 命令成功后清理按钮 pending 状态,避免无整页刷新时按钮残留禁用。 +6. `rust/crates/mnote-web/src/ssr/pages/layout.rs` + - 追加修复 Rust 3000 页面内 mindmap 创建路径:主文档壳现在同时监听 `wolai:assets-changed`,并观察同页 `/api/mindmap//` POST 成功请求。 + - mindmap 创建成功后立即把 `mindmap-.json` 作为 File Tree asset row 插入当前页面下,避免等刷新后 projection 才出现。 + - 这覆盖 Leptos/Rust island 未触发 React `emitAssetsChanged()` 的路径。 2026-05-14 回归复现与补修: @@ -152,6 +156,8 @@ - 复现证据:旧 3000 进程为 `/mnt/Data1T/mnote/rust/target/debug/mnote-web (deleted)`;点击“新建页面”后接口返回成功并跳转到 `/documents/tree_1778714360642_10?...`,但 5 秒后 `#sidebar-tree-root` 仍不包含该 document id。 - 当前源码 3002 在补修前也复现同类问题:新建后跳转到 `/documents/tree_1778714400752_12?...`,但 Page Tree DOM 不包含当前 document id。 - 补修后已重启 3000,并在 3000 主入口完成新建/删除无刷新 smoke。 +- 用户继续复测确认页面新建/删除已恢复,但页面内新建思维导图仍需刷新。真实 smoke 复核后确认:3000 页面内导图创建会走 `/api/mindmap//` 写入链路,主文档壳原先没有把该成功写入同步 materialize 到 File Tree asset row。 +- 补修后已在 3000 主入口通过 slash 菜单真实新建思维导图,并确认 File Tree 无刷新出现 `asset:` 行。 ## 7. 验收标准 @@ -159,7 +165,7 @@ 1. [x] 在 `http://127.0.0.1:3000/documents/` 主页面壳中新建页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到新页面。代码证据:React Sidebar 路径由 `handleCreate()` 本地 upsert documents;Rust 3000 主文档壳由 `tree:local-command` 本地插入 Page Tree 行与 File Tree `doc:` / `index:` 行;单测覆盖:`sidebar-local-projection.test.ts`,Rust runtime 覆盖:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`。 2. [x] 在主页面壳中删除页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到目标页面消失。代码证据:React Sidebar 路径由 `handleDelete()` / `handleDeleteResourceSelection()` 同步 `removeSidebarDocumentRecords()`;Rust 3000 主文档壳由 `tree:local-command` 在 `purge/delete/archive` 成功后调用 remove delta 本地移除;单测覆盖:`sidebar-local-projection.test.ts` 与 `sidebar-delete-preflight-source.test.ts`,Rust runtime 覆盖:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`。 -3. [x] 在主页面壳中新建 mindmap 后,不刷新浏览器即可在 File Tree 中看到对应 `mindmap-.json` 行。代码证据:`emitAssetsChanged(asset)` 已进入本地 `mindmapAssets`,`localFileTreeProjection` 用本地 mindmap assets 重建 asset row;单测覆盖:`sidebar-local-projection.test.ts`。 +3. [x] 在主页面壳中新建 mindmap 后,不刷新浏览器即可在 File Tree 中看到对应 `mindmap-.json` 行。代码证据:React Sidebar 路径由 `emitAssetsChanged(asset)` 进入本地 `mindmapAssets` 并重建 asset row;Rust 3000 主文档壳由 `wolai:assets-changed` listener 和 `/api/mindmap//` POST observer 本地插入 File Tree asset row;单测覆盖:`sidebar-local-projection.test.ts`,Rust runtime 覆盖:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`。 4. [x] 上述三条路径均不得依赖 `window.location.reload()` 或顶层 navigation。代码证据:本轮没有新增 reload;create/delete 以本地 state/projection apply 为主,显式 refetch 仅作 snapshot 校准。 5. [x] smoke 需要记录事件或本地 apply 标记、最终 DOM 状态与相关网络请求。React Sidebar 路径保留 `tmp/task426-sidebar-main-no-reload-smoke/result.json` 作为 Next/3001 与 mindmap asset 证据;Rust 3000 主文档壳新增 `tmp/task426-mnote-web-main-no-reload-smoke/result.json`,覆盖真实 3000 页面新建/删除无刷新 DOM 结果。代码级测试同时覆盖:`sidebar-delete-preflight-source.test.ts` 锁定 create/delete 事件与本地 documents apply;`use-sidebar-data.test.tsx` 锁定 Convex live 显式 refetch 会真实拉 `/api/sidebar`;`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture` 锁定 Rust 主文档壳 runtime 基础行为。 6. [x] 修复后补测试,防止 `sidebarQuery.refetch()` 在 Convex live 模式下继续作为空操作吞掉显式刷新请求。测试覆盖:`use-sidebar-data.test.tsx`。 @@ -195,10 +201,12 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task426-mnote-web-main-no-r - `ok=true` - `baseUrl=http://127.0.0.1:3000` -- `createdDocumentId=tree_1778715292685_1` -- 新建后:`localApplied=create`、`pageRows=1`、`fileRows=["doc:tree_1778715292685_1","index:tree_1778715292685_1"]` +- `createdDocumentId=tree_1778720530823_2` +- `mindmapId=mindmap_1778720531308` +- 新建页面后:`localApplied=create`、`pageRows=1`、`fileRows=["doc:tree_1778720530823_2","index:tree_1778720530823_2"]` +- 新建思维导图后:`localApplied=true`、`rows=[{"rowId":"asset:mindmap_1778720531308","docId":"tree_1778720530823_2","title":"mindmap-mindmap_1778720531308.json…","kind":"mindmap"}]` - 删除后:`localApplied=remove`、`pageRows=0`、`fileRows=[]` -- `requests` 仅包含两次 `POST /api/tree/commands`,无浏览器刷新依赖 +- `requests` 包含两次 `POST /api/tree/commands`、一次 mindmap projection GET、两次 `/api/mindmap//` POST;无浏览器刷新依赖 - 结果文件:`tmp/task426-mnote-web-main-no-reload-smoke/result.json` 补充对比: diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 6f287ae3..db0461bb 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -1997,6 +1997,120 @@ const SIDEBAR_TREE_JS: &str = r##" document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId); } + function removeFileTreeAssetRow(assetId) { + var normalized = String(assetId || '').trim(); + if (!normalized) return false; + var row = document.querySelector('.tree-row[data-shell-mode="filetree"][data-asset-id="' + cssEscape(normalized) + '"]'); + var node = row ? row.closest('.tree-node') : null; + if (!node || !node.parentElement) return false; + node.parentElement.removeChild(node); + return true; + } + + function applyAssetsChangedToFileTree(detail) { + detail = detail || {}; + var docId = String(detail.docId || detail.documentId || currentDocumentId() || '').trim(); + var changed = false; + var explicitAsset = detail.asset && typeof detail.asset === 'object' ? detail.asset : null; + if (explicitAsset) { + appendUploadedAssetRow(explicitAsset, docId); + changed = true; + } + var mindmapAssetIds = Array.isArray(detail.mindmapAssetIds) ? detail.mindmapAssetIds : []; + if (detail.mindmapDeleted === true) { + mindmapAssetIds.forEach(function(assetId) { + if (removeFileTreeAssetRow(assetId)) changed = true; + }); + } + if (changed) { + document.documentElement.setAttribute('data-mnote-assets-local-applied', 'true'); + } + } + + function mindmapAssetFromTarget(documentId, mindmapId) { + var docId = String(documentId || currentDocumentId() || '').trim(); + var assetId = String(mindmapId || '').trim(); + if (!docId || !assetId) return null; + var fileName = 'mindmap-' + assetId + '.json'; + return { + id: assetId, + document_id: docId, + asset_type: 'mindmap', + file_name: fileName, + file_url: '/documents/' + encodeURIComponent(docId) + '/' + encodeURIComponent(fileName) + }; + } + + function parseMindmapApiTarget(input) { + try { + var rawUrl = typeof input === 'string' + ? input + : input && typeof input.url === 'string' + ? input.url + : ''; + if (!rawUrl) return null; + var url = new URL(rawUrl, window.location.origin); + var match = /^\/api\/mindmap\/([^/]+)\/([^/]+)$/.exec(url.pathname); + if (!match) return null; + return { + documentId: decodeURIComponent(match[1]), + mindmapId: decodeURIComponent(match[2]) + }; + } catch (_) { + return null; + } + } + + function requestMethod(input, init) { + return String( + init && init.method + ? init.method + : input && typeof input.method === 'string' + ? input.method + : 'GET' + ).toUpperCase(); + } + + function requestBodyHasMindmapCreateOnly(init) { + var body = init && typeof init.body === 'string' ? init.body : ''; + if (!body) return false; + try { + var payload = JSON.parse(body); + return payload && payload.createOnly === true; + } catch (_) { + return false; + } + } + + function applyMindmapApiMutationToFileTree(target) { + if (!target || !target.documentId || !target.mindmapId) return; + var asset = mindmapAssetFromTarget(target.documentId, target.mindmapId); + if (!asset) return; + appendUploadedAssetRow(asset, target.documentId); + document.documentElement.setAttribute('data-mnote-assets-local-applied', 'true'); + document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', target.mindmapId); + } + + function installMindmapAssetFetchObserver() { + if (window.__mnoteMindmapAssetFetchObserverInstalled === true) return; + if (typeof window.fetch !== 'function') return; + window.__mnoteMindmapAssetFetchObserverInstalled = true; + var originalFetch = window.fetch.bind(window); + window.fetch = function(input, init) { + var target = parseMindmapApiTarget(input); + var method = requestMethod(input, init); + var createOnly = requestBodyHasMindmapCreateOnly(init || {}); + return originalFetch(input, init).then(function(response) { + if (target && method === 'POST' && response && response.ok) { + if (createOnly || target.documentId === currentDocumentId()) { + applyMindmapApiMutationToFileTree(target); + } + } + return response; + }); + }; + } + async function insertUploadedAssetIntoEditor(asset) { var editorRoot = document.querySelector('.editor-surface .ProseMirror'); var editor = editorRoot && editorRoot.editor; @@ -2090,6 +2204,11 @@ const SIDEBAR_TREE_JS: &str = r##" return payload.asset; } + window.addEventListener('wolai:assets-changed', function(event) { + applyAssetsChangedToFileTree(event.detail || {}); + }); + installMindmapAssetFetchObserver(); + async function uploadFilesWithResolvedTarget(files, detail, options) { var list = Array.from(files || []).filter(Boolean); if (!list.length) return []; @@ -4614,6 +4733,11 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("applyCreatedDocumentLocally")); assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'create")); assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'remove")); + assert!(SIDEBAR_TREE_JS.contains("wolai:assets-changed")); + assert!(SIDEBAR_TREE_JS.contains("applyAssetsChangedToFileTree")); + assert!(SIDEBAR_TREE_JS.contains("installMindmapAssetFetchObserver")); + assert!(SIDEBAR_TREE_JS.contains("parseMindmapApiTarget")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-assets-local-applied")); assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node")); assert!(SIDEBAR_TREE_JS.contains("dragstart")); assert!(SIDEBAR_TREE_JS.contains("drop")); diff --git a/scripts/task426-mnote-web-main-no-reload-smoke.js b/scripts/task426-mnote-web-main-no-reload-smoke.js index dee99a1a..686f2323 100644 --- a/scripts/task426-mnote-web-main-no-reload-smoke.js +++ b/scripts/task426-mnote-web-main-no-reload-smoke.js @@ -41,6 +41,44 @@ async function readCreatedState(page, documentId) { }, documentId); } +async function insertMindmapThroughSlash(page) { + const editor = page + .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]') + .first(); + await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.type("/"); + const item = page.getByTestId("slash-item-mindmap").first(); + await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await item.click({ timeout: UI_TIMEOUT_MS }); + const root = page.getByTestId("mnote-mindmap-editor-root").first(); + await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const mindmapId = await root.evaluate((node) => node instanceof HTMLElement ? node.dataset.mnoteMindmapId || "" : ""); + assert(mindmapId, "新建思维导图后缺少 mindmapId"); + return mindmapId; +} + +async function readMindmapAssetState(page, documentId, mindmapId) { + return page.evaluate( + ({ documentId: docId, mindmapId: assetId }) => { + const rows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]")) + .filter((row) => row.getAttribute("data-asset-id") === assetId) + .map((row) => ({ + rowId: row.getAttribute("data-row-id"), + docId: row.getAttribute("data-doc-id") || row.getAttribute("data-document-id"), + title: row.textContent?.trim() || "", + kind: row.querySelector(".tree-kind-badge")?.getAttribute("data-kind") || "", + })); + return { + rows, + localApplied: document.documentElement.getAttribute("data-mnote-assets-local-applied") || "", + expectedDocId: docId, + }; + }, + { documentId, mindmapId }, + ); +} + async function main() { await fs.mkdir(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); @@ -49,7 +87,7 @@ async function main() { const requests = []; page.on("response", (response) => { const url = response.url(); - if (url.includes("/api/tree/commands") || url.includes("/api/sidebar") || url.includes("/api/tree/events")) { + if (url.includes("/api/tree/commands") || url.includes("/api/sidebar") || url.includes("/api/tree/events") || url.includes("/api/mindmap/")) { requests.push({ url, status: response.status() }); } }); @@ -78,6 +116,20 @@ async function main() { assert.equal(afterCreate.pageRows, 1, "Page Tree 应立即出现新页面"); assert.deepEqual(afterCreate.fileRows.sort(), [`doc:${createdDocumentId}`, `index:${createdDocumentId}`].sort(), "File Tree 应立即出现新页面 doc/index 行"); + const mindmapId = await insertMindmapThroughSlash(page); + await page.waitForFunction( + ({ assetId }) => { + return Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${CSS.escape(assetId)}"]`)); + }, + { assetId: mindmapId }, + { timeout: UI_TIMEOUT_MS }, + ); + const afterMindmapCreate = await readMindmapAssetState(page, createdDocumentId, mindmapId); + assert.equal(afterMindmapCreate.localApplied, "true", "新建思维导图应由 assets-changed 本地 apply 到 File Tree"); + assert.equal(afterMindmapCreate.rows.length, 1, "File Tree 应立即出现新建思维导图 asset 行"); + assert.equal(afterMindmapCreate.rows[0].rowId, `asset:${mindmapId}`, "File Tree mindmap rowId 应匹配 asset id"); + assert.equal(afterMindmapCreate.rows[0].docId, createdDocumentId, "File Tree mindmap asset 应挂在当前页面下"); + const rowSelector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssString(createdDocumentId)}"]`; await page.locator(rowSelector).scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS }); page.once("dialog", async (dialog) => { @@ -105,7 +157,9 @@ async function main() { baseUrl: BASE_URL, beforeCount, createdDocumentId, + mindmapId, afterCreate, + afterMindmapCreate, afterDelete, requests, };