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 new file mode 100644 index 00000000..669df1c8 --- /dev/null +++ b/bugs/04-tree-domain/done/4-26-sidebar-filetree-projection-refresh-stalled-after-tree-asset-mutations-v1.md @@ -0,0 +1,207 @@ +# 4-26 [done][bug] Sidebar File Tree projection 在树/资产变更后不刷新 v1 + +> 更新时间:2026-05-14 +> +> 分类归属: +> - `04-tree-domain/done` +> - 关联缺陷:`bugs/04-tree-domain/done/4-25-tree-command-create-delete-reload-latency-v1.md` +> - 涉及边界:`05-editor-mainline/sidebar shell`、`06-mindmap/mindmap asset` +> +> 用户反馈: +> - “新建页面/删除页面必须得刷新浏览器才能出现新页面或看到页面删除。” +> - “包括新建思维导图,也是得刷新才能看到。” +> - “有点像是全局事件被禁用了或类似的。” + +## 1. 问题定义 + +当前主页面 Sidebar / File Tree 在页面新建、页面删除、mindmap 新建后没有即时反映最新 projection。浏览器刷新后能看到正确结果,说明后端数据已经写入,问题集中在 `tree command / asset mutation -> sidebar projection -> File Tree UI` 的前端刷新和局部 apply 链路。 + +这不是单纯的“整页 reload 太慢”问题,而是 4-25 去掉强刷新后暴露出的新回归:普通命令不再强制刷新浏览器,但 File Tree projection 也没有被同步更新。 + +## 2. 真实现象 + +已确认现象: + +1. 新建页面后,当前页面壳里的 File Tree / Sidebar 不能立即看到新页面。 +2. 删除页面后,当前页面壳里的 File Tree / Sidebar 仍可能保留旧页面行。 +3. 新建 mindmap 后,当前页面壳里的 File Tree 不能立即看到 `mindmap-.json` 资源行。 +4. 手动刷新浏览器后,页面或 mindmap 资源会按后端最新数据出现/消失。 + +期望结果: + +1. `tree.node.create` 成功后,Page Tree 与 File Tree 都能在当前页面壳中即时出现新页面。 +2. `tree.node.archive` / 删除成功后,Page Tree 与 File Tree 都能在当前页面壳中即时移除目标页面及子树。 +3. `mindmaps.put(createOnly=true)` 或新 mindmap mount 成功后,File Tree 能即时出现对应 mindmap asset row。 +4. 不依赖浏览器刷新作为普通成功路径;刷新只能作为 projection 无法局部 apply 时的显式 fallback。 + +## 3. 确认证据 + +### 3.1 主 Sidebar 的数据源仍来自 preferred snapshot + +`wolai-frontend/src/components/sidebar/sidebar.tsx:201` 到 `:209`: + +- Sidebar 通过 `usePreferredSidebarSnapshot()` 在 `initialData`、`sidebarQuery.data`、`treeStream.data` 之间选择数据源。 +- 后续 File Tree projection 仍读取这个 `sidebarData`。 + +### 3.2 Page Tree 有本地 setTree,但 File Tree projection 没有同步局部更新 + +`wolai-frontend/src/components/sidebar/sidebar.tsx:305` 到 `:316`: + +- `sidebarData.kernelSidebarTree` 变化时会同步到本地 `tree` state。 + +`wolai-frontend/src/components/sidebar/sidebar.tsx:1900` 到 `:1957`: + +- `handleCreate()` 在 `createDocumentCommand()` 成功后只对页面树做本地 `setTree()`,然后 `router.push()`。 +- 该路径没有同步更新 `sidebarData.kernelFileTreeProjection.items`,也没有广播 `emitDocumentsChanged()`。 + +`wolai-frontend/src/components/sidebar/sidebar.tsx:2239` 到 `:2249`: + +- `handleDelete()` 成功后调用 `removeDocumentsFromTree()` 和 `emitDocumentsChanged()`。 +- 该本地删除只影响 Page Tree 的 `tree` state,不会同步移除 File Tree projection items。 + +### 3.3 File Tree shell 直接依赖 stale projection items + +`wolai-frontend/src/components/sidebar/sidebar.tsx:678` 到 `:687`: + +- 非搜索状态下 `effectiveResourceTreeShellItems` 直接等于 `sidebarData.kernelFileTreeProjection.items`。 +- 如果 preferred snapshot 没有变化,File Tree 行模型就不会包含新页面、删除结果或新 mindmap asset。 + +`wolai-frontend/src/components/sidebar/sidebar.tsx:695` 到 `:702`: + +- `buildFileTreeShellRowById()` 基于 `effectiveResourceTreeShellItems` 构建 row map。 +- 即使 `assetById` 里有新 asset,只要 projection items 没有对应 row,File Tree 也无法显示这条资源。 + +### 3.4 全局事件监听存在,但 refetch 在 Convex live 模式下是空操作 + +`wolai-frontend/src/components/sidebar/sidebar-events.ts:17` 到 `:28`: + +- `wolai:documents-changed` / `wolai:assets-changed` 会触发 `sidebarRefetch()`。 +- 这说明全局事件监听并非完全禁用。 + +`wolai-frontend/src/hooks/use-sidebar-data.ts:93` 到 `:101`: + +- `sidebarQuery.refetch()` 在 Convex live subscription 模式下转调 `convexSidebar.refetch()`。 + +`wolai-frontend/src/hooks/use-convex-sidebar-data.ts:90` 到 `:96`: + +- `hasLiveSubscription = shouldFetch`。 +- `refetch` 明确是空操作,注释为“Convex 会自动同步数据”。 + +因此,只要 Convex live query 没有及时推送或 preferred snapshot 仍选择旧 tree stream/query 数据,`emitDocumentsChanged()` / `emitAssetsChanged()` 对 File Tree projection 就不会强制拉取新 snapshot。 + +### 3.5 mindmap 新建只乐观补 asset 列表,不补 File Tree projection row + +`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx:1527` 到 `:1534`、`:1591` 到 `:1621`、`:1636` 到 `:1647`: + +- mindmap 保存、初始同步、实例就绪都会调用 `emitAssetsChanged(docId, asset)`。 + +`wolai-frontend/src/components/sidebar/sidebar.tsx:506` 到 `:528`: + +- Sidebar 收到带 `asset` 的事件后会把 mindmap 加入 `mindmapAssets` 本地 state。 +- 但 File Tree 的 row 输入仍是 `sidebarData.kernelFileTreeProjection.items`,没有把新 mindmap asset materialize 成 projection item。 + +这解释了“新建思维导图也必须刷新浏览器才能看到”:asset 本地列表可能更新了,但 Rust-family File Tree shell 的可见行不来自这个列表本身,而来自 stale file projection。 + +## 4. 当前判断 + +该问题已通过代码路径确认,属于事件/刷新链路和 projection 局部 apply 缺口: + +1. 事件监听存在,不是全局事件完全被禁用。 +2. 文档变更事件在 create 路径缺失,在 delete 路径存在但只触发空 refetch。 +3. asset 变更事件能把 mindmap 加进本地 asset state,但不能生成 File Tree projection row。 +4. File Tree shell 以 `kernelFileTreeProjection.items` 为事实源,当前缺少对 create / archive / mindmap asset create 的局部 reducer 或强制 projection reload。 +5. 浏览器刷新后能看到正确结果,进一步说明后端写入成功,前端 projection 没有及时刷新。 + +## 5. 建议修复方向 + +1. 将 `tree.node.create` / `tree.node.archive` 的 command result 或 stream delta 同步应用到 `kernelFileTreeProjection.items`,不要只更新 Page Tree state。 +2. `emitDocumentsChanged()` 应能触发一个真实的 projection refresh:在 Convex live 模式下不能继续是空操作,至少需要 invalidate/fetch `/api/sidebar` 或 `/api/tree/projections/file`。 +3. `emitAssetsChanged(asset)` 对 mindmap create 应能 materialize 对应 File Tree asset row,或触发 File Tree projection 重新拉取。 +4. `usePreferredSidebarSnapshot()` 需要避免旧 tree stream snapshot 长时间压住更新后的 query/http projection。 +5. 增加主页面 Sidebar smoke,覆盖真实 `/documents/` 页面内的新建页面、删除页面、新建 mindmap,断言不刷新浏览器即可看到 UI 变化。 + +## 6. 修复记录 + +2026-05-14 已完成修复: + +1. `wolai-frontend/src/components/sidebar/sidebar-local-projection.ts` + - 新增 Sidebar 本地 documents/file-tree projection helper。 + - `tree.node.create` 成功后可将新页面 upsert 到本地 documents,并重建 File Tree projection。 + - `tree.node.archive` / 删除成功后可从本地 documents 中移除目标页面及子树,并重建 File Tree projection。 + - mindmap asset 进入本地 `mindmapAssets` 后,可 materialize 成 File Tree asset row。 +2. `wolai-frontend/src/components/sidebar/sidebar.tsx` + - 新增本地 `documents` state,并随后端 sidebar snapshot 校准。 + - File Tree 非搜索状态改为消费 `localFileTreeProjection.items`,不再只依赖 stale `sidebarData.kernelFileTreeProjection.items`。 + - `handleCreate()` 成功后同步 upsert documents、广播 `emitDocumentsChanged(nextNode.id)`,Page Tree 与 File Tree 都可即时反映。 + - `handleDelete()` / 文件树批量删除成功后同步移除 documents 子树,File Tree 不再保留旧 projection row。 + - rename / move / Rust-family tree shell mutation 也同步更新本地 documents,避免 File Tree row 标题或父级滞后。 +3. `wolai-frontend/src/hooks/use-sidebar-data.ts` + - Convex live 模式下的显式 `refetch()` 不再只是空操作;现在会拉取 `/api/sidebar?workspaceId=` 作为手动 snapshot 补偿。 + - 手动 snapshot 绑定 workspaceId,避免跨工作区污染。 +4. `wolai-frontend/src/components/sidebar/sidebar-sync.ts` + - `buildSidebarDataSyncKey()` 纳入顶层 `documents`,避免只有 documents 变化时稳定缓存仍命中旧 sidebar data。 + +## 7. 验收标准 + +只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`: + +1. [x] 在 `http://127.0.0.1:3000/documents/` 主页面壳中新建页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到新页面。代码证据:`handleCreate()` 本地 upsert documents,`localFileTreeProjection.items` 立即包含 `doc:` / `index:`;单测覆盖:`sidebar-local-projection.test.ts`。 +2. [x] 在主页面壳中删除页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到目标页面消失。代码证据:`handleDelete()` / `handleDeleteResourceSelection()` 同步 `removeSidebarDocumentRecords()`,并移除子树;单测覆盖:`sidebar-local-projection.test.ts` 与 `sidebar-delete-preflight-source.test.ts`。 +3. [x] 在主页面壳中新建 mindmap 后,不刷新浏览器即可在 File Tree 中看到对应 `mindmap-.json` 行。代码证据:`emitAssetsChanged(asset)` 已进入本地 `mindmapAssets`,`localFileTreeProjection` 用本地 mindmap assets 重建 asset row;单测覆盖:`sidebar-local-projection.test.ts`。 +4. [x] 上述三条路径均不得依赖 `window.location.reload()` 或顶层 navigation。代码证据:本轮没有新增 reload;create/delete 以本地 state/projection apply 为主,显式 refetch 仅作 snapshot 校准。 +5. [x] smoke 需要记录 `documents-changed` / `assets-changed` 事件、`/api/tree/events` 或 projection refetch 是否发生,以及最终 DOM 稳定耗时。已补真实浏览器 smoke:`tmp/task426-sidebar-main-no-reload-smoke/result.json` 记录 `createPageVisibleMs=341`、`mindmapVisibleMs=293`、`deleteDetachedMs=1570`;事件包含 `wolai:documents-changed`、`wolai:assets-changed`、删除后的 `wolai:documents-changed`;网络包含 `/api/tree/events`、`POST /api/tree/commands`、`POST /api/mindmap/...` 与显式 `/api/sidebar?workspaceId=...` refetch。代码级测试同时覆盖:`sidebar-delete-preflight-source.test.ts` 锁定 create/delete 事件与本地 documents apply;`use-sidebar-data.test.tsx` 锁定 Convex live 显式 refetch 会真实拉 `/api/sidebar`。 +6. [x] 修复后补测试,防止 `sidebarQuery.refetch()` 在 Convex live 模式下继续作为空操作吞掉显式刷新请求。测试覆盖:`use-sidebar-data.test.tsx`。 + +## 8. 验证记录 + +已通过: + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm test -- src/components/sidebar/sidebar-delete-preflight-source.test.ts src/components/sidebar/sidebar-local-projection.test.ts src/hooks/use-sidebar-data.test.tsx src/components/sidebar/sidebar-sync.test.ts +``` + +结果:`113 passed`,`465 passed`。 + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm exec eslint src/components/sidebar/sidebar-sync.ts src/components/sidebar/sidebar-sync.test.ts src/components/sidebar/sidebar-local-projection.ts src/components/sidebar/sidebar-local-projection.test.ts src/hooks/use-sidebar-data.ts src/hooks/use-sidebar-data.test.tsx src/components/sidebar/sidebar.tsx +``` + +结果:无 error;`sidebar.tsx` 保留既有 warning。 + +真实浏览器 smoke 已通过: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3001 node +``` + +结果文件:`tmp/task426-sidebar-main-no-reload-smoke/result.json`。 + +关键结果: + +- `ok=true` +- `createPageVisibleMs=341` +- `mindmapVisibleMs=293` +- `deleteDetachedMs=1570` +- 删除确认文案:`确认删除选中的 1 个页面(删除到垃圾桶) 吗?` +- `beforeDeleteState.filetreeRows` 包含 `doc:50151ef9-e0d5-48b9-a10d-3338b417f81f`、`index:50151ef9-e0d5-48b9-a10d-3338b417f81f`、`asset:mind_task426_1778712496830` +- `finalState.filetreeRows` 不再包含上述 document / mindmap asset row +- `finalState.eventLog` 记录 `wolai:documents-changed`、`wolai:assets-changed`、删除后的 `wolai:documents-changed` +- `requests` 记录 `/api/tree/events`、`POST /api/tree/commands` create/archive、`POST /api/mindmap/...` createOnly、`GET /api/sidebar?workspaceId=...` + +已尝试: + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm exec tsc --noEmit --pretty false +``` + +结果:失败,但失败集中在既有 Convex / AI / OnlyOffice / 测试类型问题,不是本轮新增文件或本轮修改路径的专属错误。 + +## 9. 流转条件 + +当前状态:`done` + +本缺陷已完成代码修复与相关测试覆盖,按 bugs 目录规则迁移到 `bugs/04-tree-domain/done/`。 diff --git a/wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts b/wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts index 7edb0f3a..511d8856 100644 --- a/wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts +++ b/wolai-frontend/src/components/sidebar/sidebar-delete-preflight-source.test.ts @@ -51,9 +51,13 @@ describe("sidebar file tree delete preflight source", () => { const deleteBody = source.slice(deleteStart, deleteEnd); expect(createBody).not.toContain("await refreshTree();"); + expect(createBody).toContain("upsertSidebarDocumentRecord("); + expect(createBody).toContain("emitDocumentsChanged(nextNode.id);"); expect(deleteSelectionBody).toContain("removeDocumentsFromTree(docIds);"); + expect(deleteSelectionBody).toContain("removeSidebarDocumentRecords(prev, docIds)"); expect(deleteSelectionBody).not.toContain("await refreshTree();"); expect(deleteBody).toContain("removeDocumentsFromTree([documentId]);"); + expect(deleteBody).toContain("removeSidebarDocumentRecords(prev, [documentId])"); expect(deleteBody).not.toContain("await refreshTree();"); }); diff --git a/wolai-frontend/src/components/sidebar/sidebar-local-projection.test.ts b/wolai-frontend/src/components/sidebar/sidebar-local-projection.test.ts new file mode 100644 index 00000000..53bd4b98 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-local-projection.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import type { DocumentRecord } from "@/lib/documents"; +import { buildKernelFileTreeProjection } from "@/lib/kernel-file-tree"; +import type { MediaAsset } from "@/types/media"; +import { + buildSidebarLocalFileTreeProjection, + moveSidebarDocumentRecord, + removeSidebarDocumentRecords, + sidebarTreeNodeToDocumentRecord, + upsertSidebarDocumentRecord, +} from "./sidebar-local-projection"; + +function documentRecord(input: Partial & { id: string }): DocumentRecord { + return { + access_scope: "private", + id: input.id, + workspace_id: input.workspace_id ?? "ws_1", + title: input.title ?? input.id, + parent_id: input.parent_id ?? null, + sort_order: input.sort_order ?? 0, + is_starred: input.is_starred ?? false, + is_template: input.is_template ?? false, + created_at: input.created_at ?? "2026-05-14T00:00:00.000Z", + updated_at: input.updated_at ?? null, + }; +} + +function mindmapAsset(input: Partial & { id: string; document_id: string }): MediaAsset { + return { + id: input.id, + workspace_id: input.workspace_id ?? "ws_1", + document_id: input.document_id, + asset_type: "mindmap", + file_url: input.file_url ?? `/documents/${input.document_id}/mindmap-${input.id}.json`, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: input.file_name ?? `mindmap-${input.id}.json`, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: input.created_at ?? "2026-05-14T00:00:00.000Z", + updated_at: input.updated_at ?? "2026-05-14T00:00:00.000Z", + }; +} + +describe("sidebar-local-projection", () => { + it("新建页面本地 upsert 后应能生成 File Tree document/index 行", () => { + const baseProjection = buildKernelFileTreeProjection({ + documents: [documentRecord({ id: "root", sort_order: 0 })], + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }); + const created = sidebarTreeNodeToDocumentRecord( + { + id: "created", + workspace_id: "ws_1", + title: "新页面", + parent_id: "root", + sort_order: 1, + access_scope: "private", + is_starred: false, + is_template: false, + created_at: "2026-05-14T00:01:00.000Z", + updated_at: "2026-05-14T00:01:00.000Z", + children: [], + }, + "ws_1", + ); + const documents = upsertSidebarDocumentRecord([documentRecord({ id: "root", sort_order: 0 })], created); + const projection = buildSidebarLocalFileTreeProjection({ + baseProjection, + documents, + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }); + + expect(projection.items.map((item) => item.rowId)).toContain("doc:created"); + expect(projection.items.map((item) => item.rowId)).toContain("index:created"); + }); + + it("删除页面本地移除后应同时移除其子树 File Tree 行", () => { + const root = documentRecord({ id: "root" }); + const child = documentRecord({ id: "child", parent_id: "root" }); + const grandchild = documentRecord({ id: "grandchild", parent_id: "child" }); + const documents = removeSidebarDocumentRecords([root, child, grandchild], ["child"]); + const projection = buildSidebarLocalFileTreeProjection({ + baseProjection: buildKernelFileTreeProjection({ + documents: [root, child, grandchild], + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }), + documents, + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }); + + const rowIds = projection.items.map((item) => item.rowId); + expect(rowIds).toContain("doc:root"); + expect(rowIds).not.toContain("doc:child"); + expect(rowIds).not.toContain("index:grandchild"); + }); + + it("新建 mindmap asset 后应能生成 File Tree asset 行", () => { + const root = documentRecord({ id: "root" }); + const projection = buildSidebarLocalFileTreeProjection({ + baseProjection: buildKernelFileTreeProjection({ + documents: [root], + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }), + documents: [root], + mediaAssets: [], + mindmapAssets: [mindmapAsset({ id: "mind_1", document_id: "root" })], + tableAssets: [], + mindmapAssetChildren: {}, + }); + + const assetRow = projection.items.find((item) => item.resourceMeta.assetId === "mind_1"); + expect(assetRow?.rowId).toBe("asset:mind_1"); + expect(assetRow?.resourceMeta.assetKind).toBe("mindmap"); + }); + + it("移动页面本地更新后 File Tree row 的父级应同步变化", () => { + const root = documentRecord({ id: "root", sort_order: 0 }); + const target = documentRecord({ id: "target", parent_id: null, sort_order: 1 }); + const moved = moveSidebarDocumentRecord([root, target], "target", "root", 0); + const projection = buildSidebarLocalFileTreeProjection({ + baseProjection: buildKernelFileTreeProjection({ + documents: [root, target], + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }), + documents: moved, + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }); + + const targetRow = projection.items.find((item) => item.rowId === "doc:target"); + expect(targetRow?.parentNodeId).toBe("root"); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/sidebar-local-projection.ts b/wolai-frontend/src/components/sidebar/sidebar-local-projection.ts new file mode 100644 index 00000000..8c6a34f3 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-local-projection.ts @@ -0,0 +1,154 @@ +import type { SidebarInitialData } from "@/components/sidebar/types"; +import type { DocumentRecord } from "@/lib/documents"; +import { + buildKernelFileTreeProjection, + type KernelFileTreeProjection, +} from "@/lib/kernel-file-tree"; +import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; +import type { MediaAsset } from "@/types/media"; + +export function buildDocumentListSyncKey(documents: readonly DocumentRecord[]): string { + return JSON.stringify( + documents.map((document) => ({ + id: document.id, + workspaceId: document.workspace_id, + title: document.title ?? null, + parentId: document.parent_id ?? null, + sortOrder: document.sort_order ?? null, + accessScope: document.access_scope, + isStarred: document.is_starred ?? null, + isTemplate: document.is_template, + createdAt: document.created_at, + updatedAt: document.updated_at ?? null, + })), + ); +} + +export function sidebarTreeNodeToDocumentRecord( + node: SidebarTreeNode, + fallbackWorkspaceId: string, +): DocumentRecord { + return { + access_scope: node.access_scope ?? "private", + id: node.id, + workspace_id: node.workspace_id || fallbackWorkspaceId, + title: node.title ?? "无标题", + parent_id: node.parent_id ?? null, + sort_order: node.sort_order ?? null, + is_starred: node.is_starred ?? false, + is_template: node.is_template ?? false, + created_at: node.created_at || "", + updated_at: node.updated_at ?? node.created_at ?? null, + }; +} + +export function upsertSidebarDocumentRecord( + documents: readonly DocumentRecord[], + document: DocumentRecord, +): DocumentRecord[] { + let found = false; + const next = documents.map((item) => { + if (item.id !== document.id) { + return item; + } + found = true; + return document; + }); + if (!found) { + next.push(document); + } + return next; +} + +export function renameSidebarDocumentRecord( + documents: readonly DocumentRecord[], + documentId: string, + title: string, + updatedAt: string | null, +): DocumentRecord[] { + let changed = false; + const next = documents.map((document) => { + if (document.id !== documentId) { + return document; + } + changed = true; + return { + ...document, + title, + updated_at: updatedAt ?? document.updated_at, + }; + }); + return changed ? next : [...documents]; +} + +export function moveSidebarDocumentRecord( + documents: readonly DocumentRecord[], + documentId: string, + parentId: string | null, + sortOrder: number | null, +): DocumentRecord[] { + let changed = false; + const next = documents.map((document) => { + if (document.id !== documentId) { + return document; + } + changed = true; + return { + ...document, + parent_id: parentId, + sort_order: sortOrder, + }; + }); + return changed ? next : [...documents]; +} + +export function removeSidebarDocumentRecords( + documents: readonly DocumentRecord[], + documentIds: readonly string[], +): DocumentRecord[] { + const requestedIds = new Set(documentIds.map((id) => id.trim()).filter(Boolean)); + if (requestedIds.size === 0) { + return [...documents]; + } + + const childrenByParentId = new Map(); + documents.forEach((document) => { + const parentId = document.parent_id?.trim(); + if (!parentId) { + return; + } + const bucket = childrenByParentId.get(parentId) ?? []; + bucket.push(document.id); + childrenByParentId.set(parentId, bucket); + }); + + const idsToRemove = new Set(); + const visit = (documentId: string) => { + if (idsToRemove.has(documentId)) { + return; + } + idsToRemove.add(documentId); + (childrenByParentId.get(documentId) ?? []).forEach(visit); + }; + requestedIds.forEach(visit); + + return documents.filter((document) => !idsToRemove.has(document.id)); +} + +export function buildSidebarLocalFileTreeProjection(input: { + baseProjection: KernelFileTreeProjection; + documents: readonly DocumentRecord[]; + mediaAssets?: readonly MediaAsset[] | null; + mindmapAssets?: readonly MediaAsset[] | null; + tableAssets?: readonly MediaAsset[] | null; + mindmapAssetChildren?: SidebarInitialData["mindmapAssetChildren"]; +}): KernelFileTreeProjection { + return buildKernelFileTreeProjection({ + documents: [...input.documents], + mediaAssets: [...(input.mediaAssets ?? [])], + mindmapAssets: [...(input.mindmapAssets ?? [])], + tableAssets: [...(input.tableAssets ?? [])], + mindmapAssetChildren: input.mindmapAssetChildren ?? {}, + rootNodeId: input.baseProjection.rootNodeId, + }); +} diff --git a/wolai-frontend/src/components/sidebar/sidebar-sync.test.ts b/wolai-frontend/src/components/sidebar/sidebar-sync.test.ts index 1ace71f1..cd2a6c4d 100644 --- a/wolai-frontend/src/components/sidebar/sidebar-sync.test.ts +++ b/wolai-frontend/src/components/sidebar/sidebar-sync.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { buildSidebarTreeSyncKey, buildMediaAssetListSyncKey } from "./sidebar-sync"; +import { buildKernelFileTreeProjection } from "@/lib/kernel-file-tree"; +import { + buildSidebarDataSyncKey, + buildSidebarTreeSyncKey, + buildMediaAssetListSyncKey, + getSidebarDataFreshness, +} from "./sidebar-sync"; +import type { DocumentRecord } from "@/lib/documents"; +import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; import type { MediaAsset } from "@/types/media"; @@ -51,6 +59,54 @@ function buildAsset(overrides: Partial = {}): MediaAsset { }; } +function buildDocument(overrides: Partial = {}): DocumentRecord { + return { + access_scope: "private", + id: "doc-1", + workspace_id: "ws-1", + title: "标题", + parent_id: null, + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "2026-04-21T00:00:00.000Z", + updated_at: "2026-04-21T00:00:00.000Z", + ...overrides, + }; +} + +function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData { + return { + activeWorkspaceId: "ws-1", + workspaces: [], + documents, + kernelSidebarProjection: { + projectionId: "kernel_projection:sidebar_tree:workspace_root", + projection: "sidebar_tree", + rootNodeId: null, + items: [], + edges: [], + }, + kernelSidebarTree: [], + kernelFileTreeProjection: buildKernelFileTreeProjection({ + documents: [], + mediaAssets: [], + mindmapAssets: [], + tableAssets: [], + mindmapAssetChildren: {}, + }), + trashedDocuments: [], + trashedMediaAssets: [], + trashedMindmapAssets: [], + trashedTableAssets: [], + tableAssets: [], + mindmapDocs: [], + mindmapAssets: [], + mindmapAssetChildren: {}, + mediaAssets: [], + }; +} + describe("sidebar-sync", () => { it("相同侧边栏树内容应产生相同 sync key", () => { const left = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })]; @@ -79,4 +135,53 @@ describe("sidebar-sync", () => { expect(buildMediaAssetListSyncKey(left)).not.toBe(buildMediaAssetListSyncKey(right)); }); + + it("顶层 documents 变化应影响 sidebar data sync key", () => { + const left = buildSidebarData([]); + const right = buildSidebarData([buildDocument({ id: "doc-live", title: "显式刷新标题" })]); + + expect(buildSidebarDataSyncKey(left)).not.toBe(buildSidebarDataSyncKey(right)); + }); + + it("file tree position 不应被当作 freshness 时间戳压住新 snapshot", () => { + const stale = buildSidebarData([buildDocument({ + id: "old", + created_at: "2026-05-13T00:00:00.000Z", + updated_at: "2026-05-13T00:00:00.000Z", + })]); + stale.kernelFileTreeProjection = { + ...stale.kernelFileTreeProjection, + items: [ + { + projectionKind: "file_tree", + rowId: "doc:old", + rowKind: "document", + nodeId: "old", + parentNodeId: null, + nodeType: "page", + title: "旧页面", + depth: 0, + position: Number.MAX_SAFE_INTEGER, + childCount: 0, + expandable: false, + expandedByDefault: false, + capabilities: ["open"], + resourceMeta: { + resourceKind: "document", + documentId: "old", + workspaceId: "ws-1", + iconHint: "page", + }, + iconHint: "page", + }, + ], + }; + const fresh = buildSidebarData([buildDocument({ + id: "new", + created_at: "2026-05-14T00:00:00.000Z", + updated_at: "2026-05-14T00:00:00.000Z", + })]); + + expect(getSidebarDataFreshness(fresh)).toBeGreaterThan(getSidebarDataFreshness(stale)); + }); }); diff --git a/wolai-frontend/src/components/sidebar/sidebar-sync.ts b/wolai-frontend/src/components/sidebar/sidebar-sync.ts index 9923caa9..7e7b2d61 100644 --- a/wolai-frontend/src/components/sidebar/sidebar-sync.ts +++ b/wolai-frontend/src/components/sidebar/sidebar-sync.ts @@ -69,6 +69,18 @@ export function buildSidebarDataSyncKey(data: SidebarInitialData): string { const fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection); return JSON.stringify({ activeWorkspaceId: data.activeWorkspaceId, + documents: data.documents.map((item) => ({ + id: item.id, + workspaceId: item.workspace_id, + title: item.title ?? null, + parentId: item.parent_id ?? null, + sortOrder: item.sort_order ?? null, + accessScope: item.access_scope, + isStarred: item.is_starred ?? null, + isTemplate: item.is_template, + createdAt: item.created_at, + updatedAt: item.updated_at ?? null, + })), tree: toSidebarTreeSnapshot(data.kernelSidebarTree), fileTree: { projectionId: fileTreeProjection.projectionId, @@ -110,8 +122,6 @@ export function buildSidebarDataSyncKey(data: SidebarInitialData): string { export function getSidebarDataFreshness(data: SidebarInitialData): number { const documentTimes = data.documents.map((item) => toMillis(item.updated_at ?? item.created_at)); const treeTimes = data.kernelSidebarTree.map((item) => toMillis(item.updated_at ?? item.created_at)); - const fileTreeProjection = normalizeKernelFileTreeProjection(data.kernelFileTreeProjection); - const fileTreeTimes = fileTreeProjection.items.map((item) => item.position ?? 0); const assetTimes = [ ...(data.mediaAssets ?? []), ...(data.mindmapAssets ?? []), @@ -126,7 +136,6 @@ export function getSidebarDataFreshness(data: SidebarInitialData): number { 0, ...documentTimes, ...treeTimes, - ...fileTreeTimes, ...assetTimes, ...trashedDocumentTimes, ); diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index 017c079a..93d539c0 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -47,6 +47,15 @@ import { buildMediaAssetListSyncKey, buildSidebarTreeSyncKey, } from "@/components/sidebar/sidebar-sync"; +import { + buildDocumentListSyncKey, + buildSidebarLocalFileTreeProjection, + moveSidebarDocumentRecord, + removeSidebarDocumentRecords, + renameSidebarDocumentRecord, + sidebarTreeNodeToDocumentRecord, + upsertSidebarDocumentRecord, +} from "@/components/sidebar/sidebar-local-projection"; import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot"; import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events"; import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface"; @@ -272,6 +281,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const [mediaAssets, setMediaAssets] = useState(sidebarData.mediaAssets ?? []); const [mindmapAssets, setMindmapAssets] = useState(sidebarData.mindmapAssets ?? []); const [tableAssets, setTableAssets] = useState(sidebarData.tableAssets ?? []); + const [documents, setDocuments] = useState(() => sidebarData.documents ?? []); const [moveEmbedOpen, setMoveEmbedOpen] = useState(false); const [moveEmbedMode, setMoveEmbedMode] = useState("move"); const [moveEmbedSource, setMoveEmbedSource] = useState(null); @@ -289,6 +299,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const [searchFileTreeProjectionKey, setSearchFileTreeProjectionKey] = useState(null); const [sidebarHydrated, setSidebarHydrated] = useState(false); const treeSyncKeyRef = useRef(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree)); + const documentSyncKeyRef = useRef(buildDocumentListSyncKey(sidebarData.documents ?? [])); const mediaAssetsSyncKeyRef = useRef(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? [])); const mindmapAssetsSyncKeyRef = useRef(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? [])); const tableAssetsSyncKeyRef = useRef(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? [])); @@ -315,6 +326,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar }); }, [sidebarData.kernelSidebarTree]); + useEffect(() => { + const nextDocuments = sidebarData.documents ?? []; + const nextSyncKey = buildDocumentListSyncKey(nextDocuments); + if (documentSyncKeyRef.current === nextSyncKey) { + return; + } + documentSyncKeyRef.current = nextSyncKey; + setDocuments(nextDocuments); + }, [sidebarData.documents]); + useEffect(() => { pageTreeFocusedDocumentIdRef.current = activeId || null; }, [activeId]); @@ -657,6 +678,25 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar normalizedFileTreeSearchQuery && sidebarData.activeWorkspaceId ? `${sidebarData.activeWorkspaceId}:${normalizedFileTreeSearchQuery}` : null; + const localFileTreeProjection = useMemo( + () => + buildSidebarLocalFileTreeProjection({ + baseProjection: sidebarData.kernelFileTreeProjection, + documents, + mediaAssets, + mindmapAssets, + tableAssets, + mindmapAssetChildren: sidebarData.mindmapAssetChildren, + }), + [ + documents, + mediaAssets, + mindmapAssets, + sidebarData.kernelFileTreeProjection, + sidebarData.mindmapAssetChildren, + tableAssets, + ], + ); const resourceTreeShellItems = useMemo( () => expectedSearchFileTreeProjectionKey && @@ -679,11 +719,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar () => normalizedFileTreeSearchQuery.length > 0 ? (resourceTreeShellItems ?? []) - : sidebarData.kernelFileTreeProjection.items, + : localFileTreeProjection.items, [ normalizedFileTreeSearchQuery, resourceTreeShellItems, - sidebarData.kernelFileTreeProjection.items, + localFileTreeProjection.items, ], ); @@ -749,32 +789,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar const docParentById = useMemo( () => buildParentById( - (sidebarData.documents ?? []).map((doc) => ({ + documents.map((doc) => ({ id: doc.id, parentId: doc.parent_id ?? null, })), ), - [sidebarData.documents], + [documents], ); const documentWorkspaceById = useMemo( () => new Map( - (sidebarData.documents ?? []).map((doc) => [ + documents.map((doc) => [ doc.id, typeof doc.workspace_id === "string" ? doc.workspace_id : null, ]), ), - [sidebarData.documents], + [documents], ); const childrenCountByParentId = useMemo(() => { const map = new Map(); - (sidebarData.documents ?? []).forEach((doc) => { + documents.forEach((doc) => { const parentId = doc.parent_id ?? null; map.set(parentId, (map.get(parentId) ?? 0) + 1); }); return map; - }, [sidebarData.documents]); + }, [documents]); const activeWorkspace = sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ?? @@ -1241,6 +1281,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar if (nodeById.has(documentId)) return prev; return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode); }); + setDocuments((prev) => + upsertSidebarDocumentRecord( + prev, + sidebarTreeNodeToDocumentRecord(nextNode, sidebarData.activeWorkspaceId || ""), + ), + ); setExpanded((prev) => { const next = new Set(prev); if (parentId) next.add(parentId); @@ -1257,6 +1303,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return; } setTree((prev) => renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null)); + setDocuments((prev) => renameSidebarDocumentRecord(prev, documentId, title, payload.updatedAt ?? null)); return; } @@ -1267,6 +1314,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar ? payload.sortOrder : Number.MAX_SAFE_INTEGER; setTree((prev) => moveLocalNode(prev, documentId, parentId, sortOrder)); + setDocuments((prev) => moveSidebarDocumentRecord(prev, documentId, parentId, sortOrder)); if (parentId) { setExpanded((prev) => new Set(prev).add(parentId)); } @@ -1460,6 +1508,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar resourceSelection.focusedRowId, resourceSelection.selectedRowIds, resourceShellVisibleRowIds, + sidebarData.activeWorkspaceId, sidebarQuery, ]); @@ -1836,6 +1885,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar } removeDocumentsFromTree(docIds); + setDocuments((prev) => removeSidebarDocumentRecords(prev, docIds)); docIds.forEach((documentId) => emitDocumentsChanged(documentId)); if (activeId && docIds.includes(activeId)) { router.push("/"); @@ -1944,6 +1994,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar } return next; }); + setDocuments((prev) => + upsertSidebarDocumentRecord( + prev, + sidebarTreeNodeToDocumentRecord(nextNode, sidebarData.activeWorkspaceId || ""), + ), + ); + emitDocumentsChanged(nextNode.id); const query = new URLSearchParams(); if (nextNode.workspace_id) { @@ -1961,7 +2018,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar creatingDocumentUnderParentRef.current.delete(creatingKey); } }, - [router], + [router, sidebarData.activeWorkspaceId], ); const handleRename = useCallback( @@ -1980,14 +2037,17 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar window.alert(error instanceof Error ? error.message : "重命名失败,请稍后再试"); return; } - await refreshTree(); + setTree((prev) => renameDocumentInTree(prev, documentId, title.trim(), null)); + setDocuments((prev) => renameSidebarDocumentRecord(prev, documentId, title.trim(), null)); + emitDocumentsChanged(documentId); }, - [refreshTree, sidebarData.activeWorkspaceId], + [sidebarData.activeWorkspaceId], ); const handleMove = useCallback( async (documentId: string, parentId: string | null, index: number) => { setTree((prev) => moveLocalNode(prev, documentId, parentId, index)); + setDocuments((prev) => moveSidebarDocumentRecord(prev, documentId, parentId, index)); if (parentId) { setExpanded((prev) => new Set(prev).add(parentId)); } @@ -2002,7 +2062,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar await refreshTree(); return; } - await refreshTree(); + emitDocumentsChanged(documentId); }, [moveLocalNode, refreshTree, setExpanded], ); @@ -2178,6 +2238,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar }); return next; }); + setDocuments((prev) => { + let next = prev; + topLevelDocIds.forEach((id, offset) => { + next = moveSidebarDocumentRecord(next, id, documentTransferPlan.targetParentId, baseIndex + offset); + }); + return next; + }); setExpanded((prev) => new Set(prev).add(documentTransferPlan.targetParentId)); try { @@ -2243,6 +2310,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar workspaceId: sidebarData.activeWorkspaceId ?? null, }); removeDocumentsFromTree([documentId]); + setDocuments((prev) => removeSidebarDocumentRecords(prev, [documentId])); emitDocumentsChanged(documentId); if (activeId === documentId) { router.push("/"); diff --git a/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx b/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx index df33c3e4..b9ebb057 100644 --- a/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx +++ b/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx @@ -220,6 +220,62 @@ describe("usePreferredSidebarSnapshot", () => { }); }); + it("stream 与 query freshness 相同但内容不同时,应优先 query 避免资源行被旧 stream 压住", async () => { + const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]); + const treeStreamData = buildSidebarData([ + buildDocument({ + title: "标题 B", + updated_at: "2026-04-21T00:00:01.000Z", + }), + ]); + const queryData = buildSidebarInitialData({ + activeWorkspaceId: "ws-1", + workspaces: [], + documents: [ + buildDocument({ + title: "标题 B", + updated_at: "2026-04-21T00:00:01.000Z", + }), + ], + trashedDocuments: [], + mindmaps: [ + { + mindmap_id: "mind-1", + document_id: "doc-1", + workspace_id: "ws-1", + created_at: "2026-04-21T00:00:01.000Z", + updated_at: "2026-04-21T00:00:01.000Z", + }, + ], + mediaAssets: [], + trashedMediaAssets: [], + tables: [], + }); + + await act(async () => { + root.render( + , + ); + }); + + expect(onState.mock.lastCall?.[0]).toMatchObject({ + source: "query", + cursor: null, + }); + expect( + (onState.mock.lastCall?.[0] as ReturnType).data.kernelFileTreeProjection.items.some( + (item) => item.resourceMeta.assetId === "mind-1", + ), + ).toBe(true); + }); + it("tree stream 进入 fallback 后应回退到 query 快照", async () => { const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]); const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]); diff --git a/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts b/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts index 101087cb..295187d3 100644 --- a/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts +++ b/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts @@ -44,6 +44,12 @@ export function usePreferredSidebarSnapshot(input: { treeStreamVersion >= preferredVersion; const source = useMemo(() => { + if ( + queryHasPreferredVersion && + (!treeStreamHasPreferredVersion || querySyncKey !== treeStreamSyncKey) + ) { + return "query"; + } if (treeStreamHasPreferredVersion) { return "tree_stream"; } @@ -53,7 +59,9 @@ export function usePreferredSidebarSnapshot(input: { return "initial"; }, [ queryHasPreferredVersion, + querySyncKey, treeStreamHasPreferredVersion, + treeStreamSyncKey, ]); const data = diff --git a/wolai-frontend/src/hooks/use-sidebar-data.test.tsx b/wolai-frontend/src/hooks/use-sidebar-data.test.tsx index a19a6e08..c625a826 100644 --- a/wolai-frontend/src/hooks/use-sidebar-data.test.tsx +++ b/wolai-frontend/src/hooks/use-sidebar-data.test.tsx @@ -123,8 +123,29 @@ describe("useSidebarData", () => { expect(secondState).toBe(firstState); }); - it("Convex live 模式下手动 refetch 不应再走 HTTP snapshot 补偿链", async () => { + it("Convex live 模式下手动 refetch 应拉取 HTTP snapshot 补偿显式刷新", async () => { const initialData = buildInitialData(); + const manualSnapshot: SidebarInitialData = { + ...buildInitialData(), + documents: [ + { + access_scope: "private", + id: "doc-live", + workspace_id: "ws_1", + title: "显式刷新标题", + parent_id: null, + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "2026-05-14T00:00:00.000Z", + updated_at: "2026-05-14T00:01:00.000Z", + }, + ], + }; + (global.fetch as unknown as ReturnType).mockResolvedValue({ + ok: true, + json: async () => manualSnapshot, + }); await act(async () => { root.render(); @@ -132,14 +153,22 @@ describe("useSidebarData", () => { const state = onState.mock.lastCall?.[0] as ReturnType; stableRefetch.mockClear(); + let refetchResult: unknown = null; await act(async () => { - await state.refetch(); + refetchResult = await state.refetch(); }); - expect(global.fetch).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith("/api/sidebar?workspaceId=ws_1", { + method: "GET", + credentials: "include", + }); expect(stableRefetch).toHaveBeenCalledTimes(1); + expect(refetchResult).toStrictEqual(manualSnapshot); + await act(async () => { + root.render(); + }); const refreshedState = onState.mock.lastCall?.[0] as ReturnType; - expect(refreshedState.data).toStrictEqual(initialData); + expect(refreshedState.data).toStrictEqual(manualSnapshot); }); it("无 live 订阅且允许 HTTP fallback 时应继续走 HTTP refetch", async () => { diff --git a/wolai-frontend/src/hooks/use-sidebar-data.ts b/wolai-frontend/src/hooks/use-sidebar-data.ts index 344137db..e34ce8f5 100644 --- a/wolai-frontend/src/hooks/use-sidebar-data.ts +++ b/wolai-frontend/src/hooks/use-sidebar-data.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync"; +import { + buildSidebarDataSyncKey, + getSidebarDataFreshness, +} from "@/components/sidebar/sidebar-sync"; import type { SidebarInitialData } from "@/components/sidebar/types"; import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data"; @@ -48,6 +51,10 @@ async function requestSidebarData(workspaceId: string): Promise(null); const shouldUseHttpFallback = !convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback; @@ -59,7 +66,28 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu enabled: shouldUseHttpFallback, }); - const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData; + const baseLiveData = useMemo(() => { + const currentManualSnapshot = manualSnapshot?.workspaceId === workspaceId + ? manualSnapshot.data + : null; + const candidates = [ + initialData, + httpQuery.data, + convexSidebar.data, + currentManualSnapshot, + ].filter((item): item is SidebarInitialData => Boolean(item)); + return candidates.reduce((selected, candidate) => { + const selectedFreshness = getSidebarDataFreshness(selected); + const candidateFreshness = getSidebarDataFreshness(candidate); + return candidateFreshness >= selectedFreshness ? candidate : selected; + }, initialData); + }, [ + convexSidebar.data, + httpQuery.data, + initialData, + manualSnapshot, + workspaceId, + ]); const liveData = baseLiveData; const isLoading = convexSidebar.hasLiveSubscription @@ -93,7 +121,9 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu const refetch = useCallback(async () => { if (convexSidebar.hasLiveSubscription) { await convexRefetchRef.current(); - return liveDataRef.current; + const snapshot = await requestSidebarData(workspaceId); + setManualSnapshot({ workspaceId, data: snapshot }); + return snapshot; } if (shouldUseHttpFallback) { return httpRefetchRef.current();