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
219 changes: 207 additions & 12 deletions LLMD/resources/live-components.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -53,6 +57,13 @@ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState>
}
```

### 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++`
Expand Down Expand Up @@ -111,27 +122,207 @@ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState>
}
```

## Lifecycle Methods
## Lifecycle Hooks (v1.14.0)

Full lifecycle hook system — no more constructor workarounds:

```typescript
export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
static componentName = 'MyComponent'
static publicActions = ['doWork'] as const
static defaultState = { users: [] as string[], ready: false, currentRoom: '' }

private _pollTimer?: NodeJS.Timeout

// 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]
})
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<typeof MyComponent.defaultState>) {
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)
}

async doWork() { /* ... */ }
private poll() { /* ... */ }
}
```

### 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)

Data in `static persistent` survives Hot Module Replacement reloads via `globalThis`:

```typescript
export class LiveMigration extends LiveComponent<typeof LiveMigration.defaultState> {
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<string, any>,
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<typeof LiveDashboard.defaultState> {
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)
Expand Down Expand Up @@ -665,19 +856,23 @@ export class MyComponent extends LiveComponent<State> {
- 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

Expand Down
Loading
Loading