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,415 @@
|
|
|
1
|
+
# useSingleton
|
|
2
|
+
|
|
3
|
+
React hook for subscribing to a [singleton](../singleton.md) instance. Resolves the singleton from the global registry, auto-initializes it on mount, and subscribes to state changes. Multiple components sharing the same class see the same instance and the same state.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Signatures
|
|
8
|
+
|
|
9
|
+
### With DEFAULT_STATE (no args needed)
|
|
10
|
+
|
|
11
|
+
If the class defines `static DEFAULT_STATE`, no constructor args are required:
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
function useSingleton<T extends Subscribable & Disposable, S>(
|
|
15
|
+
Class: (new (...args: any[]) => T) & { DEFAULT_STATE: unknown },
|
|
16
|
+
): [S, T]
|
|
17
|
+
|
|
18
|
+
function useSingleton<T extends Disposable>(
|
|
19
|
+
Class: (new (...args: any[]) => T) & { DEFAULT_STATE: unknown },
|
|
20
|
+
): T
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Subscribable (ViewModel, Collection, Channel)
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
function useSingleton<T extends Subscribable & Disposable, S>(
|
|
27
|
+
Class: new (...args: Args) => T,
|
|
28
|
+
...args: Args
|
|
29
|
+
): [S, T]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Returns a `[state, instance]` tuple — identical shape to `useLocal`. The component re-renders when state changes.
|
|
33
|
+
|
|
34
|
+
### Non-Subscribable (Service, Controller)
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
function useSingleton<T extends Disposable>(
|
|
38
|
+
Class: new (...args: Args) => T,
|
|
39
|
+
...args: Args
|
|
40
|
+
): T
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Returns the instance directly. No state subscription — the class has no reactive state to subscribe to.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Parameters
|
|
48
|
+
|
|
49
|
+
| Parameter | Type | Description |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `Class` | `new (...args) => T` | The class to resolve as a singleton. Any `Disposable` works — [ViewModel](../ViewModel.md), [Service](../Service.md), [Collection](../Collection.md), [Channel](../Channel.md), [Controller](../Controller.md). |
|
|
52
|
+
| `...args` | `Args` | Constructor arguments. Used **only on first creation**. Subsequent calls return the cached instance and ignore arguments. |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Behavior
|
|
57
|
+
|
|
58
|
+
### Singleton Resolution
|
|
59
|
+
|
|
60
|
+
`useSingleton` calls `singleton(Class, ...args)` on every render. The singleton registry returns the existing instance if one exists and hasn't been disposed. If no instance exists (or the previous one was disposed), a new one is created with the provided arguments.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// Both components get the exact same CounterVM instance
|
|
64
|
+
function Counter() {
|
|
65
|
+
const [state, vm] = useSingleton(CounterVM, 0);
|
|
66
|
+
return <div>{state.count}</div>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function App() {
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<Counter />
|
|
73
|
+
<Counter />
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Auto-Initialization
|
|
80
|
+
|
|
81
|
+
On mount, `useSingleton` calls `init()` on the instance if it implements `Initializable` (duck-typed check for an `init` method). Since `init()` is idempotent, multiple components mounting with the same singleton class only trigger `onInit()` once.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
class InitVM extends ViewModel<State> {
|
|
85
|
+
protected onInit() {
|
|
86
|
+
console.log('runs once, even with 5 components');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// All five components share one instance; onInit runs once
|
|
91
|
+
function App() {
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<Comp /> <Comp /> <Comp /> <Comp /> <Comp />
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This applies to non-subscribable singletons too:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
class ConfigService extends Service {
|
|
104
|
+
protected onInit() {
|
|
105
|
+
// Runs once on first mount
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function Comp() {
|
|
110
|
+
const service = useSingleton(ConfigService);
|
|
111
|
+
// service.init() called automatically
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### State Subscription (Subscribable Only)
|
|
116
|
+
|
|
117
|
+
When the instance implements `Subscribable` (has `state` and `subscribe`), the hook uses `useInstance` internally to bind the instance's state to React via `useSyncExternalStore`. This includes:
|
|
118
|
+
|
|
119
|
+
- **State changes** — re-renders when `set()` produces a new state reference
|
|
120
|
+
- **Async status changes** — re-renders when any async method's `TaskState` changes (loading starts, completes, or fails)
|
|
121
|
+
- **Subscribable member notifications** — re-renders when a Collection, Channel, or other subscribable member notifies
|
|
122
|
+
|
|
123
|
+
### State Survives Unmount
|
|
124
|
+
|
|
125
|
+
Because the instance lives in the singleton registry (not in component state), its state persists across mount/unmount cycles. Navigating away and back does not reset the ViewModel.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// Mount, increment to 1, unmount
|
|
129
|
+
const { unmount } = render(<Counter />);
|
|
130
|
+
act(() => screen.getByRole('button').click());
|
|
131
|
+
unmount();
|
|
132
|
+
|
|
133
|
+
// Re-mount — state is still 1
|
|
134
|
+
render(<Counter />);
|
|
135
|
+
screen.getByTestId('count').textContent; // '1'
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
This is the key difference from `useLocal`, where state is tied to the component lifecycle.
|
|
139
|
+
|
|
140
|
+
### No Auto-Dispose
|
|
141
|
+
|
|
142
|
+
`useSingleton` does **not** dispose the instance on unmount. The singleton persists until explicitly torn down via `teardown(Class)` or `teardownAll()`. This is intentional — other components may still be subscribed to the same instance.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Usage
|
|
147
|
+
|
|
148
|
+
### Shared ViewModel (App-Wide State)
|
|
149
|
+
|
|
150
|
+
Use `useSingleton` for state that must survive route changes and be accessible from anywhere — auth, theme, cart.
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { ViewModel } from 'mvc-kit';
|
|
154
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
155
|
+
|
|
156
|
+
interface CartState {
|
|
157
|
+
items: CartItem[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
class CartViewModel extends ViewModel<CartState> {
|
|
161
|
+
get itemCount(): number {
|
|
162
|
+
return this.state.items.length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get total(): number {
|
|
166
|
+
return this.state.items.reduce((sum, item) => sum + item.price, 0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
addItem(item: CartItem) {
|
|
170
|
+
this.set({ items: [...this.state.items, item] });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
removeItem(id: string) {
|
|
174
|
+
this.set({ items: this.state.items.filter(i => i.id !== id) });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
class CartViewModel extends ViewModel<CartState> {
|
|
179
|
+
static DEFAULT_STATE: CartState = { items: [] };
|
|
180
|
+
// ...methods
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Header — shows item count badge (no args needed with DEFAULT_STATE)
|
|
184
|
+
function CartIcon() {
|
|
185
|
+
const [state, vm] = useSingleton(CartViewModel);
|
|
186
|
+
return <span className="badge">{vm.itemCount}</span>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Drawer — shows full cart with remove buttons
|
|
190
|
+
function CartDrawer() {
|
|
191
|
+
const [state, vm] = useSingleton(CartViewModel);
|
|
192
|
+
return (
|
|
193
|
+
<aside>
|
|
194
|
+
{state.items.map(item => (
|
|
195
|
+
<CartItem key={item.id} item={item} onRemove={() => vm.removeItem(item.id)} />
|
|
196
|
+
))}
|
|
197
|
+
<p>Total: ${vm.total}</p>
|
|
198
|
+
</aside>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Both `CartIcon` and `CartDrawer` subscribe to the same `CartViewModel`. When `CartDrawer` removes an item, `CartIcon`'s badge updates instantly.
|
|
204
|
+
|
|
205
|
+
### Non-Subscribable Singleton (Service)
|
|
206
|
+
|
|
207
|
+
For Services and other non-reactive singletons, the hook returns the instance directly.
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
class ApiService extends Service {
|
|
211
|
+
async fetchUser(id: string, signal?: AbortSignal): Promise<User> {
|
|
212
|
+
const res = await fetch(`/api/users/${id}`, { signal });
|
|
213
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
214
|
+
return res.json();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function Dashboard() {
|
|
219
|
+
const api = useSingleton(ApiService);
|
|
220
|
+
// api is a stable reference — same instance on every render
|
|
221
|
+
// No reactive state — use a ViewModel for that
|
|
222
|
+
return <div>...</div>;
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Since Services have no reactive state, state changes won't trigger re-renders. If you need the component to react to data from a Service, wrap it in a ViewModel that calls the Service and holds the result in state.
|
|
227
|
+
|
|
228
|
+
### Shared State Across Sibling Components
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
function UsersPage() {
|
|
232
|
+
return (
|
|
233
|
+
<div className="layout">
|
|
234
|
+
<UsersTable />
|
|
235
|
+
<OnDutySidebar />
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function UsersTable() {
|
|
241
|
+
const [state, vm] = useSingleton(UsersViewModel, { items: [], search: '' });
|
|
242
|
+
return <table>{/* renders vm.filtered */}</table>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function OnDutySidebar() {
|
|
246
|
+
const [state, vm] = useSingleton(UsersViewModel, { items: [], search: '' });
|
|
247
|
+
return <aside>{/* renders vm.onDutyUsers */}</aside>;
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Both components read from and mutate the same ViewModel. A search typed in `UsersTable` is immediately visible in `OnDutySidebar`'s derived getters.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Constructor Arguments
|
|
256
|
+
|
|
257
|
+
Arguments are passed to `singleton()`, which forwards them to the constructor **only on first creation**. Once the instance exists in the registry, arguments are ignored on all subsequent calls.
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
class CounterVM extends ViewModel<{ count: number }> {
|
|
261
|
+
constructor(initial = 0) {
|
|
262
|
+
super({ count: initial });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// First mount creates the instance with initial=0
|
|
267
|
+
const [state1] = useSingleton(CounterVM, 0);
|
|
268
|
+
|
|
269
|
+
// Second component — same instance, argument 100 is ignored
|
|
270
|
+
const [state2] = useSingleton(CounterVM, 100);
|
|
271
|
+
state2.count; // 0 (not 100)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
This matches the `singleton()` registry semantics. If you need a fresh instance with different arguments, call `teardown(Class)` first.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## useSingleton vs useLocal
|
|
279
|
+
|
|
280
|
+
| Behavior | `useSingleton` | `useLocal` |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| Instance lifetime | Global — survives unmount | Component-scoped — disposed on unmount |
|
|
283
|
+
| Shared across components | Yes — same instance for all callers | No — each component gets its own |
|
|
284
|
+
| Auto-dispose on unmount | No | Yes |
|
|
285
|
+
| `deps` array for recreate | Not supported | Supported |
|
|
286
|
+
| `init()` call count | Once globally (idempotent) | Once per component mount |
|
|
287
|
+
| State persistence | Survives route changes | Lost on unmount |
|
|
288
|
+
| Typical use | Auth, theme, cart, app-wide state | Page-level, form-level, card-level state |
|
|
289
|
+
|
|
290
|
+
**Default to `useLocal`.** Promote to `useSingleton` only when state must be shared across unrelated components or survive route changes.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Testing
|
|
295
|
+
|
|
296
|
+
### Test Isolation
|
|
297
|
+
|
|
298
|
+
Always call `teardownAll()` in test cleanup to prevent singleton leakage between tests:
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
import { teardownAll } from 'mvc-kit';
|
|
302
|
+
|
|
303
|
+
afterEach(() => {
|
|
304
|
+
teardownAll();
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Asserting Shared State
|
|
309
|
+
|
|
310
|
+
```tsx
|
|
311
|
+
test('shared state across components', () => {
|
|
312
|
+
function App() {
|
|
313
|
+
return (
|
|
314
|
+
<>
|
|
315
|
+
<Counter />
|
|
316
|
+
<Counter />
|
|
317
|
+
</>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
render(<App />);
|
|
322
|
+
const counts = screen.getAllByTestId('count');
|
|
323
|
+
|
|
324
|
+
// Both start at 0
|
|
325
|
+
expect(counts[0].textContent).toBe('0');
|
|
326
|
+
expect(counts[1].textContent).toBe('0');
|
|
327
|
+
|
|
328
|
+
// Click one — both update
|
|
329
|
+
act(() => screen.getAllByRole('button')[0].click());
|
|
330
|
+
expect(counts[0].textContent).toBe('1');
|
|
331
|
+
expect(counts[1].textContent).toBe('1');
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Asserting State Persistence Across Remounts
|
|
336
|
+
|
|
337
|
+
```tsx
|
|
338
|
+
test('state survives unmount and remount', () => {
|
|
339
|
+
const { unmount } = render(<Counter />);
|
|
340
|
+
|
|
341
|
+
act(() => screen.getByRole('button').click());
|
|
342
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
343
|
+
|
|
344
|
+
unmount();
|
|
345
|
+
render(<Counter />);
|
|
346
|
+
|
|
347
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Injecting Mocks via Provider
|
|
352
|
+
|
|
353
|
+
Use `Provider` to inject mock instances in tests and Storybook:
|
|
354
|
+
|
|
355
|
+
```tsx
|
|
356
|
+
import { Provider } from 'mvc-kit/react';
|
|
357
|
+
|
|
358
|
+
const mockService = new UserService();
|
|
359
|
+
jest.spyOn(mockService, 'getAll').mockResolvedValue(mockUsers);
|
|
360
|
+
|
|
361
|
+
render(
|
|
362
|
+
<Provider provide={[
|
|
363
|
+
[UserService, mockService],
|
|
364
|
+
[UsersCollection, testCollection],
|
|
365
|
+
]}>
|
|
366
|
+
<UsersPage />
|
|
367
|
+
</Provider>
|
|
368
|
+
);
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## Best Practices
|
|
374
|
+
|
|
375
|
+
**Default to `useLocal`, promote to `useSingleton` when needed.** Most ViewModels are component-scoped. Only use `useSingleton` for app-wide concerns (auth, theme, cart) or when multiple unrelated components must share state.
|
|
376
|
+
|
|
377
|
+
**Don't use `useSingleton` for Services inside components that also have a ViewModel.** The ViewModel should resolve its own Services via `singleton()` in property initializers. Components don't import infrastructure.
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
// Bad: component resolves the service
|
|
381
|
+
function UsersPage() {
|
|
382
|
+
const api = useSingleton(UserService); // leaky
|
|
383
|
+
const [state, vm] = useLocal(UsersViewModel, { items: [] });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Good: ViewModel resolves its own dependencies
|
|
387
|
+
class UsersViewModel extends ViewModel<State> {
|
|
388
|
+
private service = singleton(UserService); // encapsulated
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Always `teardownAll()` in tests.** Singleton leakage is the most common cause of flaky tests.
|
|
393
|
+
|
|
394
|
+
**Define `static DEFAULT_STATE` on singleton ViewModels.** This eliminates repeated initial state at every call site and prevents accidental divergence:
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
class CartViewModel extends ViewModel<CartState> {
|
|
398
|
+
static DEFAULT_STATE: CartState = { items: [] };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// All consumers — no args needed:
|
|
402
|
+
const [state, vm] = useSingleton(CartViewModel);
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**Don't pass different constructor arguments from different components.** Arguments are first-call-only. If two components pass different args, the second one's args are silently ignored. Use `static DEFAULT_STATE` to declare the initial state once, or use a factory pattern with `teardown` for re-creation.
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## Related
|
|
410
|
+
|
|
411
|
+
- [singleton](../singleton.md) — the underlying registry that `useSingleton` delegates to
|
|
412
|
+
- [ViewModel](../ViewModel.md) — the most common class used with `useSingleton`
|
|
413
|
+
- [Service](../Service.md) — non-subscribable singletons returned as plain instances
|
|
414
|
+
- [Collection](../Collection.md) — subscribable data cache (typically encapsulated by a ViewModel, not used directly with `useSingleton`)
|
|
415
|
+
- [Channel](../Channel.md) — persistent connection singletons
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { ViewModel } from '../ViewModel';
|
|
7
|
+
import { Service } from '../Service';
|
|
8
|
+
import { teardownAll } from '../singleton';
|
|
9
|
+
import { useSingleton } from './use-singleton';
|
|
10
|
+
|
|
11
|
+
interface CounterState {
|
|
12
|
+
count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class CounterVM extends ViewModel<CounterState> {
|
|
16
|
+
constructor(initial = 0) {
|
|
17
|
+
super({ count: initial });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
increment() {
|
|
21
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Counter() {
|
|
26
|
+
const [state, vm] = useSingleton(CounterVM, 0);
|
|
27
|
+
return (
|
|
28
|
+
<div>
|
|
29
|
+
<div data-testid="count">{state.count}</div>
|
|
30
|
+
<button onClick={() => vm.increment()}>Increment</button>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useSingleton', () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
teardownAll();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('with Subscribable', () => {
|
|
41
|
+
it('should return state and vm tuple', () => {
|
|
42
|
+
render(<Counter />);
|
|
43
|
+
expect(screen.getByTestId('count').textContent).toBe('0');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should share state across multiple components', () => {
|
|
47
|
+
function App() {
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<Counter />
|
|
51
|
+
<Counter />
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
render(<App />);
|
|
57
|
+
const counts = screen.getAllByTestId('count');
|
|
58
|
+
expect(counts).toHaveLength(2);
|
|
59
|
+
expect(counts[0].textContent).toBe('0');
|
|
60
|
+
expect(counts[1].textContent).toBe('0');
|
|
61
|
+
|
|
62
|
+
act(() => {
|
|
63
|
+
screen.getAllByRole('button')[0].click();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(counts[0].textContent).toBe('1');
|
|
67
|
+
expect(counts[1].textContent).toBe('1');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should maintain singleton across remounts', () => {
|
|
71
|
+
const { unmount } = render(<Counter />);
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
screen.getByRole('button').click();
|
|
75
|
+
});
|
|
76
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
77
|
+
|
|
78
|
+
unmount();
|
|
79
|
+
|
|
80
|
+
// Render fresh component - should still have the singleton state
|
|
81
|
+
render(<Counter />);
|
|
82
|
+
|
|
83
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('with Disposable-only (Service)', () => {
|
|
88
|
+
class ApiService extends Service {
|
|
89
|
+
private data: string[] = [];
|
|
90
|
+
|
|
91
|
+
addItem(item: string): void {
|
|
92
|
+
this.data.push(item);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getItems(): string[] {
|
|
96
|
+
return [...this.data];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ServiceComponent() {
|
|
101
|
+
const api = useSingleton(ApiService);
|
|
102
|
+
const [items, setItems] = useState<string[]>([]);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div>
|
|
106
|
+
<div data-testid="svc-count">{items.length}</div>
|
|
107
|
+
<button onClick={() => {
|
|
108
|
+
api.addItem('item');
|
|
109
|
+
setItems(api.getItems());
|
|
110
|
+
}}>
|
|
111
|
+
Add
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
it('should return service instance directly', () => {
|
|
118
|
+
render(<ServiceComponent />);
|
|
119
|
+
expect(screen.getByTestId('svc-count').textContent).toBe('0');
|
|
120
|
+
|
|
121
|
+
act(() => {
|
|
122
|
+
screen.getByRole('button').click();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(screen.getByTestId('svc-count').textContent).toBe('1');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should share service across components', () => {
|
|
129
|
+
function App() {
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
<ServiceComponent />
|
|
133
|
+
<ServiceComponent />
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
render(<App />);
|
|
139
|
+
const counts = screen.getAllByTestId('svc-count');
|
|
140
|
+
|
|
141
|
+
act(() => {
|
|
142
|
+
screen.getAllByRole('button')[0].click();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Both should see the same data
|
|
146
|
+
act(() => {
|
|
147
|
+
screen.getAllByRole('button')[1].click();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// After the second click updates its local state
|
|
151
|
+
expect(counts[1].textContent).toBe('2');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should return same instance on re-render', () => {
|
|
155
|
+
let serviceRef: ApiService | null = null;
|
|
156
|
+
|
|
157
|
+
function Tracker() {
|
|
158
|
+
const service = useSingleton(ApiService);
|
|
159
|
+
if (serviceRef === null) {
|
|
160
|
+
serviceRef = service;
|
|
161
|
+
} else {
|
|
162
|
+
expect(service).toBe(serviceRef);
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { rerender } = render(<Tracker />);
|
|
168
|
+
rerender(<Tracker />);
|
|
169
|
+
rerender(<Tracker />);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('DEFAULT_STATE', () => {
|
|
174
|
+
class DefaultCounterVM extends ViewModel<CounterState> {
|
|
175
|
+
static DEFAULT_STATE: CounterState = { count: 7 };
|
|
176
|
+
|
|
177
|
+
increment() {
|
|
178
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function DefaultCounter() {
|
|
183
|
+
const [state, vm] = useSingleton(DefaultCounterVM);
|
|
184
|
+
return (
|
|
185
|
+
<div>
|
|
186
|
+
<div data-testid="default-count">{state.count}</div>
|
|
187
|
+
<button onClick={() => vm.increment()}>Increment</button>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it('works without constructor args when DEFAULT_STATE is present', () => {
|
|
193
|
+
render(<DefaultCounter />);
|
|
194
|
+
expect(screen.getByTestId('default-count').textContent).toBe('7');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('shares DEFAULT_STATE singleton across components', () => {
|
|
198
|
+
function App() {
|
|
199
|
+
return (
|
|
200
|
+
<>
|
|
201
|
+
<DefaultCounter />
|
|
202
|
+
<DefaultCounter />
|
|
203
|
+
</>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
render(<App />);
|
|
208
|
+
const counts = screen.getAllByTestId('default-count');
|
|
209
|
+
expect(counts).toHaveLength(2);
|
|
210
|
+
expect(counts[0].textContent).toBe('7');
|
|
211
|
+
expect(counts[1].textContent).toBe('7');
|
|
212
|
+
|
|
213
|
+
act(() => {
|
|
214
|
+
screen.getAllByRole('button')[0].click();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(counts[0].textContent).toBe('8');
|
|
218
|
+
expect(counts[1].textContent).toBe('8');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('init lifecycle', () => {
|
|
223
|
+
it('should auto-call init on mount for Subscribable', () => {
|
|
224
|
+
let initCalled = false;
|
|
225
|
+
class InitVM extends ViewModel<CounterState> {
|
|
226
|
+
constructor(initial = 0) {
|
|
227
|
+
super({ count: initial });
|
|
228
|
+
}
|
|
229
|
+
protected onInit() {
|
|
230
|
+
initCalled = true;
|
|
231
|
+
}
|
|
232
|
+
increment() {
|
|
233
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function Comp() {
|
|
238
|
+
const [state] = useSingleton(InitVM, 0);
|
|
239
|
+
return <div data-testid="count">{state.count}</div>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
render(<Comp />);
|
|
243
|
+
expect(initCalled).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should call init only once across multiple components sharing singleton', () => {
|
|
247
|
+
let initCount = 0;
|
|
248
|
+
class InitVM extends ViewModel<CounterState> {
|
|
249
|
+
constructor(initial = 0) {
|
|
250
|
+
super({ count: initial });
|
|
251
|
+
}
|
|
252
|
+
protected onInit() {
|
|
253
|
+
initCount++;
|
|
254
|
+
}
|
|
255
|
+
increment() {
|
|
256
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function Comp() {
|
|
261
|
+
const [state] = useSingleton(InitVM, 0);
|
|
262
|
+
return <div data-testid="count">{state.count}</div>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function App() {
|
|
266
|
+
return (
|
|
267
|
+
<>
|
|
268
|
+
<Comp />
|
|
269
|
+
<Comp />
|
|
270
|
+
</>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
render(<App />);
|
|
275
|
+
// init() is idempotent — even though two components call it, onInit runs once
|
|
276
|
+
expect(initCount).toBe(1);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should auto-call init for Disposable-only singleton', () => {
|
|
280
|
+
let initCalled = false;
|
|
281
|
+
class InitService extends Service {
|
|
282
|
+
protected onInit() {
|
|
283
|
+
initCalled = true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function Comp() {
|
|
288
|
+
useSingleton(InitService);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
render(<Comp />);
|
|
293
|
+
expect(initCalled).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|