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.
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 +10 -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,266 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { StrictMode } from 'react';
5
+ import { render, screen, act } from '@testing-library/react';
6
+ import { ViewModel } from '../ViewModel';
7
+ import { Model } from '../Model';
8
+ import { Collection } from '../Collection';
9
+ import { Controller } from '../Controller';
10
+ import { useLocal } from './use-local';
11
+ import { useModel } from './use-model';
12
+ import type { ValidationErrors } from '../types';
13
+
14
+ // ── Test classes ──
15
+
16
+ interface CounterState {
17
+ count: number;
18
+ }
19
+
20
+ class CounterVM extends ViewModel<CounterState> {
21
+ constructor() {
22
+ super({ count: 0 });
23
+ }
24
+
25
+ increment() {
26
+ this.set((prev) => ({ count: prev.count + 1 }));
27
+ }
28
+ }
29
+
30
+ interface Todo {
31
+ id: string;
32
+ text: string;
33
+ done: boolean;
34
+ }
35
+
36
+ class TodoCollection extends Collection<Todo> {}
37
+
38
+ class TestController extends Controller {
39
+ greet(): string {
40
+ return 'hello';
41
+ }
42
+ }
43
+
44
+ interface FormState {
45
+ name: string;
46
+ email: string;
47
+ }
48
+
49
+ class FormModel extends Model<FormState> {
50
+ setName(name: string) {
51
+ this.set({ name });
52
+ }
53
+
54
+ setEmail(email: string) {
55
+ this.set({ email });
56
+ }
57
+
58
+ protected validate(state: FormState): ValidationErrors<FormState> {
59
+ const errors: ValidationErrors<FormState> = {};
60
+ if (!state.name) errors.name = 'Required';
61
+ if (!state.email.includes('@')) errors.email = 'Invalid email';
62
+ return errors;
63
+ }
64
+ }
65
+
66
+ // ── Tests ──
67
+
68
+ describe('React StrictMode compatibility', () => {
69
+ it('useLocal + ViewModel renders and supports interaction', () => {
70
+ function Counter() {
71
+ const [state, vm] = useLocal(CounterVM);
72
+ return (
73
+ <div>
74
+ <span data-testid="count">{state.count}</span>
75
+ <button onClick={() => vm.increment()}>+</button>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ render(
81
+ <StrictMode>
82
+ <Counter />
83
+ </StrictMode>
84
+ );
85
+
86
+ expect(screen.getByTestId('count').textContent).toBe('0');
87
+
88
+ act(() => {
89
+ screen.getByRole('button').click();
90
+ });
91
+
92
+ expect(screen.getByTestId('count').textContent).toBe('1');
93
+ });
94
+
95
+ it('useLocal + Collection renders and supports interaction', () => {
96
+ function TodoList() {
97
+ const [items, collection] = useLocal(TodoCollection);
98
+ return (
99
+ <div>
100
+ <span data-testid="count">{items.length}</span>
101
+ <button onClick={() => collection.add({ id: String(items.length + 1), text: 'New', done: false })}>
102
+ Add
103
+ </button>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ render(
109
+ <StrictMode>
110
+ <TodoList />
111
+ </StrictMode>
112
+ );
113
+
114
+ expect(screen.getByTestId('count').textContent).toBe('0');
115
+
116
+ act(() => {
117
+ screen.getByRole('button').click();
118
+ });
119
+
120
+ expect(screen.getByTestId('count').textContent).toBe('1');
121
+ });
122
+
123
+ it('useLocal + Controller (Disposable-only) renders without crash', () => {
124
+ function Greeter() {
125
+ const ctrl = useLocal(TestController);
126
+ return <span data-testid="greeting">{ctrl.greet()}</span>;
127
+ }
128
+
129
+ render(
130
+ <StrictMode>
131
+ <Greeter />
132
+ </StrictMode>
133
+ );
134
+
135
+ expect(screen.getByTestId('greeting').textContent).toBe('hello');
136
+ });
137
+
138
+ it('useModel renders and supports interaction', () => {
139
+ function Form() {
140
+ const { state, errors, valid, model } = useModel(() => new FormModel({ name: '', email: '' }));
141
+ return (
142
+ <div>
143
+ <span data-testid="name">{state.name}</span>
144
+ <span data-testid="valid">{String(valid)}</span>
145
+ <span data-testid="name-error">{errors.name ?? ''}</span>
146
+ <button onClick={() => model.setName('Alice')}>Set Name</button>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ render(
152
+ <StrictMode>
153
+ <Form />
154
+ </StrictMode>
155
+ );
156
+
157
+ expect(screen.getByTestId('name').textContent).toBe('');
158
+ expect(screen.getByTestId('valid').textContent).toBe('false');
159
+
160
+ act(() => {
161
+ screen.getByRole('button').click();
162
+ });
163
+
164
+ expect(screen.getByTestId('name').textContent).toBe('Alice');
165
+ expect(screen.getByTestId('name-error').textContent).toBe('');
166
+ });
167
+
168
+ it('useLocal + ViewModel: onInit called exactly once in StrictMode', () => {
169
+ let initCount = 0;
170
+
171
+ class InitCounterVM extends ViewModel<CounterState> {
172
+ constructor() {
173
+ super({ count: 0 });
174
+ }
175
+ protected onInit() {
176
+ initCount++;
177
+ }
178
+ increment() {
179
+ this.set((prev) => ({ count: prev.count + 1 }));
180
+ }
181
+ }
182
+
183
+ function Counter() {
184
+ const [state] = useLocal(InitCounterVM);
185
+ return <span data-testid="init-count">{state.count}</span>;
186
+ }
187
+
188
+ render(
189
+ <StrictMode>
190
+ <Counter />
191
+ </StrictMode>
192
+ );
193
+
194
+ expect(initCount).toBe(1);
195
+ });
196
+
197
+ it('useModel: onInit called exactly once in StrictMode', () => {
198
+ let initCount = 0;
199
+
200
+ class InitFormModel extends Model<FormState> {
201
+ protected onInit() {
202
+ initCount++;
203
+ }
204
+ setName(name: string) {
205
+ this.set({ name });
206
+ }
207
+ setEmail(email: string) {
208
+ this.set({ email });
209
+ }
210
+ }
211
+
212
+ function Form() {
213
+ const { state } = useModel(() => new InitFormModel({ name: '', email: '' }));
214
+ return <span data-testid="init-name">{state.name}</span>;
215
+ }
216
+
217
+ render(
218
+ <StrictMode>
219
+ <Form />
220
+ </StrictMode>
221
+ );
222
+
223
+ expect(initCount).toBe(1);
224
+ });
225
+
226
+ it('useLocal + ViewModel: signal not aborted during StrictMode fake unmount cycle', () => {
227
+ let capturedSignal: AbortSignal | null = null;
228
+
229
+ class SignalVM extends ViewModel<CounterState> {
230
+ constructor() {
231
+ super({ count: 0 });
232
+ }
233
+ getSignal() {
234
+ capturedSignal = this.disposeSignal;
235
+ return this.disposeSignal;
236
+ }
237
+ }
238
+
239
+ function SignalComponent() {
240
+ const [state, vm] = useLocal(SignalVM);
241
+ vm.getSignal();
242
+ return <span data-testid="signal-count">{state.count}</span>;
243
+ }
244
+
245
+ const { unmount } = render(
246
+ <StrictMode>
247
+ <SignalComponent />
248
+ </StrictMode>
249
+ );
250
+
251
+ // After StrictMode mount/unmount/remount cycle, signal should NOT be aborted
252
+ expect(capturedSignal).not.toBeNull();
253
+ expect(capturedSignal!.aborted).toBe(false);
254
+
255
+ // On real unmount, signal IS aborted
256
+ unmount();
257
+
258
+ // Need to wait for the deferred disposal timeout
259
+ return new Promise<void>((resolve) => {
260
+ setTimeout(() => {
261
+ expect(capturedSignal!.aborted).toBe(true);
262
+ resolve();
263
+ }, 10);
264
+ });
265
+ });
266
+ });
@@ -0,0 +1,25 @@
1
+ import type { Subscribable, Disposable } from '../types';
2
+
3
+ /**
4
+ * Extract state type from a Subscribable.
5
+ */
6
+ export type StateOf<T> = T extends Subscribable<infer S> ? S : never;
7
+
8
+ /**
9
+ * Extract item type from a Collection.
10
+ */
11
+ export type ItemOf<T> = T extends Subscribable<(infer I)[]> ? I : never;
12
+
13
+ /**
14
+ * Constructor type for singleton classes.
15
+ */
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ export type SingletonClass<T extends Disposable, Args extends unknown[] = any[]> = new (
18
+ ...args: Args
19
+ ) => T;
20
+
21
+ /**
22
+ * Provider registry mapping classes to instances.
23
+ */
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ export type ProviderRegistry = Map<new (...args: any[]) => any, any>;
@@ -0,0 +1,214 @@
1
+ # useEvent & useEmit
2
+
3
+ React hooks for subscribing to and emitting typed events from an [EventBus](../EventBus.md) or [ViewModel](../ViewModel.md).
4
+
5
+ ---
6
+
7
+ ## useEvent
8
+
9
+ Subscribe to a typed event with automatic cleanup on unmount.
10
+
11
+ ### Signature
12
+
13
+ ```typescript
14
+ function useEvent<E extends Record<string, any>, K extends keyof E>(
15
+ source: EventBus<E> | { events: EventBus<E> },
16
+ event: K,
17
+ handler: (payload: E[K]) => void
18
+ ): void
19
+ ```
20
+
21
+ ### Parameters
22
+
23
+ | Parameter | Type | Description |
24
+ |---|---|---|
25
+ | `source` | `EventBus<E>` or `{ events: EventBus<E> }` | An EventBus instance directly, or any object with an `events` property (e.g. a ViewModel). |
26
+ | `event` | `K extends keyof E` | The event name to subscribe to. Type-checked against the event map. |
27
+ | `handler` | `(payload: E[K]) => void` | Callback invoked when the event fires. Payload type is inferred from the event map. |
28
+
29
+ ### Behavior
30
+
31
+ - Calls `bus.on(event, handler)` inside a `useEffect` and returns the unsubscribe function as cleanup.
32
+ - The handler is stored in a ref (`useRef`) and updated on every render, so the callback always closes over the latest component state — no stale closures, no need for `useCallback`.
33
+ - The effect re-runs only when the `bus` instance or `event` name changes (not on handler changes).
34
+ - On unmount, the subscription is removed automatically.
35
+
36
+ ### With a Standalone EventBus
37
+
38
+ ```tsx
39
+ import { useEvent } from 'mvc-kit/react';
40
+
41
+ interface AppEvents {
42
+ 'item:created': { id: string; name: string };
43
+ 'auth:logout': undefined;
44
+ }
45
+
46
+ function Notifications({ bus }: { bus: EventBus<AppEvents> }) {
47
+ useEvent(bus, 'item:created', ({ name }) => {
48
+ toast.success(`${name} created`);
49
+ });
50
+
51
+ return null;
52
+ }
53
+ ```
54
+
55
+ ### With a ViewModel
56
+
57
+ ViewModels expose their internal EventBus via an `events` getter. `useEvent` detects this automatically — pass the ViewModel directly as the source.
58
+
59
+ ```tsx
60
+ interface Events {
61
+ saved: { id: string };
62
+ validationFailed: undefined;
63
+ }
64
+
65
+ class ItemViewModel extends ViewModel<State, Events> {
66
+ async save() {
67
+ const result = await this.service.save(this.state.draft, this.disposeSignal);
68
+ this.emit('saved', { id: result.id });
69
+ }
70
+ }
71
+
72
+ function ItemPage() {
73
+ const [state, vm] = useLocal(ItemViewModel, { draft: null });
74
+
75
+ useEvent(vm, 'saved', ({ id }) => {
76
+ toast.success(`Saved ${id}`);
77
+ navigate(`/items/${id}`);
78
+ });
79
+
80
+ return <div>{/* ... */}</div>;
81
+ }
82
+ ```
83
+
84
+ Key detail: `emit()` on a ViewModel is **protected** — only the ViewModel can emit. Components can only subscribe.
85
+
86
+ ### Multiple Events
87
+
88
+ Call `useEvent` once per event type. Each subscription is independent:
89
+
90
+ ```tsx
91
+ function ItemPage() {
92
+ const [state, vm] = useLocal(ItemViewModel, { /* ... */ });
93
+
94
+ useEvent(vm, 'saved', ({ id }) => {
95
+ toast.success(`Item ${id} saved`);
96
+ });
97
+
98
+ useEvent(vm, 'validationFailed', () => {
99
+ scrollToFirstError();
100
+ });
101
+
102
+ return <div>{/* ... */}</div>;
103
+ }
104
+ ```
105
+
106
+ ### Unmount Safety
107
+
108
+ When the component unmounts, the subscription is cleaned up. Emitting after unmount does not throw — the handler simply isn't called.
109
+
110
+ ```tsx
111
+ const { unmount } = render(<Listener bus={bus} />);
112
+ unmount();
113
+ bus.emit('increment', { amount: 1 }); // safe, no error
114
+ ```
115
+
116
+ ---
117
+
118
+ ## useEmit
119
+
120
+ Returns a stable `emit` function bound to an EventBus. Useful in components that only emit events (no subscription needed).
121
+
122
+ ### Signature
123
+
124
+ ```typescript
125
+ function useEmit<E extends Record<string, any>>(
126
+ bus: EventBus<E>
127
+ ): <K extends keyof E>(event: K, payload: E[K]) => void
128
+ ```
129
+
130
+ ### Parameters
131
+
132
+ | Parameter | Type | Description |
133
+ |---|---|---|
134
+ | `bus` | `EventBus<E>` | The EventBus to emit on. |
135
+
136
+ ### Behavior
137
+
138
+ - Wraps `bus.emit` in a `useCallback` memoized on the `bus` reference.
139
+ - The returned function is referentially stable across re-renders as long as the bus instance doesn't change.
140
+ - Fully type-safe — event names and payloads are checked against the event map.
141
+
142
+ ### Usage
143
+
144
+ ```tsx
145
+ import { useEmit } from 'mvc-kit/react';
146
+
147
+ function ActionBar({ bus }: { bus: EventBus<AppEvents> }) {
148
+ const emit = useEmit(bus);
149
+
150
+ return (
151
+ <div>
152
+ <button onClick={() => emit('increment', { amount: 1 })}>+1</button>
153
+ <button onClick={() => emit('increment', { amount: 5 })}>+5</button>
154
+ <button onClick={() => emit('auth:logout', undefined)}>Log Out</button>
155
+ </div>
156
+ );
157
+ }
158
+ ```
159
+
160
+ ### Emitter + Listener Pattern
161
+
162
+ `useEmit` and `useEvent` compose naturally when sibling components communicate through a shared bus:
163
+
164
+ ```tsx
165
+ function App() {
166
+ const bus = useMemo(() => new EventBus<AppEvents>(), []);
167
+
168
+ return (
169
+ <>
170
+ <EventEmitter bus={bus} />
171
+ <EventListener bus={bus} />
172
+ </>
173
+ );
174
+ }
175
+ ```
176
+
177
+ ---
178
+
179
+ ## When to Use Which Source
180
+
181
+ | Scenario | Source | Hook |
182
+ |---|---|---|
183
+ | ViewModel emits one-shot signals (saved, deleted, validation) | ViewModel instance | `useEvent(vm, ...)` |
184
+ | App-wide broadcasts (logout, data loaded on another route) | Singleton EventBus | `useEvent(bus, ...)` |
185
+ | Component only needs to emit, not subscribe | Singleton EventBus | `useEmit(bus)` |
186
+
187
+ ---
188
+
189
+ ## Best Practices
190
+
191
+ **Use `useEvent` for imperative one-shot signals, not data synchronization.** Toasts, redirects, scroll-to-error, and animations are good fits. For reactive data, use `useLocal` / `useSingleton` with ViewModel state and getters.
192
+
193
+ **Prefer ViewModel events over standalone EventBus for component-scoped signals.** The ViewModel's internal bus is lazy, auto-disposed, and type-safe via the second generic parameter. Only use a standalone singleton EventBus for cross-route or app-wide broadcasts.
194
+
195
+ **No `useCallback` needed for handlers.** The handler ref pattern inside `useEvent` ensures the latest closure is always called without creating a new subscription on every render.
196
+
197
+ **Pass `undefined` for events with no payload.** The event map uses `undefined` (not `void`) for empty payloads:
198
+
199
+ ```typescript
200
+ interface Events {
201
+ reset: undefined;
202
+ }
203
+
204
+ bus.emit('reset', undefined);
205
+ ```
206
+
207
+ **Don't use `useEvent` for Collection changes.** Collections carry state; EventBus carries intent. Subscribe to collections inside the ViewModel via `subscribeTo`, not in the component.
208
+
209
+ ---
210
+
211
+ ## Related
212
+
213
+ - [EventBus](../EventBus.md) — the underlying pub/sub primitive
214
+ - [ViewModel](../ViewModel.md) — built-in events via second generic parameter
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { render, screen, act } from '@testing-library/react';
5
+ import { useState } from 'react';
6
+ import { EventBus } from '../EventBus';
7
+ import { ViewModel } from '../ViewModel';
8
+ import { useEvent, useEmit } from './use-event-bus';
9
+
10
+ interface AppEvents {
11
+ increment: { amount: number };
12
+ reset: undefined;
13
+ }
14
+
15
+ function EventListener({ bus }: { bus: EventBus<AppEvents> }) {
16
+ const [count, setCount] = useState(0);
17
+
18
+ useEvent(bus, 'increment', (payload) => {
19
+ setCount((c) => c + payload.amount);
20
+ });
21
+
22
+ useEvent(bus, 'reset', () => {
23
+ setCount(0);
24
+ });
25
+
26
+ return <div data-testid="count">{count}</div>;
27
+ }
28
+
29
+ function EventEmitter({ bus }: { bus: EventBus<AppEvents> }) {
30
+ const emit = useEmit(bus);
31
+
32
+ return (
33
+ <div>
34
+ <button onClick={() => emit('increment', { amount: 1 })}>+1</button>
35
+ <button onClick={() => emit('increment', { amount: 5 })}>+5</button>
36
+ <button onClick={() => emit('reset', undefined)}>Reset</button>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ describe('useEvent', () => {
42
+ it('should subscribe to events', () => {
43
+ const bus = new EventBus<AppEvents>();
44
+
45
+ render(<EventListener bus={bus} />);
46
+ expect(screen.getByTestId('count').textContent).toBe('0');
47
+
48
+ act(() => {
49
+ bus.emit('increment', { amount: 10 });
50
+ });
51
+
52
+ expect(screen.getByTestId('count').textContent).toBe('10');
53
+ });
54
+
55
+ it('should handle multiple event types', () => {
56
+ const bus = new EventBus<AppEvents>();
57
+
58
+ render(<EventListener bus={bus} />);
59
+
60
+ act(() => {
61
+ bus.emit('increment', { amount: 5 });
62
+ });
63
+ expect(screen.getByTestId('count').textContent).toBe('5');
64
+
65
+ act(() => {
66
+ bus.emit('reset', undefined);
67
+ });
68
+ expect(screen.getByTestId('count').textContent).toBe('0');
69
+ });
70
+
71
+ it('should unsubscribe on unmount', () => {
72
+ const bus = new EventBus<AppEvents>();
73
+ const { unmount } = render(<EventListener bus={bus} />);
74
+
75
+ act(() => {
76
+ bus.emit('increment', { amount: 1 });
77
+ });
78
+ expect(screen.getByTestId('count').textContent).toBe('1');
79
+
80
+ unmount();
81
+
82
+ // This should not throw even after unmount
83
+ expect(() => bus.emit('increment', { amount: 1 })).not.toThrow();
84
+ });
85
+ });
86
+
87
+ describe('useEmit', () => {
88
+ it('should return a stable emit function', () => {
89
+ const bus = new EventBus<AppEvents>();
90
+
91
+ function App() {
92
+ return (
93
+ <>
94
+ <EventListener bus={bus} />
95
+ <EventEmitter bus={bus} />
96
+ </>
97
+ );
98
+ }
99
+
100
+ render(<App />);
101
+
102
+ act(() => {
103
+ screen.getByRole('button', { name: '+1' }).click();
104
+ });
105
+ expect(screen.getByTestId('count').textContent).toBe('1');
106
+
107
+ act(() => {
108
+ screen.getByRole('button', { name: '+5' }).click();
109
+ });
110
+ expect(screen.getByTestId('count').textContent).toBe('6');
111
+
112
+ act(() => {
113
+ screen.getByRole('button', { name: 'Reset' }).click();
114
+ });
115
+ expect(screen.getByTestId('count').textContent).toBe('0');
116
+ });
117
+ });
118
+
119
+ describe('useEvent with ViewModel', () => {
120
+ interface VMEvents {
121
+ notify: { text: string };
122
+ }
123
+
124
+ class TestVM extends ViewModel<{ count: number }, VMEvents> {
125
+ fire(text: string) {
126
+ this.emit('notify', { text });
127
+ }
128
+ }
129
+
130
+ function VMListener({ vm }: { vm: TestVM }) {
131
+ const [message, setMessage] = useState('');
132
+
133
+ useEvent(vm, 'notify', (payload) => {
134
+ setMessage(payload.text);
135
+ });
136
+
137
+ return <div data-testid="message">{message}</div>;
138
+ }
139
+
140
+ it('subscribes to ViewModel events and fires handler', () => {
141
+ const vm = new TestVM({ count: 0 });
142
+
143
+ render(<VMListener vm={vm} />);
144
+ expect(screen.getByTestId('message').textContent).toBe('');
145
+
146
+ act(() => {
147
+ vm.fire('hello');
148
+ });
149
+
150
+ expect(screen.getByTestId('message').textContent).toBe('hello');
151
+ });
152
+
153
+ it('auto-unsubscribes on unmount', () => {
154
+ const vm = new TestVM({ count: 0 });
155
+
156
+ const { unmount } = render(<VMListener vm={vm} />);
157
+
158
+ act(() => {
159
+ vm.fire('first');
160
+ });
161
+ expect(screen.getByTestId('message').textContent).toBe('first');
162
+
163
+ unmount();
164
+
165
+ // Should not throw after unmount
166
+ expect(() => vm.fire('second')).not.toThrow();
167
+ });
168
+ });
@@ -0,0 +1,40 @@
1
+ import { useEffect, useCallback, useRef } from 'react';
2
+ import { EventBus } from '../EventBus';
3
+
4
+ /**
5
+ * Subscribe to a typed event, auto-unsubscribes on unmount.
6
+ * Accepts an EventBus directly or any object with an `events` property (e.g. a ViewModel).
7
+ */
8
+ export function useEvent<E extends Record<string, any>, K extends keyof E>(
9
+ source: EventBus<E> | { events: EventBus<E> },
10
+ event: K,
11
+ handler: (payload: E[K]) => void
12
+ ): void {
13
+ const bus = source instanceof EventBus ? source : source.events;
14
+
15
+ // Use ref to keep handler stable across re-renders
16
+ const handlerRef = useRef(handler);
17
+ handlerRef.current = handler;
18
+
19
+ useEffect(() => {
20
+ const unsubscribe = bus.on(event, (payload) => {
21
+ handlerRef.current(payload);
22
+ });
23
+
24
+ return unsubscribe;
25
+ }, [bus, event]);
26
+ }
27
+
28
+ /**
29
+ * Get a stable emit function for an EventBus.
30
+ */
31
+ export function useEmit<E extends Record<string, any>>(
32
+ bus: EventBus<E>
33
+ ): <K extends keyof E>(event: K, payload: E[K]) => void {
34
+ return useCallback(
35
+ <K extends keyof E>(event: K, payload: E[K]) => {
36
+ bus.emit(event, payload);
37
+ },
38
+ [bus]
39
+ );
40
+ }