From e5d40336b38b285a143c4a9b03fc0a1983d4f87e Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:15:13 +0800 Subject: [PATCH] fix(workflow): lazy-load Loro collaboration runtime (#39631) --- ...laboration-manager.logs-and-events.spec.ts | 2 + ...llaboration-manager.merge-behavior.test.ts | 2 + ...laboration-manager.runtime-loading.spec.ts | 88 +++++++++++++++++++ ...n-manager.socket-and-subscriptions.spec.ts | 3 + .../__tests__/collaboration-manager.test.ts | 2 + .../core/__tests__/test-crdt-runtime.ts | 7 ++ .../core/collaboration-manager.ts | 25 +++++- .../collaboration/core/crdt-runtime.ts | 21 +++++ 8 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts create mode 100644 web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts create mode 100644 web/app/components/workflow/collaboration/core/crdt-runtime.ts diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts index 80af37b671a..7ac65fcc8e3 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts @@ -6,6 +6,7 @@ import { LoroDoc } from 'loro-crdt' import { BlockEnum } from '@/app/components/workflow/types' import { CollaborationManager } from '../collaboration-manager' import { webSocketClient } from '../websocket-manager' +import { attachCrdtRuntime } from './test-crdt-runtime' type ReactFlowStore = { getState: () => { @@ -70,6 +71,7 @@ const createEdge = (id: string, source: string, target: string): Edge => ({ const setupManagerWithDoc = () => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const doc = new LoroDoc() const internals = getManagerInternals(manager) internals.doc = doc diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts index adc55768173..85b95c37c22 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts @@ -3,6 +3,7 @@ import type { Node } from '@/app/components/workflow/types' import { LoroDoc } from 'loro-crdt/base64' import { BlockEnum } from '@/app/components/workflow/types' import { CollaborationManager } from '../collaboration-manager' +import { attachCrdtRuntime } from './test-crdt-runtime' const NODE_ID = 'node-1' const LLM_NODE_ID = 'llm-node' @@ -163,6 +164,7 @@ const getManagerInternals = (manager: CollaborationManager): CollaborationManage const getManager = (doc: LoroDoc) => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const internals = getManagerInternals(manager) internals.doc = doc internals.nodesMap = doc.getMap('nodes') diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts new file mode 100644 index 00000000000..7fdefbb6fc7 --- /dev/null +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts @@ -0,0 +1,88 @@ +import type { Socket } from 'socket.io-client' + +const loroModuleState = vi.hoisted(() => ({ evaluations: 0 })) + +vi.mock('loro-crdt', async (importOriginal) => { + loroModuleState.evaluations += 1 + return importOriginal() +}) + +const createMockSocket = (): Socket => + ({ + id: 'socket-runtime-loading', + connected: true, + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }) as unknown as Socket + +const loadCollaborationModules = async () => { + const [{ CollaborationManager }, { webSocketClient }] = await Promise.all([ + import('../collaboration-manager'), + import('../websocket-manager'), + ]) + + return { CollaborationManager, webSocketClient } +} + +describe('CollaborationManager CRDT runtime loading', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.resetModules() + loroModuleState.evaluations = 0 + }) + + it('does not evaluate Loro when the manager module is loaded', async () => { + const { CollaborationManager } = await loadCollaborationModules() + const manager = new CollaborationManager() + + expect(loroModuleState.evaluations).toBe(0) + expect(manager.isConnected()).toBe(false) + }) + + it('does not create connection state when the runtime fails to load', async () => { + const { CollaborationManager, webSocketClient } = await loadCollaborationModules() + const manager = new CollaborationManager() + const runtimeError = new Error('runtime-load-failed') + const loadRuntimeSpy = vi + .spyOn( + manager as unknown as { + loadCrdtRuntime: () => Promise<(typeof import('../crdt-runtime'))['crdtRuntime']> + }, + 'loadCrdtRuntime', + ) + .mockRejectedValue(runtimeError) + const connectSpy = vi.spyOn(webSocketClient, 'connect') + + await expect(manager.connect('app-runtime-failure')).rejects.toBe(runtimeError) + + expect(loadRuntimeSpy).toHaveBeenCalledTimes(1) + expect(connectSpy).not.toHaveBeenCalled() + expect(manager.isConnected()).toBe(false) + }) + + it('initializes one session for concurrent consumers of the same app', async () => { + const { CollaborationManager, webSocketClient } = await loadCollaborationModules() + const manager = new CollaborationManager() + const socket = createMockSocket() + const connectSpy = vi.spyOn(webSocketClient, 'connect').mockReturnValue(socket) + const disconnectSpy = vi + .spyOn(webSocketClient, 'disconnect') + .mockImplementation(() => undefined) + + const [firstConnectionId, secondConnectionId] = await Promise.all([ + manager.connect('app-concurrent'), + manager.connect('app-concurrent'), + ]) + + expect(firstConnectionId).not.toBe(secondConnectionId) + expect(connectSpy).toHaveBeenCalledTimes(1) + expect(loroModuleState.evaluations).toBe(1) + + manager.disconnect(firstConnectionId) + expect(disconnectSpy).not.toHaveBeenCalled() + + manager.disconnect(secondConnectionId) + expect(disconnectSpy).toHaveBeenCalledWith('app-concurrent') + }) +}) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts index 4e3fa225dc9..5ecae6c355c 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts @@ -13,6 +13,7 @@ import { LoroDoc, LoroMap } from 'loro-crdt' import { BlockEnum } from '@/app/components/workflow/types' import { CollaborationManager } from '../collaboration-manager' import { webSocketClient } from '../websocket-manager' +import { attachCrdtRuntime } from './test-crdt-runtime' type ReactFlowStore = { getState: () => { @@ -147,6 +148,7 @@ const createMockSocket = (id = 'socket-1'): MockSocket => { const setupManagerWithDoc = () => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const doc = new LoroDoc() const internals = getManagerInternals(manager) internals.doc = doc @@ -1371,6 +1373,7 @@ describe('CollaborationManager socket and subscription behavior', () => { it('covers private guard branches for socket helpers and container migration', async () => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const internals = getManagerInternals(manager) const socket = createMockSocket('socket-private') const getSocketSpy = vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(null) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts index 1bf1958d4b1..4a957c9d1a6 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts @@ -8,6 +8,7 @@ import { LoroDoc } from 'loro-crdt/base64' import { Position } from 'reactflow' import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { BlockEnum } from '@/app/components/workflow/types' +import { attachCrdtRuntime } from './test-crdt-runtime' const NODE_ID = '1760342909316' @@ -247,6 +248,7 @@ const setupManager = (): { internals: CollaborationManagerInternals } => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const doc = new LoroDoc() const internals = getManagerInternals(manager) internals.doc = doc diff --git a/web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts b/web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts new file mode 100644 index 00000000000..852012bf06c --- /dev/null +++ b/web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts @@ -0,0 +1,7 @@ +import type { CollaborationManager } from '../collaboration-manager' +import { crdtRuntime } from '../crdt-runtime' + +export const attachCrdtRuntime = (manager: CollaborationManager): void => { + const internals = manager as unknown as { crdtRuntime: typeof crdtRuntime } + internals.crdtRuntime = crdtRuntime +} diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 5b2c45a20af..02bbd7b7e13 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -1,6 +1,6 @@ 'use client' -import type { Value } from 'loro-crdt' +import type { LoroDoc, LoroList, LoroMap, UndoManager, Value } from 'loro-crdt' import type { Socket } from 'socket.io-client' import type { CommonNodeType, Edge, Node } from '../../types' import type { @@ -17,13 +17,14 @@ import type { WorkflowSyncRequest, WorkflowSyncResult, } from '../types/collaboration' +import type { CRDTProvider } from './crdt-provider' import { cloneDeep } from 'es-toolkit/object' import { isEqual } from 'es-toolkit/predicate' -import { LoroDoc, LoroList, LoroMap, UndoManager } from 'loro-crdt' -import { CRDTProvider } from './crdt-provider' import { EventEmitter } from './event-emitter' import { emitWithAuthGuard, webSocketClient } from './websocket-manager' +type CrdtRuntime = (typeof import('./crdt-runtime'))['crdtRuntime'] + type NodePanelPresenceEventData = { nodeId: string action: 'open' | 'close' @@ -154,6 +155,7 @@ const toUint8Array = (value: unknown): Uint8Array | null => { } export class CollaborationManager { + private crdtRuntime: CrdtRuntime | null = null private doc: LoroDoc | null = null private undoManager: UndoManager | null = null private provider: CRDTProvider | null = null @@ -259,6 +261,8 @@ export class CollaborationManager { private getNodeContainer(nodeId: string): LoroMap> { if (!this.nodesMap) throw new Error('Nodes map not initialized') + const { LoroMap } = this.getCrdtRuntime() + let container = this.nodesMap.get(nodeId) as unknown const isMapContainer = ( @@ -292,6 +296,7 @@ export class CollaborationManager { private ensureDataContainer( nodeContainer: LoroMap>, ): LoroMap> { + const { LoroMap } = this.getCrdtRuntime() let dataContainer = nodeContainer.get('data') as unknown if ( @@ -309,6 +314,7 @@ export class CollaborationManager { nodeContainer: LoroMap>, key: string, ): LoroList { + const { LoroList } = this.getCrdtRuntime() const dataContainer = this.ensureDataContainer(nodeContainer) let list = dataContainer.get(key) as unknown @@ -527,7 +533,18 @@ export class CollaborationManager { this.disconnect() } + private async loadCrdtRuntime(): Promise { + const { crdtRuntime } = await import('./crdt-runtime') + return crdtRuntime + } + + private getCrdtRuntime(): CrdtRuntime { + if (!this.crdtRuntime) throw new Error('CRDT runtime not initialized') + return this.crdtRuntime + } + private initializeCrdt(socket: Socket): void { + const { CRDTProvider, LoroDoc, UndoManager } = this.getCrdtRuntime() this.provider?.destroy() this.undoManager = null this.doc = new LoroDoc() @@ -622,6 +639,8 @@ export class CollaborationManager { } async connect(appId: string, reactFlowStore?: ReactFlowStore): Promise { + this.crdtRuntime ??= await this.loadCrdtRuntime() + const connectionId = Math.random().toString(36).substring(2, 11) this.activeConnections.add(connectionId) diff --git a/web/app/components/workflow/collaboration/core/crdt-runtime.ts b/web/app/components/workflow/collaboration/core/crdt-runtime.ts new file mode 100644 index 00000000000..6e38c26d406 --- /dev/null +++ b/web/app/components/workflow/collaboration/core/crdt-runtime.ts @@ -0,0 +1,21 @@ +'use client' + +import { LoroDoc, LoroList, LoroMap, UndoManager } from 'loro-crdt' +import { CRDTProvider } from './crdt-provider' + +/** + * Production code must load this module only through CollaborationManager.connect(). + * Importing either Loro or CRDTProvider from the manager would evaluate Loro's browser WASM + * loader on pages that only reference the manager. CRDTProvider belongs here because it also + * imports loro-crdt at runtime. + * + * TODO: Move graph sync, snapshots, and undo/redo behind an opaque CrdtGraphRuntime interface + * so CollaborationManager no longer depends on Loro constructors or types. + */ +export const crdtRuntime = { + CRDTProvider, + LoroDoc, + LoroList, + LoroMap, + UndoManager, +}