Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions core/client/hooks/useLiveComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 8 additions & 7 deletions core/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type FluxStackServerWebSocket = ServerWebSocket<FluxStackWSData>
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -281,16 +281,16 @@ export abstract class LiveComponent<TState = ComponentState> {
}
}

// 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, {
set(target, prop, value) {
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
},
Expand Down Expand Up @@ -417,11 +417,12 @@ export abstract class LiveComponent<TState = ComponentState> {
return Array.from(this.joinedRooms)
}

// State management (batch update - single emit)
// State management (batch update - single emit with delta)
public setState(updates: Partial<TState> | ((prev: TState) => Partial<TState>)) {
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
Expand Down
229 changes: 229 additions & 0 deletions tests/unit/core/live-component-delta.test.ts
Original file line number Diff line number Diff line change
@@ -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<TestState> {
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<typeof vi.fn>
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<typeof vi.fn>
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<typeof vi.fn>).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')
})
})
})
Loading