From c82428bbd73b23b1a9fa6f3f419613956b378af6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 14:18:54 +0000 Subject: [PATCH 1/2] Add delta state updates for Live Components Instead of sending the entire state object on every mutation, Live Components now emit STATE_DELTA with only the changed properties. This reduces WebSocket payload size and bandwidth usage, especially for components with large state objects. - Server proxy set trap: emits delta with single changed property - Server setState(): emits delta with partial updates - Client: merges STATE_DELTA into existing state via shallow merge - Full STATE_UPDATE preserved for mount/rehydrate scenarios https://claude.ai/code/session_01HB4ENmFebWTsco9UMa1z3D --- core/client/hooks/useLiveComponent.ts | 8 ++++++++ core/types/types.ts | 15 ++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/core/client/hooks/useLiveComponent.ts b/core/client/hooks/useLiveComponent.ts index a3f1358d..3c3cd864 100644 --- a/core/client/hooks/useLiveComponent.ts +++ b/core/client/hooks/useLiveComponent.ts @@ -573,6 +573,14 @@ export function useLiveComponent< } } break + case 'STATE_DELTA': + if (message.payload?.delta) { + const oldState = storeRef.current?.getState().state ?? stateData + const mergedState = { ...oldState, ...message.payload.delta } as TState + updateState(mergedState) + onStateChange?.(mergedState, oldState) + } + break case 'STATE_REHYDRATED': if (message.payload?.state && message.payload?.newComponentId) { setComponentId(message.payload.newComponentId) diff --git a/core/types/types.ts b/core/types/types.ts index d5083ad5..8ab0e17c 100644 --- a/core/types/types.ts +++ b/core/types/types.ts @@ -46,7 +46,7 @@ export type FluxStackServerWebSocket = ServerWebSocket export interface LiveMessage { type: 'COMPONENT_MOUNT' | 'COMPONENT_UNMOUNT' | 'COMPONENT_REHYDRATE' | 'COMPONENT_ACTION' | 'CALL_ACTION' | - 'ACTION_RESPONSE' | 'PROPERTY_UPDATE' | 'STATE_UPDATE' | 'STATE_REHYDRATED' | + 'ACTION_RESPONSE' | 'PROPERTY_UPDATE' | 'STATE_UPDATE' | 'STATE_DELTA' | 'STATE_REHYDRATED' | 'ERROR' | 'BROADCAST' | 'FILE_UPLOAD_START' | 'FILE_UPLOAD_CHUNK' | 'FILE_UPLOAD_COMPLETE' | 'COMPONENT_PING' | 'COMPONENT_PONG' | // Room system messages @@ -117,7 +117,7 @@ export interface WebSocketMessage { } export interface WebSocketResponse { - type: 'MESSAGE_RESPONSE' | 'CONNECTION_ESTABLISHED' | 'ERROR' | 'BROADCAST' | 'ACTION_RESPONSE' | 'COMPONENT_MOUNTED' | 'COMPONENT_REHYDRATED' | 'STATE_UPDATE' | 'STATE_REHYDRATED' | 'FILE_UPLOAD_PROGRESS' | 'FILE_UPLOAD_COMPLETE' | 'FILE_UPLOAD_ERROR' | 'FILE_UPLOAD_START_RESPONSE' | 'COMPONENT_PONG' | + type: 'MESSAGE_RESPONSE' | 'CONNECTION_ESTABLISHED' | 'ERROR' | 'BROADCAST' | 'ACTION_RESPONSE' | 'COMPONENT_MOUNTED' | 'COMPONENT_REHYDRATED' | 'STATE_UPDATE' | 'STATE_DELTA' | 'STATE_REHYDRATED' | 'FILE_UPLOAD_PROGRESS' | 'FILE_UPLOAD_COMPLETE' | 'FILE_UPLOAD_ERROR' | 'FILE_UPLOAD_START_RESPONSE' | 'COMPONENT_PONG' | // Room system responses 'ROOM_EVENT' | 'ROOM_STATE' | 'ROOM_SYSTEM' | 'ROOM_JOINED' | 'ROOM_LEFT' originalType?: string @@ -281,7 +281,7 @@ export abstract class LiveComponent { } } - // Create a Proxy that auto-emits STATE_UPDATE on any mutation + // Create a Proxy that auto-emits STATE_DELTA on any mutation private createStateProxy(state: TState): TState { const self = this return new Proxy(state as object, { @@ -289,8 +289,8 @@ export abstract class LiveComponent { const oldValue = (target as any)[prop] if (oldValue !== value) { (target as any)[prop] = value - // Auto-sync to frontend - self.emit('STATE_UPDATE', { state: self._state }) + // Delta sync - send only the changed property + self.emit('STATE_DELTA', { delta: { [prop]: value } }) } return true }, @@ -417,11 +417,12 @@ export abstract class LiveComponent { return Array.from(this.joinedRooms) } - // State management (batch update - single emit) + // 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) - this.emit('STATE_UPDATE', { state: this._state }) + // Delta sync - send only the changed properties + this.emit('STATE_DELTA', { delta: newUpdates }) } // Generic setValue action - set any state key with type safety From 8c71aadc99709a7e0fbe80937a2d704a36180893 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 14:23:05 +0000 Subject: [PATCH 2/2] Add tests for Live Component delta state updates 16 tests covering: - Proxy set emits STATE_DELTA with only changed property - setState emits STATE_DELTA with partial updates (single message) - No emit when value is unchanged - Function updater form support - Internal state consistency after delta mutations - Action-driven state changes via proxy and setState - Message format (componentId, timestamp) https://claude.ai/code/session_01HB4ENmFebWTsco9UMa1z3D --- tests/unit/core/live-component-delta.test.ts | 229 +++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/unit/core/live-component-delta.test.ts diff --git a/tests/unit/core/live-component-delta.test.ts b/tests/unit/core/live-component-delta.test.ts new file mode 100644 index 00000000..a3d237cd --- /dev/null +++ b/tests/unit/core/live-component-delta.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +/** + * Tests for Live Component Delta State Updates + * + * Validates that state mutations emit STATE_DELTA (partial) instead of + * STATE_UPDATE (full state), reducing WebSocket payload size. + */ + +// Mock the room dependencies before importing the module +vi.mock('@core/server/live/RoomEventBus', () => ({ + 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' + +// Concrete test component +interface TestState { + count: number + name: string + items: string[] +} + +class TestComponent extends LiveComponent { + static componentName = 'TestComponent' + static defaultState: TestState = { count: 0, name: 'default', items: [] } + + async increment() { + this.state.count++ + return { success: true } + } + + async setName(payload: { name: string }) { + this.state.name = payload.name + return { success: true } + } + + async batchUpdate(payload: { count: number; name: string }) { + this.setState({ count: payload.count, name: payload.name }) + return { success: true } + } +} + +function createMockWs(): FluxStackWebSocket { + return { + send: vi.fn(), + close: vi.fn(), + data: { + connectionId: 'test-conn', + 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 +} + +function getAllSentMessages(ws: FluxStackWebSocket): any[] { + const sendMock = ws.send as ReturnType + return sendMock.mock.calls.map((call: any[]) => JSON.parse(call[0])) +} + +describe('LiveComponent Delta State Updates', () => { + let ws: FluxStackWebSocket + let component: TestComponent + + beforeEach(() => { + ws = createMockWs() + component = new TestComponent({}, ws) + // Clear constructor-related sends + ;(ws.send as ReturnType).mockClear() + }) + + describe('Proxy set (this.state.prop = value)', () => { + it('should emit STATE_DELTA with only the changed property', () => { + component.state.count = 42 + + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.payload).toEqual({ delta: { count: 42 } }) + }) + + it('should not include unchanged properties in delta', () => { + component.state.name = 'updated' + + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.payload).toEqual({ delta: { name: 'updated' } }) + // Should NOT contain count or items + expect(msg.payload.delta.count).toBeUndefined() + expect(msg.payload.delta.items).toBeUndefined() + }) + + it('should not emit if value is the same', () => { + component.state.count = 0 // same as default + + expect(ws.send).not.toHaveBeenCalled() + }) + + it('should emit separate deltas for sequential property changes', () => { + component.state.count = 1 + component.state.name = 'hello' + + const messages = getAllSentMessages(ws) + expect(messages).toHaveLength(2) + expect(messages[0].payload).toEqual({ delta: { count: 1 } }) + expect(messages[1].payload).toEqual({ delta: { name: 'hello' } }) + }) + }) + + describe('setState (batch update)', () => { + it('should emit STATE_DELTA with partial updates', () => { + component.setState({ count: 10, name: 'batch' }) + + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.payload).toEqual({ delta: { count: 10, name: 'batch' } }) + }) + + it('should emit single STATE_DELTA for multiple properties', () => { + component.setState({ count: 5, name: 'multi' }) + + const messages = getAllSentMessages(ws) + // setState emits a single message (not one per property) + expect(messages).toHaveLength(1) + expect(messages[0].payload.delta).toEqual({ count: 5, name: 'multi' }) + }) + + it('should support function updater form', () => { + component.setState(prev => ({ count: prev.count + 1 })) + + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.payload).toEqual({ delta: { count: 1 } }) + }) + + it('should not include unchanged properties from function updater', () => { + component.setState(prev => ({ count: prev.count + 5 })) + + const msg = getLastSentMessage(ws) + expect(msg.payload.delta).toEqual({ count: 5 }) + expect(msg.payload.delta.name).toBeUndefined() + }) + }) + + describe('Internal state consistency', () => { + it('should update internal state correctly with proxy set', () => { + component.state.count = 99 + expect(component.state.count).toBe(99) + expect(component.getSerializableState().count).toBe(99) + }) + + it('should update internal state correctly with setState', () => { + component.setState({ count: 50, name: 'test' }) + expect(component.state.count).toBe(50) + expect(component.state.name).toBe('test') + }) + + it('should preserve untouched properties after proxy set', () => { + component.state.count = 10 + expect(component.state.name).toBe('default') + expect(component.state.items).toEqual([]) + }) + + it('should preserve untouched properties after setState', () => { + component.setState({ count: 10 }) + expect(component.state.name).toBe('default') + expect(component.state.items).toEqual([]) + }) + }) + + describe('Action-driven state changes', () => { + it('should emit delta when action modifies state via proxy', async () => { + await component.increment() + + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.payload).toEqual({ delta: { count: 1 } }) + }) + + it('should emit delta when action modifies state via setState', async () => { + await component.batchUpdate({ count: 100, name: 'action-batch' }) + + const msg = getLastSentMessage(ws) + expect(msg.type).toBe('STATE_DELTA') + expect(msg.payload.delta).toEqual({ count: 100, name: 'action-batch' }) + }) + }) + + describe('Message format', () => { + it('should include componentId in delta messages', () => { + component.state.count = 1 + + const msg = getLastSentMessage(ws) + expect(msg.componentId).toBe(component.id) + }) + + it('should include timestamp in delta messages', () => { + component.state.count = 1 + + const msg = getLastSentMessage(ws) + expect(msg.timestamp).toBeDefined() + expect(typeof msg.timestamp).toBe('number') + }) + }) +})