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.
- 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 +19 -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
|
@@ -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
|
+
}
|