fix(workflow): lazy-load Loro collaboration runtime (#39631)

This commit is contained in:
yyh 2026-07-27 15:15:13 +08:00 committed by GitHub
parent 58e2bcbba1
commit e5d40336b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 147 additions and 3 deletions

View File

@ -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

View File

@ -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')

View File

@ -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')
})
})

View File

@ -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)

View File

@ -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

View File

@ -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
}

View File

@ -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<Record<string, Value>> {
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<Record<string, Value>>,
): LoroMap<Record<string, Value>> {
const { LoroMap } = this.getCrdtRuntime()
let dataContainer = nodeContainer.get('data') as unknown
if (
@ -309,6 +314,7 @@ export class CollaborationManager {
nodeContainer: LoroMap<Record<string, Value>>,
key: string,
): LoroList<unknown> {
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<CrdtRuntime> {
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<string> {
this.crdtRuntime ??= await this.loadCrdtRuntime()
const connectionId = Math.random().toString(36).substring(2, 11)
this.activeConnections.add(connectionId)

View File

@ -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,
}