@@ -413,7 +413,9 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
413413 // Lifecycle hook: onStateChange (with recursion guard)
414414 if ( ! self . _inStateChange ) {
415415 self . _inStateChange = true
416- try { self . onStateChange ( changes ) } catch { } finally { self . _inStateChange = false }
416+ try { self . onStateChange ( changes ) } catch ( err : any ) {
417+ console . error ( `[${ self . id } ] onStateChange error:` , err ?. message || err )
418+ } finally { self . _inStateChange = false }
417419 }
418420 // Debug: track proxy mutation
419421 _liveDebugger ?. trackStateChange (
@@ -490,14 +492,18 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
490492 if ( self . joinedRooms . has ( roomId ) ) return
491493 self . joinedRooms . add ( roomId )
492494 liveRoomManager . joinRoom ( self . id , roomId , self . ws , initialState )
493- try { self . onRoomJoin ( roomId ) } catch { }
495+ try { self . onRoomJoin ( roomId ) } catch ( err : any ) {
496+ console . error ( `[${ self . id } ] onRoomJoin error:` , err ?. message || err )
497+ }
494498 } ,
495499
496500 leave : ( ) => {
497501 if ( ! self . joinedRooms . has ( roomId ) ) return
498502 self . joinedRooms . delete ( roomId )
499503 liveRoomManager . leaveRoom ( self . id , roomId )
500- try { self . onRoomLeave ( roomId ) } catch { }
504+ try { self . onRoomLeave ( roomId ) } catch ( err : any ) {
505+ console . error ( `[${ self . id } ] onRoomLeave error:` , err ?. message || err )
506+ }
501507 } ,
502508
503509 emit : ( event : string , data : any ) : number => {
@@ -755,21 +761,68 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
755761 */
756762 protected onAction ( action : string , payload : any ) : void | false | Promise < void | false > { }
757763
764+ /**
765+ * [Singleton only] Called when a new client connection joins the singleton.
766+ * Fires for EVERY new client including the first.
767+ * Use for visitor counting, presence tracking, etc.
768+ *
769+ * @param connectionId - The connection identifier of the new client
770+ * @param connectionCount - Total number of active connections after join
771+ *
772+ * @example
773+ * protected onClientJoin(connectionId: string, connectionCount: number) {
774+ * this.state.visitors = connectionCount
775+ * }
776+ */
777+ protected onClientJoin ( connectionId : string , connectionCount : number ) : void { }
778+
779+ /**
780+ * [Singleton only] Called when a client disconnects from the singleton.
781+ * Fires for EVERY leaving client. Use for presence tracking, cleanup.
782+ *
783+ * @param connectionId - The connection identifier of the leaving client
784+ * @param connectionCount - Total number of active connections after leave
785+ *
786+ * @example
787+ * protected onClientLeave(connectionId: string, connectionCount: number) {
788+ * this.state.visitors = connectionCount
789+ * if (connectionCount === 0) {
790+ * // Last client left — save state or cleanup
791+ * }
792+ * }
793+ */
794+ protected onClientLeave ( connectionId : string , connectionCount : number ) : void { }
795+
758796 // State management (batch update - single emit with delta)
759797 public setState ( updates : Partial < TState > | ( ( prev : TState ) => Partial < TState > ) ) {
760798 const newUpdates = typeof updates === 'function' ? updates ( this . _state ) : updates
761- Object . assign ( this . _state as object , newUpdates )
762- // Delta sync - send only the changed properties
763- this . emit ( 'STATE_DELTA' , { delta : newUpdates } )
799+
800+ // Filter to only keys that actually changed (consistent with proxy behavior)
801+ const actualChanges : Partial < TState > = { } as Partial < TState >
802+ let hasChanges = false
803+ for ( const key of Object . keys ( newUpdates as object ) as Array < keyof TState > ) {
804+ if ( ( this . _state as any ) [ key ] !== ( newUpdates as any ) [ key ] ) {
805+ ( actualChanges as any ) [ key ] = ( newUpdates as any ) [ key ]
806+ hasChanges = true
807+ }
808+ }
809+
810+ if ( ! hasChanges ) return // No-op: nothing actually changed
811+
812+ Object . assign ( this . _state as object , actualChanges )
813+ // Delta sync - send only the actually changed properties
814+ this . emit ( 'STATE_DELTA' , { delta : actualChanges } )
764815 // Lifecycle hook: onStateChange (with recursion guard)
765816 if ( ! this . _inStateChange ) {
766817 this . _inStateChange = true
767- try { this . onStateChange ( newUpdates ) } catch { } finally { this . _inStateChange = false }
818+ try { this . onStateChange ( actualChanges ) } catch ( err : any ) {
819+ console . error ( `[${ this . id } ] onStateChange error:` , err ?. message || err )
820+ } finally { this . _inStateChange = false }
768821 }
769822 // Debug: track state change
770823 _liveDebugger ?. trackStateChange (
771824 this . id ,
772- newUpdates as Record < string , unknown > ,
825+ actualChanges as Record < string , unknown > ,
773826 this . _state as Record < string , unknown > ,
774827 'setState'
775828 )
@@ -800,6 +853,7 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
800853 'onMount' , 'onDestroy' , 'onConnect' , 'onDisconnect' ,
801854 'onStateChange' , 'onRoomJoin' , 'onRoomLeave' ,
802855 'onRehydrate' , 'onAction' ,
856+ 'onClientJoin' , 'onClientLeave' ,
803857 // State management internals
804858 'setState' , 'emit' , 'broadcast' , 'broadcastToRoom' ,
805859 'createStateProxy' , 'createDirectStateAccessors' , 'generateId' ,
@@ -866,9 +920,23 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
866920 _liveDebugger ?. trackActionCall ( this . id , action , payload )
867921
868922 // Lifecycle hook: onAction (return false to cancel)
869- const hookResult = await this . onAction ( action , payload )
923+ let hookResult : void | false | Promise < void | false >
924+ try {
925+ hookResult = await this . onAction ( action , payload )
926+ } catch ( hookError : any ) {
927+ // If onAction itself threw, treat as action error
928+ // but don't leak hook internals to the client
929+ _liveDebugger ?. trackActionError ( this . id , action , hookError . message , Date . now ( ) - actionStart )
930+ this . emit ( 'ERROR' , {
931+ action,
932+ error : `Action '${ action } ' failed pre-validation`
933+ } )
934+ throw hookError
935+ }
870936 if ( hookResult === false ) {
871- throw new Error ( `Action '${ action } ' cancelled by onAction hook` )
937+ // Cancelled actions are NOT errors — do not emit ERROR to client
938+ _liveDebugger ?. trackActionError ( this . id , action , 'Action cancelled' , Date . now ( ) - actionStart )
939+ throw new Error ( `Action '${ action } ' was cancelled` )
872940 }
873941
874942 // Execute method
@@ -879,13 +947,15 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
879947
880948 return result
881949 } catch ( error : any ) {
882- // Debug: track action error
883- _liveDebugger ?. trackActionError ( this . id , action , error . message , Date . now ( ) - actionStart )
950+ // Debug: track action error (avoid double-tracking for onAction errors)
951+ if ( ! error . message ?. includes ( 'was cancelled' ) && ! error . message ?. includes ( 'pre-validation' ) ) {
952+ _liveDebugger ?. trackActionError ( this . id , action , error . message , Date . now ( ) - actionStart )
884953
885- this . emit ( 'ERROR' , {
886- action,
887- error : error . message
888- } )
954+ this . emit ( 'ERROR' , {
955+ action,
956+ error : error . message
957+ } )
958+ }
889959 throw error
890960 }
891961 }
0 commit comments