diff --git a/core/server/live/ComponentRegistry.ts b/core/server/live/ComponentRegistry.ts index 189fd4a..8ea5a63 100644 --- a/core/server/live/ComponentRegistry.ts +++ b/core/server/live/ComponentRegistry.ts @@ -312,6 +312,11 @@ export class ComponentRegistry { liveLog('lifecycle', existing.instance.id, `๐Ÿ”— Singleton '${componentName}' โ€” new connection joined (${existing.connections.size} total)`) + // ๐Ÿ”„ Lifecycle: notify singleton about new client + try { (existing.instance as any).onClientJoin(connId, existing.connections.size) } catch (err: any) { + console.error(`[${componentName}] onClientJoin error:`, err?.message || err) + } + return { componentId: existing.instance.id, initialState: existing.instance.getSerializableState(), @@ -378,7 +383,9 @@ export class ComponentRegistry { type: type as any, componentId: component.id, payload, - timestamp: Date.now() + timestamp: Date.now(), + userId: component.userId, + room: component.room } const serialized = JSON.stringify(message) const singleton = this.singletons.get(componentName) @@ -398,6 +405,11 @@ export class ComponentRegistry { } liveLog('lifecycle', component.id, `๐Ÿ”— Singleton '${componentName}' created`) + + // ๐Ÿ”„ Lifecycle: notify singleton about first client + try { (component as any).onClientJoin(connId, 1) } catch (err: any) { + console.error(`[${componentName}] onClientJoin error:`, err?.message || err) + } } // Update metadata state @@ -649,6 +661,22 @@ export class ComponentRegistry { } } + /** Check if a component ID belongs to a singleton */ + private isSingletonComponent(componentId: string): boolean { + for (const [, singleton] of this.singletons) { + if (singleton.instance.id === componentId) return true + } + return false + } + + /** Get the singleton entry by component ID */ + private getSingletonByComponentId(componentId: string): { instance: LiveComponent; connections: Map } | null { + for (const [, singleton] of this.singletons) { + if (singleton.instance.id === componentId) return singleton + } + return null + } + /** * Remove a single connection from a singleton. If no connections remain, destroy it. * @returns true if the component was a singleton (handled), false otherwise @@ -660,7 +688,10 @@ export class ComponentRegistry { if (connId) singleton.connections.delete(connId) if (singleton.connections.size === 0) { - // Last connection gone โ€” destroy singleton fully + // Last connection gone โ€” call onDisconnect then destroy singleton fully + try { (singleton.instance as any).onDisconnect() } catch (err: any) { + console.error(`[${componentId}] onDisconnect error:`, err?.message || err) + } this.cleanupComponent(componentId) this.singletons.delete(name) liveLog('lifecycle', componentId, `๐Ÿ—‘๏ธ Singleton '${name}' destroyed (${context}: no connections remaining)`) @@ -681,6 +712,16 @@ export class ComponentRegistry { if (ws) { const connId = ws.data?.connectionId ws.data?.components?.delete(componentId) + + // Notify singleton about client leaving (before connection removal) + if (this.isSingletonComponent(componentId)) { + const singleton = this.getSingletonByComponentId(componentId) + const remainingAfterRemoval = singleton ? singleton.connections.size - 1 : 0 + try { (component as any).onClientLeave(connId || 'unknown', Math.max(0, remainingAfterRemoval)) } catch (err: any) { + console.error(`[${componentId}] onClientLeave error:`, err?.message || err) + } + } + if (this.removeSingletonConnection(componentId, connId, 'unmount')) return } else { if (this.removeSingletonConnection(componentId, undefined, 'unmount')) return @@ -918,9 +959,21 @@ 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) { + + // Check if this is a singleton component + const isSingleton = this.isSingletonComponent(componentId) + + if (component && isSingleton) { + // For singletons: call onClientLeave (per-connection) instead of onDisconnect + // onDisconnect only fires when the last client leaves (handled in removeSingletonConnection) + const singleton = this.getSingletonByComponentId(componentId) + const remainingAfterRemoval = singleton ? singleton.connections.size - 1 : 0 + try { (component as any).onClientLeave(connId || 'unknown', Math.max(0, remainingAfterRemoval)) } catch (err: any) { + console.error(`[${componentId}] onClientLeave error:`, err?.message || err) + } + } else if (component) { + // Non-singleton: call onDisconnect as before try { (component as any).onDisconnect() } catch (err: any) { console.error(`[${componentId}] onDisconnect error:`, err?.message || err) } diff --git a/core/types/types.ts b/core/types/types.ts index affdb22..fb297da 100644 --- a/core/types/types.ts +++ b/core/types/types.ts @@ -413,7 +413,9 @@ export abstract class LiveComponent { if (!self.joinedRooms.has(roomId)) return self.joinedRooms.delete(roomId) liveRoomManager.leaveRoom(self.id, roomId) - try { self.onRoomLeave(roomId) } catch {} + try { self.onRoomLeave(roomId) } catch (err: any) { + console.error(`[${self.id}] onRoomLeave error:`, err?.message || err) + } }, emit: (event: string, data: any): number => { @@ -755,21 +761,68 @@ export abstract class LiveComponent {} + /** + * [Singleton only] Called when a new client connection joins the singleton. + * Fires for EVERY new client including the first. + * Use for visitor counting, presence tracking, etc. + * + * @param connectionId - The connection identifier of the new client + * @param connectionCount - Total number of active connections after join + * + * @example + * protected onClientJoin(connectionId: string, connectionCount: number) { + * this.state.visitors = connectionCount + * } + */ + protected onClientJoin(connectionId: string, connectionCount: number): void {} + + /** + * [Singleton only] Called when a client disconnects from the singleton. + * Fires for EVERY leaving client. Use for presence tracking, cleanup. + * + * @param connectionId - The connection identifier of the leaving client + * @param connectionCount - Total number of active connections after leave + * + * @example + * protected onClientLeave(connectionId: string, connectionCount: number) { + * this.state.visitors = connectionCount + * if (connectionCount === 0) { + * // Last client left โ€” save state or cleanup + * } + * } + */ + protected onClientLeave(connectionId: string, connectionCount: number): 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 - Object.assign(this._state as object, newUpdates) - // Delta sync - send only the changed properties - this.emit('STATE_DELTA', { delta: newUpdates }) + + // Filter to only keys that actually changed (consistent with proxy behavior) + const actualChanges: Partial = {} as Partial + let hasChanges = false + for (const key of Object.keys(newUpdates as object) as Array) { + if ((this._state as any)[key] !== (newUpdates as any)[key]) { + (actualChanges as any)[key] = (newUpdates as any)[key] + hasChanges = true + } + } + + if (!hasChanges) return // No-op: nothing actually changed + + Object.assign(this._state as object, actualChanges) + // Delta sync - send only the actually changed properties + this.emit('STATE_DELTA', { delta: actualChanges }) // Lifecycle hook: onStateChange (with recursion guard) if (!this._inStateChange) { this._inStateChange = true - try { this.onStateChange(newUpdates) } catch {} finally { this._inStateChange = false } + try { this.onStateChange(actualChanges) } catch (err: any) { + console.error(`[${this.id}] onStateChange error:`, err?.message || err) + } finally { this._inStateChange = false } } // Debug: track state change _liveDebugger?.trackStateChange( this.id, - newUpdates as Record, + actualChanges as Record, this._state as Record, 'setState' ) @@ -800,6 +853,7 @@ export abstract class LiveComponent + try { + hookResult = await this.onAction(action, payload) + } catch (hookError: any) { + // If onAction itself threw, treat as action error + // but don't leak hook internals to the client + _liveDebugger?.trackActionError(this.id, action, hookError.message, Date.now() - actionStart) + this.emit('ERROR', { + action, + error: `Action '${action}' failed pre-validation` + }) + throw hookError + } if (hookResult === false) { - throw new Error(`Action '${action}' cancelled by onAction hook`) + // Cancelled actions are NOT errors โ€” do not emit ERROR to client + _liveDebugger?.trackActionError(this.id, action, 'Action cancelled', Date.now() - actionStart) + throw new Error(`Action '${action}' was cancelled`) } // Execute method @@ -879,13 +947,15 @@ 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, EMIT_OVERRIDE_KEY } 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 getAllSentMessages(ws: FluxStackWebSocket): any[] { + const sendMock = ws.send as ReturnType + return sendMock.mock.calls.map((call: any[]) => JSON.parse(call[0])) +} + +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 + name: string +} + +class RateLimitedComponent extends LiveComponent { + static componentName = 'RateLimitedComponent' + static defaultState: CounterState = { count: 0, name: 'test' } + static publicActions = ['increment', 'blockedAction'] as const + + private _callCount = 0 + + protected onAction(action: string, payload: any): void | false | Promise { + this._callCount++ + if (this._callCount > 2) return false // Rate limit after 2 calls + } + + async increment() { + this.state.count++ + return { success: true } + } + + async blockedAction() { + return { blocked: false } + } +} + +class StateChangeLoggingComponent extends LiveComponent { + static componentName = 'StateChangeLoggingComponent' + static defaultState: CounterState = { count: 0, name: 'test' } + static publicActions = ['setCount'] as const + + stateChangeLog: Array> = [] + + protected onStateChange(changes: Partial): void { + this.stateChangeLog.push(changes) + } + + async setCount(payload: { count: number }) { + this.setState({ count: payload.count }) + return { success: true } + } +} + +class ThrowingStateChangeComponent extends LiveComponent { + static componentName = 'ThrowingStateChangeComponent' + static defaultState: CounterState = { count: 0, name: 'test' } + static publicActions = ['setCount'] as const + + protected onStateChange(changes: Partial): void { + throw new Error('onStateChange exploded!') + } + + async setCount(payload: { count: number }) { + this.setState({ count: payload.count }) + return { success: true } + } +} + +interface DashboardState { + visitors: number + lastVisitor: string +} + +class SingletonDashboard extends LiveComponent { + static componentName = 'SingletonDashboard' + static singleton = true + static defaultState: DashboardState = { visitors: 0, lastVisitor: '' } + static publicActions = ['recordVisit'] as const + + connectCalls: string[] = [] + disconnectCalls: string[] = [] + clientJoinCalls: Array<{ connectionId: string; connectionCount: number }> = [] + clientLeaveCalls: Array<{ connectionId: string; connectionCount: number }> = [] + + protected onConnect(): void { + this.connectCalls.push('connected') + } + + protected onDisconnect(): void { + this.disconnectCalls.push('disconnected') + } + + protected onClientJoin(connectionId: string, connectionCount: number): void { + this.clientJoinCalls.push({ connectionId, connectionCount }) + } + + protected onClientLeave(connectionId: string, connectionCount: number): void { + this.clientLeaveCalls.push({ connectionId, connectionCount }) + } + + async recordVisit(payload: { visitor: string }) { + this.state.visitors++ + this.state.lastVisitor = payload.visitor + return { success: true } + } +} + +// ===================================================== +// ๐Ÿ› BUG 1: onAction cancellation leaks info to client +// ===================================================== +describe('๐Ÿ› Bug #1: onAction cancellation should NOT emit ERROR to client', () => { + let ws: FluxStackWebSocket + let component: RateLimitedComponent + + beforeEach(() => { + ws = createMockWs() + component = new RateLimitedComponent({}, ws) + }) + + it('should NOT send ERROR message when onAction returns false (cancellation)', async () => { + // First 2 calls pass + await component.executeAction('increment', {}) + await component.executeAction('increment', {}) + ;(ws.send as ReturnType).mockClear() + + // 3rd call should be cancelled by onAction + await expect(component.executeAction('blockedAction', {})).rejects.toThrow() + + // BUG: The cancellation currently emits an ERROR message to the client + // revealing internal server logic ("cancelled by onAction hook") + const messages = getAllSentMessages(ws) + const errorMessages = messages.filter(m => m.type === 'ERROR') + + // After fix: no ERROR should be emitted for cancelled actions + expect(errorMessages).toHaveLength(0) + }) + + it('cancelled action error should NOT reveal internal hook details', async () => { + await component.executeAction('increment', {}) + await component.executeAction('increment', {}) + + try { + await component.executeAction('blockedAction', {}) + } catch (err: any) { + // The error should be generic, not mentioning "onAction hook" + expect(err.message).not.toContain('onAction hook') + expect(err.message).toContain('cancelled') + } + }) +}) + +// ===================================================== +// ๐Ÿ› BUG 2: setState emits delta when values unchanged +// ===================================================== +describe('๐Ÿ› Bug #2: setState should skip emit when values are unchanged', () => { + let ws: FluxStackWebSocket + let component: StateChangeLoggingComponent + + beforeEach(() => { + ws = createMockWs() + component = new StateChangeLoggingComponent({}, ws) + }) + + it('should NOT emit STATE_DELTA when setState sets the same values', () => { + // Initial state is { count: 0, name: 'test' } + ;(ws.send as ReturnType).mockClear() + + // Set the SAME values + component.setState({ count: 0, name: 'test' }) + + // BUG: Currently emits STATE_DELTA even though nothing changed + const messages = getAllSentMessages(ws) + const deltaMessages = messages.filter(m => m.type === 'STATE_DELTA') + + // After fix: no delta should be emitted for unchanged values + expect(deltaMessages).toHaveLength(0) + }) + + it('should NOT call onStateChange when setState sets the same values', () => { + component.stateChangeLog = [] + + // Set the SAME values + component.setState({ count: 0, name: 'test' }) + + // After fix: onStateChange should not be called for no-ops + expect(component.stateChangeLog).toHaveLength(0) + }) + + it('should emit STATE_DELTA only for keys that actually changed', () => { + ;(ws.send as ReturnType).mockClear() + + // Set count to new value but name stays the same + component.setState({ count: 5, name: 'test' }) + + const messages = getAllSentMessages(ws) + const deltaMessages = messages.filter(m => m.type === 'STATE_DELTA') + + expect(deltaMessages).toHaveLength(1) + // Only the changed key should be in the delta + expect(deltaMessages[0].payload.delta).toEqual({ count: 5 }) + }) + + it('proxy set should remain consistent โ€” skip emit for unchanged values', () => { + ;(ws.send as ReturnType).mockClear() + + // Proxy already checks oldValue !== value, this should NOT emit + component.state.count = 0 // Same value + + const messages = getAllSentMessages(ws) + const deltaMessages = messages.filter(m => m.type === 'STATE_DELTA') + expect(deltaMessages).toHaveLength(0) + }) +}) + +// ===================================================== +// ๐Ÿ› BUG 3: Silent catch {} in onStateChange proxy +// ===================================================== +describe('๐Ÿ› Bug #3: onStateChange errors should be logged, not silently swallowed', () => { + let ws: FluxStackWebSocket + let component: ThrowingStateChangeComponent + let consoleErrorSpy: ReturnType + + beforeEach(() => { + ws = createMockWs() + component = new ThrowingStateChangeComponent({}, ws) + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + }) + + it('should log error when onStateChange throws during proxy mutation', () => { + // Trigger proxy set which calls onStateChange + component.state.count = 42 + + // BUG: Currently the error is silently swallowed with empty catch {} + // After fix: should log the error + expect(consoleErrorSpy).toHaveBeenCalled() + const errorCall = consoleErrorSpy.mock.calls.find(call => + typeof call[0] === 'string' && call[0].includes('onStateChange') + ) + expect(errorCall).toBeDefined() + }) + + it('should log error when onStateChange throws during setState', () => { + consoleErrorSpy.mockClear() + + component.setState({ count: 99 }) + + // BUG: Currently the error is silently swallowed with empty catch {} + // After fix: should log the error + expect(consoleErrorSpy).toHaveBeenCalled() + const errorCall = consoleErrorSpy.mock.calls.find(call => + typeof call[0] === 'string' && call[0].includes('onStateChange') + ) + expect(errorCall).toBeDefined() + }) + + it('should log error when onRoomJoin hook throws', () => { + consoleErrorSpy.mockClear() + + class ThrowingRoomJoinComponent extends LiveComponent { + static componentName = 'ThrowingRoomJoinComponent' + static defaultState: CounterState = { count: 0, name: 'test' } + + protected onRoomJoin(roomId: string): void { + throw new Error('onRoomJoin exploded!') + } + } + + const comp = new ThrowingRoomJoinComponent({}, ws, { room: 'test-room' }) + // Trigger room join via $room + comp.$room('my-room').join() + + expect(consoleErrorSpy).toHaveBeenCalled() + const errorCall = consoleErrorSpy.mock.calls.find(call => + typeof call[0] === 'string' && call[0].includes('onRoomJoin') + ) + expect(errorCall).toBeDefined() + }) + + it('should log error when onRoomLeave hook throws', () => { + consoleErrorSpy.mockClear() + + class ThrowingRoomLeaveComponent extends LiveComponent { + static componentName = 'ThrowingRoomLeaveComponent' + static defaultState: CounterState = { count: 0, name: 'test' } + + protected onRoomLeave(roomId: string): void { + throw new Error('onRoomLeave exploded!') + } + } + + const comp = new ThrowingRoomLeaveComponent({}, ws, { room: 'test-room' }) + comp.$room('my-room').join() + consoleErrorSpy.mockClear() + comp.$room('my-room').leave() + + expect(consoleErrorSpy).toHaveBeenCalled() + const errorCall = consoleErrorSpy.mock.calls.find(call => + typeof call[0] === 'string' && call[0].includes('onRoomLeave') + ) + expect(errorCall).toBeDefined() + }) +}) + +// ===================================================== +// ๐Ÿ› BUG 4: Singleton broadcast missing userId/room +// ===================================================== +describe('๐Ÿ› Bug #4: Singleton broadcast should include userId and room metadata', () => { + it('normal (non-singleton) emit includes userId and room', () => { + const ws = createMockWs('conn-1') + const component = new SingletonDashboard({}, ws, { userId: 'user-1', room: 'dashboard' }) + + ;(ws.send as ReturnType).mockClear() + + // Trigger a state change (normal emit path) + component.state.visitors = 5 + + const msg = getLastSentMessage(ws) + expect(msg.userId).toBe('user-1') + expect(msg.room).toBe('dashboard') + }) + + it('singleton emit override should include userId and room (contract test)', () => { + const ws1 = createMockWs('conn-1') + const ws2 = createMockWs('conn-2') + + const component = new SingletonDashboard({}, ws1, { userId: 'user-1', room: 'dashboard' }) + + // Simulate the FIXED emit override as ComponentRegistry would set up + // This test validates the contract: the override MUST include userId and room + const connections = new Map() + connections.set('conn-1', ws1) + connections.set('conn-2', ws2) + + ;(component as any)[EMIT_OVERRIDE_KEY] = (type: string, payload: any) => { + const message = { + type: type, + componentId: component.id, + payload, + timestamp: Date.now(), + // These MUST be present โ€” the fix ensures ComponentRegistry includes them + userId: component.userId, + room: component.room + } + const serialized = JSON.stringify(message) + for (const [, connWs] of connections) { + try { connWs.send(serialized) } catch {} + } + } + + ;(ws1.send as ReturnType).mockClear() + ;(ws2.send as ReturnType).mockClear() + + // Trigger a state change which will emit via the override + component.state.visitors = 5 + + const msg1 = getLastSentMessage(ws1) + const msg2 = getLastSentMessage(ws2) + + expect(msg1.userId).toBe('user-1') + expect(msg1.room).toBe('dashboard') + expect(msg2.userId).toBe('user-1') + expect(msg2.room).toBe('dashboard') + }) +}) + +// ===================================================== +// ๐Ÿ› BUG 5: Singleton onDisconnect fires for every client +// ===================================================== +describe('๐Ÿ› Bug #5: Singleton onDisconnect should only fire when last client disconnects', () => { + it('should have onClientJoin/onClientLeave hooks for per-connection notifications', () => { + const ws = createMockWs() + const component = new SingletonDashboard({}, ws) + + // The new hooks should exist on the component + expect(typeof (component as any).onClientJoin).toBe('function') + expect(typeof (component as any).onClientLeave).toBe('function') + }) + + it('onClientJoin should receive connectionId and count', () => { + const ws = createMockWs() + const component = new SingletonDashboard({}, ws) + + // Simulate a client join notification + ;(component as any).onClientJoin('conn-2', 2) + + expect(component.clientJoinCalls).toHaveLength(1) + expect(component.clientJoinCalls[0]).toEqual({ connectionId: 'conn-2', connectionCount: 2 }) + }) + + it('onClientLeave should receive connectionId and remaining count', () => { + const ws = createMockWs() + const component = new SingletonDashboard({}, ws) + + // Simulate a client leave notification + ;(component as any).onClientLeave('conn-2', 1) + + expect(component.clientLeaveCalls).toHaveLength(1) + expect(component.clientLeaveCalls[0]).toEqual({ connectionId: 'conn-2', connectionCount: 1 }) + }) +}) + +// ===================================================== +// ๐Ÿ› BUG 6: onAction throwing vs returning false +// ===================================================== +describe('๐Ÿ› Bug #6: onAction that throws should propagate error correctly', () => { + it('should handle onAction that throws (not returns false) as an action error', async () => { + class ThrowingActionHookComponent extends LiveComponent { + static componentName = 'ThrowingActionHookComponent' + static defaultState: CounterState = { count: 0, name: 'test' } + static publicActions = ['doSomething'] as const + + protected onAction(action: string, payload: any): void | false { + throw new Error('Pre-validation failed: invalid token') + } + + async doSomething() { + return { success: true } + } + } + + const ws = createMockWs() + const component = new ThrowingActionHookComponent({}, ws) + + // onAction throws โ€” this should propagate as an error, not crash + await expect(component.executeAction('doSomething', {})) + .rejects.toThrow('Pre-validation failed: invalid token') + + // But the error message sent to client should NOT reveal "onAction hook" details + const errorMsg = getLastSentMessage(ws) + expect(errorMsg.type).toBe('ERROR') + expect(errorMsg.payload.error).not.toContain('onAction') + }) +}) + +// ===================================================== +// Additional regression: setState with function updater +// ===================================================== +describe('setState with function updater should also skip unchanged values', () => { + let ws: FluxStackWebSocket + let component: StateChangeLoggingComponent + + beforeEach(() => { + ws = createMockWs() + component = new StateChangeLoggingComponent({}, ws) + }) + + it('should not emit when function updater returns same values', () => { + ;(ws.send as ReturnType).mockClear() + component.stateChangeLog = [] + + component.setState((prev) => ({ count: prev.count })) // Returns same count + + const messages = getAllSentMessages(ws) + const deltaMessages = messages.filter(m => m.type === 'STATE_DELTA') + + expect(deltaMessages).toHaveLength(0) + expect(component.stateChangeLog).toHaveLength(0) + }) +}) + +// ===================================================== +// Lifecycle hooks should be blocked from BLOCKED_ACTIONS +// ===================================================== +describe('New lifecycle hooks (onClientJoin/onClientLeave) should be blocked from remote execution', () => { + it('should block onClientJoin from being called remotely', async () => { + const ws = createMockWs() + const component = new SingletonDashboard({}, ws) + + await expect(component.executeAction('onClientJoin', { connectionId: 'fake' })) + .rejects.toThrow("is not callable") + }) + + it('should block onClientLeave from being called remotely', async () => { + const ws = createMockWs() + const component = new SingletonDashboard({}, ws) + + await expect(component.executeAction('onClientLeave', { connectionId: 'fake' })) + .rejects.toThrow("is not callable") + }) +}) diff --git a/tests/unit/core/live-component-dx.test.ts b/tests/unit/core/live-component-dx.test.ts index 48679ec..db671c6 100644 --- a/tests/unit/core/live-component-dx.test.ts +++ b/tests/unit/core/live-component-dx.test.ts @@ -846,7 +846,7 @@ describe('LiveComponent DX Enhancements', () => { const component = new CancelComponent({ count: 0, label: 'test' }, ws) - await expect(component.executeAction('increment', {})).rejects.toThrow('cancelled by onAction') + await expect(component.executeAction('increment', {})).rejects.toThrow('was cancelled') expect(executed).toBe(false) }) @@ -1013,7 +1013,7 @@ describe('LiveComponent DX โ€” Extended Coverage', () => { } const component = new AsyncCancelComponent({}, ws) - await expect(component.executeAction('doStuff', {})).rejects.toThrow('cancelled by onAction') + await expect(component.executeAction('doStuff', {})).rejects.toThrow('was cancelled') expect(executed).toBe(false) }) @@ -1674,7 +1674,7 @@ describe('LiveComponent DX โ€” Extended Coverage', () => { expect(r1).toEqual({ count: 1 }) // reset cancelled - await expect(component.executeAction('reset', {})).rejects.toThrow('cancelled by onAction') + await expect(component.executeAction('reset', {})).rejects.toThrow('was cancelled') expect(component.state.count).toBe(1) // still 1, not reset }) })