From c28b9ac5658436e1b3cf30a240a69bb53d7be962 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 19:14:14 -0300 Subject: [PATCH] fix: force fresh reconnect when returning after idle period The WebSocket can go stale during inactivity without triggering any error status (channel still shows 'joined' but socket is dead). Now when the user returns after 60+ seconds of inactivity, the connection is torn down and re-established to guarantee a fresh WebSocket. This ensures CRDT broadcasts actually reach other clients after returning from idle. Co-Authored-By: Claude Opus 4.5 --- src/lib/collaboration/realtime.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) 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 }