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:
@@ -69,9 +69,20 @@ export function buildWorkspaceTreeStreamUrl(
|
||||
cursor?: string | null,
|
||||
): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const url = new URL("/api/stream/events", `${normalizedBaseUrl}/`);
|
||||
url.searchParams.set("stream", "workspace");
|
||||
url.searchParams.set("projection", "sidebar_tree");
|
||||
const shouldUseSameOriginProxy =
|
||||
normalizedBaseUrl.length > 0 &&
|
||||
typeof window !== "undefined" &&
|
||||
(() => {
|
||||
try {
|
||||
const runtimeUrl = new URL(`${normalizedBaseUrl}/`);
|
||||
return runtimeUrl.origin !== window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const url = shouldUseSameOriginProxy
|
||||
? new URL("/api/mnote-web/stream", window.location.origin)
|
||||
: new URL("/api/mnote-web/stream", `${normalizedBaseUrl}/`);
|
||||
url.searchParams.set("workspaceId", workspaceId.trim());
|
||||
if (typeof cursor === "string" && cursor.trim()) {
|
||||
url.searchParams.set("cursor", cursor.trim());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildWorkspaceTreeStreamUrl,
|
||||
normalizeTreeStreamSnapshot,
|
||||
@@ -6,14 +6,38 @@ import {
|
||||
} from "./protocol";
|
||||
|
||||
describe("tree-stream/protocol", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("构造 workspace sidebar stream url", () => {
|
||||
vi.stubGlobal("window", {
|
||||
...window,
|
||||
location: {
|
||||
...window.location,
|
||||
origin: "http://127.0.0.1:3000",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"),
|
||||
).toBe(
|
||||
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1&cursor=evt_9",
|
||||
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
|
||||
);
|
||||
});
|
||||
|
||||
it("同源 runtime baseUrl 下仍走同源 stream route", () => {
|
||||
vi.stubGlobal("window", {
|
||||
...window,
|
||||
location: {
|
||||
...window.location,
|
||||
origin: "http://127.0.0.1:3000",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3000/", "ws_1", null),
|
||||
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
|
||||
});
|
||||
|
||||
it("解析 snapshot / delta / resync 协议消息", () => {
|
||||
expect(
|
||||
parseTreeStreamMessage({
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { act, useEffect } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useSidebarTreeStream } from "./use-sidebar-tree-stream";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
|
||||
const mockRuntimeConfig = vi.hoisted(() => ({
|
||||
getMnoteRuntimeConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/runtime-config", () => mockRuntimeConfig);
|
||||
|
||||
vi.mock("@/lib/mnote-web-auth", () => ({
|
||||
ensureMnoteWebAuthCookie: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
type MockEventListener = (event: MessageEvent<string>) => void;
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readonly withCredentials: boolean;
|
||||
readonly listeners = new Map<string, Set<MockEventListener>>();
|
||||
onmessage: MockEventListener | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
constructor(url: string, options?: EventSourceInit) {
|
||||
this.url = url;
|
||||
this.withCredentials = Boolean(options?.withCredentials);
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: EventListener) {
|
||||
const typedListener = listener as unknown as MockEventListener;
|
||||
const next = this.listeners.get(type) ?? new Set<MockEventListener>();
|
||||
next.add(typedListener);
|
||||
this.listeners.set(type, next);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: EventListener) {
|
||||
const typedListener = listener as unknown as MockEventListener;
|
||||
this.listeners.get(type)?.delete(typedListener);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
emit(type: string, payload: unknown) {
|
||||
const event = {
|
||||
type,
|
||||
data: JSON.stringify(payload),
|
||||
} as MessageEvent<string>;
|
||||
this.listeners.get(type)?.forEach((listener) => listener(event));
|
||||
if (type === "message" && this.onmessage) {
|
||||
this.onmessage(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var EventSource: typeof MockEventSource;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function buildInitialData(): SidebarInitialData {
|
||||
return {
|
||||
activeWorkspaceId: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [],
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [],
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
trashedTableAssets: [],
|
||||
tableAssets: [],
|
||||
mindmapDocs: [],
|
||||
mindmapAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
mediaAssets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ onState }: { onState: (state: ReturnType<typeof useSidebarTreeStream>) => void }) {
|
||||
const state = useSidebarTreeStream(buildInitialData());
|
||||
|
||||
useEffect(() => {
|
||||
onState(state);
|
||||
}, [onState, state]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useSidebarTreeStream", () => {
|
||||
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;
|
||||
mockRuntimeConfig.getMnoteRuntimeConfig.mockReturnValue({
|
||||
mnoteWebBaseUrl: "http://127.0.0.1:3104",
|
||||
});
|
||||
MockEventSource.instances = [];
|
||||
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
onState.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("收到带 cursor 的 snapshot 后不应重建 EventSource", async () => {
|
||||
await act(async () => {
|
||||
root.render(<Harness onState={onState} />);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
MockEventSource.instances[0]?.emit("snapshot", {
|
||||
stream: "workspace",
|
||||
workspaceId: "ws_1",
|
||||
cursor: "evt_2",
|
||||
projection: "sidebar_tree",
|
||||
data: {
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [],
|
||||
kernel_sidebar_projection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(onState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
cursor: "evt_2",
|
||||
status: "live",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
const workspaceId = initialData.activeWorkspaceId;
|
||||
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
|
||||
const streamEnabled = Boolean(baseUrl && workspaceId);
|
||||
const cursorRef = useRef<string | null>(null);
|
||||
|
||||
const [state, setState] = useState<SidebarTreeStreamState>({
|
||||
data: null,
|
||||
@@ -50,8 +51,13 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
});
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cursorRef.current = state.cursor;
|
||||
}, [state.cursor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamEnabled) {
|
||||
cursorRef.current = null;
|
||||
setState({
|
||||
data: null,
|
||||
status: "idle",
|
||||
@@ -134,7 +140,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
|
||||
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, cursorRef.current);
|
||||
eventSource = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = eventSource;
|
||||
eventSource.addEventListener("snapshot", handleMessage as EventListener);
|
||||
@@ -164,7 +170,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [baseUrl, state.cursor, streamEnabled, workspaceId]);
|
||||
}, [baseUrl, streamEnabled, workspaceId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user