mvc-kit 2.12.0 → 2.12.2

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.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,90 @@
1
+ import type { Disposable, Subscribable, Listener, EventPayload } from './types';
2
+ import { bindPublicMethods } from './bindPublicMethods';
3
+
4
+ const PROTECTED_KEYS = new Set(['addCleanup', 'subscribeTo', 'listenTo']);
5
+
6
+ /**
7
+ * Base class for stateless orchestrators.
8
+ * Controllers coordinate between ViewModels, Models, and Services.
9
+ */
10
+ export abstract class Controller implements Disposable {
11
+ private _disposed = false;
12
+ private _initialized = false;
13
+ private _abortController: AbortController | null = null;
14
+ private _cleanups: (() => void)[] | null = null;
15
+
16
+ constructor() {
17
+ bindPublicMethods(this, Object.prototype, PROTECTED_KEYS);
18
+ }
19
+
20
+ /** Whether this instance has been disposed. */
21
+ get disposed(): boolean {
22
+ return this._disposed;
23
+ }
24
+
25
+ /** Whether init() has been called. */
26
+ get initialized(): boolean {
27
+ return this._initialized;
28
+ }
29
+
30
+ /** AbortSignal that fires when this instance is disposed. Lazily created. */
31
+ get disposeSignal(): AbortSignal {
32
+ if (!this._abortController) {
33
+ this._abortController = new AbortController();
34
+ }
35
+ return this._abortController.signal;
36
+ }
37
+
38
+ /** Initializes the instance. Called automatically by React hooks after mount. */
39
+ init(): void | Promise<void> {
40
+ if (this._initialized || this._disposed) return;
41
+ this._initialized = true;
42
+ return this.onInit?.();
43
+ }
44
+
45
+ /** Tears down the instance, releasing all subscriptions and resources. */
46
+ dispose(): void {
47
+ if (this._disposed) {
48
+ return;
49
+ }
50
+
51
+ this._disposed = true;
52
+ this._abortController?.abort();
53
+ if (this._cleanups) {
54
+ for (const fn of this._cleanups) fn();
55
+ this._cleanups = null;
56
+ }
57
+ this.onDispose?.();
58
+ }
59
+
60
+ /** Registers a cleanup function to be called on dispose. @protected */
61
+ protected addCleanup(fn: () => void): void {
62
+ if (!this._cleanups) {
63
+ this._cleanups = [];
64
+ }
65
+ this._cleanups.push(fn);
66
+ }
67
+
68
+ /** Subscribes to an external Subscribable with automatic cleanup on dispose. @protected */
69
+ protected subscribeTo<T>(source: Subscribable<T>, listener: Listener<T>): () => void {
70
+ const unsubscribe = source.subscribe(listener);
71
+ this.addCleanup(unsubscribe);
72
+ return unsubscribe;
73
+ }
74
+
75
+ /** Subscribes to a typed event on a Channel or EventBus with automatic cleanup on dispose. @protected */
76
+ protected listenTo<K extends string, S extends { on(event: K, handler: (payload: any) => void): () => void }>(
77
+ source: S,
78
+ event: K,
79
+ handler: (payload: EventPayload<S, K>) => void,
80
+ ): () => void {
81
+ const unsubscribe = source.on(event, handler);
82
+ this.addCleanup(unsubscribe);
83
+ return unsubscribe;
84
+ }
85
+
86
+ /** Lifecycle hook called at the end of init(). Override to load initial data. @protected */
87
+ protected onInit?(): void | Promise<void>;
88
+ /** Lifecycle hook called during dispose(). Override for custom teardown. @protected */
89
+ protected onDispose?(): void;
90
+ }
@@ -0,0 +1,308 @@
1
+ # EventBus
2
+
3
+ Typed pub/sub event bus for broadcasting one-shot signals across decoupled parts of the application.
4
+
5
+ ---
6
+
7
+ ## When to Use
8
+
9
+ Use EventBus for **intent-based communication** between parts of the app that have no direct reference to each other: toast notifications, logout broadcasts, cross-route coordination. If you need to react to **data changes**, subscribe to a Collection instead.
10
+
11
+ | Scenario | Use |
12
+ |---|---|
13
+ | "Users were loaded on another route" | EventBus |
14
+ | "The user list changed" | Collection subscription |
15
+ | "User logged out globally" | EventBus |
16
+ | "Show a toast after save" | ViewModel events (built-in) |
17
+
18
+ ---
19
+
20
+ ## Defining an Event Map
21
+
22
+ Events are defined as a `Record<string, any>` where keys are event names and values are payload types. Use `undefined` for events with no payload.
23
+
24
+ ```typescript
25
+ interface AppEvents {
26
+ 'auth:logout': undefined;
27
+ 'users:loaded': undefined;
28
+ 'item:created': { id: string; name: string };
29
+ }
30
+ ```
31
+
32
+ ## Creating an EventBus
33
+
34
+ ### App-Level (Singleton)
35
+
36
+ Subclass for singleton identity, then resolve with `singleton()`:
37
+
38
+ ```typescript
39
+ import { EventBus } from 'mvc-kit';
40
+
41
+ export class AppEventBus extends EventBus<AppEvents> {}
42
+
43
+ // In a ViewModel:
44
+ private bus = singleton(AppEventBus);
45
+ ```
46
+
47
+ ### Standalone
48
+
49
+ ```typescript
50
+ const bus = new EventBus<AppEvents>();
51
+ ```
52
+
53
+ ---
54
+
55
+ ## API
56
+
57
+ ### `on(event, handler): () => void`
58
+
59
+ Subscribe to an event. Returns an unsubscribe function.
60
+
61
+ ```typescript
62
+ const unsub = bus.on('item:created', ({ id, name }) => {
63
+ console.log(`Created ${name} (${id})`);
64
+ });
65
+
66
+ // Later:
67
+ unsub();
68
+ ```
69
+
70
+ Multiple handlers can subscribe to the same event. Each receives the payload independently. Handlers are called synchronously in registration order.
71
+
72
+ Calling `on()` after dispose returns a no-op function (does not throw).
73
+
74
+ ### `once(event, handler): () => void`
75
+
76
+ Subscribe to an event for a single invocation. The handler auto-unsubscribes after the first emit. Returns an unsubscribe function for early cancellation.
77
+
78
+ ```typescript
79
+ bus.once('auth:logout', () => {
80
+ redirectToLogin();
81
+ });
82
+ ```
83
+
84
+ Can be manually unsubscribed before the event fires:
85
+
86
+ ```typescript
87
+ const unsub = bus.once('auth:logout', handler);
88
+ unsub(); // handler will never be called
89
+ ```
90
+
91
+ ### `emit(event, payload): void`
92
+
93
+ Emit an event to all current subscribers. Pass `undefined` for events with no payload.
94
+
95
+ ```typescript
96
+ bus.emit('item:created', { id: '42', name: 'Widget' });
97
+ bus.emit('auth:logout', undefined);
98
+ ```
99
+
100
+ Emitting with no subscribers is a safe no-op. Emitting after dispose throws:
101
+
102
+ ```
103
+ Error: Cannot emit on disposed EventBus
104
+ ```
105
+
106
+ ### `dispose(): void`
107
+
108
+ Tears down the bus: aborts `disposeSignal`, runs `addCleanup` callbacks, calls `onDispose()`, clears all handlers. Idempotent — subsequent calls are no-ops.
109
+
110
+ ### `disposed: boolean`
111
+
112
+ Read-only flag. `true` after `dispose()` has been called.
113
+
114
+ ### `disposeSignal: AbortSignal`
115
+
116
+ Lazily-created abort signal that fires on dispose. Use it to cancel async work tied to the bus lifecycle.
117
+
118
+ ```typescript
119
+ class MyBus extends EventBus<AppEvents> {
120
+ startPolling() {
121
+ const signal = this.disposeSignal;
122
+ // signal aborts when bus is disposed
123
+ }
124
+ }
125
+ ```
126
+
127
+ The signal is created on first access — zero cost if never used.
128
+
129
+ ---
130
+
131
+ ## Lifecycle Hooks (Subclass Only)
132
+
133
+ ### `onDispose(): void` *(protected, optional)*
134
+
135
+ Override to run custom teardown logic. Called once during dispose, after the abort signal fires but before handlers are cleared.
136
+
137
+ ```typescript
138
+ class AppEventBus extends EventBus<AppEvents> {
139
+ protected onDispose() {
140
+ console.log('App event bus disposed');
141
+ }
142
+ }
143
+ ```
144
+
145
+ ### `addCleanup(fn): void` *(protected)*
146
+
147
+ Register a cleanup function that runs on dispose, before `onDispose()`. Useful for tearing down external subscriptions set up by the subclass.
148
+
149
+ ```typescript
150
+ class AppEventBus extends EventBus<AppEvents> {
151
+ setup() {
152
+ const unsub = externalSource.subscribe(data => {
153
+ this.emit('item:created', data);
154
+ });
155
+ this.addCleanup(unsub);
156
+ }
157
+ }
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Dispose Order
163
+
164
+ When `dispose()` is called:
165
+
166
+ 1. `_disposed` set to `true`
167
+ 2. `disposeSignal` aborted (if it was accessed)
168
+ 3. `addCleanup` callbacks run (in registration order)
169
+ 4. `onDispose()` called (if overridden)
170
+ 5. All handlers cleared
171
+
172
+ ---
173
+
174
+ ## Type Safety
175
+
176
+ Event names and payloads are fully type-checked at compile time. Invalid event names are rejected by TypeScript:
177
+
178
+ ```typescript
179
+ const bus = new EventBus<AppEvents>();
180
+
181
+ bus.on('auth:logout', () => {}); // OK
182
+ bus.emit('item:created', { id: '1', name: 'X' }); // OK
183
+
184
+ bus.on('badEvent', () => {}); // TS error
185
+ bus.emit('auth:logout', { extra: true }); // TS error
186
+ ```
187
+
188
+ The `_types` phantom brand ensures correct generic inference when the bus is passed through helper functions like `useEvent()`.
189
+
190
+ ---
191
+
192
+ ## React Integration
193
+
194
+ ### `useEvent(source, event, handler)`
195
+
196
+ Subscribe to an event with automatic cleanup on unmount. Accepts an `EventBus` directly or any object with an `events` property (e.g. a ViewModel).
197
+
198
+ ```tsx
199
+ import { useEvent } from 'mvc-kit/react';
200
+
201
+ // With a standalone EventBus
202
+ function Notifications({ bus }: { bus: EventBus<AppEvents> }) {
203
+ useEvent(bus, 'item:created', ({ name }) => {
204
+ toast.success(`${name} created`);
205
+ });
206
+ return null;
207
+ }
208
+
209
+ // With a ViewModel (reads vm.events internally)
210
+ function ItemPage() {
211
+ const [state, vm] = useLocal(ItemViewModel, { /* ... */ });
212
+
213
+ useEvent(vm, 'saved', ({ id }) => {
214
+ toast.success(`Saved ${id}`);
215
+ navigate(`/items/${id}`);
216
+ });
217
+
218
+ return <div>{/* ... */}</div>;
219
+ }
220
+ ```
221
+
222
+ The handler ref is stable across re-renders — no stale closure issues, no need for `useCallback`.
223
+
224
+ ### `useEmit(bus)`
225
+
226
+ Returns a stable `emit` function bound to the bus. Useful in components that only emit (no subscription).
227
+
228
+ ```tsx
229
+ import { useEmit } from 'mvc-kit/react';
230
+
231
+ function ActionBar({ bus }: { bus: EventBus<AppEvents> }) {
232
+ const emit = useEmit(bus);
233
+
234
+ return (
235
+ <button onClick={() => emit('auth:logout', undefined)}>
236
+ Log Out
237
+ </button>
238
+ );
239
+ }
240
+ ```
241
+
242
+ ---
243
+
244
+ ## ViewModel Built-in Events
245
+
246
+ ViewModels have an internal EventBus exposed via the `events` getter. Rather than creating a separate EventBus, use the second generic parameter:
247
+
248
+ ```typescript
249
+ interface Events {
250
+ saved: { id: string };
251
+ validationFailed: undefined;
252
+ }
253
+
254
+ class ItemViewModel extends ViewModel<State, Events> {
255
+ async save() {
256
+ const result = await this.service.save(this.state.draft, this.disposeSignal);
257
+ this.emit('saved', { id: result.id }); // protected — only the VM can emit
258
+ }
259
+ }
260
+ ```
261
+
262
+ Key differences from a standalone EventBus:
263
+
264
+ - The internal bus is **lazy** — created on first `emit()` or `events` access.
265
+ - `emit()` is **protected** — components cannot emit, only subscribe.
266
+ - The bus **auto-disposes** with the ViewModel.
267
+ - `emit()` is silently skipped (not thrown) if the ViewModel or bus is disposed — safe during async cleanup callbacks.
268
+
269
+ Components subscribe with `useEvent(vm, ...)`:
270
+
271
+ ```tsx
272
+ useEvent(vm, 'saved', ({ id }) => navigate(`/items/${id}`));
273
+ ```
274
+
275
+ ---
276
+
277
+ ## Best Practices
278
+
279
+ **Keep the event map small.** If it grows past 10-15 events, concerns are likely entangled and should be decoupled differently.
280
+
281
+ **EventBus carries intent, Collections carry state.** Don't use EventBus as a proxy for data changes — subscribe to the Collection directly.
282
+
283
+ **Prefer ViewModel events for component-scoped signals.** Only use a standalone singleton EventBus for cross-route or app-wide broadcasts.
284
+
285
+ **Use `listenTo` in ViewModels and Controllers.** When subscribing to an EventBus from a lifecycle-managed class, use `this.listenTo(bus, 'event', handler)` instead of `bus.on('event', handler)` — it auto-cleans up on dispose (and reset, for ViewModels).
286
+
287
+ **EventBus does NOT implement `Subscribable`.** It won't be auto-tracked by ViewModel's subscribable detection. This is by design — EventBus is for discrete events, not continuous state.
288
+
289
+ **Use `undefined` (not `void`) for empty payloads.** The event map values are payload types passed to handlers.
290
+
291
+ ```typescript
292
+ // In the type definition
293
+ interface Events {
294
+ logout: undefined; // not void
295
+ }
296
+
297
+ // When emitting
298
+ bus.emit('logout', undefined);
299
+ ```
300
+
301
+ ## Method Binding
302
+
303
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
304
+
305
+ ```tsx
306
+ const { emit, on } = bus;
307
+ on("userLoggedIn", handler); // point-free works
308
+ ```
@@ -0,0 +1,295 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { EventBus } from './EventBus';
3
+ import { singleton, teardownAll } from './singleton';
4
+ import type { useEvent } from './react/use-event-bus';
5
+
6
+ interface AppEvents {
7
+ userLoggedIn: { userId: string };
8
+ userLoggedOut: undefined;
9
+ itemAdded: { itemId: number; name: string };
10
+ }
11
+
12
+ describe('EventBus', () => {
13
+ describe('initialization', () => {
14
+ it('starts not disposed', () => {
15
+ const bus = new EventBus<AppEvents>();
16
+ expect(bus.disposed).toBe(false);
17
+ });
18
+ });
19
+
20
+ describe('on() and emit()', () => {
21
+ it('notifies handler when event is emitted', () => {
22
+ const bus = new EventBus<AppEvents>();
23
+ const handler = vi.fn();
24
+
25
+ bus.on('userLoggedIn', handler);
26
+ bus.emit('userLoggedIn', { userId: '123' });
27
+
28
+ expect(handler).toHaveBeenCalledWith({ userId: '123' });
29
+ });
30
+
31
+ it('notifies multiple handlers', () => {
32
+ const bus = new EventBus<AppEvents>();
33
+ const handler1 = vi.fn();
34
+ const handler2 = vi.fn();
35
+
36
+ bus.on('userLoggedIn', handler1);
37
+ bus.on('userLoggedIn', handler2);
38
+ bus.emit('userLoggedIn', { userId: 'abc' });
39
+
40
+ expect(handler1).toHaveBeenCalledWith({ userId: 'abc' });
41
+ expect(handler2).toHaveBeenCalledWith({ userId: 'abc' });
42
+ });
43
+
44
+ it('does not notify handlers for other events', () => {
45
+ const bus = new EventBus<AppEvents>();
46
+ const loginHandler = vi.fn();
47
+ const logoutHandler = vi.fn();
48
+
49
+ bus.on('userLoggedIn', loginHandler);
50
+ bus.on('userLoggedOut', logoutHandler);
51
+ bus.emit('userLoggedIn', { userId: '123' });
52
+
53
+ expect(loginHandler).toHaveBeenCalled();
54
+ expect(logoutHandler).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it('unsubscribe function works', () => {
58
+ const bus = new EventBus<AppEvents>();
59
+ const handler = vi.fn();
60
+
61
+ const unsubscribe = bus.on('userLoggedIn', handler);
62
+ bus.emit('userLoggedIn', { userId: '1' });
63
+ expect(handler).toHaveBeenCalledTimes(1);
64
+
65
+ unsubscribe();
66
+ bus.emit('userLoggedIn', { userId: '2' });
67
+ expect(handler).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ it('handles emit with no subscribers', () => {
71
+ const bus = new EventBus<AppEvents>();
72
+ expect(() => bus.emit('userLoggedIn', { userId: '123' })).not.toThrow();
73
+ });
74
+ });
75
+
76
+ describe('once()', () => {
77
+ it('only notifies handler once', () => {
78
+ const bus = new EventBus<AppEvents>();
79
+ const handler = vi.fn();
80
+
81
+ bus.once('userLoggedIn', handler);
82
+ bus.emit('userLoggedIn', { userId: '1' });
83
+ bus.emit('userLoggedIn', { userId: '2' });
84
+ bus.emit('userLoggedIn', { userId: '3' });
85
+
86
+ expect(handler).toHaveBeenCalledTimes(1);
87
+ expect(handler).toHaveBeenCalledWith({ userId: '1' });
88
+ });
89
+
90
+ it('can be manually unsubscribed before event', () => {
91
+ const bus = new EventBus<AppEvents>();
92
+ const handler = vi.fn();
93
+
94
+ const unsubscribe = bus.once('userLoggedIn', handler);
95
+ unsubscribe();
96
+ bus.emit('userLoggedIn', { userId: '1' });
97
+
98
+ expect(handler).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+
102
+ describe('dispose', () => {
103
+ it('sets disposed to true', () => {
104
+ const bus = new EventBus<AppEvents>();
105
+ bus.dispose();
106
+ expect(bus.disposed).toBe(true);
107
+ });
108
+
109
+ it('clears all handlers', () => {
110
+ const bus = new EventBus<AppEvents>();
111
+ const handler = vi.fn();
112
+ bus.on('userLoggedIn', handler);
113
+ bus.dispose();
114
+
115
+ // Can't emit after dispose, but handlers are cleared
116
+ expect(bus.disposed).toBe(true);
117
+ });
118
+
119
+ it('is idempotent', () => {
120
+ const bus = new EventBus<AppEvents>();
121
+ bus.dispose();
122
+ bus.dispose();
123
+ expect(bus.disposed).toBe(true);
124
+ });
125
+
126
+ it('throws on emit after dispose', () => {
127
+ const bus = new EventBus<AppEvents>();
128
+ bus.dispose();
129
+ expect(() => bus.emit('userLoggedIn', { userId: '123' })).toThrow(
130
+ 'Cannot emit on disposed EventBus'
131
+ );
132
+ });
133
+
134
+ it('returns no-op on subscribe after dispose', () => {
135
+ const bus = new EventBus<AppEvents>();
136
+ bus.dispose();
137
+ const unsub = bus.on('userLoggedIn', () => {});
138
+ expect(typeof unsub).toBe('function');
139
+ expect(() => unsub()).not.toThrow();
140
+ });
141
+ });
142
+
143
+ describe('type safety', () => {
144
+ it('handles undefined payloads', () => {
145
+ const bus = new EventBus<AppEvents>();
146
+ const handler = vi.fn();
147
+
148
+ bus.on('userLoggedOut', handler);
149
+ bus.emit('userLoggedOut', undefined);
150
+
151
+ expect(handler).toHaveBeenCalledWith(undefined);
152
+ });
153
+
154
+ it('handles complex payloads', () => {
155
+ const bus = new EventBus<AppEvents>();
156
+ const handler = vi.fn();
157
+
158
+ bus.on('itemAdded', handler);
159
+ bus.emit('itemAdded', { itemId: 42, name: 'Widget' });
160
+
161
+ expect(handler).toHaveBeenCalledWith({ itemId: 42, name: 'Widget' });
162
+ });
163
+ });
164
+
165
+ describe('singleton integration', () => {
166
+ beforeEach(() => {
167
+ teardownAll();
168
+ });
169
+
170
+ it('can be used with singleton registry', () => {
171
+ class AppEventBus extends EventBus<AppEvents> {}
172
+
173
+ const bus1 = singleton(AppEventBus);
174
+ const bus2 = singleton(AppEventBus);
175
+ expect(bus1).toBe(bus2);
176
+ });
177
+ });
178
+
179
+ describe('type-level: invalid event names are rejected', () => {
180
+ it('rejects invalid keys on direct bus.on()', () => {
181
+ const bus = new EventBus<AppEvents>();
182
+
183
+ // @ts-expect-error - 'badEvent' is not a key of AppEvents
184
+ bus.on('badEvent', () => {});
185
+ });
186
+
187
+ it('rejects invalid keys through useEvent with subclass', () => {
188
+ class MyBus extends EventBus<AppEvents> {}
189
+ const bus = new MyBus();
190
+
191
+ // Compile-time only — runtime call is a no-op
192
+ const _useEvent = (() => {}) as unknown as typeof useEvent;
193
+ // @ts-expect-error - 'badEvent' is not a key of AppEvents
194
+ _useEvent(bus, 'badEvent', () => {});
195
+ });
196
+ });
197
+
198
+ describe('signal and addCleanup', () => {
199
+ it('signal returns an AbortSignal', () => {
200
+ const bus = new EventBus<AppEvents>();
201
+ expect(bus.disposeSignal).toBeInstanceOf(AbortSignal);
202
+ });
203
+
204
+ it('returns the same signal on multiple accesses', () => {
205
+ const bus = new EventBus<AppEvents>();
206
+ const s1 = bus.disposeSignal;
207
+ const s2 = bus.disposeSignal;
208
+ expect(s1).toBe(s2);
209
+ });
210
+
211
+ it('signal is not aborted before dispose', () => {
212
+ const bus = new EventBus<AppEvents>();
213
+ expect(bus.disposeSignal.aborted).toBe(false);
214
+ });
215
+
216
+ it('signal is aborted after dispose', () => {
217
+ const bus = new EventBus<AppEvents>();
218
+ const signal = bus.disposeSignal;
219
+ bus.dispose();
220
+ expect(signal.aborted).toBe(true);
221
+ });
222
+
223
+ it('signal is aborted before onDispose runs', () => {
224
+ let wasAbortedDuringDispose = false;
225
+ class CheckBus extends EventBus<AppEvents> {
226
+ protected onDispose(): void {
227
+ wasAbortedDuringDispose = this.disposeSignal.aborted;
228
+ }
229
+ }
230
+ const bus = new CheckBus();
231
+ bus.disposeSignal; // force lazy creation
232
+ bus.dispose();
233
+ expect(wasAbortedDuringDispose).toBe(true);
234
+ });
235
+
236
+ it('addCleanup fires on dispose', () => {
237
+ let cleaned = false;
238
+ class CleanupBus extends EventBus<AppEvents> {
239
+ setup() {
240
+ this.addCleanup(() => { cleaned = true; });
241
+ }
242
+ }
243
+ const bus = new CleanupBus();
244
+ bus.setup();
245
+ expect(cleaned).toBe(false);
246
+ bus.dispose();
247
+ expect(cleaned).toBe(true);
248
+ });
249
+
250
+ it('dispose works without accessing signal (lazy, zero cost)', () => {
251
+ const bus = new EventBus<AppEvents>();
252
+ bus.dispose();
253
+ expect(bus.disposed).toBe(true);
254
+ });
255
+ });
256
+
257
+ describe('onDispose hook', () => {
258
+ it('calls onDispose on dispose (bug fix)', () => {
259
+ let called = false;
260
+ class DisposeBus extends EventBus<AppEvents> {
261
+ protected onDispose(): void {
262
+ called = true;
263
+ }
264
+ }
265
+ const bus = new DisposeBus();
266
+ bus.dispose();
267
+ expect(called).toBe(true);
268
+ });
269
+
270
+ it('onDispose called only once even with multiple dispose calls', () => {
271
+ let callCount = 0;
272
+ class CountingBus extends EventBus<AppEvents> {
273
+ protected onDispose(): void {
274
+ callCount++;
275
+ }
276
+ }
277
+ const bus = new CountingBus();
278
+ bus.dispose();
279
+ bus.dispose();
280
+ bus.dispose();
281
+ expect(callCount).toBe(1);
282
+ });
283
+ });
284
+
285
+ describe('method binding', () => {
286
+ it('destructured methods work point-free', () => {
287
+ const bus = new EventBus<AppEvents>();
288
+ const { on, emit } = bus;
289
+ const handler = vi.fn();
290
+ on('userLoggedIn', handler);
291
+ emit('userLoggedIn', { userId: '123' });
292
+ expect(handler).toHaveBeenCalledWith({ userId: '123' });
293
+ });
294
+ });
295
+ });