mirror of
https://github.com/langgenius/dify.git
synced 2026-07-28 23:59:34 +08:00
fix(workflow): lazy-load Loro collaboration runtime (#39631)
This commit is contained in:
parent
58e2bcbba1
commit
e5d40336b3
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user