diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 0396e54..e5b0cb8 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -44,6 +44,7 @@ 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 +const STALE_THRESHOLD_MS = 60_000 // 60s of inactivity = force fresh reconnect on return export class RealtimeConnection { private channel: RealtimeChannel | null = null @@ -58,6 +59,7 @@ export class RealtimeConnection { private callbacks: RealtimeCallbacks private isDestroyed = false private isPaused = false + private lastActivityTime = Date.now() private supabase = createClient() constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) { @@ -70,6 +72,7 @@ export class RealtimeConnection { async connect(): Promise { if (this.isDestroyed) return this.isPaused = false + this.lastActivityTime = Date.now() this.callbacks.onConnectionStateChange('connecting') this.resetInactivityTimer() this.clearConnectionTimer() @@ -244,15 +247,43 @@ export class RealtimeConnection { /** * Notify that user activity has occurred, resetting the inactivity timer. * If the connection was paused due to inactivity, it will resume. + * If the connection was idle for a significant period, force a fresh reconnect + * to ensure the WebSocket isn't stale. */ notifyActivity(): void { if (this.isDestroyed) return + const now = Date.now() + const idleDuration = now - this.lastActivityTime + this.lastActivityTime = now this.resetInactivityTimer() + if (this.isPaused) { this.resume() + } else if (idleDuration > STALE_THRESHOLD_MS && this.channel) { + // Connection may appear alive but WebSocket could be stale after idle. + // Force a fresh reconnect to ensure broadcasts actually work. + this.forceReconnect() } } + /** + * Force a fresh reconnect by tearing down the current channel and reconnecting. + */ + private async forceReconnect(): Promise { + if (this.isDestroyed || this.isPaused) return + this.stopHeartbeat() + this.clearReconnectTimer() + this.clearConnectionTimer() + + if (this.channel) { + this.supabase.removeChannel(this.channel) + this.channel = null + } + + this.reconnectAttempts = 0 + await this.connect() + } + getIsPaused(): boolean { return this.isPaused }