@@ -15,7 +15,7 @@ import { ANONYMOUS_CONTEXT } from './auth/LiveAuthContext'
1515import type { LiveComponentAuth , LiveActionAuthMap } from './auth/types'
1616import { liveLog , registerComponentLogging , unregisterComponentLogging } from './LiveLogger'
1717import { liveDebugger } from './LiveDebugger'
18- import { _setLiveDebugger } from '@core/types/types'
18+ import { _setLiveDebugger , EMIT_OVERRIDE_KEY } from '@core/types/types'
1919
2020// Inject debugger into types.ts (server-only) so LiveComponent class can use it
2121// without importing the server-side LiveDebugger module directly
@@ -290,7 +290,7 @@ export class ComponentRegistry {
290290 const existing = this . singletons . get ( componentName )
291291 if ( existing ) {
292292 // Add this ws connection to the singleton
293- const connId = ws . data ?. connectionId || `ws-${ Date . now ( ) } `
293+ const connId = ws . data ?. connectionId || `ws-${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } `
294294 existing . connections . set ( connId , ws )
295295
296296 // Initialize WebSocket data if needed
@@ -367,13 +367,13 @@ export class ComponentRegistry {
367367
368368 // 🔗 Register singleton with broadcast emit
369369 if ( isSingleton ) {
370- const connId = ws . data . connectionId || `ws-${ Date . now ( ) } `
370+ const connId = ws . data . connectionId || `ws-${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } `
371371 const connections = new Map < string , FluxStackWebSocket > ( )
372372 connections . set ( connId , ws )
373373 this . singletons . set ( componentName , { instance : component , connections } )
374374
375- // Override emit to broadcast to all connections
376- ; ( component as any ) . _setEmitOverride ( ( type : string , payload : any ) => {
375+ // Override emit to broadcast to all connections (via Symbol key)
376+ ; ( component as any ) [ EMIT_OVERRIDE_KEY ] = ( type : string , payload : any ) => {
377377 const message : LiveMessage = {
378378 type : type as any ,
379379 componentId : component . id ,
@@ -383,11 +383,19 @@ export class ComponentRegistry {
383383 const serialized = JSON . stringify ( message )
384384 const singleton = this . singletons . get ( componentName )
385385 if ( singleton ) {
386- for ( const [ , connWs ] of singleton . connections ) {
387- try { connWs . send ( serialized ) } catch { }
386+ const deadConnections : string [ ] = [ ]
387+ for ( const [ connId , connWs ] of singleton . connections ) {
388+ try { connWs . send ( serialized ) } catch {
389+ deadConnections . push ( connId )
390+ }
391+ }
392+ // Remove dead connections
393+ for ( const connId of deadConnections ) {
394+ singleton . connections . delete ( connId )
395+ liveLog ( 'lifecycle' , component . id , `🔗 Singleton '${ componentName } ' — removed dead connection '${ connId } '` )
388396 }
389397 }
390- } )
398+ }
391399
392400 liveLog ( 'lifecycle' , component . id , `🔗 Singleton '${ componentName } ' created` )
393401 }
@@ -428,6 +436,11 @@ export class ComponentRegistry {
428436 await ( component as any ) . onMount ( )
429437 } catch ( err : any ) {
430438 console . error ( `[${ componentName } ] onMount error:` , err ?. message || err )
439+ // Notify client that mount initialization failed
440+ ; ( component as any ) . emit ( 'ERROR' , {
441+ action : 'onMount' ,
442+ error : `Mount initialization failed: ${ err ?. message || err } `
443+ } )
431444 }
432445
433446 // Debug: track component mount
@@ -597,6 +610,10 @@ export class ComponentRegistry {
597610 await ( component as any ) . onMount ( )
598611 } catch ( err : any ) {
599612 console . error ( `[${ componentName } ] onMount error (rehydration):` , err ?. message || err )
613+ ; ( component as any ) . emit ( 'ERROR' , {
614+ action : 'onMount' ,
615+ error : `Mount initialization failed (rehydration): ${ err ?. message || err } `
616+ } )
600617 }
601618
602619 return {
@@ -620,7 +637,7 @@ export class ComponentRegistry {
620637 }
621638 if ( ! ws . data ) {
622639 ( ws as { data : FluxStackWSData } ) . data = {
623- connectionId : `ws-${ Date . now ( ) } ` ,
640+ connectionId : `ws-${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ,
624641 components : new Map ( ) ,
625642 subscriptions : new Set ( ) ,
626643 connectedAt : new Date ( ) ,
@@ -632,37 +649,41 @@ export class ComponentRegistry {
632649 }
633650 }
634651
652+ /**
653+ * Remove a single connection from a singleton. If no connections remain, destroy it.
654+ * @returns true if the component was a singleton (handled), false otherwise
655+ */
656+ private removeSingletonConnection ( componentId : string , connId ?: string , context = 'unmount' ) : boolean {
657+ for ( const [ name , singleton ] of this . singletons ) {
658+ if ( singleton . instance . id !== componentId ) continue
659+
660+ if ( connId ) singleton . connections . delete ( connId )
661+
662+ if ( singleton . connections . size === 0 ) {
663+ // Last connection gone — destroy singleton fully
664+ this . cleanupComponent ( componentId )
665+ this . singletons . delete ( name )
666+ liveLog ( 'lifecycle' , componentId , `🗑️ Singleton '${ name } ' destroyed (${ context } : no connections remaining)` )
667+ } else {
668+ liveLog ( 'lifecycle' , componentId , `🔗 Singleton '${ name } ' — connection removed via ${ context } (${ singleton . connections . size } remaining)` )
669+ }
670+ return true
671+ }
672+ return false
673+ }
674+
635675 // Unmount component (with singleton awareness)
636676 async unmountComponent ( componentId : string , ws ?: FluxStackWebSocket ) {
637677 const component = this . components . get ( componentId )
638678 if ( ! component ) return
639679
640680 // 🔗 Singleton: remove connection, only destroy when last client leaves
641- for ( const [ name , singleton ] of this . singletons ) {
642- if ( singleton . instance . id === componentId ) {
643- if ( ws ) {
644- const connId = ws . data ?. connectionId
645- if ( connId ) singleton . connections . delete ( connId )
646- ws . data ?. components ?. delete ( componentId )
647- }
648-
649- if ( singleton . connections . size === 0 ) {
650- // Last connection gone — destroy singleton
651- liveDebugger . trackComponentUnmount ( componentId )
652- component . destroy ?.( )
653- this . unsubscribeFromAllRooms ( componentId )
654- this . components . delete ( componentId )
655- this . metadata . delete ( componentId )
656- this . wsConnections . delete ( componentId )
657- this . singletons . delete ( name )
658- performanceMonitor . removeComponent ( componentId )
659- unregisterComponentLogging ( componentId )
660- liveLog ( 'lifecycle' , componentId , `🗑️ Singleton '${ name } ' destroyed (last connection unmounted)` )
661- } else {
662- liveLog ( 'lifecycle' , componentId , `🔗 Singleton '${ name } ' — connection removed (${ singleton . connections . size } remaining)` )
663- }
664- return
665- }
681+ if ( ws ) {
682+ const connId = ws . data ?. connectionId
683+ ws . data ?. components ?. delete ( componentId )
684+ if ( this . removeSingletonConnection ( componentId , connId , 'unmount' ) ) return
685+ } else {
686+ if ( this . removeSingletonConnection ( componentId , undefined , 'unmount' ) ) return
666687 }
667688
668689 // Non-singleton: normal unmount
@@ -905,28 +926,8 @@ export class ComponentRegistry {
905926 }
906927 }
907928
908- // Check if this is a singleton
909- let isSingleton = false
910- for ( const [ name , singleton ] of this . singletons ) {
911- if ( singleton . instance . id === componentId ) {
912- // Remove this connection from the singleton
913- if ( connId ) singleton . connections . delete ( connId )
914-
915- if ( singleton . connections . size === 0 ) {
916- // Last connection — destroy singleton
917- this . cleanupComponent ( componentId )
918- this . singletons . delete ( name )
919- liveLog ( 'lifecycle' , componentId , `🔗 Singleton '${ name } ' destroyed (no more connections)` )
920- } else {
921- liveLog ( 'lifecycle' , componentId , `🔗 Singleton '${ name } ' — connection left (${ singleton . connections . size } remaining)` )
922- }
923-
924- isSingleton = true
925- break
926- }
927- }
928-
929- if ( ! isSingleton ) {
929+ // Singleton-aware cleanup via shared helper
930+ if ( ! this . removeSingletonConnection ( componentId , connId || undefined , 'disconnect' ) ) {
930931 this . cleanupComponent ( componentId )
931932 }
932933 }
0 commit comments