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 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-24 18:20:41 -03:00
parent eb86ccd291
commit cd73b31739
2 changed files with 160 additions and 3 deletions

View File

@ -682,6 +682,38 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, userId, userDisplayName]) }, [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) // Sync local React Flow state changes to CRDT (skip remote-originated updates)
const nodesForCRDT = useMemo(() => { const nodesForCRDT = useMemo(() => {
return nodes.map((node) => ({ return nodes.map((node) => ({

View File

@ -42,17 +42,22 @@ type RealtimeCallbacks = {
const HEARTBEAT_INTERVAL_MS = 30_000 const HEARTBEAT_INTERVAL_MS = 30_000
const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000 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 { export class RealtimeConnection {
private channel: RealtimeChannel | null = null private channel: RealtimeChannel | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private inactivityTimer: ReturnType<typeof setTimeout> | null = null
private connectionTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0 private reconnectAttempts = 0
private projectId: string private projectId: string
private userId: string private userId: string
private displayName: string private displayName: string
private callbacks: RealtimeCallbacks private callbacks: RealtimeCallbacks
private isDestroyed = false private isDestroyed = false
private isPaused = false
private supabase = createClient() private supabase = createClient()
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) { constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
@ -64,7 +69,20 @@ export class RealtimeConnection {
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.isDestroyed) return if (this.isDestroyed) return
this.isPaused = false
this.callbacks.onConnectionStateChange('connecting') 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}`, { this.channel = this.supabase.channel(`project:${this.projectId}`, {
config: { presence: { key: this.userId } }, config: { presence: { key: this.userId } },
@ -143,6 +161,7 @@ export class RealtimeConnection {
}) })
.subscribe(async (status) => { .subscribe(async (status) => {
if (this.isDestroyed) return if (this.isDestroyed) return
this.clearConnectionTimer()
if (status === 'SUBSCRIBED') { if (status === 'SUBSCRIBED') {
this.reconnectAttempts = 0 this.reconnectAttempts = 0
@ -162,15 +181,24 @@ export class RealtimeConnection {
this.callbacks.onConnectionStateChange('reconnecting') this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect() this.scheduleReconnect()
} else if (status === 'CLOSED') { } else if (status === 'CLOSED') {
if (!this.isPaused) {
// Unexpected close - attempt to reconnect
this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect()
} else {
this.callbacks.onConnectionStateChange('disconnected') this.callbacks.onConnectionStateChange('disconnected')
} }
}
}) })
} }
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
this.isDestroyed = true this.isDestroyed = true
this.isPaused = false
this.stopHeartbeat() this.stopHeartbeat()
this.clearReconnectTimer() this.clearReconnectTimer()
this.clearInactivityTimer()
this.clearConnectionTimer()
if (this.channel) { if (this.channel) {
await this.deleteSession() await this.deleteSession()
@ -181,6 +209,54 @@ export class RealtimeConnection {
this.callbacks.onConnectionStateChange('disconnected') this.callbacks.onConnectionStateChange('disconnected')
} }
/**
* Pause the connection (e.g. on inactivity or tab hidden).
* Unlike disconnect(), this allows resuming later.
*/
async pause(): Promise<void> {
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<void> {
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 { getChannel(): RealtimeChannel | null {
return this.channel return this.channel
} }
@ -254,19 +330,44 @@ export class RealtimeConnection {
private startHeartbeat(): void { private startHeartbeat(): void {
this.stopHeartbeat() this.stopHeartbeat()
let consecutiveFailures = 0
this.heartbeatTimer = setInterval(async () => { this.heartbeatTimer = setInterval(async () => {
if (this.isDestroyed) { if (this.isDestroyed || this.isPaused) {
this.stopHeartbeat() this.stopHeartbeat()
return 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 { try {
await this.supabase await this.supabase
.from('collaboration_sessions') .from('collaboration_sessions')
.update({ last_heartbeat: new Date().toISOString() }) .update({ last_heartbeat: new Date().toISOString() })
.eq('project_id', this.projectId) .eq('project_id', this.projectId)
.eq('user_id', this.userId) .eq('user_id', this.userId)
consecutiveFailures = 0
} catch { } 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) }, HEARTBEAT_INTERVAL_MS)
} }
@ -307,4 +408,28 @@ export class RealtimeConnection {
this.reconnectTimer = null 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
}
}
} }