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 index 58e91af43ad..5e63ae366f1 100644 --- 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 @@ -49,11 +49,10 @@ describe('CollaborationManager CRDT runtime loading', () => { expect(manager.isConnected()).toBe(false) }) - it('does not create connection state when the runtime fails to load and allows a retry', async () => { + it('falls back to local editing instead of leaving the UI stuck when the runtime fails to load', async () => { const { CollaborationManager, webSocketClient } = await loadCollaborationModules() const manager = new CollaborationManager() const runtimeError = new Error('runtime-load-failed') - const retryError = new Error('runtime-retry-failed') const loadRuntimeSpy = vi .spyOn( manager as unknown as { @@ -62,19 +61,44 @@ describe('CollaborationManager CRDT runtime loading', () => { 'loadCrdtRuntime', ) .mockRejectedValueOnce(runtimeError) - .mockRejectedValueOnce(retryError) const connectSpy = vi.spyOn(webSocketClient, 'connect') - await expect(manager.connect('app-runtime-failure')).rejects.toBe(runtimeError) + const connectionId = await manager.connect('app-runtime-failure') expect(loadRuntimeSpy).toHaveBeenCalledTimes(1) expect(connectSpy).not.toHaveBeenCalled() expect(manager.isConnected()).toBe(false) + expect(manager.canApplyLocalGraphMutation()).toBe(true) + expect(manager.canPersistLocalGraph()).toBe(true) - await expect(manager.connect('app-runtime-failure')).rejects.toBe(retryError) + manager.disconnect(connectionId) + }) - expect(loadRuntimeSpy).toHaveBeenCalledTimes(2) + it('keeps editing blocked when the runtime fails to load on a configured socket URL', async () => { + const { CollaborationManager } = await loadCollaborationModules() + const websocketManager = await import('../websocket-manager') + vi.spyOn(websocketManager, 'isDefaultSocketUrl').mockReturnValue(false) + 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', + ) + .mockRejectedValueOnce(runtimeError) + const connectSpy = vi.spyOn(websocketManager.webSocketClient, 'connect') + + const connectionId = await manager.connect('app-runtime-failure-configured-socket') + + expect(loadRuntimeSpy).toHaveBeenCalledTimes(1) expect(connectSpy).not.toHaveBeenCalled() + expect(manager.isConnected()).toBe(false) + expect(manager.canApplyLocalGraphMutation()).toBe(false) + expect(manager.canPersistLocalGraph()).toBe(false) + + manager.disconnect(connectionId) }) it('initializes one session for concurrent consumers of the same app', async () => { diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 6323fc4e559..a032b1e7647 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -664,7 +664,24 @@ export class CollaborationManager { } const connectGeneration = this.connectGeneration - if (!this.crdtRuntime) await this.ensureCrdtRuntime() + if (!this.crdtRuntime) { + try { + await this.ensureCrdtRuntime() + } catch (error) { + console.error('Failed to load collaboration runtime, falling back to local editing:', error) + if (connectGeneration === this.connectGeneration && this.targetAppId === appId) { + this.currentAppId = appId + // Mirrors activateLocalDraftFallback(): only degrade to local-only editing on the + // default self-hosted socket URL. A configured (Cloud/Enterprise) socket implies a real + // collaboration server may have other users editing live, so editing stays blocked here. + if (isDefaultSocketUrl() && !this.hasEstablishedConnection) + this.localDraftFallbackActive = true + this.activeConnections.add(connectionId) + this.emitGraphReadyState() + } + return connectionId + } + } if (connectGeneration !== this.connectGeneration || this.targetAppId !== appId) return connectionId