mvc-kit 2.12.0 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
package/src/Channel.md
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# Channel
|
|
2
|
+
|
|
3
|
+
A Channel manages a persistent external connection (WebSocket, SSE, or any transport) with built-in auto-reconnect, typed message routing, and subscribable connection status. It implements `Subscribable<ChannelStatus>`, `Initializable`, and `Disposable`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Subclass Contract
|
|
8
|
+
|
|
9
|
+
Extend `Channel<MessageMap>` and implement two abstract methods:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
interface ChatMessages {
|
|
13
|
+
message: { userId: string; text: string };
|
|
14
|
+
typing: { userId: string };
|
|
15
|
+
presence: { online: string[] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class ChatChannel extends Channel<ChatMessages> {
|
|
19
|
+
private ws: WebSocket | null = null;
|
|
20
|
+
|
|
21
|
+
protected open(signal: AbortSignal): void {
|
|
22
|
+
this.ws = new WebSocket('wss://chat.example.com');
|
|
23
|
+
|
|
24
|
+
this.ws.onopen = () => {
|
|
25
|
+
// Connection established — framework handles status transition
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
this.ws.onmessage = (e) => {
|
|
29
|
+
const { type, payload } = JSON.parse(e.data);
|
|
30
|
+
this.receive(type, payload); // dispatch to on() handlers
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
this.ws.onclose = () => {
|
|
34
|
+
if (!signal.aborted) {
|
|
35
|
+
this.disconnected(); // triggers auto-reconnect
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
this.ws.onerror = () => this.ws?.close();
|
|
40
|
+
|
|
41
|
+
signal.addEventListener('abort', () => this.ws?.close());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected close(): void {
|
|
45
|
+
this.ws?.close();
|
|
46
|
+
this.ws = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Method | Purpose |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `open(signal)` | Establish the connection. The `signal` aborts on disconnect or dispose. Can return `void` or `Promise<void>`. |
|
|
54
|
+
| `close()` | Tear down the transport. Must not throw (errors are swallowed during dispose). |
|
|
55
|
+
|
|
56
|
+
### Synchronous vs Async Open
|
|
57
|
+
|
|
58
|
+
`open()` can be synchronous (returns `void`) or asynchronous (returns `Promise<void>`). When synchronous, the channel transitions to `connected` immediately. When async, it transitions after the promise resolves. If it rejects, reconnect is scheduled.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Connection Status
|
|
63
|
+
|
|
64
|
+
The `state` property exposes a frozen `ChannelStatus` object:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
interface ChannelStatus {
|
|
68
|
+
readonly connected: boolean; // true when open() succeeded
|
|
69
|
+
readonly reconnecting: boolean; // true during backoff/retry
|
|
70
|
+
readonly attempt: number; // current reconnect attempt (0 when connected or idle)
|
|
71
|
+
readonly error: string | null; // last error message, or null
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Initial status is `{ connected: false, reconnecting: false, attempt: 0, error: null }`.
|
|
76
|
+
|
|
77
|
+
### Status Transitions
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
Idle ──connect()──→ Connecting ──open() succeeds──→ Connected
|
|
81
|
+
│ │
|
|
82
|
+
open() fails disconnected()
|
|
83
|
+
│ │
|
|
84
|
+
▼ ▼
|
|
85
|
+
Reconnecting ◄───────────────── Reconnecting
|
|
86
|
+
│ │
|
|
87
|
+
timer fires → Connecting timer fires → Connecting
|
|
88
|
+
│
|
|
89
|
+
MAX_ATTEMPTS exceeded → Idle (with error)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Subscribing to Status Changes
|
|
93
|
+
|
|
94
|
+
Channel implements `Subscribable<ChannelStatus>`. Listeners receive `(next, prev)`:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
channel.subscribe((next, prev) => {
|
|
98
|
+
console.log(`${prev.connected} → ${next.connected}`);
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Status notifications are **deduplicated** — if the status object hasn't changed (all four fields match), no notification fires. This means calling `disconnect()` on an already-idle channel triggers no listeners.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Connection Control
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
channel.connect(); // initiate connection (idempotent while connected/connecting)
|
|
110
|
+
channel.disconnect(); // manual disconnect, cancels pending reconnect, resets status
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**`connect()` behavior:**
|
|
114
|
+
- Idempotent when already connected or connecting — calling it again is a no-op.
|
|
115
|
+
- If called during reconnecting, it cancels the backoff timer and immediately attempts a fresh connection (attempt resets to 0).
|
|
116
|
+
- Ignored after dispose (DEV mode logs a warning).
|
|
117
|
+
- DEV mode warns if called before `init()`.
|
|
118
|
+
|
|
119
|
+
**`disconnect()` behavior:**
|
|
120
|
+
- Cancels any pending reconnect timer.
|
|
121
|
+
- Aborts the in-flight `open()` signal.
|
|
122
|
+
- Calls `close()` if connected or connecting.
|
|
123
|
+
- Resets status to idle (`connected: false, reconnecting: false, attempt: 0, error: null`).
|
|
124
|
+
- No-op if idle or disposed.
|
|
125
|
+
|
|
126
|
+
**Reconnect cycle:**
|
|
127
|
+
After `disconnect()` + `connect()`, the channel reconnects cleanly — each cycle creates a fresh `AbortController` for the open signal.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Message Routing
|
|
132
|
+
|
|
133
|
+
Subclasses dispatch incoming data via `this.receive(type, payload)`. Consumers subscribe via `on()` and `once()`:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// Subscribe to all messages of a type
|
|
137
|
+
const unsub = channel.on('message', ({ userId, text }) => {
|
|
138
|
+
console.log(`${userId}: ${text}`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Subscribe to only the first message
|
|
142
|
+
channel.once('presence', ({ online }) => {
|
|
143
|
+
console.log('Initial presence:', online);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Unsubscribe
|
|
147
|
+
unsub();
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Key details:**
|
|
151
|
+
- `receive()` is **protected** — only the subclass can dispatch messages.
|
|
152
|
+
- Multiple handlers for the same type all fire in registration order.
|
|
153
|
+
- Handlers for different types are independent.
|
|
154
|
+
- `once()` auto-unsubscribes after the first delivery. Its unsubscribe function can cancel it before it fires.
|
|
155
|
+
- `on()` on a disposed channel returns a no-op unsubscribe.
|
|
156
|
+
- `receive()` after dispose is silently ignored (DEV mode logs a warning).
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Auto-Reconnect
|
|
161
|
+
|
|
162
|
+
When `disconnected()` is called from a connected or connecting state, the Channel schedules reconnection with exponential backoff and jitter.
|
|
163
|
+
|
|
164
|
+
### Tuning via Static Properties
|
|
165
|
+
|
|
166
|
+
Override these on your subclass to control reconnect behavior:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
class ChatChannel extends Channel<ChatMessages> {
|
|
170
|
+
static RECONNECT_BASE = 1000; // initial backoff (ms), default 1000
|
|
171
|
+
static RECONNECT_MAX = 30000; // max backoff cap (ms), default 30000
|
|
172
|
+
static RECONNECT_FACTOR = 2; // exponential multiplier, default 2
|
|
173
|
+
static MAX_ATTEMPTS = Infinity; // give up after N attempts, default Infinity
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Backoff Formula
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
delay = random() * min(RECONNECT_BASE * RECONNECT_FACTOR^attempt, RECONNECT_MAX)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The `random()` jitter prevents thundering-herd reconnect storms when many clients drop simultaneously.
|
|
184
|
+
|
|
185
|
+
### MAX_ATTEMPTS
|
|
186
|
+
|
|
187
|
+
When `attempt > MAX_ATTEMPTS`, the channel stops reconnecting and transitions to idle with an error. The `error` field contains the last error message or `'Max reconnection attempts reached'`.
|
|
188
|
+
|
|
189
|
+
### Interrupting Reconnect
|
|
190
|
+
|
|
191
|
+
- **`disconnect()`** — cancels the backoff timer, resets status to idle. No further attempts.
|
|
192
|
+
- **`connect()`** during reconnect — cancels the timer, starts a fresh attempt from attempt 0.
|
|
193
|
+
- **`dispose()`** — cancels everything. Timer is cleared, signals are aborted, transport is closed.
|
|
194
|
+
|
|
195
|
+
### disconnected() Guards
|
|
196
|
+
|
|
197
|
+
`disconnected()` is only meaningful from connected or connecting states. If called while idle or already reconnecting, it's a no-op. This prevents double-reconnect from redundant close events.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Lifecycle
|
|
202
|
+
|
|
203
|
+
### init / dispose
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
const channel = new ChatChannel();
|
|
207
|
+
channel.init(); // sets initialized flag, calls onInit()
|
|
208
|
+
// ...use the channel...
|
|
209
|
+
channel.dispose(); // tears down everything
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- `init()` is **idempotent** — calling it twice only runs `onInit()` once.
|
|
213
|
+
- `init()` after `dispose()` is a no-op.
|
|
214
|
+
- `dispose()` is **idempotent** — safe to call multiple times.
|
|
215
|
+
|
|
216
|
+
### onInit / onDispose
|
|
217
|
+
|
|
218
|
+
Optional lifecycle hooks:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
class ChatChannel extends Channel<ChatMessages> {
|
|
222
|
+
protected onInit() {
|
|
223
|
+
// Set up external subscriptions, start timers, etc.
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
protected onDispose() {
|
|
227
|
+
// Final cleanup after everything else is torn down
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Dispose Sequence
|
|
233
|
+
|
|
234
|
+
When `dispose()` is called:
|
|
235
|
+
|
|
236
|
+
1. Sets `disposed = true`
|
|
237
|
+
2. Cancels pending reconnect timer
|
|
238
|
+
3. Aborts the per-connection signal (`connectAbort`)
|
|
239
|
+
4. Aborts the `disposeSignal`
|
|
240
|
+
5. Calls `close()` (errors swallowed)
|
|
241
|
+
6. Runs all registered `addCleanup()` callbacks
|
|
242
|
+
7. Calls `onDispose()`
|
|
243
|
+
8. Clears all status listeners and message handlers
|
|
244
|
+
|
|
245
|
+
### disposeSignal
|
|
246
|
+
|
|
247
|
+
Lazy `AbortSignal` that aborts when the channel is disposed. The signal passed to `open()` is composed from both the dispose signal and a per-connection signal, so it aborts on either dispose **or** disconnect.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
// In a ViewModel using a channel
|
|
251
|
+
protected onInit() {
|
|
252
|
+
this.channel.on('message', (msg) => {
|
|
253
|
+
this.set({ messages: [...this.state.messages, msg] });
|
|
254
|
+
});
|
|
255
|
+
this.channel.connect();
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Infrastructure Helpers
|
|
262
|
+
|
|
263
|
+
### addCleanup(fn)
|
|
264
|
+
|
|
265
|
+
Register a cleanup function that runs on dispose:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
protected onInit() {
|
|
269
|
+
const unsub = externalService.on('event', this.handleEvent);
|
|
270
|
+
this.addCleanup(unsub);
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### subscribeTo(source, listener)
|
|
275
|
+
|
|
276
|
+
Subscribe to another `Subscribable` with automatic cleanup on dispose:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
protected onInit() {
|
|
280
|
+
this.subscribeTo(someOtherChannel, (status) => {
|
|
281
|
+
// React to another channel's status changes
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### listenTo(source, event, handler)
|
|
287
|
+
|
|
288
|
+
Subscribe to a typed event on another Channel or EventBus with automatic cleanup on dispose:
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
protected onInit() {
|
|
292
|
+
this.listenTo(this.appBus, 'auth:logout', () => {
|
|
293
|
+
this.disconnect();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## ViewModel Integration
|
|
301
|
+
|
|
302
|
+
Channels are typically singletons. ViewModels subscribe to them for status changes and message handling:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
class ChatViewModel extends ViewModel<State> {
|
|
306
|
+
private channel = singleton(ChatChannel);
|
|
307
|
+
|
|
308
|
+
protected onInit() {
|
|
309
|
+
// Auto-tracked: channel status changes invalidate getters
|
|
310
|
+
this.subscribeTo(this.channel, () => {
|
|
311
|
+
this.set({ connectionStatus: this.channel.state });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Message handling
|
|
315
|
+
const unsub = this.channel.on('message', (msg) => {
|
|
316
|
+
this.set({ messages: [...this.state.messages, msg] });
|
|
317
|
+
});
|
|
318
|
+
this.addCleanup(unsub);
|
|
319
|
+
|
|
320
|
+
this.channel.connect();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get isConnected() {
|
|
324
|
+
return this.channel.state.connected; // auto-tracked via subscribable detection
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### pipeChannel — Channel-to-Collection Bridge
|
|
330
|
+
|
|
331
|
+
For the common pattern of piping Channel messages into a Collection, use `pipeChannel`:
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
class DashboardCardViewModel extends ViewModel {
|
|
335
|
+
private channel = singleton(DashboardChannel);
|
|
336
|
+
private collection = singleton(DashboardCollection);
|
|
337
|
+
|
|
338
|
+
protected onInit() {
|
|
339
|
+
this.pipeChannel(this.channel, 'data', this.collection);
|
|
340
|
+
this.channel.connect();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
This calls `channel.init()` (idempotent), subscribes to the event, and upserts each payload into the collection. Auto-cleanup on dispose and reset. Use `listenTo` directly if you need to transform or filter messages.
|
|
346
|
+
|
|
347
|
+
### Auto-Tracking
|
|
348
|
+
|
|
349
|
+
Because Channel implements `Subscribable` (has a `subscribe` method), the ViewModel's getter memoization system auto-detects it. When the channel's status changes:
|
|
350
|
+
|
|
351
|
+
1. The ViewModel's `_revision` counter increments
|
|
352
|
+
2. Memoized getter caches invalidate
|
|
353
|
+
3. Getters that read `channel.state` recompute on next access
|
|
354
|
+
4. React re-renders via `useSyncExternalStore`
|
|
355
|
+
|
|
356
|
+
### Singleton Pattern
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
const ch1 = singleton(ChatChannel);
|
|
360
|
+
const ch2 = singleton(ChatChannel);
|
|
361
|
+
ch1 === ch2; // true — same instance
|
|
362
|
+
|
|
363
|
+
teardownAll(); // disposes the channel along with all other singletons
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## Best Practices
|
|
369
|
+
|
|
370
|
+
**Always listen for abort in `open()`.** The signal passed to `open()` aborts on both disconnect and dispose. Attach a listener to clean up the transport:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
signal.addEventListener('abort', () => this.ws?.close());
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Call `disconnected()` from transport close events, not error events.** Let the transport's close handler trigger reconnect. Error events typically precede close — handling both causes double reconnect (which is safely guarded, but unnecessary).
|
|
377
|
+
|
|
378
|
+
**Don't call `disconnected()` if the signal is aborted.** When the abort signal fires, the framework is intentionally closing the connection (disconnect or dispose). Only call `disconnected()` for unexpected drops:
|
|
379
|
+
|
|
380
|
+
```typescript
|
|
381
|
+
this.ws.onclose = () => {
|
|
382
|
+
if (!signal.aborted) this.disconnected();
|
|
383
|
+
};
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Keep `close()` side-effect-free and safe.** It's called during dispose with errors swallowed, and can be called multiple times. Don't throw, and nullify references to prevent double-close issues.
|
|
387
|
+
|
|
388
|
+
**Use singleton resolution.** Channels represent persistent connections shared across ViewModels. Always resolve via `singleton(MyChannel)` — never `new MyChannel()` in multiple places.
|
|
389
|
+
|
|
390
|
+
**Override static config instead of overriding `_calculateDelay`.** The static properties (`RECONNECT_BASE`, `RECONNECT_MAX`, `RECONNECT_FACTOR`, `MAX_ATTEMPTS`) cover all tuning needs. Override `_calculateDelay` only if you need a completely custom backoff strategy (e.g., server-sent retry hints).
|
|
391
|
+
|
|
392
|
+
**Use `listenTo` for `on()` subscriptions in ViewModels.** While `subscribeTo` auto-registers cleanup for state subscriptions, message subscriptions via `channel.on()` need `listenTo` for automatic cleanup:
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
// Prescribed — auto-cleanup on dispose and reset
|
|
396
|
+
this.listenTo(this.channel, 'message', handler);
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
> See `BEST_PRACTICES.md` for ViewModel integration patterns and prescribed usage.
|
|
400
|
+
|
|
401
|
+
## Method Binding
|
|
402
|
+
|
|
403
|
+
All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
const { connect, disconnect } = channel;
|
|
407
|
+
<button onClick={connect}>Connect</button> // point-free works
|
|
408
|
+
```
|