feat: complete tree shell cutover and regression coverage
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyTreeCommand,
|
||||
createDocumentCommand,
|
||||
deleteDocumentCommand,
|
||||
embedDocumentCommand,
|
||||
moveDocumentCommand,
|
||||
purgeDocumentCommand,
|
||||
renameDocumentCommand,
|
||||
restoreDocumentCommand,
|
||||
} from "@/lib/documents/tree-command-client";
|
||||
|
||||
describe("tree-command-client", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("通过统一 client 发送 tree/document command 并返回结果", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, success: true, items: [] }),
|
||||
} as Response);
|
||||
|
||||
await createDocumentCommand(null);
|
||||
await renameDocumentCommand({ documentId: "doc_1", title: "新标题" });
|
||||
await moveDocumentCommand({ documentId: "doc_1", parentId: null, position: 0 });
|
||||
await deleteDocumentCommand({ documentId: "doc_1" });
|
||||
await restoreDocumentCommand({ documentId: "doc_1" });
|
||||
await purgeDocumentCommand({ documentId: "doc_1" });
|
||||
await embedDocumentCommand({ sourceId: "doc_1", targetId: "doc_2" });
|
||||
await copyTreeCommand({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(8);
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
|
||||
"/api/documents/create",
|
||||
"/api/documents/title",
|
||||
"/api/documents/move",
|
||||
"/api/documents/delete",
|
||||
"/api/documents/restore",
|
||||
"/api/documents/purge",
|
||||
"/api/documents/embed",
|
||||
"/api/documents/copy-tree",
|
||||
]);
|
||||
});
|
||||
|
||||
it("在后端返回错误时抛出统一异常", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: "删除失败" }),
|
||||
} as Response);
|
||||
|
||||
await expect(deleteDocumentCommand({ documentId: "doc_1" })).rejects.toThrow("删除失败");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,41 @@ type DocumentCommandErrorPayload = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const TREE_COMMAND_PROTOCOL = {
|
||||
create: {
|
||||
preferredCommandName: "tree.node.create",
|
||||
compatCommandName: "documents.create",
|
||||
},
|
||||
rename: {
|
||||
preferredCommandName: "tree.node.rename",
|
||||
compatCommandName: "documents.title.update",
|
||||
},
|
||||
move: {
|
||||
preferredCommandName: "tree.subtree.move",
|
||||
compatCommandName: "documents.move",
|
||||
},
|
||||
archive: {
|
||||
preferredCommandName: "tree.node.archive",
|
||||
compatCommandName: "documents.delete",
|
||||
},
|
||||
restore: {
|
||||
preferredCommandName: "tree.node.restore",
|
||||
compatCommandName: "documents.restore",
|
||||
},
|
||||
purge: {
|
||||
preferredCommandName: "tree.node.purge",
|
||||
compatCommandName: "documents.purge",
|
||||
},
|
||||
embed: {
|
||||
preferredCommandName: "tree.node.embed",
|
||||
compatCommandName: "documents.embed",
|
||||
},
|
||||
copy: {
|
||||
preferredCommandName: "tree.subtree.copy",
|
||||
compatCommandName: "documents.copy_tree",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type DocumentCreateCommandResult = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type {
|
||||
TreeProjectionCapability,
|
||||
TreeProjectionResourceMeta,
|
||||
} from "@/lib/tree-protocol";
|
||||
|
||||
export type KernelSidebarProjectionEdge = {
|
||||
id: string;
|
||||
@@ -12,11 +16,16 @@ export type KernelSidebarProjectionItem = {
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: "page";
|
||||
projectionKind: "sidebar_tree";
|
||||
title: string | null;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
childCount: number;
|
||||
expandable: boolean;
|
||||
expandedByDefault: boolean;
|
||||
capabilities: TreeProjectionCapability[];
|
||||
resourceMeta: TreeProjectionResourceMeta;
|
||||
iconHint: string | null;
|
||||
};
|
||||
|
||||
export type KernelSidebarProjection = {
|
||||
@@ -64,6 +73,25 @@ function dedupeRecords(records: DocumentRecord[]) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function buildSidebarCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||
const capabilities: TreeProjectionCapability[] = [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
];
|
||||
if (childCount > 0) {
|
||||
capabilities.unshift("expand");
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function buildChildrenByParent(records: DocumentRecord[]) {
|
||||
const childrenByParentId = new Map<string | null, DocumentRecord[]>();
|
||||
const recordIds = new Set(records.map((record) => record.id));
|
||||
@@ -102,11 +130,21 @@ export function buildKernelSidebarProjection(
|
||||
nodeId: child.id,
|
||||
parentNodeId: parentId,
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: child.title ?? "无标题",
|
||||
depth,
|
||||
position: child.sort_order ?? null,
|
||||
childCount: childNodes.length,
|
||||
expandable: childNodes.length > 0,
|
||||
expandedByDefault: true,
|
||||
capabilities: buildSidebarCapabilities(childNodes.length),
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: child.id,
|
||||
workspaceId: child.workspace_id ?? null,
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
});
|
||||
if (parentId && recordById.has(parentId)) {
|
||||
edges.push({
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const MNOTE_WEB_AUTH_COOKIE_PATH = "/api/auth/mnote-web-token";
|
||||
|
||||
export async function ensureMnoteWebAuthCookie(): Promise<void> {
|
||||
const response = await fetch(MNOTE_WEB_AUTH_COOKIE_PATH, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("mnote-web 鉴权 cookie 准备失败");
|
||||
}
|
||||
}
|
||||
@@ -221,11 +221,32 @@ describe("buildSidebarInitialData", () => {
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
@@ -276,11 +297,32 @@ describe("buildSidebarInitialData", () => {
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
@@ -319,7 +361,7 @@ describe("buildSidebarInitialData", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("缺少 kernel projection 时不再本地补真相,而是返回空契约 projection", () => {
|
||||
it("缺少 kernel projection 时会按 documents 重建 projection 并保留层级", () => {
|
||||
expect(
|
||||
mapSidebarDatasetListQueryResultToInitialData({
|
||||
active_workspace_id: "ws_1",
|
||||
@@ -337,6 +379,18 @@ describe("buildSidebarInitialData", () => {
|
||||
created_at: "2026-04-14T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
id: "doc_2",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 2",
|
||||
parent_id: "doc_1",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-14T00:01:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
@@ -351,13 +405,16 @@ describe("buildSidebarInitialData", () => {
|
||||
).toMatchObject({
|
||||
activeWorkspaceId: "ws_1",
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:missing",
|
||||
items: [],
|
||||
edges: [],
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
},
|
||||
kernelSidebarTree: [
|
||||
{
|
||||
id: "doc_1",
|
||||
children: [
|
||||
{
|
||||
id: "doc_2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -96,6 +96,12 @@ function readKernelSidebarProjection(
|
||||
return camelCaseProjection;
|
||||
}
|
||||
|
||||
if (Array.isArray(result.documents) && result.documents.length > 0) {
|
||||
// 说明:部分 query transport 只回 documents,没有同步附带 projection。
|
||||
// 这里按同一协议即时重建,避免 UI 把缺失节点全部降级到根层。
|
||||
return buildProjectionContract(result.documents);
|
||||
}
|
||||
|
||||
return EMPTY_KERNEL_SIDEBAR_PROJECTION;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
||||
|
||||
describe("tree-projection contract", () => {
|
||||
it("page_tree projection item 持续输出共享 contract 字段", () => {
|
||||
const items = buildPageTreeProjectionItems([
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "root",
|
||||
workspace_id: "ws_1",
|
||||
title: "Root",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
rowId: "page:root",
|
||||
nodeId: "root",
|
||||
parentNodeId: null,
|
||||
projectionKind: "page_tree",
|
||||
expandable: false,
|
||||
expandedByDefault: true,
|
||||
iconHint: "page",
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "root",
|
||||
iconHint: "page",
|
||||
},
|
||||
});
|
||||
expect(items[0]?.capabilities).toContain("open");
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,12 @@
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
|
||||
export type TreeProjectionKind = "page_tree";
|
||||
|
||||
export type TreeProjectionNodeType = "page";
|
||||
|
||||
export type TreeProjectionCapability =
|
||||
| "expand"
|
||||
| "open"
|
||||
| "drag"
|
||||
| "drop"
|
||||
| "select"
|
||||
| "create-child";
|
||||
|
||||
export type TreeProjectionResourceMeta = {
|
||||
resourceKind: "document";
|
||||
documentId: string;
|
||||
};
|
||||
import type {
|
||||
TreeProjectionCapability,
|
||||
TreeProjectionItemBase,
|
||||
} from "@/lib/tree-protocol";
|
||||
|
||||
export type PageTreeProjectionItem = {
|
||||
rowId: `page:${string}`;
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: TreeProjectionNodeType;
|
||||
projectionKind: TreeProjectionKind;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
title: string;
|
||||
childCount: number;
|
||||
capabilities: TreeProjectionCapability[];
|
||||
resourceMeta: TreeProjectionResourceMeta;
|
||||
} & TreeProjectionItemBase & {
|
||||
node: SidebarTreeNode;
|
||||
};
|
||||
|
||||
@@ -45,6 +23,11 @@ function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
];
|
||||
if (childCount > 0) {
|
||||
capabilities.unshift("expand");
|
||||
@@ -80,11 +63,16 @@ export function buildPageTreeProjectionItems(
|
||||
position: node.kernel?.position ?? node.sort_order ?? index,
|
||||
title: node.title ?? "无标题",
|
||||
childCount,
|
||||
expandable: childCount > 0,
|
||||
expandedByDefault: node.kernel?.expandedByDefault ?? true,
|
||||
capabilities: buildPageCapabilities(childCount),
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: node.id,
|
||||
workspaceId: node.workspace_id,
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
node: {
|
||||
...node,
|
||||
parent_id: parentNodeId,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TreeProjectionItemBase } from "@/lib/tree-protocol";
|
||||
|
||||
describe("tree-protocol", () => {
|
||||
it("冻结共享 tree projection 字段与 file_tree 扩展语义", () => {
|
||||
const item: TreeProjectionItemBase = {
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 2,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: ["expand", "open", "drag", "drop", "select", "create-child"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
assetKind: "file",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
};
|
||||
|
||||
expect(item.resourceMeta.resourceKind).toBe("document");
|
||||
expect(item.resourceMeta.assetKind).toBe("file");
|
||||
expect(item.capabilities).toContain("expand");
|
||||
expect(item.expandable).toBe(true);
|
||||
expect(item.iconHint).toBe("page");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export type TreeProjectionKind = "sidebar_tree" | "page_tree" | "file_tree";
|
||||
|
||||
export type TreeProjectionNodeType =
|
||||
| "workspace"
|
||||
| "folder"
|
||||
| "page"
|
||||
| "section"
|
||||
| "asset"
|
||||
| "mindmap"
|
||||
| "table"
|
||||
| "book"
|
||||
| "pdf"
|
||||
| "index";
|
||||
|
||||
export type TreeProjectionCapability =
|
||||
| "expand"
|
||||
| "open"
|
||||
| "drag"
|
||||
| "drop"
|
||||
| "select"
|
||||
| "create-child"
|
||||
| "rename"
|
||||
| "archive"
|
||||
| "restore"
|
||||
| "context-menu"
|
||||
| "reorder"
|
||||
| "open-asset"
|
||||
| "pick";
|
||||
|
||||
export type TreeProjectionResourceKind =
|
||||
| "workspace"
|
||||
| "document"
|
||||
| "index"
|
||||
| "asset"
|
||||
| "asset_folder"
|
||||
| "mindmap"
|
||||
| "table"
|
||||
| "book"
|
||||
| "pdf";
|
||||
|
||||
export type TreeProjectionAssetKind =
|
||||
| "file"
|
||||
| "mindmap"
|
||||
| "table"
|
||||
| "book"
|
||||
| "pdf"
|
||||
| "image"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "unknown";
|
||||
|
||||
export type TreeProjectionResourceMeta = {
|
||||
resourceKind: TreeProjectionResourceKind;
|
||||
documentId?: string | null;
|
||||
assetId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
assetKind?: TreeProjectionAssetKind | null;
|
||||
iconHint?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TreeProjectionItemBase = {
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: TreeProjectionNodeType;
|
||||
projectionKind: TreeProjectionKind;
|
||||
title: string;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
childCount: number;
|
||||
expandable: boolean;
|
||||
expandedByDefault: boolean;
|
||||
capabilities: TreeProjectionCapability[];
|
||||
resourceMeta: TreeProjectionResourceMeta;
|
||||
iconHint: string | null;
|
||||
};
|
||||
@@ -40,21 +40,64 @@ const baseSidebarData: SidebarInitialData = {
|
||||
nodeId: "root",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: "Root",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"expand",
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
nodeId: "child",
|
||||
parentNodeId: "root",
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: "Child",
|
||||
depth: 1,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "child",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseTreeStreamMessage,
|
||||
} from "@/lib/tree-stream/protocol";
|
||||
import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta";
|
||||
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
|
||||
|
||||
export interface SidebarTreeStreamState {
|
||||
data: SidebarInitialData | null;
|
||||
@@ -60,9 +61,8 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
|
||||
const eventSource = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = eventSource;
|
||||
let cancelled = false;
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
const envelope = parseTreeStreamMessage({
|
||||
@@ -121,21 +121,45 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
status: previous.data ? "live" : "fallback",
|
||||
error: previous.error ?? new Error("tree stream 连接失败"),
|
||||
}));
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
eventSource?.close();
|
||||
if (eventSourceRef.current === eventSource) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource.addEventListener("delta", handleMessage as EventListener);
|
||||
eventSource.addEventListener("resync", handleMessage as EventListener);
|
||||
eventSource.onmessage = handleMessage;
|
||||
eventSource.onerror = handleError;
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureMnoteWebAuthCookie();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
|
||||
eventSource = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = eventSource;
|
||||
eventSource.addEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource.addEventListener("delta", handleMessage as EventListener);
|
||||
eventSource.addEventListener("resync", handleMessage as EventListener);
|
||||
eventSource.onmessage = handleMessage;
|
||||
eventSource.onerror = handleError;
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
status: previous.data ? "live" : "fallback",
|
||||
error: previous.error ?? new Error("tree stream 鉴权失败"),
|
||||
}));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource.removeEventListener("delta", handleMessage as EventListener);
|
||||
eventSource.removeEventListener("resync", handleMessage as EventListener);
|
||||
eventSource.close();
|
||||
cancelled = true;
|
||||
eventSource?.removeEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource?.removeEventListener("delta", handleMessage as EventListener);
|
||||
eventSource?.removeEventListener("resync", handleMessage as EventListener);
|
||||
eventSource?.close();
|
||||
if (eventSourceRef.current === eventSource) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user