|
1 | 1 | # Live Components |
2 | 2 |
|
3 | | -**Version:** 1.13.0 | **Updated:** 2025-02-09 |
| 3 | +**Version:** 1.14.0 | **Updated:** 2025-02-27 |
4 | 4 |
|
5 | 5 | ## Quick Facts |
6 | 6 |
|
7 | 7 | - Server-side state management with WebSocket sync |
8 | 8 | - **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) |
9 | 12 | - **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) |
10 | 14 | - Automatic state persistence and re-hydration (with anti-replay nonces) |
11 | 15 | - Room-based event system for multi-user sync |
12 | 16 | - Type-safe client-server communication (FluxStackWebSocket) |
@@ -53,6 +57,13 @@ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> |
53 | 57 | } |
54 | 58 | ``` |
55 | 59 |
|
| 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 | + |
56 | 67 | ### Key Changes in v1.13.0 |
57 | 68 |
|
58 | 69 | 1. **Direct state access** - `this.count++` instead of `this.state.count++` |
@@ -111,27 +122,207 @@ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> |
111 | 122 | } |
112 | 123 | ``` |
113 | 124 |
|
114 | | -## Lifecycle Methods |
| 125 | +## Lifecycle Hooks (v1.14.0) |
| 126 | + |
| 127 | +Full lifecycle hook system — no more constructor workarounds: |
115 | 128 |
|
116 | 129 | ```typescript |
117 | 130 | export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> { |
118 | 131 | 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 |
119 | 291 | static defaultState = { |
120 | | - // Define state here |
| 292 | + visitors: 0, |
| 293 | + alerts: [] as string[], |
| 294 | + lastRefresh: '' |
121 | 295 | } |
122 | 296 |
|
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 | + } |
127 | 301 |
|
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 } |
131 | 312 | } |
132 | 313 | } |
133 | 314 | ``` |
134 | 315 |
|
| 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 | + |
135 | 326 | ## State Management |
136 | 327 |
|
137 | 328 | ### Reactive State Proxy (How It Works) |
@@ -665,19 +856,23 @@ export class MyComponent extends LiveComponent<State> { |
665 | 856 | - Define `static defaultState` inside the class |
666 | 857 | - Use `typeof ClassName.defaultState` for type parameter |
667 | 858 | - 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 |
669 | 861 | - Use `emitRoomEventWithState` for state changes in rooms |
670 | 862 | - Handle errors in actions (throw Error) |
671 | 863 | - 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 |
672 | 866 |
|
673 | 867 | **NEVER:** |
674 | 868 | - Omit `static publicActions` (component will deny ALL remote actions) |
675 | 869 | - Export separate `defaultState` constant (use static) |
676 | 870 | - Create constructor just to call super() (not needed) |
677 | 871 | - Forget `static componentName` (breaks minification) |
| 872 | +- Override `destroy()` directly — use `onDestroy()` instead (v1.14.0) |
678 | 873 | - Emit room events without subscribing first |
679 | 874 | - 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) |
681 | 876 | - Include `setValue` in `publicActions` unless you trust clients to modify any state key |
682 | 877 | - Store sensitive data (tokens, API keys, secrets) in `state` — use `$private` instead |
683 | 878 |
|
|
0 commit comments