Skip to content

Commit a2d7e30

Browse files
Merge pull request #68 from FluxStackCore/claude/enhance-live-component-dx-wrYbV
Add lifecycle hooks, HMR persistence, and singleton components
2 parents 9d32579 + a9d3ff8 commit a2d7e30

File tree

5 files changed

+2413
-69
lines changed

5 files changed

+2413
-69
lines changed

LLMD/resources/live-components.md

Lines changed: 207 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
# Live Components
22

3-
**Version:** 1.13.0 | **Updated:** 2025-02-09
3+
**Version:** 1.14.0 | **Updated:** 2025-02-27
44

55
## Quick Facts
66

77
- Server-side state management with WebSocket sync
88
- **Direct state access** - `this.count++` auto-syncs (v1.13.0)
9+
- **Lifecycle hooks** - `onMount()` / `onDestroy()` for proper initialization and cleanup (v1.14.0)
10+
- **HMR persistence** - `static persistent` + `this.$persistent` survives hot reloads (v1.14.0)
11+
- **Singleton components** - `static singleton = true` for shared server-side instances (v1.14.0)
912
- **Mandatory `publicActions`** - Only whitelisted methods are callable from client (secure by default)
13+
- **Helpful error messages** - Forgotten `publicActions` entries show exactly what to fix (v1.14.0)
1014
- Automatic state persistence and re-hydration (with anti-replay nonces)
1115
- Room-based event system for multi-user sync
1216
- Type-safe client-server communication (FluxStackWebSocket)
@@ -53,6 +57,13 @@ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState>
5357
}
5458
```
5559

60+
### Key Changes in v1.14.0
61+
62+
1. **Lifecycle hooks** - `onMount()` (async) and `onDestroy()` (sync) replace constructor/destroy workarounds
63+
2. **HMR persistence** - `static persistent` + `this.$persistent` for data that survives hot module reloads
64+
3. **Singleton components** - `static singleton = true` for shared state across all connected clients
65+
4. **Better publicActions errors** - Clear message when a method exists but is missing from `publicActions`
66+
5667
### Key Changes in v1.13.0
5768

5869
1. **Direct state access** - `this.count++` instead of `this.state.count++`
@@ -111,27 +122,207 @@ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState>
111122
}
112123
```
113124

114-
## Lifecycle Methods
125+
## Lifecycle Hooks (v1.14.0)
126+
127+
Full lifecycle hook system — no more constructor workarounds:
115128

