mvc-kit 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,90 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { SortDescriptor } from '../../Sorting';
3
+
4
+ /** Props passed to a custom sort indicator render function. */
5
+ export interface SortHeaderProps {
6
+ active: boolean;
7
+ direction: 'asc' | 'desc';
8
+ index: number;
9
+ onToggle: () => void;
10
+ }
11
+
12
+ /** Controlled selection state using callback props. */
13
+ export interface SelectionState<K = string | number> {
14
+ selected: ReadonlySet<K>;
15
+ onToggle: (key: K) => void;
16
+ onToggleAll: (allKeys: K[]) => void;
17
+ }
18
+
19
+ /** Structural interface matching Selection<K> — duck-type for direct helper pass-through. */
20
+ export interface SelectionHelper {
21
+ readonly selected: ReadonlySet<any>;
22
+ toggle(key: any): void;
23
+ toggleAll(allKeys: any[]): void;
24
+ }
25
+
26
+ /** Controlled pagination state using callback props. */
27
+ export interface PaginationState {
28
+ page: number;
29
+ total: number;
30
+ onPageChange: (page: number) => void;
31
+ }
32
+
33
+ /** Structural interface matching Pagination — duck-type for direct helper pass-through. */
34
+ export interface PaginationHelper {
35
+ readonly page: number;
36
+ readonly pageSize: number;
37
+ setPage(page: number): void;
38
+ }
39
+
40
+ /** Structural interface matching Sorting<T> — duck-type for direct helper pass-through. */
41
+ export interface SortingHelper {
42
+ readonly sorts: readonly SortDescriptor[];
43
+ toggle(key: string): void;
44
+ }
45
+
46
+ /** Computed pagination info passed to renderPagination slots. */
47
+ export interface PaginationInfo {
48
+ page: number;
49
+ pageCount: number;
50
+ total: number;
51
+ pageSize: number;
52
+ hasPrev: boolean;
53
+ hasNext: boolean;
54
+ goToPage: (p: number) => void;
55
+ goPrev: () => void;
56
+ goNext: () => void;
57
+ }
58
+
59
+ /** Loading and error state props for async-aware components. */
60
+ export interface AsyncStateProps {
61
+ loading?: boolean;
62
+ error?: string | null;
63
+ }
64
+
65
+ /** Column definition for DataTable. */
66
+ export interface Column<T> {
67
+ key: string;
68
+ header: ReactNode;
69
+ render: (item: T, index: number) => ReactNode;
70
+ sortable?: boolean;
71
+ width?: string;
72
+ align?: 'left' | 'center' | 'right';
73
+ }
74
+
75
+ // ── Detection functions (duck-type discriminators) ──
76
+
77
+ /** @internal Detect whether a selection prop is a Selection helper instance. */
78
+ export function isSelectionHelper(s: SelectionState | SelectionHelper): s is SelectionHelper {
79
+ return 'toggle' in s && !('onToggle' in s);
80
+ }
81
+
82
+ /** @internal Detect whether a pagination prop is a Pagination helper instance. */
83
+ export function isPaginationHelper(p: PaginationState | PaginationHelper): p is PaginationHelper {
84
+ return 'setPage' in p && !('onPageChange' in p);
85
+ }
86
+
87
+ /** @internal Detect whether a sort prop is a Sorting helper instance. */
88
+ export function isSortingHelper(s: readonly SortDescriptor[] | SortingHelper): s is SortingHelper {
89
+ return s != null && !Array.isArray(s) && 'sorts' in s && 'toggle' in s;
90
+ }
@@ -0,0 +1,261 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { describe, it, expect, afterEach } from 'vitest';
4
+ import { render, screen, act } from '@testing-library/react';
5
+ import { ViewModel } from '../ViewModel';
6
+ import { Collection } from '../Collection';
7
+ import { useLocal } from './use-local';
8
+ import { teardownAll } from '../singleton';
9
+
10
+ interface Item {
11
+ id: string;
12
+ name: string;
13
+ status?: string;
14
+ }
15
+
16
+ class TestCollection extends Collection<Item> {}
17
+
18
+ interface TestState {
19
+ search: string;
20
+ loading: boolean;
21
+ error: string | null;
22
+ }
23
+
24
+ class ItemsViewModel extends ViewModel<TestState> {
25
+ collection = new TestCollection();
26
+ computeCount = 0;
27
+
28
+ get filtered(): Item[] {
29
+ this.computeCount++;
30
+ const { search } = this.state;
31
+ let items = [...this.collection.items];
32
+ if (search) {
33
+ const q = search.toLowerCase();
34
+ items = items.filter((i) => i.name.toLowerCase().includes(q));
35
+ }
36
+ return items;
37
+ }
38
+
39
+ get total(): number {
40
+ return this.collection.length;
41
+ }
42
+
43
+ get hasResults(): boolean {
44
+ return this.filtered.length > 0;
45
+ }
46
+
47
+ setSearch(search: string) {
48
+ this.set({ search });
49
+ }
50
+
51
+ setLoading(loading: boolean) {
52
+ this.set({ loading });
53
+ }
54
+ }
55
+
56
+ const testData: Item[] = [
57
+ { id: '1', name: 'Alice' },
58
+ { id: '2', name: 'Bob' },
59
+ { id: '3', name: 'Charlie' },
60
+ ];
61
+
62
+ describe('derived state - React integration', () => {
63
+ afterEach(() => {
64
+ teardownAll();
65
+ });
66
+
67
+ it('useLocal renders with initial state and getter values', () => {
68
+ function TestComponent() {
69
+ const [state, vm] = useLocal(ItemsViewModel, {
70
+ search: '',
71
+ loading: false,
72
+ error: null,
73
+ });
74
+
75
+ // Populate collection on first render
76
+ React.useEffect(() => {
77
+ vm.collection.reset(testData);
78
+ }, [vm]);
79
+
80
+ return (
81
+ <div>
82
+ <span data-testid="search">{state.search}</span>
83
+ <span data-testid="filtered-count">{vm.filtered.length}</span>
84
+ <span data-testid="total">{vm.total}</span>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ render(<TestComponent />);
90
+
91
+ expect(screen.getByTestId('search').textContent).toBe('');
92
+ expect(screen.getByTestId('filtered-count').textContent).toBe('3');
93
+ expect(screen.getByTestId('total').textContent).toBe('3');
94
+ });
95
+
96
+ it('state change triggers single re-render with consistent values', () => {
97
+ let renderCount = 0;
98
+
99
+ function TestComponent() {
100
+ const [state, vm] = useLocal(ItemsViewModel, {
101
+ search: '',
102
+ loading: false,
103
+ error: null,
104
+ });
105
+
106
+ renderCount++;
107
+
108
+ React.useEffect(() => {
109
+ vm.collection.reset(testData);
110
+ }, [vm]);
111
+
112
+ return (
113
+ <div>
114
+ <span data-testid="search">{state.search}</span>
115
+ <span data-testid="filtered-count">{vm.filtered.length}</span>
116
+ <span data-testid="total">{vm.total}</span>
117
+ <button data-testid="search-btn" onClick={() => vm.setSearch('alice')}>
118
+ Search
119
+ </button>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ render(<TestComponent />);
125
+ const initialRenderCount = renderCount;
126
+
127
+ act(() => {
128
+ screen.getByTestId('search-btn').click();
129
+ });
130
+
131
+ // Should have triggered exactly one additional render
132
+ expect(renderCount).toBe(initialRenderCount + 1);
133
+ expect(screen.getByTestId('search').textContent).toBe('alice');
134
+ expect(screen.getByTestId('filtered-count').textContent).toBe('1');
135
+ expect(screen.getByTestId('total').textContent).toBe('3');
136
+ });
137
+
138
+ it('collection change triggers re-render with updated getters', () => {
139
+ function TestComponent() {
140
+ const [_, vm] = useLocal(ItemsViewModel, {
141
+ search: '',
142
+ loading: false,
143
+ error: null,
144
+ });
145
+
146
+ return (
147
+ <div>
148
+ <span data-testid="filtered-count">{vm.filtered.length}</span>
149
+ <span data-testid="total">{vm.total}</span>
150
+ <button
151
+ data-testid="populate-btn"
152
+ onClick={() => vm.collection.reset(testData)}
153
+ >
154
+ Populate
155
+ </button>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ render(<TestComponent />);
161
+
162
+ expect(screen.getByTestId('filtered-count').textContent).toBe('0');
163
+ expect(screen.getByTestId('total').textContent).toBe('0');
164
+
165
+ act(() => {
166
+ screen.getByTestId('populate-btn').click();
167
+ });
168
+
169
+ expect(screen.getByTestId('filtered-count').textContent).toBe('3');
170
+ expect(screen.getByTestId('total').textContent).toBe('3');
171
+ });
172
+
173
+ it('unrelated state change does not recompute getters', () => {
174
+ function TestComponent() {
175
+ const [state, vm] = useLocal(ItemsViewModel, {
176
+ search: '',
177
+ loading: false,
178
+ error: null,
179
+ });
180
+
181
+ React.useEffect(() => {
182
+ vm.collection.reset(testData);
183
+ }, [vm]);
184
+
185
+ return (
186
+ <div>
187
+ <span data-testid="loading">{String(state.loading)}</span>
188
+ <span data-testid="filtered-count">{vm.filtered.length}</span>
189
+ <span data-testid="compute-count">{vm.computeCount}</span>
190
+ <button data-testid="loading-btn" onClick={() => vm.setLoading(true)}>
191
+ Load
192
+ </button>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ render(<TestComponent />);
198
+
199
+ // Initial render computes the getter
200
+ const initialComputeCount = parseInt(
201
+ screen.getByTestId('compute-count').textContent || '0'
202
+ );
203
+ expect(initialComputeCount).toBeGreaterThan(0);
204
+
205
+ act(() => {
206
+ screen.getByTestId('loading-btn').click();
207
+ });
208
+
209
+ // Component re-renders (loading state changed), but getter returns cached value
210
+ expect(screen.getByTestId('loading').textContent).toBe('true');
211
+ expect(screen.getByTestId('filtered-count').textContent).toBe('3');
212
+ expect(screen.getByTestId('compute-count').textContent).toBe(
213
+ String(initialComputeCount)
214
+ );
215
+ });
216
+
217
+ it('React StrictMode double-mount works correctly', () => {
218
+ function TestComponent() {
219
+ const [_, vm] = useLocal(ItemsViewModel, {
220
+ search: '',
221
+ loading: false,
222
+ error: null,
223
+ });
224
+
225
+ React.useEffect(() => {
226
+ vm.collection.reset(testData);
227
+ }, [vm]);
228
+
229
+ return (
230
+ <div>
231
+ <span data-testid="filtered-count">{vm.filtered.length}</span>
232
+ <span data-testid="total">{vm.total}</span>
233
+ <span data-testid="has-results">{String(vm.hasResults)}</span>
234
+ <button data-testid="search-btn" onClick={() => vm.setSearch('alice')}>
235
+ Search
236
+ </button>
237
+ </div>
238
+ );
239
+ }
240
+
241
+ // StrictMode will mount, unmount, and remount the component in dev mode
242
+ render(
243
+ <React.StrictMode>
244
+ <TestComponent />
245
+ </React.StrictMode>
246
+ );
247
+
248
+ // Should work without errors
249
+ expect(screen.getByTestId('filtered-count').textContent).toBe('3');
250
+ expect(screen.getByTestId('total').textContent).toBe('3');
251
+ expect(screen.getByTestId('has-results').textContent).toBe('true');
252
+
253
+ // Interactions should still work correctly
254
+ act(() => {
255
+ screen.getByTestId('search-btn').click();
256
+ });
257
+
258
+ expect(screen.getByTestId('filtered-count').textContent).toBe('1');
259
+ expect(screen.getByTestId('has-results').textContent).toBe('true');
260
+ });
261
+ });
@@ -0,0 +1,24 @@
1
+ import type { Subscribable } from '../types';
2
+
3
+ /** @internal Type guard for Subscribable */
4
+ export const isSubscribable = (obj: unknown): obj is Subscribable<unknown> =>
5
+ obj !== null &&
6
+ typeof obj === 'object' &&
7
+ 'state' in obj &&
8
+ 'subscribe' in obj &&
9
+ typeof (obj as Subscribable<unknown>).subscribe === 'function';
10
+
11
+ /** @internal Type guard for Initializable */
12
+ export const isInitializable = (obj: unknown): obj is { init(): void | Promise<void> } =>
13
+ obj !== null &&
14
+ typeof obj === 'object' &&
15
+ 'init' in obj &&
16
+ typeof (obj as any).init === 'function';
17
+
18
+ /** @internal Type guard for subscribe-only objects (has subscribe but no state). */
19
+ export const isSubscribeOnly = (obj: unknown): obj is { subscribe(cb: () => void): () => void } =>
20
+ obj !== null &&
21
+ typeof obj === 'object' &&
22
+ !('state' in obj) &&
23
+ 'subscribe' in obj &&
24
+ typeof (obj as any).subscribe === 'function';
@@ -0,0 +1,40 @@
1
+ // Types
2
+ export type { StateOf, ItemOf, SingletonClass, ProviderRegistry } from './types';
3
+
4
+ // Generic reactive state bindings
5
+ export { useInstance } from './use-instance';
6
+ export { useLocal } from './use-local';
7
+ export { useSingleton } from './use-singleton';
8
+
9
+ // Model (specialized - returns rich ModelHandle with validation)
10
+ export { useModel, useModelRef, useField } from './use-model';
11
+ export type { ModelHandle, FieldHandle } from './use-model';
12
+
13
+ // EventBus (specialized)
14
+ export { useEvent, useEmit } from './use-event-bus';
15
+
16
+ // Utilities
17
+ export { useTeardown } from './use-teardown';
18
+ export { Provider, useResolve } from './provider';
19
+ export type { ProviderProps } from './provider';
20
+
21
+ // Headless components
22
+ export { DataTable } from './components/DataTable';
23
+ export type { DataTableProps } from './components/DataTable';
24
+ export { CardList } from './components/CardList';
25
+ export type { CardListProps } from './components/CardList';
26
+ export { InfiniteScroll } from './components/InfiniteScroll';
27
+ export type { InfiniteScrollProps } from './components/InfiniteScroll';
28
+
29
+ // Component types
30
+ export type {
31
+ Column,
32
+ SortHeaderProps,
33
+ SelectionState,
34
+ SelectionHelper,
35
+ PaginationState,
36
+ PaginationHelper,
37
+ PaginationInfo,
38
+ SortingHelper,
39
+ AsyncStateProps,
40
+ } from './components/types';
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { render, screen, act } from '@testing-library/react';
5
+ import { useState } from 'react';
6
+ import { Service } from '../Service';
7
+ import { ViewModel } from '../ViewModel';
8
+ import { teardownAll } from '../singleton';
9
+ import { Provider, useResolve } from './provider';
10
+
11
+ class ApiService extends Service {
12
+ fetchData(): string {
13
+ return 'real-data';
14
+ }
15
+ }
16
+
17
+ class MockApiService extends Service {
18
+ fetchData(): string {
19
+ return 'mock-data';
20
+ }
21
+ }
22
+
23
+ interface CountState {
24
+ count: number;
25
+ }
26
+
27
+ class CounterVM extends ViewModel<CountState> {
28
+ constructor() {
29
+ super({ count: 0 });
30
+ }
31
+
32
+ increment() {
33
+ this.set((prev) => ({ count: prev.count + 1 }));
34
+ }
35
+ }
36
+
37
+ function ApiConsumer() {
38
+ const api = useResolve(ApiService);
39
+ const [data, setData] = useState('');
40
+
41
+ return (
42
+ <div>
43
+ <div data-testid="data">{data}</div>
44
+ <button onClick={() => setData(api.fetchData())}>Fetch</button>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ function CounterConsumer() {
50
+ const vm = useResolve(CounterVM);
51
+ return <div data-testid="count">{vm.state.count}</div>;
52
+ }
53
+
54
+ describe('Provider and useResolve', () => {
55
+ afterEach(() => {
56
+ teardownAll();
57
+ });
58
+
59
+ it('should resolve from provider when provided', () => {
60
+ const mockApi = new MockApiService();
61
+
62
+ render(
63
+ <Provider provide={[[ApiService, mockApi]]}>
64
+ <ApiConsumer />
65
+ </Provider>
66
+ );
67
+
68
+ act(() => {
69
+ screen.getByRole('button').click();
70
+ });
71
+
72
+ expect(screen.getByTestId('data').textContent).toBe('mock-data');
73
+ });
74
+
75
+ it('should fallback to singleton when not provided', () => {
76
+ render(<ApiConsumer />);
77
+
78
+ act(() => {
79
+ screen.getByRole('button').click();
80
+ });
81
+
82
+ expect(screen.getByTestId('data').textContent).toBe('real-data');
83
+ });
84
+
85
+ it('should work with ViewModels', () => {
86
+ const mockVm = new CounterVM();
87
+ mockVm.increment();
88
+ mockVm.increment();
89
+
90
+ render(
91
+ <Provider provide={[[CounterVM, mockVm]]}>
92
+ <CounterConsumer />
93
+ </Provider>
94
+ );
95
+
96
+ expect(screen.getByTestId('count').textContent).toBe('2');
97
+ });
98
+
99
+ it('should provide multiple dependencies', () => {
100
+ const mockApi = new MockApiService();
101
+ const mockVm = new CounterVM();
102
+ mockVm.increment();
103
+
104
+ render(
105
+ <Provider
106
+ provide={[
107
+ [ApiService, mockApi],
108
+ [CounterVM, mockVm],
109
+ ]}
110
+ >
111
+ <ApiConsumer />
112
+ <CounterConsumer />
113
+ </Provider>
114
+ );
115
+
116
+ act(() => {
117
+ screen.getByRole('button').click();
118
+ });
119
+
120
+ expect(screen.getByTestId('data').textContent).toBe('mock-data');
121
+ expect(screen.getByTestId('count').textContent).toBe('1');
122
+ });
123
+
124
+ it('should allow nested providers', () => {
125
+ const outerMock = new MockApiService();
126
+ const innerVm = new CounterVM();
127
+ innerVm.increment();
128
+
129
+ render(
130
+ <Provider provide={[[ApiService, outerMock]]}>
131
+ <Provider provide={[[CounterVM, innerVm]]}>
132
+ <ApiConsumer />
133
+ <CounterConsumer />
134
+ </Provider>
135
+ </Provider>
136
+ );
137
+
138
+ // Note: Inner provider only has CounterVM, so ApiService
139
+ // will fallback to singleton (not outer mock) due to context replacement
140
+ // This is expected React context behavior
141
+ expect(screen.getByTestId('count').textContent).toBe('1');
142
+ });
143
+ });
@@ -0,0 +1,55 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react';
2
+ import { singleton } from '../singleton';
3
+ import type { Disposable } from '../types';
4
+ import type { ProviderRegistry } from './types';
5
+
6
+ const ProviderContext = createContext<ProviderRegistry | null>(null);
7
+
8
+ /** Props for the `Provider` component used to inject test/Storybook dependencies. */
9
+ export interface ProviderProps {
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ provide: Array<[new (...args: any[]) => any, any]>;
12
+ children: ReactNode;
13
+ }
14
+
15
+ /**
16
+ * DI container for testing and Storybook.
17
+ */
18
+ export function Provider({ provide, children }: ProviderProps): ReactNode {
19
+ const registry = useMemo(() => {
20
+ const map: ProviderRegistry = new Map();
21
+ for (const [Class, instance] of provide) {
22
+ map.set(Class, instance);
23
+ }
24
+ return map;
25
+ }, [provide]);
26
+
27
+ return (
28
+ <ProviderContext.Provider value={registry}>
29
+ {children}
30
+ </ProviderContext.Provider>
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Resolve from Provider context or fallback to singleton().
36
+ * If the class defines `static DEFAULT_STATE`, no constructor args are needed.
37
+ */
38
+ export function useResolve<T>(
39
+ Class: (new (...args: any[]) => T) & { DEFAULT_STATE: unknown },
40
+ ): T;
41
+ export function useResolve<T, Args extends unknown[] = unknown[]>(
42
+ Class: new (...args: Args) => T,
43
+ ...args: Args
44
+ ): T;
45
+ export function useResolve<T, Args extends unknown[] = unknown[]>(
46
+ Class: (new (...args: Args) => T) & { DEFAULT_STATE?: unknown },
47
+ ...args: Args
48
+ ): T {
49
+ const registry = useContext(ProviderContext);
50
+
51
+ if (registry?.has(Class)) {
52
+ return registry.get(Class) as T;
53
+ }
54
+ return singleton(Class as new (...args: Args) => T & Disposable, ...args);
55
+ }