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,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
+ });