feat(editor): save leptos island and page aggregate alignment progress

- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
@@ -0,0 +1,52 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { DocumentRecord } from "@/lib/documents";
import type { SidebarInitialData } from "@/components/sidebar/types";
const PreferredSidebarSnapshotContext = createContext<SidebarInitialData | null>(null);
interface PreferredSidebarSnapshotProviderProps {
data: SidebarInitialData;
children: ReactNode;
}
export function PreferredSidebarSnapshotProvider({
data,
children,
}: PreferredSidebarSnapshotProviderProps) {
return (
<PreferredSidebarSnapshotContext.Provider value={data}>
{children}
</PreferredSidebarSnapshotContext.Provider>
);
}
export function useOptionalPreferredSidebarSnapshotData() {
return useContext(PreferredSidebarSnapshotContext);
}
export function usePreferredSidebarSnapshotData() {
const context = useOptionalPreferredSidebarSnapshotData();
if (!context) {
throw new Error("usePreferredSidebarSnapshotData 必须在 PreferredSidebarSnapshotProvider 中使用");
}
return context;
}
export function usePreferredSidebarDocument(documentId: string): DocumentRecord | null {
const snapshot = useOptionalPreferredSidebarSnapshotData();
return useMemo(() => {
if (!snapshot) {
return null;
}
return snapshot.documents.find((item) => item.id === documentId) ?? null;
}, [documentId, snapshot]);
}
export function usePreferredSidebarDocumentTitle(documentId: string): string | null {
const document = usePreferredSidebarDocument(documentId);
const title = document?.title?.trim();
return title ? title : null;
}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import type { MediaAsset } from "@/types/media";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT } from "@/lib/events";
import { bindSidebarRefreshEvents } from "./sidebar-events";
function buildAsset(overrides: Partial<MediaAsset> = {}): MediaAsset {
return {
id: "asset-1",
workspace_id: "ws-1",
document_id: "doc-1",
asset_type: "image",
file_url: "/files/a.png",
thumbnail_url: null,
bucket: null,
storage_path: "uploads/a.png",
file_name: "a.png",
file_size: 1,
mime_type: "image/png",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
describe("bindSidebarRefreshEvents", () => {
it("收到 documents-changed 时应触发 sidebar 与共享摘要刷新", () => {
const target = new EventTarget();
const onAsset = vi.fn();
const sidebarRefetch = vi.fn();
const refreshShareSummary = vi.fn();
const refreshGroupPublicSummary = vi.fn();
const dispose = bindSidebarRefreshEvents({
target,
onAsset,
sidebarRefetch,
refreshShareSummary,
refreshGroupPublicSummary,
});
target.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId: "doc-1" } }));
expect(onAsset).not.toHaveBeenCalled();
expect(sidebarRefetch).toHaveBeenCalledTimes(1);
expect(refreshShareSummary).toHaveBeenCalledTimes(1);
expect(refreshGroupPublicSummary).toHaveBeenCalledTimes(1);
dispose();
});
it("收到 assets-changed 且带 asset 时应先透传 asset 再刷新", () => {
const target = new EventTarget();
const onAsset = vi.fn();
const sidebarRefetch = vi.fn();
const refreshShareSummary = vi.fn();
const refreshGroupPublicSummary = vi.fn();
const asset = buildAsset({ asset_type: "mindmap" });
const dispose = bindSidebarRefreshEvents({
target,
onAsset,
sidebarRefetch,
refreshShareSummary,
refreshGroupPublicSummary,
});
target.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail: { docId: "doc-1", asset } }));
expect(onAsset).toHaveBeenCalledWith(asset);
expect(sidebarRefetch).toHaveBeenCalledTimes(1);
expect(refreshShareSummary).toHaveBeenCalledTimes(1);
expect(refreshGroupPublicSummary).toHaveBeenCalledTimes(1);
dispose();
});
});
@@ -0,0 +1,34 @@
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT } from "@/lib/events";
import type { MediaAsset } from "@/types/media";
type SidebarRefreshEventDetail = {
docId?: string;
asset?: MediaAsset;
};
export function bindSidebarRefreshEvents(input: {
target?: EventTarget;
onAsset?: (asset?: MediaAsset) => void;
sidebarRefetch: () => void | Promise<unknown>;
refreshShareSummary: () => void | Promise<unknown>;
refreshGroupPublicSummary: () => void | Promise<unknown>;
}): () => void {
const target = input.target ?? window;
const handler = (event: Event) => {
const custom = event as CustomEvent<SidebarRefreshEventDetail>;
if (custom.detail?.asset) {
input.onAsset?.(custom.detail.asset);
}
void input.sidebarRefetch();
void input.refreshShareSummary();
void input.refreshGroupPublicSummary();
};
target.addEventListener(ASSETS_CHANGED_EVENT, handler);
target.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
return () => {
target.removeEventListener(ASSETS_CHANGED_EVENT, handler);
target.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
};
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { buildSidebarTreeSyncKey, buildMediaAssetListSyncKey } from "./sidebar-sync";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
function buildNode(overrides: Partial<SidebarTreeNode> = {}): SidebarTreeNode {
return {
id: "doc-1",
workspace_id: "ws-1",
title: "标题",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
access_scope: "private",
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: 0,
childCount: 0,
expandedByDefault: true,
},
...overrides,
};
}
function buildAsset(overrides: Partial<MediaAsset> = {}): MediaAsset {
return {
id: "asset-1",
workspace_id: "ws-1",
document_id: "doc-1",
asset_type: "image",
file_url: "/files/a.png",
thumbnail_url: null,
bucket: null,
storage_path: "uploads/a.png",
file_name: "a.png",
file_size: 1,
mime_type: "image/png",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
describe("sidebar-sync", () => {
it("相同侧边栏树内容应产生相同 sync key", () => {
const left = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })];
const right = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })];
expect(buildSidebarTreeSyncKey(left)).toBe(buildSidebarTreeSyncKey(right));
});
it("侧边栏树语义变化时应产生不同 sync key", () => {
const left = [buildNode({ title: "标题 A" })];
const right = [buildNode({ title: "标题 B" })];
expect(buildSidebarTreeSyncKey(left)).not.toBe(buildSidebarTreeSyncKey(right));
});
it("相同附件列表内容应产生相同 sync key", () => {
const left = [buildAsset()];
const right = [buildAsset()];
expect(buildMediaAssetListSyncKey(left)).toBe(buildMediaAssetListSyncKey(right));
});
it("附件列表语义变化时应产生不同 sync key", () => {
const left = [buildAsset({ file_name: "a.png" })];
const right = [buildAsset({ file_name: "b.png" })];
expect(buildMediaAssetListSyncKey(left)).not.toBe(buildMediaAssetListSyncKey(right));
});
});
@@ -0,0 +1,93 @@
import type { SidebarInitialData } from "@/components/sidebar/types";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
type SidebarTreeSnapshot = {
id: string;
title: string | null;
parentId: string | null;
sortOrder: number | null;
updatedAt: string | null;
children: SidebarTreeSnapshot[];
};
function toSidebarTreeSnapshot(nodes: SidebarTreeNode[]): SidebarTreeSnapshot[] {
return nodes.map((node) => ({
id: node.id,
title: node.title ?? null,
parentId: node.parent_id ?? null,
sortOrder: node.sort_order ?? null,
updatedAt: node.updated_at ?? null,
children: toSidebarTreeSnapshot(node.children ?? []),
}));
}
function toMediaAssetSnapshot(assets: MediaAsset[]) {
return assets.map((asset) => ({
id: asset.id,
assetType: asset.asset_type,
documentId: asset.document_id,
fileName: asset.file_name ?? null,
updatedAt: asset.updated_at ?? null,
storagePath: asset.storage_path ?? null,
fileUrl: asset.file_url ?? null,
}));
}
function toMillis(value: string | null | undefined): number {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function buildSidebarTreeSyncKey(nodes: SidebarTreeNode[]): string {
return JSON.stringify(toSidebarTreeSnapshot(nodes));
}
export function buildMediaAssetListSyncKey(assets: MediaAsset[]): string {
return JSON.stringify(toMediaAssetSnapshot(assets));
}
export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
return JSON.stringify({
activeWorkspaceId: data.activeWorkspaceId,
tree: toSidebarTreeSnapshot(data.kernelSidebarTree),
mediaAssets: toMediaAssetSnapshot(data.mediaAssets ?? []),
mindmapAssets: toMediaAssetSnapshot(data.mindmapAssets ?? []),
tableAssets: toMediaAssetSnapshot(data.tableAssets ?? []),
trashedDocuments: (data.trashedDocuments ?? []).map((item) => ({
id: item.id,
title: item.title ?? null,
parentId: item.parent_id ?? null,
deletedAt: item.deleted_at,
accessScope: item.access_scope,
})),
trashedMediaAssets: toMediaAssetSnapshot(data.trashedMediaAssets ?? []),
trashedMindmapAssets: toMediaAssetSnapshot(data.trashedMindmapAssets ?? []),
trashedTableAssets: toMediaAssetSnapshot(data.trashedTableAssets ?? []),
});
}
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 assetTimes = [
...(data.mediaAssets ?? []),
...(data.mindmapAssets ?? []),
...(data.tableAssets ?? []),
...(data.trashedMediaAssets ?? []),
...(data.trashedMindmapAssets ?? []),
...(data.trashedTableAssets ?? []),
].map((item) => toMillis(item.updated_at ?? item.created_at));
const trashedDocumentTimes = (data.trashedDocuments ?? []).map((item) => toMillis(item.deleted_at));
return Math.max(
0,
...documentTimes,
...treeTimes,
...assetTimes,
...trashedDocumentTimes,
);
}
+100 -31
View File
@@ -39,6 +39,12 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
import {
buildMediaAssetListSyncKey,
buildSidebarTreeSyncKey,
} from "@/components/sidebar/sidebar-sync";
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import {
@@ -65,7 +71,7 @@ import {
} from "@/components/sidebar/tree-pane-bindings";
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api";
@@ -139,6 +145,9 @@ const extractMindmapIdFromStoragePath = (
interface SidebarProps {
initialData: SidebarInitialData;
sidebarData?: SidebarInitialData;
sidebarQuery?: SidebarDataResult;
treeStream?: ReturnType<typeof useSidebarTreeStream>;
}
interface ContextMenuState {
@@ -147,7 +156,17 @@ interface ContextMenuState {
y: number;
}
export function Sidebar({ initialData }: SidebarProps) {
export function Sidebar({ initialData, sidebarData, sidebarQuery, treeStream }: SidebarProps) {
if (sidebarQuery && treeStream) {
return (
<SidebarContent
initialData={initialData}
sidebarData={sidebarData}
sidebarQuery={sidebarQuery}
treeStream={treeStream}
/>
);
}
return <SidebarConvex initialData={initialData} />;
}
@@ -161,11 +180,12 @@ function SidebarConvex({ initialData }: SidebarProps) {
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
interface SidebarContentProps {
initialData: SidebarInitialData;
sidebarData?: SidebarInitialData;
sidebarQuery: SidebarDataResult;
treeStream: ReturnType<typeof useSidebarTreeStream>;
}
function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) {
function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebarQuery, treeStream }: SidebarContentProps) {
const convex = useConvex();
useConvexAuth();
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
@@ -175,10 +195,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
// 处理数据
const sidebarData = useMemo(() => {
return treeStream.data ?? sidebarQuery.data ?? initialData;
}, [treeStream.data, sidebarQuery, initialData]);
const preferredSidebarSnapshot = usePreferredSidebarSnapshot({
initialData,
sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data,
});
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
const sidebarHydrate = useSidebarStore.persist?.rehydrate;
const sidebarRefetch = sidebarQuery.refetch;
// isLoading 判断
const isLoading = sidebarQuery.isLoading;
const segments = useSelectedLayoutSegments();
@@ -250,6 +275,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
anchorRowId: null,
focusedRowId: null,
}));
const [sidebarHydrated, setSidebarHydrated] = useState(false);
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
@@ -257,19 +287,36 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
const nextTree = sidebarData.kernelSidebarTree;
const nextSyncKey = buildSidebarTreeSyncKey(nextTree);
if (treeSyncKeyRef.current === nextSyncKey) {
return;
}
treeSyncKeyRef.current = nextSyncKey;
setTree(() => {
const nextTree = sidebarData.kernelSidebarTree;
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
return nextTree;
});
}, [sidebarData.kernelSidebarTree]);
useEffect(() => {
setMediaAssets(sidebarData.mediaAssets ?? []);
const nextAssets = sidebarData.mediaAssets ?? [];
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
if (mediaAssetsSyncKeyRef.current === nextSyncKey) {
return;
}
mediaAssetsSyncKeyRef.current = nextSyncKey;
setMediaAssets(nextAssets);
}, [sidebarData.mediaAssets]);
useEffect(() => {
setMindmapAssets(sidebarData.mindmapAssets ?? []);
const nextAssets = sidebarData.mindmapAssets ?? [];
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
if (mindmapAssetsSyncKeyRef.current === nextSyncKey) {
return;
}
mindmapAssetsSyncKeyRef.current = nextSyncKey;
setMindmapAssets(nextAssets);
}, [sidebarData.mindmapAssets]);
useEffect(() => {
@@ -280,13 +327,35 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
hidden.delete(id);
}
});
setTableAssets((sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id)));
const nextAssets = (sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id));
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
if (tableAssetsSyncKeyRef.current === nextSyncKey) {
return;
}
tableAssetsSyncKeyRef.current = nextSyncKey;
setTableAssets(nextAssets);
}, [sidebarData.tableAssets]);
useEffect(() => {
setOpen(false);
}, [activeId, setOpen]);
useEffect(() => {
if (sidebarHydrated) {
return;
}
let cancelled = false;
void (async () => {
await sidebarHydrate?.();
if (!cancelled) {
setSidebarHydrated(true);
}
})();
return () => {
cancelled = true;
};
}, [sidebarHydrate, sidebarHydrated]);
useEffect(() => {
const onSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
@@ -294,7 +363,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
if (tableId) {
hiddenTableIdsRef.current.delete(tableId);
}
void sidebarQuery.refetch();
void sidebarRefetch();
};
const onDeleted = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
@@ -303,7 +372,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
hiddenTableIdsRef.current.set(tableId, Date.now());
setTableAssets((prev) => prev.filter((item) => item.id !== tableId));
}
void sidebarQuery.refetch();
void sidebarRefetch();
};
window.addEventListener("online-table-saved", onSaved as EventListener);
window.addEventListener("online-table-deleted", onDeleted as EventListener);
@@ -311,11 +380,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
window.removeEventListener("online-table-saved", onSaved as EventListener);
window.removeEventListener("online-table-deleted", onDeleted as EventListener);
};
}, [sidebarQuery]);
}, [sidebarRefetch]);
const refreshTree = useCallback(async () => {
await sidebarQuery.refetch();
}, [sidebarQuery]);
await sidebarRefetch();
}, [sidebarRefetch]);
const refreshShareSummary = useCallback(async () => {
try {
@@ -379,9 +448,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
}, [refreshGroupPublicSummary]);
useEffect(() => {
const handler = (event: Event) => {
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
const asset = custom.detail?.asset as MediaAsset | undefined;
const dispose = bindSidebarRefreshEvents({
onAsset: (asset) => {
if (asset?.id) {
if (asset.asset_type === "mindmap") {
setMindmapAssets((prev) => {
@@ -400,18 +468,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
});
}
}
void sidebarQuery.refetch();
},
sidebarRefetch,
// 同步刷新共享/公共摘要,避免跨页面操作后出现“幽灵共享条目”(点开 404 / 无标题)。
void refreshShareSummary();
void refreshGroupPublicSummary();
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
return () => {
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
};
}, [sidebarQuery, refreshShareSummary, refreshGroupPublicSummary]);
refreshShareSummary,
refreshGroupPublicSummary,
});
return dispose;
}, [sidebarRefetch, refreshShareSummary, refreshGroupPublicSummary]);
useEffect(() => {
// 说明:shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state
@@ -1450,11 +1514,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
await refreshTree();
const query = new URLSearchParams();
query.set("edit", "1");
if (nextNode.workspace_id) {
query.set("workspaceId", nextNode.workspace_id);
}
router.push(`/documents/${nextNode.id}?${query.toString()}`);
const nextQuery = query.toString();
router.push(
nextQuery
? `/documents/${nextNode.id}?${nextQuery}`
: `/documents/${nextNode.id}`,
);
} catch (error) {
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
} finally {
@@ -2603,6 +2671,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
return (
<>
{!sidebarHydrated ? null : null}
<aside className="hidden h-full min-w-0 overflow-hidden md:flex shrink-0" style={{ width }}>
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar">
{sidebarBody}
@@ -0,0 +1,144 @@
import { act, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarInitialData } from "@/lib/sidebar-data";
import type { DocumentRecord } from "@/lib/documents";
import { usePreferredSidebarSnapshot } from "./use-preferred-sidebar-snapshot";
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
return {
access_scope: "private",
id: "doc-1",
workspace_id: "ws-1",
title: "标题 A",
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 buildSidebarInitialData({
activeWorkspaceId: "ws-1",
workspaces: [],
documents,
trashedDocuments: [],
mindmaps: [],
mediaAssets: [],
trashedMediaAssets: [],
tables: [],
});
}
function Harness(props: {
initialData: SidebarInitialData;
sidebarQueryData: SidebarInitialData;
treeStreamData: SidebarInitialData | null;
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
}) {
const state = usePreferredSidebarSnapshot(props);
useEffect(() => {
props.onState(state);
}, [props, state]);
return null;
}
describe("usePreferredSidebarSnapshot", () => {
let container: HTMLDivElement;
let root: Root;
const onState = vi.fn();
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
onState.mockClear();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("tree stream 落后于 query refetch 时应优先使用更新后的 query 快照", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
const refreshedQuery = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const caughtUpTreeStream = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={initialData}
treeStreamData={staleTreeStream}
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "tree_stream",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
}),
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={refreshedQuery}
treeStreamData={staleTreeStream}
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "query",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={refreshedQuery}
treeStreamData={caughtUpTreeStream}
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "tree_stream",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
});
});
@@ -0,0 +1,77 @@
import { useMemo } from "react";
import type { SidebarInitialData } from "@/components/sidebar/types";
import {
buildSidebarDataSyncKey,
getSidebarDataFreshness,
} from "@/components/sidebar/sidebar-sync";
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
export function usePreferredSidebarSnapshot(input: {
initialData: SidebarInitialData;
sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null;
}) {
const querySyncKey = useMemo(
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
[input.sidebarQueryData],
);
const treeStreamSyncKey = useMemo(
() => (input.treeStreamData ? buildSidebarDataSyncKey(input.treeStreamData) : null),
[input.treeStreamData],
);
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
const queryFreshness = useMemo(
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : Number.NEGATIVE_INFINITY),
[input.sidebarQueryData],
);
const treeStreamFreshness = useMemo(
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : Number.NEGATIVE_INFINITY),
[input.treeStreamData],
);
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
if (input.treeStreamData && input.sidebarQueryData) {
if (treeStreamSyncKey === querySyncKey) {
return "tree_stream";
}
return queryFreshness > treeStreamFreshness ? "query" : "tree_stream";
}
if (input.treeStreamData) {
return "tree_stream";
}
if (input.sidebarQueryData) {
return "query";
}
return "initial";
}, [
input.sidebarQueryData,
input.treeStreamData,
queryFreshness,
querySyncKey,
treeStreamFreshness,
treeStreamSyncKey,
]);
const data =
source === "tree_stream" && input.treeStreamData
? input.treeStreamData
: source === "query" && input.sidebarQueryData
? input.sidebarQueryData
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData;
const syncKey =
source === "tree_stream"
? treeStreamSyncKey ?? initialSyncKey
: source === "query"
? querySyncKey ?? initialSyncKey
: initialSyncKey;
return useMemo(
() => ({
data,
source,
syncKey,
}),
[data, source, syncKey],
);
}