Skip to content

Commit fb866f7

Browse files
Merge pull request #69 from FluxStackCore/claude/review-latest-pr-gxUMG
Improve singleton connection handling and prevent state recursion
2 parents a2d7e30 + 5686a63 commit fb866f7

File tree

4 files changed

+162
-92
lines changed

4 files changed

+162
-92
lines changed

app/client/src/components/AppLayout.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const routeFlameHue: Record<string, string> = {
2626
'/api-test': '90deg', // lima
2727
}
2828

29+
// Cache favicon blob URLs by hue to avoid recreating blobs on every navigation
30+
const faviconUrlCache = new Map<string, string>()
31+
2932
export function AppLayout() {
3033
const location = useLocation()
3134
const [menuOpen, setMenuOpen] = useState(false)
@@ -34,14 +37,18 @@ export function AppLayout() {
3437
const current = navItems.find(item => item.to === location.pathname)
3538
document.title = current ? `${current.label} - FluxStack` : 'FluxStack'
3639

37-
// Dynamic favicon with hue-rotate
40+
// Dynamic favicon with hue-rotate (cached per hue value)
3841
const hue = routeFlameHue[location.pathname] || '0deg'
39-
const colored = faviconSvg.replace(
40-
'<svg ',
41-
`<svg style="filter: hue-rotate(${hue})" `
42-
)
43-
const blob = new Blob([colored], { type: 'image/svg+xml' })
44-
const url = URL.createObjectURL(blob)
42+
let url = faviconUrlCache.get(hue)
43+
if (!url) {
44+
const colored = faviconSvg.replace(
45+
'<svg ',
46+
`<svg style="filter: hue-rotate(${hue})" `
47+
)
48+
const blob = new Blob([colored], { type: 'image/svg+xml' })
49+
url = URL.createObjectURL(blob)
50+
faviconUrlCache.set(hue, url)
51+
}
4552
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
4653
if (!link) {
4754
link = document.createElement('link')
@@ -50,7 +57,6 @@ export function AppLayout() {
5057
}
5158
link.type = 'image/svg+xml'
5259
link.href = url
53-
return () => URL.revokeObjectURL(url)
5460
}, [location.pathname])
5561

5662
return (

core/server/live/ComponentRegistry.ts

Lines changed: 57 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { ANONYMOUS_CONTEXT } from './auth/LiveAuthContext'
1515
import type { LiveComponentAuth, LiveActionAuthMap } from './auth/types'
1616
import { liveLog, registerComponentLogging, unregisterComponentLogging } from './LiveLogger'
1717
import { 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
}

core/types/types.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { liveRoomManager } from '@core/server/live/LiveRoomManager'
55
import { ANONYMOUS_CONTEXT } from '@core/server/live/auth/LiveAuthContext'
66
import { liveLog, liveWarn } from '@core/server/live/LiveLogger'
77

8+
/** @internal Symbol key for singleton emit override — prevents accidental access from userland code */
9+
export const EMIT_OVERRIDE_KEY = Symbol.for('fluxstack:emitOverride')
10+
811
// ===== Debug Instrumentation (injectable to avoid client-side import) =====
912
// The real debugger is injected by ComponentRegistry at server startup.
1013
// This avoids importing server-only LiveDebugger.ts from this shared types file.
@@ -340,8 +343,11 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
340343
// Cached room handles
341344
private roomHandles: Map<string, ServerRoomHandle> = new Map()
342345

343-
// Internal: emit override for singleton broadcasting (injected by ComponentRegistry)
344-
private _emitOverride: ((type: string, payload: any) => void) | null = null
346+
// Guard against infinite recursion in onStateChange
347+
private _inStateChange = false
348+
349+
// Guard: tracks whether we're inside onStateChange to prevent infinite recursion
350+
// Internal: emit override for singleton broadcasting is stored via EMIT_OVERRIDE_KEY symbol
345351

346352
constructor(initialState: Partial<TState>, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) {
347353
this.id = this.generateId()
@@ -404,8 +410,11 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
404410
const changes = { [prop]: value } as Partial<TState>
405411
// Delta sync - send only the changed property
406412
self.emit('STATE_DELTA', { delta: changes })
407-
// Lifecycle hook: onStateChange
408-
try { self.onStateChange(changes) } catch {}
413+
// Lifecycle hook: onStateChange (with recursion guard)
414+
if (!self._inStateChange) {
415+
self._inStateChange = true
416+
try { self.onStateChange(changes) } catch {} finally { self._inStateChange = false }
417+
}
409418
// Debug: track proxy mutation
410419
_liveDebugger?.trackStateChange(
411420
self.id,
@@ -633,10 +642,8 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
633642
// 🔗 Singleton Support (internal)
634643
// ========================================
635644

636-
/** @internal Used by ComponentRegistry to override emit for singleton broadcasting */
637-
public _setEmitOverride(fn: ((type: string, payload: any) => void) | null): void {
638-
this._emitOverride = fn
639-
}
645+
/** @internal Used by ComponentRegistry to override emit for singleton broadcasting (Symbol-keyed) */
646+
public [EMIT_OVERRIDE_KEY]: ((type: string, payload: any) => void) | null = null
640647

641648
// ========================================
642649
// 🔄 Lifecycle Hooks
@@ -754,8 +761,11 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
754761
Object.assign(this._state as object, newUpdates)
755762
// Delta sync - send only the changed properties
756763
this.emit('STATE_DELTA', { delta: newUpdates })
757-
// Lifecycle hook: onStateChange
758-
try { this.onStateChange(newUpdates) } catch {}
764+
// Lifecycle hook: onStateChange (with recursion guard)
765+
if (!this._inStateChange) {
766+
this._inStateChange = true
767+
try { this.onStateChange(newUpdates) } catch {} finally { this._inStateChange = false }
768+
}
759769
// Debug: track state change
760770
_liveDebugger?.trackStateChange(
761771
this.id,
@@ -799,8 +809,8 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
799809
'$private', '_privateState',
800810
// HMR persistence
801811
'$persistent',
802-
// Singleton internals
803-
'_setEmitOverride', '_emitOverride',
812+
// Singleton internals (Symbol-keyed, but block string equivalents too)
813+
'_inStateChange',
804814
// Room internals
805815
'$room', '$rooms', 'subscribeToRoom', 'unsubscribeFromRoom',
806816
'emitRoomEvent', 'onRoomEvent', 'emitRoomEventWithState',
@@ -882,9 +892,10 @@ export abstract class LiveComponent<TState = ComponentState, TPrivate extends Re
882892

883893
// Send message to client (or all clients for singletons)
884894
protected emit(type: string, payload: any) {
885-
// Singleton override: broadcast to all connections
886-
if (this._emitOverride) {
887-
this._emitOverride(type, payload)
895+
// Singleton override: broadcast to all connections (via Symbol key)
896+
const override = this[EMIT_OVERRIDE_KEY]
897+
if (override) {
898+
override(type, payload)
888899
return
889900
}
890901

0 commit comments

Comments
 (0)