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 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-24 19:14:14 -03:00
parent fa8437d03a
commit c28b9ac565
1 changed files with 31 additions and 0 deletions

View File

@ -44,6 +44,7 @@ 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 INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing
const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection 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 { export class RealtimeConnection {
private channel: RealtimeChannel | null = null private channel: RealtimeChannel | null = null
@ -58,6 +59,7 @@ export class RealtimeConnection {
private callbacks: RealtimeCallbacks private callbacks: RealtimeCallbacks
private isDestroyed = false private isDestroyed = false
private isPaused = false private isPaused = false
private lastActivityTime = Date.now()
private supabase = createClient() private supabase = createClient()
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) { constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
@ -70,6 +72,7 @@ export class RealtimeConnection {
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.isDestroyed) return if (this.isDestroyed) return
this.isPaused = false this.isPaused = false
this.lastActivityTime = Date.now()
this.callbacks.onConnectionStateChange('connecting') this.callbacks.onConnectionStateChange('connecting')
this.resetInactivityTimer() this.resetInactivityTimer()
this.clearConnectionTimer() this.clearConnectionTimer()
@ -244,15 +247,43 @@ export class RealtimeConnection {
/** /**
* Notify that user activity has occurred, resetting the inactivity timer. * 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 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 { notifyActivity(): void {
if (this.isDestroyed) return if (this.isDestroyed) return
const now = Date.now()
const idleDuration = now - this.lastActivityTime
this.lastActivityTime = now
this.resetInactivityTimer() this.resetInactivityTimer()
if (this.isPaused) { if (this.isPaused) {
this.resume() 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<void> {
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 { getIsPaused(): boolean {
return this.isPaused return this.isPaused
} }