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
}, [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) => ({

View File

@ -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<typeof setInterval> | 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 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<void> {
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') {
if (!this.isPaused) {
// Unexpected close - attempt to reconnect
this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect()
} else {
this.callbacks.onConnectionStateChange('disconnected')
}
}
})
}
async disconnect(): Promise<void> {
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<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 {
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
}
}
}