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:
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user