11import type { RealtimeChannel } from '@supabase/supabase-js' ;
22import type { Patch } from 'immer' ;
3- import { useEffect , useRef } from 'react' ;
3+ import { useEffect , useRef , useState } from 'react' ;
44import { loglev } from '@/core/logger/log' ;
55import { supabaseClient } from '@/core/supabase' ;
66import { useStore } from '@/core/zustand' ;
77import { onStorePatches } from '@/core/zustand-helpers/immer' ;
88import { saveRemoteGame } from '@/games/save/saveRemoteGame' ;
99import { useCurrentFactoryId } from '@/notes/useNotesContext' ;
1010import { flushRemoteGameOnUnload } from './flushRemoteGameOnUnload' ;
11- import { hasOtherPeersConnected } from './peersSlice' ;
11+ import { hasOtherPeersConnectedOnChannel } from './peersSlice' ;
1212import {
1313 computeLeaderAndPeers ,
1414 handleFullStateRequest ,
@@ -32,6 +32,7 @@ import {
3232 type PresencePayload ,
3333 SENDER_ID ,
3434} from './realtimeSyncTypes' ;
35+ import { safeChannelSend } from './safeChannelSend' ;
3536
3637const logger = loglev . getLogger ( 'games:realtime-sync' ) ;
3738
@@ -50,6 +51,10 @@ export function useRealtimeGameSync() {
5051 const isLeaderRef = useRef ( false ) ;
5152 const factoryIdRef = useRef ( factoryId ) ;
5253 factoryIdRef . current = factoryId ;
54+ // Bumped to force the subscribe effect to re-run and recreate the channel
55+ // after CHANNEL_ERROR / CLOSED / TIMED_OUT. Reset on successful SUBSCRIBED.
56+ const [ reconnectEpoch , setReconnectEpoch ] = useState ( 0 ) ;
57+ const reconnectAttemptsRef = useRef ( 0 ) ;
5358
5459 useEffect ( ( ) => {
5560 if ( ! channelRef . current || ! session ) return ;
@@ -76,7 +81,9 @@ export function useRealtimeGameSync() {
7681 }
7782
7883 const channelName = `game:${ savedId } ` ;
79- logger . info ( `Joining realtime channel: ${ channelName } ` ) ;
84+ logger . info (
85+ `Joining realtime channel: ${ channelName } (epoch=${ reconnectEpoch } )` ,
86+ ) ;
8087
8188 const channel = supabaseClient . channel ( channelName , {
8289 config : { private : true } ,
@@ -93,7 +100,29 @@ export function useRealtimeGameSync() {
93100 let pendingPatches : Patch [ ] = [ ] ;
94101 let flushTimer : ReturnType < typeof setTimeout > | null = null ;
95102 let autoSaveTimer : ReturnType < typeof setTimeout > | null = null ;
103+ let reconnectTimer : ReturnType < typeof setTimeout > | null = null ;
96104 let hasDirtySinceLastSave = false ;
105+ // Set to true in the effect cleanup so the subscribe callback (which fires
106+ // with CLOSED when we voluntarily remove the channel) does not schedule a
107+ // reconnect loop. Only genuine errors while the effect is still active
108+ // should trigger a retry.
109+ let isCleaningUp = false ;
110+
111+ function scheduleReconnect ( ) {
112+ if ( isCleaningUp ) return ;
113+ if ( reconnectTimer !== null ) return ;
114+ const attempt = reconnectAttemptsRef . current ;
115+ // Exponential backoff capped at 30s: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
116+ const delay = Math . min ( 1000 * 2 ** attempt , 30_000 ) ;
117+ reconnectAttemptsRef . current = attempt + 1 ;
118+ logger . warn (
119+ `Scheduling realtime reconnect in ${ delay } ms (attempt=${ attempt + 1 } )` ,
120+ ) ;
121+ reconnectTimer = setTimeout ( ( ) => {
122+ reconnectTimer = null ;
123+ setReconnectEpoch ( e => e + 1 ) ;
124+ } , delay ) ;
125+ }
97126
98127 const doRequestFullState = ( ) =>
99128 requestFullStateWithFallback ( channel , gameId , refs , timers ) ;
@@ -117,9 +146,8 @@ export function useRealtimeGameSync() {
117146 flushTimer = null ;
118147 if ( ! channelRef . current || pendingPatches . length === 0 ) return ;
119148
120- if ( ! hasOtherPeersConnected ( ) ) {
149+ if ( ! hasOtherPeersConnectedOnChannel ( channelRef . current ) ) {
121150 pendingPatches = [ ] ;
122- logger . debug ( 'Skipping broadcast: no other peers connected' ) ;
123151 return ;
124152 }
125153
@@ -128,20 +156,20 @@ export function useRealtimeGameSync() {
128156 const batch = pendingPatches ;
129157 pendingPatches = [ ] ;
130158
131- try {
132- channelRef . current . send ( {
159+ safeChannelSend ( {
160+ channel : channelRef . current ,
161+ message : {
133162 type : 'broadcast' ,
134163 event : BROADCAST_EVENT ,
135164 payload : {
136165 senderId : SENDER_ID ,
137166 seq,
138167 patches : batch ,
139168 } satisfies PatchBroadcastPayload ,
140- } ) ;
141- logger . debug ( `Broadcasted ${ batch . length } patches (seq=${ seq } )` ) ;
142- } catch ( err ) {
143- logger . error ( 'Failed to broadcast patches' , err ) ;
144- }
169+ } ,
170+ context : `patch batch seq=${ seq } ` ,
171+ } ) ;
172+ logger . debug ( `Broadcasted ${ batch . length } patches (seq=${ seq } )` ) ;
145173 }
146174
147175 channel
@@ -177,6 +205,7 @@ export function useRealtimeGameSync() {
177205 useStore . getState ( ) . setRealtimeSyncConnected ( status === 'SUBSCRIBED' ) ;
178206
179207 if ( status === 'SUBSCRIBED' ) {
208+ reconnectAttemptsRef . current = 0 ;
180209 const user = session . user ;
181210 await channel . track ( {
182211 senderId : SENDER_ID ,
@@ -189,6 +218,12 @@ export function useRealtimeGameSync() {
189218 factoryId : factoryIdRef . current ,
190219 } satisfies PresencePayload ) ;
191220 doRequestFullState ( ) ;
221+ } else if (
222+ status === 'CHANNEL_ERROR' ||
223+ status === 'CLOSED' ||
224+ status === 'TIMED_OUT'
225+ ) {
226+ scheduleReconnect ( ) ;
192227 }
193228 } ) ;
194229
@@ -222,6 +257,7 @@ export function useRealtimeGameSync() {
222257 } ) ;
223258
224259 return ( ) => {
260+ isCleaningUp = true ;
225261 window . removeEventListener ( 'beforeunload' , onBeforeUnload ) ;
226262 unsubscribePatches ( ) ;
227263 if ( flushTimer !== null ) {
@@ -245,6 +281,10 @@ export function useRealtimeGameSync() {
245281 clearTimeout ( timers . dbFallback ) ;
246282 timers . dbFallback = null ;
247283 }
284+ if ( reconnectTimer !== null ) {
285+ clearTimeout ( reconnectTimer ) ;
286+ reconnectTimer = null ;
287+ }
248288
249289 if ( channelRef . current ) {
250290 logger . info ( `Leaving realtime channel: ${ channelName } ` ) ;
@@ -255,5 +295,5 @@ export function useRealtimeGameSync() {
255295 useStore . getState ( ) . setRealtimeSyncConnected ( false ) ;
256296 useStore . getState ( ) . clearPeers ( ) ;
257297 } ;
258- } , [ session , savedId , selectedGameId ] ) ;
298+ } , [ session , savedId , selectedGameId , reconnectEpoch ] ) ;
259299}
0 commit comments