Skip to content

Commit 09c02ac

Browse files
Merge pull request #70 from FluxStackCore/claude/review-recent-prs-Zj9ue
Review and analyze last two pull requests
2 parents fb866f7 + 856c48f commit 09c02ac

File tree

4 files changed

+691
-23
lines changed

4 files changed

+691
-23
lines changed

core/server/live/ComponentRegistry.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ export class ComponentRegistry {
312312

313313
liveLog('lifecycle', existing.instance.id, `🔗 Singleton '${componentName}' — new connection joined (${existing.connections.size} total)`)
314314

315+
// 🔄 Lifecycle: notify singleton about new client
316+
try { (existing.instance as any).onClientJoin(connId, existing.connections.size) } catch (err: any) {
317+
console.error(`[${componentName}] onClientJoin error:`, err?.message || err)
318+
}
319+
315320
return {
316321
componentId: existing.instance.id,
317322
initialState: existing.instance.getSerializableState(),
@@ -378,7 +383,9 @@ export class ComponentRegistry {
378383
type: type as any,
379384
componentId: component.id,
380385
payload,
381-
timestamp: Date.now()
386+
timestamp: Date.now(),
387+
userId: component.userId,
388+
room: component.room
382389
}
383390
const serialized = JSON.stringify(message)
384391
const singleton = this.singletons.get(componentName)
@@ -398,6 +405,11 @@ export class ComponentRegistry {
398405
}
399406

400407
liveLog('lifecycle', component.id, `🔗 Singleton '${componentName}' created`)
408+
409+
// 🔄 Lifecycle: notify singleton about first client
410+
try { (component as any).onClientJoin(connId, 1) } catch (err: any) {
411+
console.error(`[${componentName}] onClientJoin error:`, err?.message || err)
412+
}
401413
}
402414

403415
// Update metadata state
@@ -649,6 +661,22 @@ export class ComponentRegistry {
649661
}
650662
}
651663

664+
/** Check if a component ID belongs to a singleton */
665+
private isSingletonComponent(componentId: string): boolean {
666+
for (const [, singleton] of this.singletons) {
667+
if (singleton.instance.id === componentId) return true
668+
}
669+
return false
670+
}
671+
672+
/** Get the singleton entry by component ID */
673+
private getSingletonByComponentId(componentId: string): { instance: LiveComponent; connections: Map<string, FluxStackWebSocket> } | null {
674+
for (const [, singleton] of this.singletons) {
675+
if (singleton.instance.id === componentId) return singleton
676+
}
677+
return null
678+
}
679+
652680
/**
653681
* Remove a single connection from a singleton. If no connections remain, destroy it.
654682
* @returns true if the component was a singleton (handled), false otherwise
@@ -660,7 +688,10 @@ export class ComponentRegistry {
660688
if (connId) singleton.connections.delete(connId)
661689

662690
if (singleton.connections.size === 0) {
663-
// Last connection gone — destroy singleton fully
691+
// Last connection gone — call onDisconnect then destroy singleton fully
692+
try { (singleton.instance as any).onDisconnect() } catch (err: any) {
693+
console.error(`[${componentId}] onDisconnect error:`, err?.message || err)
694+
}
664695
this.cleanupComponent(componentId)
665696
this.singletons.delete(name)
666697
liveLog('lifecycle', componentId, `🗑️ Singleton '${name}' destroyed (${context}: no connections remaining)`)
@@ -681,6 +712,16 @@ export class ComponentRegistry {
681712
if (ws) {
682713
const connId = ws.data?.connectionId
683714
ws.data?.components?.delete(componentId)
715+
716+
// Notify singleton about client leaving (before connection removal)
717+
if (this.isSingletonComponent(componentId)) {
718+
const singleton = this.getSingletonByComponentId(componentId)
719+
const remainingAfterRemoval = singleton ? singleton.connections.size - 1 : 0
720+
try { (component as any).onClientLeave(connId || 'unknown', Math.max(0, remainingAfterRemoval)) } catch (err: any) {
721+
console.error(`[${componentId}] onClientLeave error:`, err?.message || err)
722+
}
723+
}
724+
684725
if (this.removeSingletonConnection(componentId, connId, 'unmount')) return
685726
} else {
686727
if (this.removeSingletonConnection(componentId, undefined, 'unmount')) return
@@ -918,9 +959,21 @@ export class ComponentRegistry {
918959
liveLog('lifecycle', null, `🧹 Cleaning up ${componentsToCleanup.length} components for disconnected WebSocket`)
919960

920961
for (const componentId of componentsToCleanup) {
921-
// Call onDisconnect lifecycle hook (only fires on connection loss, not intentional unmount)
922962
const component = this.components.get(componentId)
923-
if (component) {
963+
964+
// Check if this is a singleton component
965+
const isSingleton = this.isSingletonComponent(componentId)
966+
967+
if (component && isSingleton) {
968+
// For singletons: call onClientLeave (per-connection) instead of onDisconnect
969+
// onDisconnect only fires when the last client leaves (handled in removeSingletonConnection)
970+
const singleton = this.getSingletonByComponentId(componentId)
971+
const remainingAfterRemoval = singleton ? singleton.connections.size - 1 : 0
972+
try { (component as any).onClientLeave(connId || 'unknown', Math.max(0, remainingAfterRemoval)) } catch (err: any) {
973+
console.error(`[${componentId}] onClientLeave error:`, err?.message || err)
974+
}
975+
} else if (component) {
976+
// Non-singleton: call onDisconnect as before
924977
try { (component as any).onDisconnect() } catch (err: any) {
925978
console.error(`[${componentId}] onDisconnect error:`, err?.message || err)
926979
}

core/types/types.ts

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)