From c7eebc22381ee27a796341d3f464827b533df9b1 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Thu, 13 Aug 2026 09:43:55 +0000 Subject: [PATCH 1/2] fix(web): fall back to local editing when collaboration runtime fails to load connect() rejected and left CollaborationManager without a socket or CRDT doc whenever the CRDT runtime (Loro WASM) failed to load, e.g. in an offline deployment or an unsupported browser. Callers only logged the rejection, so the "syncing data" overlay in workflow-main.tsx kept gating the canvas forever since graphReadyChange was never emitted. Catch the runtime-load failure and activate the existing local draft fallback (used elsewhere for socket connect errors) so collaboration degrades to plain non-collaborative editing instead of blocking the UI indefinitely. --- .../collaboration-manager.runtime-loading.spec.ts | 13 +++++-------- .../collaboration/core/collaboration-manager.ts | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 9 deletions(-) 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..1dd48466f0f 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,17 @@ 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) - - expect(loadRuntimeSpy).toHaveBeenCalledTimes(2) - expect(connectSpy).not.toHaveBeenCalled() + 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..387a7a9b998 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -664,7 +664,20 @@ 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 + this.localDraftFallbackActive = true + this.activeConnections.add(connectionId) + this.emitGraphReadyState() + } + return connectionId + } + } if (connectGeneration !== this.connectGeneration || this.targetAppId !== appId) return connectionId From a75256fc51a1f944412f4547ec96bd2333f2ecf2 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Thu, 13 Aug 2026 09:57:22 +0000 Subject: [PATCH 2/2] fix(web): gate CRDT-runtime-load fallback behind isDefaultSocketUrl Mirror activateLocalDraftFallback's safety check: on a configured (Cloud/Enterprise) socket URL, a real collaboration server may have other users editing live, so a CRDT-runtime-load failure must keep editing blocked rather than silently degrading to local-only edits that could clobber a concurrent collaborator's changes. --- ...laboration-manager.runtime-loading.spec.ts | 27 +++++++++++++++++++ .../core/collaboration-manager.ts | 6 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) 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 1dd48466f0f..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 @@ -74,6 +74,33 @@ describe('CollaborationManager CRDT runtime loading', () => { manager.disconnect(connectionId) }) + 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 () => { const { CollaborationManager, webSocketClient } = await loadCollaborationModules() const manager = new CollaborationManager() diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 387a7a9b998..a032b1e7647 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -671,7 +671,11 @@ export class CollaborationManager { console.error('Failed to load collaboration runtime, falling back to local editing:', error) if (connectGeneration === this.connectGeneration && this.targetAppId === appId) { this.currentAppId = appId - this.localDraftFallbackActive = true + // 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() }