116129
```typescript
117130
export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
118131
static componentName = 'MyComponent'
132+
static publicActions = ['doWork'] as const
133+
static defaultState = { users: [] as string[], ready: false, currentRoom: '' }
134+
135+
private _pollTimer?: NodeJS.Timeout
136+
137+
// 1️⃣ Called when WebSocket connection is established (before onMount)
138+
protected onConnect() {
139+
console.log('WebSocket connected for this component')
140+
}
141+
142+
// 2️⃣ Called AFTER component is fully mounted (rooms, auth, injections ready)
143+
// Can be async!
144+
protected async onMount() {
145+
this.$room.join()
146+
this.$room.on('user:joined', (user) => {
147+
this.state.users = [...this.state.users, user]
148+
})
149+
const data = await fetchInitialData(this.$auth.user?.id)
150+
this.state.ready = true
151+
this._pollTimer = setInterval(() => this.poll(), 5000)
152+
}
153+
154+
// Called after state is restored from localStorage (rehydration)
155+
protected onRehydrate(previousState: typeof MyComponent.defaultState) {
156+
if (!previousState.ready) {
157+
this.state.ready = false // Re-validate stale state
158+
}
159+
}
160+
161+
// Called after any state mutation (proxy or setState)
162+
protected onStateChange(changes: Partial<typeof MyComponent.defaultState>) {
163+
if ('users' in changes) {
164+
console.log(`User count: ${this.state.users.length}`)
165+
}
166+
}
167+
168+
// Called when joining a room
169+
protected onRoomJoin(roomId: string) {
170+
this.state.currentRoom = roomId
171+
}
172+
173+
// Called when leaving a room
174+
protected onRoomLeave(roomId: string) {
175+
if (this.state.currentRoom === roomId) this.state.currentRoom = ''
176+
}
177+
178+
// Called before each action — return false to cancel
179+
protected onAction(action: string, payload: any) {
180+
console.log(`[${this.id}] ${action}`, payload)
181+
// return false // ← would cancel the action
182+
}
183+
184+
// Called when WebSocket drops (NOT on intentional unmount)
185+
protected onDisconnect() {
186+
console.log('Connection lost — saving recovery data')
187+
}
188+
189+
// Called BEFORE internal cleanup (sync only)
190+
protected onDestroy() {
191+
clearInterval(this._pollTimer)
192+
}
193+
194+
async doWork() { /* ... */ }
195+
private poll() { /* ... */ }
196+
}
197+
```
198+
199+
### Lifecycle Order
200+
201+
```
202+
WebSocket connects
203+
└→ onConnect()
204+
└→ onMount() ← async, rooms/auth ready
205+
└→ [component active]
206+
├→ onAction(action, payload) ← before each action (return false to cancel)
207+
├→ onStateChange(changes) ← after each state mutation
208+
├→ onRoomJoin(roomId) ← when joining a room
209+
└→ onRoomLeave(roomId) ← when leaving a room
210+
211+
Connection drops:
212+
└→ onDisconnect() ← only on unexpected disconnect
213+
└→ onDestroy() ← sync, before internal cleanup
214+
215+
Rehydration (reconnect with saved state):
216+
└→ onConnect()
217+
└→ onRehydrate(previousState)
218+
└→ onMount()
219+
```
220+
221+
### Rules
222+
223+
| Hook | Async? | When |
224+
|------|--------|------|
225+
| `onConnect()` | No | WebSocket established, before mount |
226+
| `onMount()` | **Yes** | After all setup (rooms, auth, DI) |
227+
| `onRehydrate(prevState)` | No | After state restored from localStorage |
228+
| `onStateChange(changes)` | No | After every state mutation |
229+
| `onRoomJoin(roomId)` | No | After `$room.join()` |
230+
| `onRoomLeave(roomId)` | No | After `$room.leave()` |
231+
| `onAction(action, payload)` | **Yes** | Before action execution (return `false` to cancel) |
232+
| `onDisconnect()` | No | Connection lost (NOT intentional unmount) |
233+
| `onDestroy()` | No | Before internal cleanup |
234+
235+
- All hooks are optional — override only what you need
236+
- All hook errors are caught and logged — they never break the system
237+
- Constructor is still needed ONLY for `this.onRoomEvent()` subscriptions
238+
- All hooks are in BLOCKED_ACTIONS — clients cannot call them remotely
239+
240+
## HMR Persistence (v1.14.0)
241+
242+
Data in `static persistent` survives Hot Module Replacement reloads via `globalThis`:
243+
244+
```typescript
245+
export class LiveMigration extends LiveComponent<typeof LiveMigration.defaultState> {
246+
static componentName = 'LiveMigration'
247+
static publicActions = ['runMigration'] as const
248+
static defaultState = { status: 'idle', lastResult: '' }
249+
250+
// Define shape and defaults for persistent data
251+
static persistent = {
252+
cache: {} as Record<string, any>,
253+
runCount: 0
254+
}
255+
256+
protected onMount() {
257+
this.$persistent.runCount++
258+
console.log(`Mount #${this.$persistent.runCount}`) // Survives HMR!
259+
}
260+
261+
async runMigration(payload: { key: string }) {
262+
// Check HMR-safe cache
263+
if (this.$persistent.cache[payload.key]) {
264+
return { cached: true, result: this.$persistent.cache[payload.key] }
265+
}
266+
267+
const result = await expensiveComputation(payload.key)
268+
this.$persistent.cache[payload.key] = result
269+
this.state.lastResult = result
270+
return { cached: false, result }
271+
}
272+
}
273+
```
274+
275+
**Key facts:**
276+
- `this.$persistent` reads from `globalThis.__fluxstack_persistent_{ComponentName}`
277+
- Each component class has its own namespace
278+
- Defaults come from `static persistent` — initialized once, then persisted
279+
- Not sent to client — server-only
280+
- `$persistent` is in BLOCKED_ACTIONS (can't be called from client)
281+
282+
## Singleton Components (v1.14.0)
283+
284+
When `static singleton = true`, only ONE server-side instance exists. All clients share the same state:
285+
286+
```typescript
287+
export class LiveDashboard extends LiveComponent<typeof LiveDashboard.defaultState> {
288+
static componentName = 'LiveDashboard'
289+
static singleton = true // All clients share this instance
290+
static publicActions = ['refresh', 'addAlert'] as const
119291
static defaultState = {
120-
// Define state here
292+
visitors: 0,
293+
alerts: [] as string[],
294+
lastRefresh: ''
121295
}
122296

123-
// Constructor ONLY needed if:
124-
// - Subscribing to room events
125-
// - Custom initialization logic
126-
// Otherwise, omit it entirely!
297+
protected async onMount() {
298+
this.state.visitors++
299+
this.state.lastRefresh = new Date().toISOString()
300+
}
127301

128-
destroy() {
129-
// Cleanup subscriptions, timers, etc.
130-
super.destroy()
302+
async refresh() {
303+
const data = await fetchDashboardData()
304+
this.setState(data) // Broadcasts to ALL connected clients
305+
return { success: true }
306+
}
307+
308+
async addAlert(payload: { message: string }) {
309+
this.state.alerts = [...this.state.alerts, payload.message]
310+
// All clients see the new alert instantly
311+
return { success: true }
131312
}
132313
}
133314
```
134315

316+
**How it works:**
317+
- First client to mount creates the singleton instance
318+
- Subsequent clients join the existing instance and receive current state
319+
- `emit` / `setState` / `this.state.x = y` broadcast to ALL connected WebSockets
320+
- When a client disconnects, it's removed from the singleton's connections
321+
- When the LAST client disconnects, the singleton is destroyed
322+
- Stats visible at `/api/live/stats` (shows singleton connection counts)
323+
324+
**Use cases:** Shared dashboards, global migration state, admin panels, live counters
325+
135326
## State Management
136327

137328
### Reactive State Proxy (How It Works)
@@ -665,19 +856,23 @@ export class MyComponent extends LiveComponent<State> {
665856
- Define `static defaultState` inside the class
666857
- Use `typeof ClassName.defaultState` for type parameter
667858
- Use `declare` for each state property (TypeScript type hint)
668-
- Call `super.destroy()` in destroy method if overriding
859+
- Use `onMount()` for async initialization (rooms, auth, data fetching)
860+
- Use `onDestroy()` for cleanup (timers, connections) — sync only
669861
- Use `emitRoomEventWithState` for state changes in rooms
670862
- Handle errors in actions (throw Error)
671863
- Add client link: `import type { Demo as _Client } from '@client/...'`
864+
- Use `$persistent` for data that should survive HMR reloads
865+
- Use `static singleton = true` for shared cross-client state
672866

673867
**NEVER:**
674868
- Omit `static publicActions` (component will deny ALL remote actions)
675869
- Export separate `defaultState` constant (use static)
676870
- Create constructor just to call super() (not needed)
677871
- Forget `static componentName` (breaks minification)
872+
- Override `destroy()` directly — use `onDestroy()` instead (v1.14.0)
678873
- Emit room events without subscribing first
679874
- Store non-serializable data in state
680-
- Use reserved names for state properties (id, state, ws, room, userId, $room, $rooms, $private, broadcastToRoom, roomType)
875+
- Use reserved names for state properties (id, state, ws, room, userId, $room, $rooms, $private, $persistent, broadcastToRoom, roomType)
681876
- Include `setValue` in `publicActions` unless you trust clients to modify any state key
682877
- Store sensitive data (tokens, API keys, secrets) in `state` — use `$private` instead
683878

0 commit comments

Comments
 (0)