From cd73b3173934351d2ed1dbc8429cafb45cc15d69 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 18:20:41 -0300 Subject: [PATCH] fix: improve realtime connection resilience with auto-reconnect and activity-based lifecycle Add connection timeout (15s) to handle stale initial subscribes, inactivity pause (5min) to save resources when idle, and automatic resume on user activity or tab focus. The heartbeat now detects unhealthy channel states and consecutive failures to trigger reconnects. Unexpected CLOSED status also triggers reconnect instead of staying disconnected silently. Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 32 +++++ src/lib/collaboration/realtime.ts | 131 +++++++++++++++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 5c8f969..325dd07 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -682,6 +682,38 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, userId, userDisplayName]) + // Manage connection lifecycle based on visibility and user activity + useEffect(() => { + const handleVisibilityChange = () => { + if (!document.hidden) { + realtimeRef.current?.notifyActivity() + } + } + + // Throttle activity notifications to avoid excessive calls + let activityThrottled = false + const throttledActivity = () => { + if (activityThrottled) return + activityThrottled = true + realtimeRef.current?.notifyActivity() + setTimeout(() => { activityThrottled = false }, 10_000) + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + document.addEventListener('mousedown', throttledActivity) + document.addEventListener('keydown', throttledActivity) + document.addEventListener('scroll', throttledActivity, true) + document.addEventListener('mousemove', throttledActivity) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + document.removeEventListener('mousedown', throttledActivity) + document.removeEventListener('keydown', throttledActivity) + document.removeEventListener('scroll', throttledActivity, true) + document.removeEventListener('mousemove', throttledActivity) + } + }, []) + // Sync local React Flow state changes to CRDT (skip remote-originated updates) const nodesForCRDT = useMemo(() => { return nodes.map((node) => ({ diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 5efbdd5..0396e54 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -42,17 +42,22 @@ type RealtimeCallbacks = { const HEARTBEAT_INTERVAL_MS = 30_000 const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_MAX_DELAY_MS = 30_000 +const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing +const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection export class RealtimeConnection { private channel: RealtimeChannel | null = null private heartbeatTimer: ReturnType | null = null private reconnectTimer: ReturnType | null = null + private inactivityTimer: ReturnType | null = null + private connectionTimer: ReturnType | null = null private reconnectAttempts = 0 private projectId: string private userId: string private displayName: string private callbacks: RealtimeCallbacks private isDestroyed = false + private isPaused = false private supabase = createClient() constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) { @@ -64,7 +69,20 @@ export class RealtimeConnection { async connect(): Promise { if (this.isDestroyed) return + this.isPaused = false this.callbacks.onConnectionStateChange('connecting') + this.resetInactivityTimer() + this.clearConnectionTimer() + + // Set a timeout: if we don't connect within CONNECTION_TIMEOUT_MS, retry + this.connectionTimer = setTimeout(() => { + if (this.isDestroyed || this.isPaused) return + if (this.channel) { + this.supabase.removeChannel(this.channel) + this.channel = null + } + this.scheduleReconnect() + }, CONNECTION_TIMEOUT_MS) this.channel = this.supabase.channel(`project:${this.projectId}`, { config: { presence: { key: this.userId } }, @@ -143,6 +161,7 @@ export class RealtimeConnection { }) .subscribe(async (status) => { if (this.isDestroyed) return + this.clearConnectionTimer() if (status === 'SUBSCRIBED') { this.reconnectAttempts = 0 @@ -162,15 +181,24 @@ export class RealtimeConnection { this.callbacks.onConnectionStateChange('reconnecting') this.scheduleReconnect() } else if (status === 'CLOSED') { - this.callbacks.onConnectionStateChange('disconnected') + if (!this.isPaused) { + // Unexpected close - attempt to reconnect + this.callbacks.onConnectionStateChange('reconnecting') + this.scheduleReconnect() + } else { + this.callbacks.onConnectionStateChange('disconnected') + } } }) } async disconnect(): Promise { this.isDestroyed = true + this.isPaused = false this.stopHeartbeat() this.clearReconnectTimer() + this.clearInactivityTimer() + this.clearConnectionTimer() if (this.channel) { await this.deleteSession() @@ -181,6 +209,54 @@ export class RealtimeConnection { this.callbacks.onConnectionStateChange('disconnected') } + /** + * Pause the connection (e.g. on inactivity or tab hidden). + * Unlike disconnect(), this allows resuming later. + */ + async pause(): Promise { + if (this.isDestroyed || this.isPaused) return + this.isPaused = true + this.stopHeartbeat() + this.clearReconnectTimer() + this.clearInactivityTimer() + this.clearConnectionTimer() + + if (this.channel) { + await this.deleteSession() + this.supabase.removeChannel(this.channel) + this.channel = null + } + + this.callbacks.onConnectionStateChange('disconnected') + } + + /** + * Resume the connection after it was paused. + * Re-establishes the channel and presence. + */ + async resume(): Promise { + if (this.isDestroyed || !this.isPaused) return + this.isPaused = false + this.reconnectAttempts = 0 + await this.connect() + } + + /** + * Notify that user activity has occurred, resetting the inactivity timer. + * If the connection was paused due to inactivity, it will resume. + */ + notifyActivity(): void { + if (this.isDestroyed) return + this.resetInactivityTimer() + if (this.isPaused) { + this.resume() + } + } + + getIsPaused(): boolean { + return this.isPaused + } + getChannel(): RealtimeChannel | null { return this.channel } @@ -254,19 +330,44 @@ export class RealtimeConnection { private startHeartbeat(): void { this.stopHeartbeat() + let consecutiveFailures = 0 this.heartbeatTimer = setInterval(async () => { - if (this.isDestroyed) { + if (this.isDestroyed || this.isPaused) { this.stopHeartbeat() return } + + // Check if the channel is still in a healthy state + if (this.channel) { + const state = (this.channel as unknown as { state?: string }).state + if (state && state !== 'joined' && state !== 'joining') { + // Channel is in an unhealthy state - trigger reconnect + this.callbacks.onConnectionStateChange('reconnecting') + this.supabase.removeChannel(this.channel) + this.channel = null + this.stopHeartbeat() + this.scheduleReconnect() + return + } + } + try { await this.supabase .from('collaboration_sessions') .update({ last_heartbeat: new Date().toISOString() }) .eq('project_id', this.projectId) .eq('user_id', this.userId) + consecutiveFailures = 0 } catch { - // Heartbeat failure is non-critical + consecutiveFailures++ + // If heartbeat fails 3 times in a row, the connection is likely dead + if (consecutiveFailures >= 3 && this.channel) { + this.callbacks.onConnectionStateChange('reconnecting') + this.supabase.removeChannel(this.channel) + this.channel = null + this.stopHeartbeat() + this.scheduleReconnect() + } } }, HEARTBEAT_INTERVAL_MS) } @@ -307,4 +408,28 @@ export class RealtimeConnection { this.reconnectTimer = null } } + + private resetInactivityTimer(): void { + this.clearInactivityTimer() + if (this.isDestroyed) return + this.inactivityTimer = setTimeout(() => { + if (!this.isDestroyed && !this.isPaused) { + this.pause() + } + }, INACTIVITY_TIMEOUT_MS) + } + + private clearInactivityTimer(): void { + if (this.inactivityTimer) { + clearTimeout(this.inactivityTimer) + this.inactivityTimer = null + } + } + + private clearConnectionTimer(): void { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer) + this.connectionTimer = null + } + } }