From 578eac19fc7275b1d5e62111268d49a5c849b090 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 15:16:45 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20enhance=20LiveComponent=20DX=20?= =?UTF-8?q?=E2=80=94=20lifecycle=20hooks,=20HMR=20persistence,=20singleton?= =?UTF-8?q?s,=20better=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Lifecycle hooks: onMount() (async) and onDestroy() (sync) replace constructor/destroy workarounds. onMount is called by the registry after rooms, auth, and DI are ready. onDestroy is called before internal cleanup in destroy(). 2. HMR persistence: `static persistent` + `this.$persistent` stores data in globalThis that survives hot module reloads. Each component class has its own namespace. 3. Singleton components: `static singleton = true` creates one shared server-side instance. All clients see the same state. State changes broadcast to every connected WebSocket. Singletons are destroyed when the last client disconnects. 4. Better publicActions errors: When a method exists on the component but isn't listed in publicActions, the error now says exactly what to fix: "Add it to: static publicActions = [..., 'methodName']" All new internals (onMount, onDestroy, $persistent, _setEmitOverride) are added to BLOCKED_ACTIONS for security. 23 new tests, 190 total passing, 0 TypeScript errors. https://claude.ai/code/session_01V12t1cDiYmAvDsMJsYaLys --- LLMD/resources/live-components.md | 148 ++++- core/server/live/ComponentRegistry.ts | 224 ++++++-- core/types/types.ts | 131 ++++- tests/unit/core/live-component-dx.test.ts | 526 ++++++++++++++++++ .../unit/core/live-component-security.test.ts | 6 +- 5 files changed, 969 insertions(+), 66 deletions(-) create mode 100644 tests/unit/core/live-component-dx.test.ts diff --git a/LLMD/resources/live-components.md b/LLMD/resources/live-components.md index d5236ff..663caa7 100644 --- a/LLMD/resources/live-components.md +++ b/LLMD/resources/live-components.md @@ -1,12 +1,16 @@ # Live Components -**Version:** 1.13.0 | **Updated:** 2025-02-09 +**Version:** 1.14.0 | **Updated:** 2025-02-27 ## Quick Facts - Server-side state management with WebSocket sync - **Direct state access** - `this.count++` auto-syncs (v1.13.0) +- **Lifecycle hooks** - `onMount()` / `onDestroy()` for proper initialization and cleanup (v1.14.0) +- **HMR persistence** - `static persistent` + `this.$persistent` survives hot reloads (v1.14.0) +- **Singleton components** - `static singleton = true` for shared server-side instances (v1.14.0) - **Mandatory `publicActions`** - Only whitelisted methods are callable from client (secure by default) +- **Helpful error messages** - Forgotten `publicActions` entries show exactly what to fix (v1.14.0) - Automatic state persistence and re-hydration (with anti-replay nonces) - Room-based event system for multi-user sync - Type-safe client-server communication (FluxStackWebSocket) @@ -53,6 +57,13 @@ export class LiveCounter extends LiveComponent } ``` +### Key Changes in v1.14.0 + +1. **Lifecycle hooks** - `onMount()` (async) and `onDestroy()` (sync) replace constructor/destroy workarounds +2. **HMR persistence** - `static persistent` + `this.$persistent` for data that survives hot module reloads +3. **Singleton components** - `static singleton = true` for shared state across all connected clients +4. **Better publicActions errors** - Clear message when a method exists but is missing from `publicActions` + ### Key Changes in v1.13.0 1. **Direct state access** - `this.count++` instead of `this.state.count++` @@ -111,27 +122,136 @@ export class LiveCounter extends LiveComponent } ``` -## Lifecycle Methods +## Lifecycle Hooks (v1.14.0) + +Use `onMount()` and `onDestroy()` instead of constructor workarounds: ```typescript export class MyComponent extends LiveComponent { static componentName = 'MyComponent' + static publicActions = ['doWork'] as const + static defaultState = { users: [] as string[], ready: false } + + private _pollTimer?: NodeJS.Timeout + + // Called AFTER component is fully mounted (rooms, auth, injections ready) + // Can be async! + protected async onMount() { + this.$room.join() + this.$room.on('user:joined', (user) => { + this.state.users = [...this.state.users, user] + }) + + // Async init is fine + const data = await fetchInitialData(this.$auth.user?.id) + this.state.ready = true + + this._pollTimer = setInterval(() => this.poll(), 5000) + } + + // Called BEFORE internal cleanup (sync only) + protected onDestroy() { + clearInterval(this._pollTimer) + this.externalConnection?.close() + } + + async doWork() { /* ... */ } + private poll() { /* ... */ } +} +``` + +**Rules:** +- `onMount()` — can be async, called after rooms/auth/DI are ready +- `onDestroy()` — sync only, called before internal cleanup in `destroy()` +- Constructor is still needed ONLY for room event subscriptions (`this.onRoomEvent`) +- `onDestroy` errors are caught and logged — they never prevent cleanup + +## HMR Persistence (v1.14.0) + +Data in `static persistent` survives Hot Module Replacement reloads via `globalThis`: + +```typescript +export class LiveMigration extends LiveComponent { + static componentName = 'LiveMigration' + static publicActions = ['runMigration'] as const + static defaultState = { status: 'idle', lastResult: '' } + + // Define shape and defaults for persistent data + static persistent = { + cache: {} as Record, + runCount: 0 + } + + protected onMount() { + this.$persistent.runCount++ + console.log(`Mount #${this.$persistent.runCount}`) // Survives HMR! + } + + async runMigration(payload: { key: string }) { + // Check HMR-safe cache + if (this.$persistent.cache[payload.key]) { + return { cached: true, result: this.$persistent.cache[payload.key] } + } + + const result = await expensiveComputation(payload.key) + this.$persistent.cache[payload.key] = result + this.state.lastResult = result + return { cached: false, result } + } +} +``` + +**Key facts:** +- `this.$persistent` reads from `globalThis.__fluxstack_persistent_{ComponentName}` +- Each component class has its own namespace +- Defaults come from `static persistent` — initialized once, then persisted +- Not sent to client — server-only +- `$persistent` is in BLOCKED_ACTIONS (can't be called from client) + +## Singleton Components (v1.14.0) + +When `static singleton = true`, only ONE server-side instance exists. All clients share the same state: + +```typescript +export class LiveDashboard extends LiveComponent { + static componentName = 'LiveDashboard' + static singleton = true // All clients share this instance + static publicActions = ['refresh', 'addAlert'] as const static defaultState = { - // Define state here + visitors: 0, + alerts: [] as string[], + lastRefresh: '' } - // Constructor ONLY needed if: - // - Subscribing to room events - // - Custom initialization logic - // Otherwise, omit it entirely! + protected async onMount() { + this.state.visitors++ + this.state.lastRefresh = new Date().toISOString() + } - destroy() { - // Cleanup subscriptions, timers, etc. - super.destroy() + async refresh() { + const data = await fetchDashboardData() + this.setState(data) // Broadcasts to ALL connected clients + return { success: true } + } + + async addAlert(payload: { message: string }) { + this.state.alerts = [...this.state.alerts, payload.message] + // All clients see the new alert instantly + return { success: true } } } ``` +**How it works:** +- First client to mount creates the singleton instance +- Subsequent clients join the existing instance and receive current state +- `emit` / `setState` / `this.state.x = y` broadcast to ALL connected WebSockets +- When a client disconnects, it's removed from the singleton's connections +- When the LAST client disconnects, the singleton is destroyed +- Stats visible at `/api/live/stats` (shows singleton connection counts) + +**Use cases:** Shared dashboards, global migration state, admin panels, live counters + ## State Management ### Reactive State Proxy (How It Works) @@ -665,19 +785,23 @@ export class MyComponent extends LiveComponent { - Define `static defaultState` inside the class - Use `typeof ClassName.defaultState` for type parameter - Use `declare` for each state property (TypeScript type hint) -- Call `super.destroy()` in destroy method if overriding +- Use `onMount()` for async initialization (rooms, auth, data fetching) +- Use `onDestroy()` for cleanup (timers, connections) — sync only - Use `emitRoomEventWithState` for state changes in rooms - Handle errors in actions (throw Error) - Add client link: `import type { Demo as _Client } from '@client/...'` +- Use `$persistent` for data that should survive HMR reloads +- Use `static singleton = true` for shared cross-client state **NEVER:** - Omit `static publicActions` (component will deny ALL remote actions) - Export separate `defaultState` constant (use static) - Create constructor just to call super() (not needed) - Forget `static componentName` (breaks minification) +- Override `destroy()` directly — use `onDestroy()` instead (v1.14.0) - Emit room events without subscribing first - Store non-serializable data in state -- Use reserved names for state properties (id, state, ws, room, userId, $room, $rooms, $private, broadcastToRoom, roomType) +- Use reserved names for state properties (id, state, ws, room, userId, $room, $rooms, $private, $persistent, broadcastToRoom, roomType) - Include `setValue` in `publicActions` unless you trust clients to modify any state key - Store sensitive data (tokens, API keys, secrets) in `state` — use `$private` instead diff --git a/core/server/live/ComponentRegistry.ts b/core/server/live/ComponentRegistry.ts index 42d972d..c984db2 100644 --- a/core/server/live/ComponentRegistry.ts +++ b/core/server/live/ComponentRegistry.ts @@ -85,6 +85,11 @@ export class ComponentRegistry { private services: ServiceContainer private healthCheckInterval!: NodeJS.Timeout private recoveryStrategies = new Map Promise>() + // Singleton components: componentName -> { instance, connections } + private singletons = new Map + }>() constructor() { this.services = this.createServiceContainer() @@ -279,6 +284,42 @@ export class ComponentRegistry { throw new Error(`AUTH_DENIED: ${authResult.reason}`) } + // 🔗 Singleton check: return existing instance if already created + const isSingleton = (ComponentClass as any).singleton === true + if (isSingleton) { + const existing = this.singletons.get(componentName) + if (existing) { + // Add this ws connection to the singleton + const connId = ws.data?.connectionId || `ws-${Date.now()}` + existing.connections.set(connId, ws) + + // Initialize WebSocket data if needed + this.ensureWsData(ws, options?.userId) + ws.data.components.set(existing.instance.id, existing.instance) + + // Send current state to the new client + const signedState = await stateSignature.signState(existing.instance.id, { + ...existing.instance.getSerializableState(), + __componentName: componentName + }, 1, { compress: true, backup: true }) + + ws.send(JSON.stringify({ + type: 'STATE_UPDATE', + componentId: existing.instance.id, + payload: { state: existing.instance.getSerializableState(), signedState }, + timestamp: Date.now() + })) + + liveLog('lifecycle', existing.instance.id, `🔗 Singleton '${componentName}' — new connection joined (${existing.connections.size} total)`) + + return { + componentId: existing.instance.id, + initialState: existing.instance.getSerializableState(), + signedState + } + } + } + // Create component instance with registry methods const component = new ComponentClass( { ...initialState, ...props }, @@ -321,28 +362,36 @@ export class ComponentRegistry { } // Initialize WebSocket data if needed - if (!ws || typeof ws !== 'object') { - throw new Error('Invalid WebSocket object provided') - } + this.ensureWsData(ws, options?.userId) + ws.data.components.set(component.id, component) - // Ensure data object exists with proper structure - if (!ws.data) { - (ws as { data: FluxStackWSData }).data = { - connectionId: `ws-${Date.now()}`, - components: new Map(), - subscriptions: new Set(), - connectedAt: new Date(), - userId: options?.userId - } - } + // 🔗 Register singleton with broadcast emit + if (isSingleton) { + const connId = ws.data.connectionId || `ws-${Date.now()}` + const connections = new Map() + connections.set(connId, ws) + this.singletons.set(componentName, { instance: component, connections }) + + // Override emit to broadcast to all connections + ;(component as any)._setEmitOverride((type: string, payload: any) => { + const message: LiveMessage = { + type: type as any, + componentId: component.id, + payload, + timestamp: Date.now() + } + const serialized = JSON.stringify(message) + const singleton = this.singletons.get(componentName) + if (singleton) { + for (const [, connWs] of singleton.connections) { + try { connWs.send(serialized) } catch {} + } + } + }) - // Ensure components map exists - if (!ws.data.components) { - ws.data.components = new Map() + liveLog('lifecycle', component.id, `🔗 Singleton '${componentName}' created`) } - ws.data.components.set(component.id, component) - // Update metadata state metadata.state = 'active' const renderTime = Date.now() - startTime @@ -357,7 +406,7 @@ export class ComponentRegistry { performanceMonitor.recordRenderTime(component.id, renderTime) liveLog('lifecycle', component.id, `🚀 Mounted component: ${componentName} (${component.id}) in ${renderTime}ms`) - + // Send initial state to client with signature (include component name for rehydration validation) const signedState = await stateSignature.signState(component.id, { ...component.getSerializableState(), @@ -366,11 +415,18 @@ export class ComponentRegistry { compress: true, backup: true }) - ;(component as any).emit('STATE_UPDATE', { + ;(component as any).emit('STATE_UPDATE', { state: component.getSerializableState(), - signedState + signedState }) + // 🔄 Call onMount lifecycle hook (after all setup is complete) + try { + await (component as any).onMount() + } catch (err: any) { + console.error(`[${componentName}] onMount error:`, err?.message || err) + } + // Debug: track component mount liveDebugger.trackComponentMount( component.id, @@ -498,20 +554,7 @@ export class ComponentRegistry { } // Initialize WebSocket data - if (!ws.data) { - (ws as { data: FluxStackWSData }).data = { - connectionId: `ws-${Date.now()}`, - components: new Map(), - subscriptions: new Set(), - connectedAt: new Date(), - userId: options?.userId - } - } - - // Ensure components map exists - if (!ws.data.components) { - ws.data.components = new Map() - } + this.ensureWsData(ws, options?.userId) ws.data.components.set(component.id, component) // Register logging config for rehydrated component @@ -540,6 +583,13 @@ export class ComponentRegistry { newComponentId: component.id }) + // 🔄 Call onMount lifecycle hook after rehydration + try { + await (component as any).onMount() + } catch (err: any) { + console.error(`[${componentName}] onMount error (rehydration):`, err?.message || err) + } + return { success: true, newComponentId: component.id @@ -554,21 +604,62 @@ export class ComponentRegistry { } } - // Unmount component - async unmountComponent(componentId: string) { + // Ensure WebSocket data object exists with proper structure + private ensureWsData(ws: FluxStackWebSocket, userId?: string): void { + if (!ws || typeof ws !== 'object') { + throw new Error('Invalid WebSocket object provided') + } + if (!ws.data) { + (ws as { data: FluxStackWSData }).data = { + connectionId: `ws-${Date.now()}`, + components: new Map(), + subscriptions: new Set(), + connectedAt: new Date(), + userId + } + } + if (!ws.data.components) { + ws.data.components = new Map() + } + } + + // Unmount component (with singleton awareness) + async unmountComponent(componentId: string, ws?: FluxStackWebSocket) { const component = this.components.get(componentId) if (!component) return - // Debug: track unmount - liveDebugger.trackComponentUnmount(componentId) + // 🔗 Singleton: remove connection, only destroy when last client leaves + for (const [name, singleton] of this.singletons) { + if (singleton.instance.id === componentId) { + if (ws) { + const connId = ws.data?.connectionId + if (connId) singleton.connections.delete(connId) + ws.data?.components?.delete(componentId) + } - // Cleanup - component.destroy?.() + if (singleton.connections.size === 0) { + // Last connection gone — destroy singleton + liveDebugger.trackComponentUnmount(componentId) + component.destroy?.() + this.unsubscribeFromAllRooms(componentId) + this.components.delete(componentId) + this.metadata.delete(componentId) + this.wsConnections.delete(componentId) + this.singletons.delete(name) + performanceMonitor.removeComponent(componentId) + unregisterComponentLogging(componentId) + liveLog('lifecycle', componentId, `🗑️ Singleton '${name}' destroyed (last connection unmounted)`) + } else { + liveLog('lifecycle', componentId, `🔗 Singleton '${name}' — connection removed (${singleton.connections.size} remaining)`) + } + return + } + } - // Remove from room subscriptions + // Non-singleton: normal unmount + liveDebugger.trackComponentUnmount(componentId) + component.destroy?.() this.unsubscribeFromAllRooms(componentId) - - // Remove from maps this.components.delete(componentId) this.wsConnections.delete(componentId) @@ -719,7 +810,7 @@ export class ComponentRegistry { return { success: true, result: mountResult } case 'COMPONENT_UNMOUNT': - await this.unmountComponent(message.componentId) + await this.unmountComponent(message.componentId, ws) return { success: true } case 'CALL_ACTION': @@ -787,16 +878,40 @@ export class ComponentRegistry { } } - // Cleanup when WebSocket disconnects + // Cleanup when WebSocket disconnects (singleton-aware) cleanupConnection(ws: FluxStackWebSocket) { if (!ws.data?.components) return const componentsToCleanup = Array.from(ws.data.components.keys()) as string[] - + const connId = ws.data.connectionId + liveLog('lifecycle', null, `🧹 Cleaning up ${componentsToCleanup.length} components for disconnected WebSocket`) - + for (const componentId of componentsToCleanup) { - this.cleanupComponent(componentId) + // Check if this is a singleton + let isSingleton = false + for (const [name, singleton] of this.singletons) { + if (singleton.instance.id === componentId) { + // Remove this connection from the singleton + if (connId) singleton.connections.delete(connId) + + if (singleton.connections.size === 0) { + // Last connection — destroy singleton + this.cleanupComponent(componentId) + this.singletons.delete(name) + liveLog('lifecycle', componentId, `🔗 Singleton '${name}' destroyed (no more connections)`) + } else { + liveLog('lifecycle', componentId, `🔗 Singleton '${name}' — connection left (${singleton.connections.size} remaining)`) + } + + isSingleton = true + break + } + } + + if (!isSingleton) { + this.cleanupComponent(componentId) + } } // Clear the WebSocket's component map @@ -812,6 +927,12 @@ export class ComponentRegistry { definitions: this.definitions.size, rooms: this.rooms.size, connections: this.wsConnections.size, + singletons: Object.fromEntries( + Array.from(this.singletons.entries()).map(([name, s]) => [ + name, + { componentId: s.instance.id, connections: s.connections.size } + ]) + ), roomDetails: Object.fromEntries( Array.from(this.rooms.entries()).map(([roomId, components]) => [ roomId, @@ -1082,7 +1203,10 @@ export class ComponentRegistry { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval) } - + + // Cleanup all singletons + this.singletons.clear() + // Cleanup all components for (const [componentId] of this.components) { this.cleanupComponent(componentId) diff --git a/core/types/types.ts b/core/types/types.ts index d79fe96..8b0e613 100644 --- a/core/types/types.ts +++ b/core/types/types.ts @@ -283,6 +283,39 @@ export abstract class LiveComponent { + * static persistent = { + * cache: {} as Record, + * runCount: 0 + * } + * + * protected async onMount() { + * this.$persistent.runCount++ + * console.log(`Mount #${this.$persistent.runCount}`) // Survives HMR! + * } + * } + */ + static persistent?: Record + + /** + * When true, only ONE server-side instance exists for this component. + * All clients share the same state — updates broadcast to every connection. + * + * @example + * class LiveDashboard extends LiveComponent { + * static singleton = true + * static componentName = 'LiveDashboard' + * // All clients see the same dashboard data + * } + */ + static singleton?: boolean + public readonly id: string private _state: TState public state: TState // Proxy wrapper @@ -307,6 +340,9 @@ export abstract class LiveComponent = new Map() + // Internal: emit override for singleton broadcasting (injected by ComponentRegistry) + private _emitOverride: ((type: string, payload: any) => void) | null = null + constructor(initialState: Partial, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) { this.id = this.generateId() // Merge defaultState with initialState - subclass defaultState takes precedence for missing fields @@ -563,6 +599,72 @@ export abstract class LiveComponent { + const ctor = this.constructor as typeof LiveComponent + const name = ctor.componentName || ctor.name + const key = `__fluxstack_persistent_${name}` + + if (!(globalThis as any)[key]) { + (globalThis as any)[key] = { ...(ctor as any).persistent || {} } + } + + return (globalThis as any)[key] + } + + // ======================================== + // 🔗 Singleton Support (internal) + // ======================================== + + /** @internal Used by ComponentRegistry to override emit for singleton broadcasting */ + public _setEmitOverride(fn: ((type: string, payload: any) => void) | null): void { + this._emitOverride = fn + } + + // ======================================== + // 🔄 Lifecycle Hooks + // ======================================== + + /** + * Called after component is fully mounted and ready. + * At this point rooms, auth context, and all injections are available. + * Override in subclass for initialization logic. + * + * @example + * protected async onMount() { + * this.$room.join() + * this.$room.on('message:new', (msg) => { + * this.state.messages = [...this.state.messages, msg] + * }) + * this.state.users = await this.fetchUsers() + * } + */ + protected onMount(): void | Promise {} + + /** + * Called before component is destroyed (sync only). + * Override in subclass for cleanup: timers, intervals, external connections. + * + * @example + * protected onDestroy() { + * clearInterval(this._pollTimer) + * this.externalConnection?.close() + * } + */ + protected onDestroy(): void {} + // State management (batch update - single emit with delta) public setState(updates: Partial | ((prev: TState) => Partial)) { const newUpdates = typeof updates === 'function' ? updates(this._state) : updates @@ -600,6 +702,7 @@ export abstract class LiveComponent = new Set([ // Lifecycle & internal 'constructor', 'destroy', 'executeAction', 'getSerializableState', + 'onMount', 'onDestroy', // State management internals 'setState', 'emit', 'broadcast', 'broadcastToRoom', 'createStateProxy', 'createDirectStateAccessors', 'generateId', @@ -607,6 +710,10 @@ export abstract class LiveComponent ({ + roomEvents: { + on: vi.fn(), + emit: vi.fn(), + off: vi.fn() + } +})) + +vi.mock('@core/server/live/LiveRoomManager', () => ({ + liveRoomManager: { + joinRoom: vi.fn(), + leaveRoom: vi.fn(), + emitToRoom: vi.fn(), + getRoomState: vi.fn(() => ({})), + setRoomState: vi.fn() + } +})) + +// Import after mocks +import { LiveComponent } from '@core/types/types' +import type { FluxStackWebSocket } from '@core/types/types' + +// ===== Test Helpers ===== + +function createMockWs(connectionId = 'test-conn'): FluxStackWebSocket { + return { + send: vi.fn(), + close: vi.fn(), + data: { + connectionId, + components: new Map(), + subscriptions: new Set(), + connectedAt: new Date() + }, + remoteAddress: '127.0.0.1', + readyState: 1 + } as unknown as FluxStackWebSocket +} + +function getLastSentMessage(ws: FluxStackWebSocket): any { + const sendMock = ws.send as ReturnType + const lastCall = sendMock.mock.calls[sendMock.mock.calls.length - 1] + return lastCall ? JSON.parse(lastCall[0]) : null +} + +// ===== Test Components ===== + +interface CounterState { + count: number + label: string +} + +// Component with lifecycle hooks +class LifecycleComponent extends LiveComponent { + static componentName = 'LifecycleComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment'] as const + + mountCalled = false + destroyCalled = false + mountOrder: string[] = [] + + protected onMount() { + this.mountCalled = true + this.mountOrder.push('onMount') + } + + protected onDestroy() { + this.destroyCalled = true + this.mountOrder.push('onDestroy') + } + + async increment() { + this.state.count++ + return { count: this.state.count } + } +} + +// Component with async onMount +class AsyncLifecycleComponent extends LiveComponent { + static componentName = 'AsyncLifecycleComponent' + static defaultState: CounterState = { count: 0, label: 'async' } + static publicActions = ['increment'] as const + + initData: string | null = null + + protected async onMount() { + // Simulate async initialization + await new Promise(resolve => setTimeout(resolve, 10)) + this.initData = 'initialized' + this.state.label = 'ready' + } + + async increment() { + this.state.count++ + return { count: this.state.count } + } +} + +// Component with HMR persistence +class PersistentComponent extends LiveComponent { + static componentName = 'PersistentComponent' + static defaultState: CounterState = { count: 0, label: 'persistent' } + static publicActions = ['increment'] as const + static persistent = { + cache: {} as Record, + runCount: 0 + } + + protected onMount() { + this.$persistent.runCount++ + } + + async increment() { + this.state.count++ + return { count: this.state.count } + } +} + +// Component with missing publicActions entry (for error message test) +class PartialActionsComponent extends LiveComponent { + static componentName = 'PartialActionsComponent' + static defaultState: CounterState = { count: 0, label: 'partial' } + static publicActions = ['increment'] as const // Missing 'decrement' + + async increment() { + this.state.count++ + return { count: this.state.count } + } + + async decrement() { + this.state.count-- + return { count: this.state.count } + } +} + +// Singleton component +class SingletonComponent extends LiveComponent { + static componentName = 'SingletonComponent' + static defaultState: CounterState = { count: 0, label: 'singleton' } + static publicActions = ['increment'] as const + static singleton = true + + async increment() { + this.state.count++ + return { count: this.state.count } + } +} + +// ============================================= +// TESTS +// ============================================= + +describe('LiveComponent DX Enhancements', () => { + + // ===== Lifecycle Hooks ===== + describe('Lifecycle Hooks', () => { + it('onMount is called and has access to component state', () => { + const ws = createMockWs() + const component = new LifecycleComponent({ count: 5, label: 'init' }, ws) + + // onMount is not called by constructor — it's called by the registry after setup + expect(component.mountCalled).toBe(false) + + // Simulate registry calling onMount + ;(component as any).onMount() + expect(component.mountCalled).toBe(true) + expect(component.state.count).toBe(5) + }) + + it('onDestroy is called during destroy()', () => { + const ws = createMockWs() + const component = new LifecycleComponent({ count: 0, label: 'test' }, ws) + + expect(component.destroyCalled).toBe(false) + + component.destroy() + expect(component.destroyCalled).toBe(true) + }) + + it('onDestroy is called before internal cleanup', () => { + const ws = createMockWs() + const component = new LifecycleComponent({ count: 0, label: 'test' }, ws) + + component.destroy() + expect(component.mountOrder).toContain('onDestroy') + }) + + it('async onMount works correctly', async () => { + const ws = createMockWs() + const component = new AsyncLifecycleComponent({ count: 0, label: 'loading' }, ws) + + expect(component.initData).toBeNull() + + // Simulate async onMount + await (component as any).onMount() + expect(component.initData).toBe('initialized') + }) + + it('onDestroy errors do not prevent cleanup', () => { + const ws = createMockWs() + + class ErrorComponent extends LiveComponent { + static componentName = 'ErrorComponent' + static defaultState: CounterState = { count: 0, label: 'error' } + + protected onDestroy() { + throw new Error('Cleanup error') + } + } + + const component = new ErrorComponent({}, ws) + + // Should not throw even though onDestroy throws + expect(() => component.destroy()).not.toThrow() + }) + + it('default onMount and onDestroy are no-ops', () => { + const ws = createMockWs() + + class BasicComponent extends LiveComponent { + static componentName = 'BasicComponent' + static defaultState: CounterState = { count: 0, label: 'basic' } + } + + const component = new BasicComponent({}, ws) + + // Should not throw + expect(() => (component as any).onMount()).not.toThrow() + expect(() => component.destroy()).not.toThrow() + }) + }) + + // ===== HMR Persistence ===== + describe('HMR Persistence', () => { + afterEach(() => { + // Clean up globalThis + delete (globalThis as any).__fluxstack_persistent_PersistentComponent + }) + + it('$persistent returns an object with defaults from static persistent', () => { + const ws = createMockWs() + const component = new PersistentComponent({}, ws) + + expect(component.$persistent).toBeDefined() + expect(component.$persistent.runCount).toBe(0) + expect(component.$persistent.cache).toEqual({}) + }) + + it('$persistent data survives across instances (simulates HMR)', () => { + const ws = createMockWs() + + // First instance + const comp1 = new PersistentComponent({}, ws) + comp1.$persistent.runCount = 5 + comp1.$persistent.cache['key1'] = 'value1' + + // Second instance (simulates HMR reload) + const comp2 = new PersistentComponent({}, ws) + + // Data should persist via globalThis + expect(comp2.$persistent.runCount).toBe(5) + expect(comp2.$persistent.cache['key1']).toBe('value1') + }) + + it('different component classes have separate persistent stores', () => { + const ws = createMockWs() + + class OtherPersistent extends LiveComponent { + static componentName = 'OtherPersistent' + static defaultState: CounterState = { count: 0, label: 'other' } + static persistent = { value: 'default' } + } + + const comp1 = new PersistentComponent({}, ws) + comp1.$persistent.runCount = 99 + + const comp2 = new OtherPersistent({}, ws) + + // Different namespace + expect(comp2.$persistent.value).toBe('default') + expect(comp2.$persistent.runCount).toBeUndefined() + + // Clean up + delete (globalThis as any).__fluxstack_persistent_OtherPersistent + }) + + it('$persistent works with onMount lifecycle', () => { + const ws = createMockWs() + + const comp1 = new PersistentComponent({}, ws) + ;(comp1 as any).onMount() + expect(comp1.$persistent.runCount).toBe(1) + + // Simulate HMR - new instance + const comp2 = new PersistentComponent({}, ws) + ;(comp2 as any).onMount() + expect(comp2.$persistent.runCount).toBe(2) + }) + + it('component without static persistent gets empty $persistent', () => { + const ws = createMockWs() + + class NoPersistent extends LiveComponent { + static componentName = 'NoPersistent' + static defaultState: CounterState = { count: 0, label: 'none' } + } + + const component = new NoPersistent({}, ws) + expect(component.$persistent).toEqual({}) + + // Still works - can add data + component.$persistent.someKey = 'someValue' + expect(component.$persistent.someKey).toBe('someValue') + + // Clean up + delete (globalThis as any).__fluxstack_persistent_NoPersistent + }) + }) + + // ===== Better publicActions Error Messages ===== + describe('publicActions Error Messages', () => { + it('gives helpful error when method exists but not in publicActions', async () => { + const ws = createMockWs() + const component = new PartialActionsComponent({}, ws) + + try { + await component.executeAction('decrement', {}) + expect.unreachable('Should have thrown') + } catch (err: any) { + // Should mention the action name, component name, and suggest adding to publicActions + expect(err.message).toContain('decrement') + expect(err.message).toContain('PartialActionsComponent') + expect(err.message).toContain('publicActions') + expect(err.message).toContain("'decrement'") + } + }) + + it('gives generic error when method does not exist at all', async () => { + const ws = createMockWs() + const component = new PartialActionsComponent({}, ws) + + try { + await component.executeAction('nonexistent', {}) + expect.unreachable('Should have thrown') + } catch (err: any) { + // Generic error - no helpful suggestion since method doesn't exist + expect(err.message).toContain('not callable') + expect(err.message).not.toContain('publicActions') + } + }) + + it('whitelisted actions still work normally', async () => { + const ws = createMockWs() + const component = new PartialActionsComponent({ count: 0, label: 'test' }, ws) + + const result = await component.executeAction('increment', {}) + expect(result).toEqual({ count: 1 }) + }) + }) + + // ===== Singleton Pattern ===== + describe('Singleton Pattern', () => { + it('static singleton is declared on the class', () => { + expect((SingletonComponent as any).singleton).toBe(true) + }) + + it('non-singleton component does not have singleton flag', () => { + expect((LifecycleComponent as any).singleton).toBeUndefined() + }) + + it('singleton emit override broadcasts to all connections', () => { + const ws1 = createMockWs('conn-1') + const ws2 = createMockWs('conn-2') + const ws3 = createMockWs('conn-3') + + const component = new SingletonComponent({ count: 0, label: 'shared' }, ws1) + + // Simulate registry setting up singleton broadcast + const connections = new Map() + connections.set('conn-1', ws1) + connections.set('conn-2', ws2) + connections.set('conn-3', ws3) + + ;(component as any)._setEmitOverride((type: string, payload: any) => { + const message = JSON.stringify({ + type, + componentId: component.id, + payload, + timestamp: Date.now() + }) + for (const [, ws] of connections) { + ws.send(message) + } + }) + + // Trigger state change (which calls emit via proxy) + component.state.count = 42 + + // All three connections should receive the STATE_DELTA + expect((ws1.send as any).mock.calls.length).toBeGreaterThan(0) + expect((ws2.send as any).mock.calls.length).toBeGreaterThan(0) + expect((ws3.send as any).mock.calls.length).toBeGreaterThan(0) + + // Verify the message content + const msg1 = getLastSentMessage(ws1) + expect(msg1.type).toBe('STATE_DELTA') + expect(msg1.payload.delta.count).toBe(42) + + const msg2 = getLastSentMessage(ws2) + expect(msg2.type).toBe('STATE_DELTA') + expect(msg2.payload.delta.count).toBe(42) + }) + + it('without emit override, emit goes to single ws only', () => { + const ws = createMockWs() + const component = new SingletonComponent({ count: 0, label: 'single' }, ws) + + // No emit override set - normal behavior + component.state.count = 10 + + // Only the component's own ws receives the message + expect((ws.send as any).mock.calls.length).toBeGreaterThan(0) + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.payload.delta.count).toBe(10) + }) + + it('emit override can be cleared', () => { + const ws1 = createMockWs('conn-1') + const ws2 = createMockWs('conn-2') + + const component = new SingletonComponent({ count: 0, label: 'test' }, ws1) + + // Set override + ;(component as any)._setEmitOverride((_type: string, _payload: any) => { + ws2.send('override') + }) + + // Clear override + ;(component as any)._setEmitOverride(null) + + // Should go back to normal single-ws emit + component.state.count = 5 + expect((ws2.send as any).mock.calls.length).toBe(0) + const msg = getLastSentMessage(ws1) + expect(msg.type).toBe('STATE_DELTA') + }) + }) + + // ===== BLOCKED_ACTIONS ===== + describe('BLOCKED_ACTIONS includes new internals', () => { + it('blocks onMount from client', async () => { + const ws = createMockWs() + + class SecureComponent extends LiveComponent { + static componentName = 'SecureComponent' + static defaultState: CounterState = { count: 0, label: 'secure' } + static publicActions = ['increment', 'onMount'] as const // Even if listed! + + async increment() { return { success: true } } + } + + const component = new SecureComponent({}, ws) + await expect(component.executeAction('onMount', {})).rejects.toThrow('not callable') + }) + + it('blocks onDestroy from client', async () => { + const ws = createMockWs() + + class SecureComponent extends LiveComponent { + static componentName = 'SecureComponent2' + static defaultState: CounterState = { count: 0, label: 'secure' } + static publicActions = ['increment', 'onDestroy'] as const + + async increment() { return { success: true } } + } + + const component = new SecureComponent({}, ws) + await expect(component.executeAction('onDestroy', {})).rejects.toThrow('not callable') + }) + + it('blocks $persistent from client', async () => { + const ws = createMockWs() + + class SecureComponent extends LiveComponent { + static componentName = 'SecureComponent3' + static defaultState: CounterState = { count: 0, label: 'secure' } + static publicActions = ['increment', '$persistent'] as const + + async increment() { return { success: true } } + } + + const component = new SecureComponent({}, ws) + await expect(component.executeAction('$persistent', {})).rejects.toThrow('not callable') + }) + + it('blocks _setEmitOverride from client', async () => { + const ws = createMockWs() + + class SecureComponent extends LiveComponent { + static componentName = 'SecureComponent4' + static defaultState: CounterState = { count: 0, label: 'secure' } + static publicActions = ['increment', '_setEmitOverride'] as const + + async increment() { return { success: true } } + } + + const component = new SecureComponent({}, ws) + await expect(component.executeAction('_setEmitOverride', {})).rejects.toThrow('not callable') + }) + }) +}) diff --git a/tests/unit/core/live-component-security.test.ts b/tests/unit/core/live-component-security.test.ts index 84e9939..ad87f29 100644 --- a/tests/unit/core/live-component-security.test.ts +++ b/tests/unit/core/live-component-security.test.ts @@ -236,7 +236,7 @@ describe('🔒 Security: Arbitrary Method Execution (CWE-94)', () => { }) it('ATTACK: should block calling non-whitelisted method', async () => { - await expect(secure.executeAction('resetDatabase', {})).rejects.toThrow("Action 'resetDatabase' is not callable") + await expect(secure.executeAction('resetDatabase', {})).rejects.toThrow("not listed in publicActions") }) it('ATTACK: should block calling destroy even on secure component', async () => { @@ -736,7 +736,7 @@ describe('🔒 Security: setValue Protection (CWE-306)', () => { await expect(component.executeAction('setValue', { key: 'isAdmin', value: true - })).rejects.toThrow("Action 'setValue' is not callable") + })).rejects.toThrow("not listed in publicActions") }) it('should block setValue on legacy components (no publicActions = secure by default)', async () => { @@ -785,7 +785,7 @@ describe('🔒 Security: Comprehensive Attack Scenario Simulation', () => { const component = new SecureComponent({}, ws) // resetDatabase exists but is not in publicActions - await expect(component.executeAction('resetDatabase', {})).rejects.toThrow("is not callable") + await expect(component.executeAction('resetDatabase', {})).rejects.toThrow("not listed in publicActions") }) it('SCENARIO: Attacker tries to emit fake state updates to other clients', async () => { From 8e462705b52c6d0db089bec81ddf331d7ea29311 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 15:23:11 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20add=20full=20lifecycle=20hook=20sys?= =?UTF-8?q?tem=20=E2=80=94=20onConnect,=20onDisconnect,=20onStateChange,?= =?UTF-8?q?=20onRoomJoin,=20onRoomLeave,=20onRehydrate,=20onAction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete lifecycle hooks for LiveComponent: - onConnect(): fires when WebSocket is established, before onMount - onDisconnect(): fires on unexpected connection loss (NOT on intentional unmount) - onStateChange(changes): fires after every state mutation (proxy or setState) - onRoomJoin(roomId): fires after $room.join() - onRoomLeave(roomId): fires after $room.leave() - onRehydrate(previousState): fires after state is restored from localStorage - onAction(action, payload): fires before action execution, return false to cancel Lifecycle order: onConnect → onMount → [onAction/onStateChange/onRoom*] → onDisconnect → onDestroy All hooks are: - Optional (override only what you need) - Error-safe (caught and logged, never break the system) - Blocked from client (in BLOCKED_ACTIONS) 37 tests, 204 total passing, 0 TypeScript errors. https://claude.ai/code/session_01V12t1cDiYmAvDsMJsYaLys --- LLMD/resources/live-components.md | 95 +++++- core/server/live/ComponentRegistry.ts | 21 +- core/types/types.ts | 101 ++++++- tests/unit/core/live-component-dx.test.ts | 344 ++++++++++++++++++++++ 4 files changed, 543 insertions(+), 18 deletions(-) diff --git a/LLMD/resources/live-components.md b/LLMD/resources/live-components.md index 663caa7..7f860a5 100644 --- a/LLMD/resources/live-components.md +++ b/LLMD/resources/live-components.md @@ -124,35 +124,71 @@ export class LiveCounter extends LiveComponent ## Lifecycle Hooks (v1.14.0) -Use `onMount()` and `onDestroy()` instead of constructor workarounds: +Full lifecycle hook system — no more constructor workarounds: ```typescript export class MyComponent extends LiveComponent { static componentName = 'MyComponent' static publicActions = ['doWork'] as const - static defaultState = { users: [] as string[], ready: false } + static defaultState = { users: [] as string[], ready: false, currentRoom: '' } private _pollTimer?: NodeJS.Timeout - // Called AFTER component is fully mounted (rooms, auth, injections ready) + // 1️⃣ Called when WebSocket connection is established (before onMount) + protected onConnect() { + console.log('WebSocket connected for this component') + } + + // 2️⃣ Called AFTER component is fully mounted (rooms, auth, injections ready) // Can be async! protected async onMount() { this.$room.join() this.$room.on('user:joined', (user) => { this.state.users = [...this.state.users, user] }) - - // Async init is fine const data = await fetchInitialData(this.$auth.user?.id) this.state.ready = true - this._pollTimer = setInterval(() => this.poll(), 5000) } + // Called after state is restored from localStorage (rehydration) + protected onRehydrate(previousState: typeof MyComponent.defaultState) { + if (!previousState.ready) { + this.state.ready = false // Re-validate stale state + } + } + + // Called after any state mutation (proxy or setState) + protected onStateChange(changes: Partial) { + if ('users' in changes) { + console.log(`User count: ${this.state.users.length}`) + } + } + + // Called when joining a room + protected onRoomJoin(roomId: string) { + this.state.currentRoom = roomId + } + + // Called when leaving a room + protected onRoomLeave(roomId: string) { + if (this.state.currentRoom === roomId) this.state.currentRoom = '' + } + + // Called before each action — return false to cancel + protected onAction(action: string, payload: any) { + console.log(`[${this.id}] ${action}`, payload) + // return false // ← would cancel the action + } + + // Called when WebSocket drops (NOT on intentional unmount) + protected onDisconnect() { + console.log('Connection lost — saving recovery data') + } + // Called BEFORE internal cleanup (sync only) protected onDestroy() { clearInterval(this._pollTimer) - this.externalConnection?.close() } async doWork() { /* ... */ } @@ -160,11 +196,46 @@ export class MyComponent extends LiveComponent } ``` -**Rules:** -- `onMount()` — can be async, called after rooms/auth/DI are ready -- `onDestroy()` — sync only, called before internal cleanup in `destroy()` -- Constructor is still needed ONLY for room event subscriptions (`this.onRoomEvent`) -- `onDestroy` errors are caught and logged — they never prevent cleanup +### Lifecycle Order + +``` +WebSocket connects + └→ onConnect() + └→ onMount() ← async, rooms/auth ready + └→ [component active] + ├→ onAction(action, payload) ← before each action (return false to cancel) + ├→ onStateChange(changes) ← after each state mutation + ├→ onRoomJoin(roomId) ← when joining a room + └→ onRoomLeave(roomId) ← when leaving a room + +Connection drops: + └→ onDisconnect() ← only on unexpected disconnect + └→ onDestroy() ← sync, before internal cleanup + +Rehydration (reconnect with saved state): + └→ onConnect() + └→ onRehydrate(previousState) + └→ onMount() +``` + +### Rules + +| Hook | Async? | When | +|------|--------|------| +| `onConnect()` | No | WebSocket established, before mount | +| `onMount()` | **Yes** | After all setup (rooms, auth, DI) | +| `onRehydrate(prevState)` | No | After state restored from localStorage | +| `onStateChange(changes)` | No | After every state mutation | +| `onRoomJoin(roomId)` | No | After `$room.join()` | +| `onRoomLeave(roomId)` | No | After `$room.leave()` | +| `onAction(action, payload)` | **Yes** | Before action execution (return `false` to cancel) | +| `onDisconnect()` | No | Connection lost (NOT intentional unmount) | +| `onDestroy()` | No | Before internal cleanup | + +- All hooks are optional — override only what you need +- All hook errors are caught and logged — they never break the system +- Constructor is still needed ONLY for `this.onRoomEvent()` subscriptions +- All hooks are in BLOCKED_ACTIONS — clients cannot call them remotely ## HMR Persistence (v1.14.0) diff --git a/core/server/live/ComponentRegistry.ts b/core/server/live/ComponentRegistry.ts index c984db2..5d8c231 100644 --- a/core/server/live/ComponentRegistry.ts +++ b/core/server/live/ComponentRegistry.ts @@ -420,7 +420,10 @@ export class ComponentRegistry { signedState }) - // 🔄 Call onMount lifecycle hook (after all setup is complete) + // 🔄 Lifecycle hooks + try { (component as any).onConnect() } catch (err: any) { + console.error(`[${componentName}] onConnect error:`, err?.message || err) + } try { await (component as any).onMount() } catch (err: any) { @@ -583,7 +586,13 @@ export class ComponentRegistry { newComponentId: component.id }) - // 🔄 Call onMount lifecycle hook after rehydration + // 🔄 Lifecycle hooks after rehydration + try { (component as any).onConnect() } catch (err: any) { + console.error(`[${componentName}] onConnect error (rehydration):`, err?.message || err) + } + try { (component as any).onRehydrate(clientState) } catch (err: any) { + console.error(`[${componentName}] onRehydrate error:`, err?.message || err) + } try { await (component as any).onMount() } catch (err: any) { @@ -888,6 +897,14 @@ export class ComponentRegistry { liveLog('lifecycle', null, `🧹 Cleaning up ${componentsToCleanup.length} components for disconnected WebSocket`) for (const componentId of componentsToCleanup) { + // Call onDisconnect lifecycle hook (only fires on connection loss, not intentional unmount) + const component = this.components.get(componentId) + if (component) { + try { (component as any).onDisconnect() } catch (err: any) { + console.error(`[${componentId}] onDisconnect error:`, err?.message || err) + } + } + // Check if this is a singleton let isSingleton = false for (const [name, singleton] of this.singletons) { diff --git a/core/types/types.ts b/core/types/types.ts index 8b0e613..ad9e19d 100644 --- a/core/types/types.ts +++ b/core/types/types.ts @@ -401,12 +401,15 @@ export abstract class LiveComponent // Delta sync - send only the changed property - self.emit('STATE_DELTA', { delta: { [prop]: value } }) + self.emit('STATE_DELTA', { delta: changes }) + // Lifecycle hook: onStateChange + try { self.onStateChange(changes) } catch {} // Debug: track proxy mutation _liveDebugger?.trackStateChange( self.id, - { [prop]: value } as Record, + changes as Record, target as Record, 'proxy' ) @@ -478,12 +481,14 @@ export abstract class LiveComponent { if (!self.joinedRooms.has(roomId)) return self.joinedRooms.delete(roomId) liveRoomManager.leaveRoom(self.id, roomId) + try { self.onRoomLeave(roomId) } catch {} }, emit: (event: string, data: any): number => { @@ -637,6 +642,12 @@ export abstract class LiveComponent {} + /** + * Called when the WebSocket connection drops unexpectedly. + * Fires BEFORE onDestroy. NOT called on intentional unmount. + * Useful for notifying rooms, saving state, or triggering recovery. + */ + protected onDisconnect(): void {} + /** * Called before component is destroyed (sync only). * Override in subclass for cleanup: timers, intervals, external connections. @@ -665,12 +683,79 @@ export abstract class LiveComponent) { + * if ('firstName' in changes || 'lastName' in changes) { + * this.state.fullName = `${this.state.firstName} ${this.state.lastName}` + * } + * } + */ + protected onStateChange(changes: Partial): void {} + + /** + * Called when the component joins a room. + * @param roomId - The room being joined + * + * @example + * protected onRoomJoin(roomId: string) { + * console.log(`Joined room: ${roomId}`) + * this.state.currentRoom = roomId + * } + */ + protected onRoomJoin(roomId: string): void {} + + /** + * Called when the component leaves a room. + * @param roomId - The room being left + */ + protected onRoomLeave(roomId: string): void {} + + /** + * Called after component state is rehydrated from a signed state. + * Useful for validating or migrating stale state. + * + * @param previousState - The restored state from localStorage + * + * @example + * protected onRehydrate(previousState: State) { + * // Migrate old state format + * if (!previousState.version) { + * this.state.version = 2 + * } + * } + */ + protected onRehydrate(previousState: TState): void {} + + /** + * Called before an action is executed. Return false to cancel. + * Useful for logging, rate limiting, or pre-validation. + * + * @param action - The action name + * @param payload - The action payload + * @returns void (allow) or false (cancel) + * + * @example + * protected onAction(action: string, payload: any) { + * console.log(`[${this.id}] ${action}`, payload) + * if (this._rateLimited) return false + * } + */ + protected onAction(action: string, payload: any): void | false | Promise {} + // State management (batch update - single emit with delta) public setState(updates: Partial | ((prev: TState) => Partial)) { const newUpdates = typeof updates === 'function' ? updates(this._state) : updates Object.assign(this._state as object, newUpdates) // Delta sync - send only the changed properties this.emit('STATE_DELTA', { delta: newUpdates }) + // Lifecycle hook: onStateChange + try { this.onStateChange(newUpdates) } catch {} // Debug: track state change _liveDebugger?.trackStateChange( this.id, @@ -700,9 +785,11 @@ export abstract class LiveComponent = new Set([ - // Lifecycle & internal + // Lifecycle hooks (all of them) 'constructor', 'destroy', 'executeAction', 'getSerializableState', - 'onMount', 'onDestroy', + 'onMount', 'onDestroy', 'onConnect', 'onDisconnect', + 'onStateChange', 'onRoomJoin', 'onRoomLeave', + 'onRehydrate', 'onAction', // State management internals 'setState', 'emit', 'broadcast', 'broadcastToRoom', 'createStateProxy', 'createDirectStateAccessors', 'generateId', @@ -768,6 +855,12 @@ export abstract class LiveComponent { const component = new SecureComponent({}, ws) await expect(component.executeAction('_setEmitOverride', {})).rejects.toThrow('not callable') }) + + it('blocks all new lifecycle hooks from client', async () => { + const ws = createMockWs() + + class TestComp extends LiveComponent { + static componentName = 'TestCompBlocked' + static defaultState: CounterState = { count: 0, label: 'x' } + static publicActions = ['increment', 'onConnect', 'onDisconnect', 'onStateChange', 'onRoomJoin', 'onRoomLeave', 'onRehydrate', 'onAction'] as const + async increment() { return { success: true } } + } + + const component = new TestComp({}, ws) + for (const hook of ['onConnect', 'onDisconnect', 'onStateChange', 'onRoomJoin', 'onRoomLeave', 'onRehydrate', 'onAction']) { + await expect(component.executeAction(hook, {})).rejects.toThrow('not callable') + } + }) + }) + + // ===== onConnect ===== + describe('onConnect Hook', () => { + it('onConnect is callable on the component', () => { + const ws = createMockWs() + let connectCalled = false + + class ConnectComponent extends LiveComponent { + static componentName = 'ConnectComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onConnect() { + connectCalled = true + } + } + + const component = new ConnectComponent({}, ws) + ;(component as any).onConnect() + expect(connectCalled).toBe(true) + }) + }) + + // ===== onDisconnect ===== + describe('onDisconnect Hook', () => { + it('onDisconnect is callable and distinct from onDestroy', () => { + const ws = createMockWs() + const calls: string[] = [] + + class DisconnectComponent extends LiveComponent { + static componentName = 'DisconnectComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onDisconnect() { + calls.push('onDisconnect') + } + + protected onDestroy() { + calls.push('onDestroy') + } + } + + const component = new DisconnectComponent({}, ws) + + // Simulate disconnect (registry calls onDisconnect before destroy) + ;(component as any).onDisconnect() + component.destroy() + + expect(calls).toEqual(['onDisconnect', 'onDestroy']) + }) + }) + + // ===== onStateChange ===== + describe('onStateChange Hook', () => { + it('fires on proxy mutation', () => { + const ws = createMockWs() + const changes: any[] = [] + + class StateChangeComponent extends LiveComponent { + static componentName = 'StateChangeComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onStateChange(c: Partial) { + changes.push({ ...c }) + } + } + + const component = new StateChangeComponent({ count: 0, label: 'test' }, ws) + component.state.count = 42 + + expect(changes).toHaveLength(1) + expect(changes[0]).toEqual({ count: 42 }) + }) + + it('fires on setState batch', () => { + const ws = createMockWs() + const changes: any[] = [] + + class StateChangeComponent extends LiveComponent { + static componentName = 'StateChangeComponent2' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onStateChange(c: Partial) { + changes.push({ ...c }) + } + } + + const component = new StateChangeComponent({ count: 0, label: 'old' }, ws) + component.setState({ count: 10, label: 'new' }) + + expect(changes).toHaveLength(1) + expect(changes[0]).toEqual({ count: 10, label: 'new' }) + }) + + it('does not fire if value is unchanged', () => { + const ws = createMockWs() + let callCount = 0 + + class StateChangeComponent extends LiveComponent { + static componentName = 'StateChangeComponent3' + static defaultState: CounterState = { count: 5, label: 'test' } + + protected onStateChange() { + callCount++ + } + } + + const component = new StateChangeComponent({ count: 5, label: 'test' }, ws) + component.state.count = 5 // Same value + + expect(callCount).toBe(0) + }) + + it('errors in onStateChange do not break state updates', () => { + const ws = createMockWs() + + class ErrorStateComponent extends LiveComponent { + static componentName = 'ErrorStateComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onStateChange() { + throw new Error('boom') + } + } + + const component = new ErrorStateComponent({ count: 0, label: 'test' }, ws) + // Should not throw + expect(() => { component.state.count = 99 }).not.toThrow() + // State should still be updated + expect((component as any)._state.count).toBe(99) + }) + }) + + // ===== onRoomJoin / onRoomLeave ===== + describe('onRoomJoin / onRoomLeave Hooks', () => { + it('fires onRoomJoin when joining a room', () => { + const ws = createMockWs() + const roomEvents: string[] = [] + + class RoomComponent extends LiveComponent { + static componentName = 'RoomComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onRoomJoin(roomId: string) { + roomEvents.push(`join:${roomId}`) + } + + protected onRoomLeave(roomId: string) { + roomEvents.push(`leave:${roomId}`) + } + } + + const component = new RoomComponent({}, ws, { room: 'default-room' }) + component.$room('test-room').join() + + expect(roomEvents).toContain('join:test-room') + }) + + it('fires onRoomLeave when leaving a room', () => { + const ws = createMockWs() + const roomEvents: string[] = [] + + class RoomComponent extends LiveComponent { + static componentName = 'RoomComponent2' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onRoomJoin(roomId: string) { + roomEvents.push(`join:${roomId}`) + } + + protected onRoomLeave(roomId: string) { + roomEvents.push(`leave:${roomId}`) + } + } + + const component = new RoomComponent({}, ws, { room: 'default-room' }) + component.$room('test-room').join() + component.$room('test-room').leave() + + expect(roomEvents).toEqual(['join:test-room', 'leave:test-room']) + }) + }) + + // ===== onRehydrate ===== + describe('onRehydrate Hook', () => { + it('onRehydrate receives previous state', () => { + const ws = createMockWs() + let receivedState: any = null + + class RehydrateComponent extends LiveComponent { + static componentName = 'RehydrateComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onRehydrate(previousState: CounterState) { + receivedState = previousState + } + } + + const component = new RehydrateComponent({}, ws) + const oldState = { count: 42, label: 'old' } + ;(component as any).onRehydrate(oldState) + + expect(receivedState).toEqual({ count: 42, label: 'old' }) + }) + }) + + // ===== onAction ===== + describe('onAction Hook', () => { + it('fires before action execution', async () => { + const ws = createMockWs() + const log: string[] = [] + + class ActionHookComponent extends LiveComponent { + static componentName = 'ActionHookComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment'] as const + + protected onAction(action: string, _payload: any) { + log.push(`before:${action}`) + } + + async increment() { + log.push('execute:increment') + this.state.count++ + return { count: this.state.count } + } + } + + const component = new ActionHookComponent({ count: 0, label: 'test' }, ws) + await component.executeAction('increment', {}) + + expect(log).toEqual(['before:increment', 'execute:increment']) + }) + + it('returning false cancels the action', async () => { + const ws = createMockWs() + let executed = false + + class CancelComponent extends LiveComponent { + static componentName = 'CancelComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment'] as const + + protected onAction(_action: string, _payload: any): false { + return false + } + + async increment() { + executed = true + this.state.count++ + return { count: this.state.count } + } + } + + const component = new CancelComponent({ count: 0, label: 'test' }, ws) + + await expect(component.executeAction('increment', {})).rejects.toThrow('cancelled by onAction') + expect(executed).toBe(false) + }) + + it('returning void allows the action', async () => { + const ws = createMockWs() + + class AllowComponent extends LiveComponent { + static componentName = 'AllowComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment'] as const + + protected onAction() { + // No return = allow + } + + async increment() { + this.state.count++ + return { count: this.state.count } + } + } + + const component = new AllowComponent({ count: 0, label: 'test' }, ws) + const result = await component.executeAction('increment', {}) + expect(result).toEqual({ count: 1 }) + }) + }) + + // ===== Full Lifecycle Order ===== + describe('Lifecycle Order', () => { + it('hooks fire in correct order: onConnect → onMount → actions → onDestroy', async () => { + const ws = createMockWs() + const order: string[] = [] + + class FullLifecycleComponent extends LiveComponent { + static componentName = 'FullLifecycleComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment'] as const + + protected onConnect() { order.push('onConnect') } + protected onMount() { order.push('onMount') } + protected onAction(action: string) { order.push(`onAction:${action}`) } + protected onStateChange() { order.push('onStateChange') } + protected onDisconnect() { order.push('onDisconnect') } + protected onDestroy() { order.push('onDestroy') } + + async increment() { + order.push('increment') + this.state.count++ + return { success: true } + } + } + + const component = new FullLifecycleComponent({ count: 0, label: 'test' }, ws) + + // Simulate registry lifecycle + ;(component as any).onConnect() + await (component as any).onMount() + await component.executeAction('increment', {}) + ;(component as any).onDisconnect() + component.destroy() + + expect(order).toEqual([ + 'onConnect', + 'onMount', + 'onAction:increment', + 'increment', + 'onStateChange', + 'onDisconnect', + 'onDestroy' + ]) + }) }) }) From a9d3ff879c5e3b2add4d3554cf5aadf4df19d788 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 15:33:27 +0000 Subject: [PATCH 3/3] test: add 37 additional unit tests for LiveComponent DX features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive edge case and integration tests covering: - Error safety for onConnect, onDisconnect, onRoomJoin, onRoomLeave - Async onAction (cancel and allow) - onAction payload verification and selective cancellation - onStateChange with multiple rapid changes, function updaters, and during actions - onMount and onRehydrate state modification - $persistent deep object mutations and runtime key addition - Security: _ prefix blocking, # prefix blocking, no publicActions, Object.prototype - Singleton setState broadcasts and action-triggered state sync - $room default handle, $rooms tracking, idempotent join - State proxy delta emissions (per-mutation and batch) - defaultState merge behavior - Default no-op hooks verification - getSerializableState correctness - Emit message format validation - Rehydration lifecycle order (onConnect → onRehydrate → onMount) Total: 74 tests (37 original + 37 new), all passing. 484 tests pass across full suite. https://claude.ai/code/session_01V12t1cDiYmAvDsMJsYaLys --- tests/unit/core/live-component-dx.test.ts | 916 ++++++++++++++++++++++ 1 file changed, 916 insertions(+) diff --git a/tests/unit/core/live-component-dx.test.ts b/tests/unit/core/live-component-dx.test.ts index 8f82c62..bda800a 100644 --- a/tests/unit/core/live-component-dx.test.ts +++ b/tests/unit/core/live-component-dx.test.ts @@ -868,3 +868,919 @@ describe('LiveComponent DX Enhancements', () => { }) }) }) + +// ============================================= +// ADDITIONAL COMPREHENSIVE TESTS +// ============================================= + +describe('LiveComponent DX — Extended Coverage', () => { + + // ===== onConnect Error Safety ===== + describe('onConnect Error Safety', () => { + it('errors in onConnect do not prevent further lifecycle', async () => { + const ws = createMockWs() + const order: string[] = [] + + class ErrorConnectComponent extends LiveComponent { + static componentName = 'ErrorConnectComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment'] as const + + protected onConnect() { + order.push('onConnect') + throw new Error('connect failed') + } + + protected onMount() { + order.push('onMount') + } + + async increment() { + this.state.count++ + return { count: this.state.count } + } + } + + const component = new ErrorConnectComponent({ count: 0, label: 'test' }, ws) + + // Simulate registry behavior: catch onConnect errors and continue + try { (component as any).onConnect() } catch {} + ;(component as any).onMount() + + expect(order).toContain('onConnect') + expect(order).toContain('onMount') + + // Component still functional + const result = await component.executeAction('increment', {}) + expect(result).toEqual({ count: 1 }) + }) + }) + + // ===== onDisconnect Error Safety ===== + describe('onDisconnect Error Safety', () => { + it('errors in onDisconnect do not prevent cleanup', () => { + const ws = createMockWs() + + class ErrorDisconnectComponent extends LiveComponent { + static componentName = 'ErrorDisconnectComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onDisconnect() { + throw new Error('disconnect boom') + } + } + + const component = new ErrorDisconnectComponent({}, ws) + + // Simulate registry: catch onDisconnect error, then destroy + try { (component as any).onDisconnect() } catch {} + expect(() => component.destroy()).not.toThrow() + }) + }) + + // ===== Async onAction ===== + describe('Async onAction', () => { + it('async onAction returning false cancels the action', async () => { + const ws = createMockWs() + let executed = false + + class AsyncCancelComponent extends LiveComponent { + static componentName = 'AsyncCancelComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['doStuff'] as const + + protected async onAction(_action: string, _payload: any): Promise { + await new Promise(r => setTimeout(r, 5)) + return false + } + + async doStuff() { + executed = true + return { done: true } + } + } + + const component = new AsyncCancelComponent({}, ws) + await expect(component.executeAction('doStuff', {})).rejects.toThrow('cancelled by onAction') + expect(executed).toBe(false) + }) + + it('async onAction returning void allows the action', async () => { + const ws = createMockWs() + + class AsyncAllowComponent extends LiveComponent { + static componentName = 'AsyncAllowComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['doStuff'] as const + + protected async onAction(): Promise { + await new Promise(r => setTimeout(r, 5)) + // no return = allow + } + + async doStuff() { + this.state.count = 99 + return { done: true } + } + } + + const component = new AsyncAllowComponent({ count: 0, label: 'test' }, ws) + const result = await component.executeAction('doStuff', {}) + expect(result).toEqual({ done: true }) + }) + }) + + // ===== onAction Receives Correct Payload ===== + describe('onAction Payload', () => { + it('receives the action name and full payload', async () => { + const ws = createMockWs() + let receivedAction: string | null = null + let receivedPayload: any = null + + class PayloadCheckComponent extends LiveComponent { + static componentName = 'PayloadCheckComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['submit'] as const + + protected onAction(action: string, payload: any) { + receivedAction = action + receivedPayload = payload + } + + async submit(payload: { name: string; value: number }) { + return { received: true, ...payload } + } + } + + const component = new PayloadCheckComponent({}, ws) + await component.executeAction('submit', { name: 'test', value: 42 }) + + expect(receivedAction).toBe('submit') + expect(receivedPayload).toEqual({ name: 'test', value: 42 }) + }) + }) + + // ===== onStateChange — Multiple Rapid Changes ===== + describe('onStateChange Multiple Changes', () => { + it('fires for each proxy mutation separately', () => { + const ws = createMockWs() + const changes: any[] = [] + + class MultiChangeComponent extends LiveComponent { + static componentName = 'MultiChangeComponent' + static defaultState: CounterState = { count: 0, label: 'initial' } + + protected onStateChange(c: Partial) { + changes.push({ ...c }) + } + } + + const component = new MultiChangeComponent({ count: 0, label: 'initial' }, ws) + component.state.count = 1 + component.state.count = 2 + component.state.label = 'updated' + + expect(changes).toHaveLength(3) + expect(changes[0]).toEqual({ count: 1 }) + expect(changes[1]).toEqual({ count: 2 }) + expect(changes[2]).toEqual({ label: 'updated' }) + }) + + it('setState with function updater triggers onStateChange', () => { + const ws = createMockWs() + const changes: any[] = [] + + class FnUpdateComponent extends LiveComponent { + static componentName = 'FnUpdateComponent' + static defaultState: CounterState = { count: 10, label: 'test' } + + protected onStateChange(c: Partial) { + changes.push({ ...c }) + } + } + + const component = new FnUpdateComponent({ count: 10, label: 'test' }, ws) + component.setState((prev) => ({ count: prev.count + 5 })) + + expect(changes).toHaveLength(1) + expect(changes[0]).toEqual({ count: 15 }) + }) + }) + + // ===== onStateChange During Action Execution ===== + describe('onStateChange During Actions', () => { + it('fires when action modifies state via proxy', async () => { + const ws = createMockWs() + const changes: any[] = [] + + class ActionStateComponent extends LiveComponent { + static componentName = 'ActionStateComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment'] as const + + protected onStateChange(c: Partial) { + changes.push({ ...c }) + } + + async increment() { + this.state.count++ + return { count: this.state.count } + } + } + + const component = new ActionStateComponent({ count: 0, label: 'test' }, ws) + await component.executeAction('increment', {}) + + expect(changes).toHaveLength(1) + expect(changes[0]).toEqual({ count: 1 }) + }) + + it('fires when action modifies state via setState', async () => { + const ws = createMockWs() + const changes: any[] = [] + + class ActionSetStateComponent extends LiveComponent { + static componentName = 'ActionSetStateComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['update'] as const + + protected onStateChange(c: Partial) { + changes.push({ ...c }) + } + + async update() { + this.setState({ count: 42, label: 'updated' }) + return { done: true } + } + } + + const component = new ActionSetStateComponent({ count: 0, label: 'test' }, ws) + await component.executeAction('update', {}) + + expect(changes).toHaveLength(1) + expect(changes[0]).toEqual({ count: 42, label: 'updated' }) + }) + }) + + // ===== onMount Can Modify State ===== + describe('onMount State Modification', () => { + it('onMount can modify state and state is updated', async () => { + const ws = createMockWs() + + class MountStateComponent extends LiveComponent { + static componentName = 'MountStateComponent' + static defaultState: CounterState = { count: 0, label: 'loading' } + + protected onMount() { + this.state.label = 'ready' + this.state.count = 100 + } + } + + const component = new MountStateComponent({ count: 0, label: 'loading' }, ws) + ;(component as any).onMount() + + expect(component.state.label).toBe('ready') + expect(component.state.count).toBe(100) + }) + }) + + // ===== onRehydrate Can Modify State ===== + describe('onRehydrate State Modification', () => { + it('onRehydrate can migrate/modify state after rehydration', () => { + const ws = createMockWs() + + interface VersionedState { + count: number + label: string + version: number + } + + class MigrateComponent extends LiveComponent { + static componentName = 'MigrateComponent' + static defaultState: VersionedState = { count: 0, label: 'test', version: 2 } + + protected onRehydrate(previousState: VersionedState) { + if (!previousState.version || previousState.version < 2) { + this.state.version = 2 + this.state.label = 'migrated' + } + } + } + + const component = new MigrateComponent({ count: 5, label: 'old', version: 0 }, ws) + ;(component as any).onRehydrate({ count: 5, label: 'old', version: 0 }) + + expect(component.state.version).toBe(2) + expect(component.state.label).toBe('migrated') + expect(component.state.count).toBe(5) + }) + }) + + // ===== $persistent Deep Object Mutations ===== + describe('$persistent Deep Mutations', () => { + afterEach(() => { + delete (globalThis as any).__fluxstack_persistent_DeepPersistentComponent + }) + + it('nested object mutations persist across instances', () => { + const ws = createMockWs() + + class DeepPersistentComponent extends LiveComponent { + static componentName = 'DeepPersistentComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static persistent = { + nested: { items: [] as string[], meta: { version: 1 } } + } + } + + const comp1 = new DeepPersistentComponent({}, ws) + comp1.$persistent.nested.items.push('item1') + comp1.$persistent.nested.items.push('item2') + comp1.$persistent.nested.meta.version = 2 + + // New instance (HMR) + const comp2 = new DeepPersistentComponent({}, ws) + expect(comp2.$persistent.nested.items).toEqual(['item1', 'item2']) + expect(comp2.$persistent.nested.meta.version).toBe(2) + }) + + it('$persistent allows adding new keys at runtime', () => { + const ws = createMockWs() + + class DeepPersistentComponent extends LiveComponent { + static componentName = 'DeepPersistentComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static persistent = { initial: 'value' } + } + + const comp = new DeepPersistentComponent({}, ws) + comp.$persistent.newKey = 'dynamically added' + + const comp2 = new DeepPersistentComponent({}, ws) + expect(comp2.$persistent.newKey).toBe('dynamically added') + expect(comp2.$persistent.initial).toBe('value') + }) + }) + + // ===== onRoomJoin / onRoomLeave Error Safety ===== + describe('Room Hook Error Safety', () => { + it('errors in onRoomJoin do not prevent room join', () => { + const ws = createMockWs() + + class ErrorRoomComponent extends LiveComponent { + static componentName = 'ErrorRoomComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onRoomJoin(_roomId: string) { + throw new Error('room join boom') + } + } + + const component = new ErrorRoomComponent({}, ws, { room: 'default-room' }) + + // Should not throw — errors are caught inside $room.join() + expect(() => component.$room('error-room').join()).not.toThrow() + expect(component.$rooms).toContain('error-room') + }) + + it('errors in onRoomLeave do not prevent room leave', () => { + const ws = createMockWs() + + class ErrorLeaveComponent extends LiveComponent { + static componentName = 'ErrorLeaveComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onRoomLeave(_roomId: string) { + throw new Error('room leave boom') + } + } + + const component = new ErrorLeaveComponent({}, ws, { room: 'default-room' }) + component.$room('test-room').join() + expect(component.$rooms).toContain('test-room') + + expect(() => component.$room('test-room').leave()).not.toThrow() + expect(component.$rooms).not.toContain('test-room') + }) + }) + + // ===== Security: _ prefixed Methods Blocked ===== + describe('Security: Private Method Blocking', () => { + it('blocks methods starting with _ even if in publicActions', async () => { + const ws = createMockWs() + + class UnderscoreComponent extends LiveComponent { + static componentName = 'UnderscoreComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['_secretMethod', 'safeMethod'] as const + + async _secretMethod() { return { secret: true } } + async safeMethod() { return { safe: true } } + } + + const component = new UnderscoreComponent({}, ws) + await expect(component.executeAction('_secretMethod', {})).rejects.toThrow('not callable') + + // But safe method works + const result = await component.executeAction('safeMethod', {}) + expect(result).toEqual({ safe: true }) + }) + + it('blocks methods starting with # (hash prefix)', async () => { + const ws = createMockWs() + + class HashComponent extends LiveComponent { + static componentName = 'HashComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['#private', 'safeMethod'] as const + + async safeMethod() { return { ok: true } } + } + + const component = new HashComponent({}, ws) + await expect(component.executeAction('#private', {})).rejects.toThrow('not callable') + }) + }) + + // ===== Security: No publicActions Defined ===== + describe('Security: No publicActions', () => { + it('blocks ALL actions when publicActions is undefined', async () => { + const ws = createMockWs() + + class NoActionsComponent extends LiveComponent { + static componentName = 'NoActionsComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + // No publicActions defined + + async doSomething() { return { done: true } } + } + + const component = new NoActionsComponent({}, ws) + await expect(component.executeAction('doSomething', {})).rejects.toThrow('no publicActions defined') + }) + }) + + // ===== Singleton Emit Override — State Changes ===== + describe('Singleton State Sync', () => { + it('setState broadcasts to all connections via emit override', () => { + const ws1 = createMockWs('conn-1') + const ws2 = createMockWs('conn-2') + + const component = new SingletonComponent({ count: 0, label: 'shared' }, ws1) + + const connections = new Map() + connections.set('conn-1', ws1) + connections.set('conn-2', ws2) + + ;(component as any)._setEmitOverride((type: string, payload: any) => { + const message = JSON.stringify({ type, componentId: component.id, payload, timestamp: Date.now() }) + for (const [, ws] of connections) { + ws.send(message) + } + }) + + // Use setState (batch update) + component.setState({ count: 50, label: 'batch' }) + + // Both connections should receive STATE_DELTA + expect((ws1.send as any).mock.calls.length).toBeGreaterThan(0) + expect((ws2.send as any).mock.calls.length).toBeGreaterThan(0) + + const msg1 = getLastSentMessage(ws1) + expect(msg1.type).toBe('STATE_DELTA') + expect(msg1.payload.delta).toEqual({ count: 50, label: 'batch' }) + + const msg2 = getLastSentMessage(ws2) + expect(msg2.type).toBe('STATE_DELTA') + expect(msg2.payload.delta).toEqual({ count: 50, label: 'batch' }) + }) + }) + + // ===== Singleton Actions Broadcast State ===== + describe('Singleton Action Broadcasts', () => { + it('action that modifies state broadcasts to all clients', async () => { + const ws1 = createMockWs('conn-1') + const ws2 = createMockWs('conn-2') + + const component = new SingletonComponent({ count: 0, label: 'shared' }, ws1) + + const connections = new Map() + connections.set('conn-1', ws1) + connections.set('conn-2', ws2) + + ;(component as any)._setEmitOverride((type: string, payload: any) => { + const message = JSON.stringify({ type, componentId: component.id, payload, timestamp: Date.now() }) + for (const [, ws] of connections) { + ws.send(message) + } + }) + + await component.executeAction('increment', {}) + + // Both connections received the delta from the action + const msg1 = getLastSentMessage(ws1) + expect(msg1.type).toBe('STATE_DELTA') + expect(msg1.payload.delta.count).toBe(1) + + const msg2 = getLastSentMessage(ws2) + expect(msg2.type).toBe('STATE_DELTA') + expect(msg2.payload.delta.count).toBe(1) + }) + }) + + // ===== Default Room Handle Operations ===== + describe('$room Default Handle', () => { + it('$room methods use default room when room is set in constructor', () => { + const ws = createMockWs() + const events: string[] = [] + + class DefaultRoomComponent extends LiveComponent { + static componentName = 'DefaultRoomComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onRoomJoin(roomId: string) { + events.push(`join:${roomId}`) + } + + protected onRoomLeave(roomId: string) { + events.push(`leave:${roomId}`) + } + } + + const component = new DefaultRoomComponent({}, ws, { room: 'my-room' }) + + // Default room was auto-joined in constructor (but onRoomJoin is for $room.join() calls) + expect(component.$rooms).toContain('my-room') + }) + + it('throws error when calling $room methods without a default room', () => { + const ws = createMockWs() + + class NoRoomComponent extends LiveComponent { + static componentName = 'NoRoomComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + } + + const component = new NoRoomComponent({}, ws) + + // No default room — calling default methods should throw + expect(() => component.$room.join()).toThrow('No default room set') + expect(() => component.$room.leave()).toThrow('No default room set') + expect(() => component.$room.emit('test', {})).toThrow('No default room set') + }) + }) + + // ===== $rooms List ===== + describe('$rooms Array', () => { + it('tracks all joined rooms', () => { + const ws = createMockWs() + + class MultiRoomComponent extends LiveComponent { + static componentName = 'MultiRoomComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + } + + const component = new MultiRoomComponent({}, ws, { room: 'default' }) + component.$room('room-a').join() + component.$room('room-b').join() + + expect(component.$rooms).toContain('default') + expect(component.$rooms).toContain('room-a') + expect(component.$rooms).toContain('room-b') + expect(component.$rooms).toHaveLength(3) + }) + + it('removes rooms when leaving', () => { + const ws = createMockWs() + + class LeaveRoomComponent extends LiveComponent { + static componentName = 'LeaveRoomComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + } + + const component = new LeaveRoomComponent({}, ws, { room: 'default' }) + component.$room('temp').join() + expect(component.$rooms).toHaveLength(2) + + component.$room('temp').leave() + expect(component.$rooms).toHaveLength(1) + expect(component.$rooms).not.toContain('temp') + }) + + it('joining same room twice is idempotent', () => { + const ws = createMockWs() + + class IdempotentRoomComponent extends LiveComponent { + static componentName = 'IdempotentRoomComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + } + + const component = new IdempotentRoomComponent({}, ws, { room: 'default' }) + component.$room('room-x').join() + component.$room('room-x').join() // duplicate + + expect(component.$rooms.filter(r => r === 'room-x')).toHaveLength(1) + }) + }) + + // ===== State Proxy — Delta Emissions ===== + describe('State Proxy Delta Emissions', () => { + it('each proxy mutation sends a STATE_DELTA message', () => { + const ws = createMockWs() + + class DeltaComponent extends LiveComponent { + static componentName = 'DeltaComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + } + + const component = new DeltaComponent({ count: 0, label: 'test' }, ws) + component.state.count = 10 + component.state.label = 'changed' + + const calls = (ws.send as any).mock.calls + expect(calls.length).toBe(2) + + const msg1 = JSON.parse(calls[0][0]) + expect(msg1.type).toBe('STATE_DELTA') + expect(msg1.payload.delta).toEqual({ count: 10 }) + + const msg2 = JSON.parse(calls[1][0]) + expect(msg2.type).toBe('STATE_DELTA') + expect(msg2.payload.delta).toEqual({ label: 'changed' }) + }) + + it('setState sends a single STATE_DELTA with all changes', () => { + const ws = createMockWs() + + class BatchDeltaComponent extends LiveComponent { + static componentName = 'BatchDeltaComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + } + + const component = new BatchDeltaComponent({ count: 0, label: 'test' }, ws) + component.setState({ count: 99, label: 'batch' }) + + const calls = (ws.send as any).mock.calls + expect(calls.length).toBe(1) + + const msg = JSON.parse(calls[0][0]) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.payload.delta).toEqual({ count: 99, label: 'batch' }) + }) + }) + + // ===== Object.prototype Methods Blocked ===== + describe('Security: Object.prototype Blocking', () => { + it('blocks Object.prototype methods even if in publicActions', async () => { + const ws = createMockWs() + + class ProtoComponent extends LiveComponent { + static componentName = 'ProtoComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['toString', 'increment'] as const + + async increment() { return { ok: true } } + } + + const component = new ProtoComponent({}, ws) + await expect(component.executeAction('toString', {})).rejects.toThrow('not callable') + + // Normal action works + const result = await component.executeAction('increment', {}) + expect(result).toEqual({ ok: true }) + }) + }) + + // ===== Multiple executeAction Calls ===== + describe('Concurrent Action Execution', () => { + it('multiple actions can run and onAction fires for each', async () => { + const ws = createMockWs() + const actionLog: string[] = [] + + class MultiActionComponent extends LiveComponent { + static componentName = 'MultiActionComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment', 'setLabel'] as const + + protected onAction(action: string) { + actionLog.push(action) + } + + async increment() { + this.state.count++ + return { count: this.state.count } + } + + async setLabel(payload: { label: string }) { + this.state.label = payload.label + return { label: this.state.label } + } + } + + const component = new MultiActionComponent({ count: 0, label: 'test' }, ws) + + await component.executeAction('increment', {}) + await component.executeAction('setLabel', { label: 'hello' }) + await component.executeAction('increment', {}) + + expect(actionLog).toEqual(['increment', 'setLabel', 'increment']) + expect(component.state.count).toBe(2) + expect(component.state.label).toBe('hello') + }) + }) + + // ===== onAction Selective Cancellation ===== + describe('onAction Selective Cancel', () => { + it('can cancel specific actions while allowing others', async () => { + const ws = createMockWs() + + class SelectiveCancelComponent extends LiveComponent { + static componentName = 'SelectiveCancelComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + static publicActions = ['increment', 'reset'] as const + + protected onAction(action: string): void | false { + if (action === 'reset') return false + } + + async increment() { + this.state.count++ + return { count: this.state.count } + } + + async reset() { + this.state.count = 0 + return { count: 0 } + } + } + + const component = new SelectiveCancelComponent({ count: 0, label: 'test' }, ws) + + // increment allowed + const r1 = await component.executeAction('increment', {}) + expect(r1).toEqual({ count: 1 }) + + // reset cancelled + await expect(component.executeAction('reset', {})).rejects.toThrow('cancelled by onAction') + expect(component.state.count).toBe(1) // still 1, not reset + }) + }) + + // ===== Complete Lifecycle Order with Rehydration ===== + describe('Rehydration Lifecycle Order', () => { + it('follows onConnect → onRehydrate → onMount order', async () => { + const ws = createMockWs() + const order: string[] = [] + + class RehydrateLifecycleComponent extends LiveComponent { + static componentName = 'RehydrateLifecycleComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + + protected onConnect() { order.push('onConnect') } + protected onRehydrate(prev: CounterState) { order.push(`onRehydrate:count=${prev.count}`) } + protected onMount() { order.push('onMount') } + protected onDestroy() { order.push('onDestroy') } + } + + const component = new RehydrateLifecycleComponent({ count: 42, label: 'old' }, ws) + + // Simulate registry rehydration lifecycle + ;(component as any).onConnect() + ;(component as any).onRehydrate({ count: 42, label: 'old' }) + await (component as any).onMount() + + expect(order).toEqual([ + 'onConnect', + 'onRehydrate:count=42', + 'onMount' + ]) + + component.destroy() + expect(order).toContain('onDestroy') + }) + }) + + // ===== defaultState Merge ===== + describe('defaultState Merge with initialState', () => { + it('merges defaultState with partial initialState', () => { + const ws = createMockWs() + + class MergeComponent extends LiveComponent { + static componentName = 'MergeComponent' + static defaultState: CounterState = { count: 0, label: 'default-label' } + } + + // Only provide count, label should come from defaultState + const component = new MergeComponent({ count: 42 } as any, ws) + expect(component.state.count).toBe(42) + expect(component.state.label).toBe('default-label') + }) + + it('initialState overrides defaultState', () => { + const ws = createMockWs() + + class OverrideComponent extends LiveComponent { + static componentName = 'OverrideComponent' + static defaultState: CounterState = { count: 0, label: 'default' } + } + + const component = new OverrideComponent({ count: 100, label: 'custom' }, ws) + expect(component.state.count).toBe(100) + expect(component.state.label).toBe('custom') + }) + }) + + // ===== All Hooks No-Op by Default ===== + describe('Default Hook No-Ops', () => { + it('component with no hooks overridden works correctly', async () => { + const ws = createMockWs() + + class PlainComponent extends LiveComponent { + static componentName = 'PlainComponent' + static defaultState: CounterState = { count: 0, label: 'plain' } + static publicActions = ['increment'] as const + + async increment() { + this.state.count++ + return { count: this.state.count } + } + } + + const component = new PlainComponent({ count: 0, label: 'plain' }, ws) + + // All lifecycle hooks should be no-ops + expect(() => (component as any).onConnect()).not.toThrow() + expect(() => (component as any).onDisconnect()).not.toThrow() + // onMount returns void (not a Promise) by default + expect((component as any).onMount()).toBeUndefined() + expect(() => (component as any).onStateChange({})).not.toThrow() + expect(() => (component as any).onRoomJoin('test')).not.toThrow() + expect(() => (component as any).onRoomLeave('test')).not.toThrow() + expect(() => (component as any).onRehydrate({})).not.toThrow() + expect(await (component as any).onAction('test', {})).toBeUndefined() + + // Normal operations work + const result = await component.executeAction('increment', {}) + expect(result).toEqual({ count: 1 }) + + expect(() => component.destroy()).not.toThrow() + }) + }) + + // ===== getSerializableState ===== + describe('getSerializableState', () => { + it('returns current state without internal fields', () => { + const ws = createMockWs() + + class SerializableComponent extends LiveComponent { + static componentName = 'SerializableComponent' + static defaultState: CounterState = { count: 5, label: 'serialize-me' } + } + + const component = new SerializableComponent({ count: 5, label: 'serialize-me' }, ws) + const state = component.getSerializableState() + + expect(state).toEqual({ count: 5, label: 'serialize-me' }) + }) + + it('reflects state changes via proxy', () => { + const ws = createMockWs() + + class SerializableComponent extends LiveComponent { + static componentName = 'SerializableComponent2' + static defaultState: CounterState = { count: 0, label: 'test' } + } + + const component = new SerializableComponent({ count: 0, label: 'test' }, ws) + component.state.count = 77 + component.state.label = 'updated' + + const state = component.getSerializableState() + expect(state).toEqual({ count: 77, label: 'updated' }) + }) + }) + + // ===== Emit Message Format ===== + describe('Emit Message Format', () => { + it('emit sends correctly structured LiveMessage', () => { + const ws = createMockWs() + + class EmitComponent extends LiveComponent { + static componentName = 'EmitComponent' + static defaultState: CounterState = { count: 0, label: 'test' } + } + + const component = new EmitComponent({ count: 0, label: 'test' }, ws, { room: 'my-room', userId: 'user-123' }) + component.state.count = 1 + + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.componentId).toBe(component.id) + expect(msg.payload.delta).toEqual({ count: 1 }) + expect(msg.timestamp).toBeDefined() + expect(msg.userId).toBe('user-123') + expect(msg.room).toBe('my-room') + }) + }) +})