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,394 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { produceDraft, resolveDraftUpdater } from './produceDraft';
3
+
4
+ describe('produceDraft', () => {
5
+ // ── Flat mutations ──────────────────────────────────────────────
6
+
7
+ it('applies a single flat property mutation', () => {
8
+ const state = { count: 0, name: 'test' };
9
+ const result = produceDraft(state, (d) => {
10
+ d.count = 5;
11
+ });
12
+ expect(result).toEqual({ count: 5 });
13
+ });
14
+
15
+ it('applies multiple flat property mutations', () => {
16
+ const state = { count: 0, name: 'test', active: false };
17
+ const result = produceDraft(state, (d) => {
18
+ d.count = 10;
19
+ d.name = 'updated';
20
+ });
21
+ expect(result).toEqual({ count: 10, name: 'updated' });
22
+ });
23
+
24
+ it('returns only changed keys, omitting unchanged ones', () => {
25
+ const state = { a: 1, b: 2, c: 3 };
26
+ const result = produceDraft(state, (d) => {
27
+ d.b = 20;
28
+ });
29
+ expect(result).toEqual({ b: 20 });
30
+ expect(result).not.toHaveProperty('a');
31
+ expect(result).not.toHaveProperty('c');
32
+ });
33
+
34
+ // ── No-op detection ─────────────────────────────────────────────
35
+
36
+ it('returns null for empty mutator', () => {
37
+ const state = { count: 0, name: 'test' };
38
+ const result = produceDraft(state, () => {});
39
+ expect(result).toBeNull();
40
+ });
41
+
42
+ it('returns null when assigning same value (flat)', () => {
43
+ const state = { count: 5, name: 'test' };
44
+ const result = produceDraft(state, (d) => {
45
+ d.count = 5;
46
+ d.name = 'test';
47
+ });
48
+ expect(result).toBeNull();
49
+ });
50
+
51
+ it('returns null when assigning same value (nested)', () => {
52
+ const state = { config: { theme: 'dark', size: 14 } };
53
+ const result = produceDraft(state, (d) => {
54
+ d.config.theme = 'dark';
55
+ });
56
+ expect(result).toBeNull();
57
+ });
58
+
59
+ // ── Nested mutations ────────────────────────────────────────────
60
+
61
+ it('applies single-level nested mutation', () => {
62
+ const state = { config: { theme: 'dark', size: 14 }, count: 0 };
63
+ const result = produceDraft(state, (d) => {
64
+ d.config.theme = 'light';
65
+ });
66
+ expect(result).toEqual({ config: { theme: 'light', size: 14 } });
67
+ expect(result).not.toHaveProperty('count');
68
+ });
69
+
70
+ it('preserves unchanged siblings in nested object (structural sharing)', () => {
71
+ const inner = { x: 10 };
72
+ const state = { a: { b: inner, c: { y: 20 } } };
73
+ const result = produceDraft(state, (d) => {
74
+ d.a.c.y = 30;
75
+ });
76
+
77
+ // Changed path gets new reference
78
+ expect(result!.a!.c).toEqual({ y: 30 });
79
+ expect(result!.a!.c).not.toBe(state.a.c);
80
+
81
+ // Unchanged sibling keeps same reference
82
+ expect((result!.a as any).b).toBe(inner);
83
+ });
84
+
85
+ it('handles deep nesting (3 levels)', () => {
86
+ const state = { a: { b: { c: { d: 1 } } } };
87
+ const result = produceDraft(state, (d) => {
88
+ d.a.b.c.d = 2;
89
+ });
90
+ expect(result).toEqual({ a: { b: { c: { d: 2 } } } });
91
+ });
92
+
93
+ it('handles multiple nested mutations in same object', () => {
94
+ const state = { config: { theme: 'dark', size: 14, lang: 'en' } };
95
+ const result = produceDraft(state, (d) => {
96
+ d.config.theme = 'light';
97
+ d.config.size = 16;
98
+ });
99
+ expect(result).toEqual({ config: { theme: 'light', size: 16, lang: 'en' } });
100
+ });
101
+
102
+ it('handles mutations in different nested objects', () => {
103
+ const state = { a: { x: 1 }, b: { y: 2 } };
104
+ const result = produceDraft(state, (d) => {
105
+ d.a.x = 10;
106
+ d.b.y = 20;
107
+ });
108
+ expect(result).toEqual({ a: { x: 10 }, b: { y: 20 } });
109
+ });
110
+
111
+ // ── Read-after-write ────────────────────────────────────────────
112
+
113
+ it('reads reflect prior writes (flat)', () => {
114
+ const state = { count: 0 };
115
+ const result = produceDraft(state, (d) => {
116
+ d.count = 5;
117
+ d.count = d.count + 1;
118
+ });
119
+ expect(result).toEqual({ count: 6 });
120
+ });
121
+
122
+ it('reads reflect prior writes (nested)', () => {
123
+ const state = { config: { theme: 'dark' } };
124
+ const result = produceDraft(state, (d) => {
125
+ d.config.theme = 'light';
126
+ // Should read the updated value
127
+ d.config.theme = d.config.theme + '-mode';
128
+ });
129
+ expect(result).toEqual({ config: { theme: 'light-mode' } });
130
+ });
131
+
132
+ // ── Replace then mutate ─────────────────────────────────────────
133
+
134
+ it('replace nested object then mutate further', () => {
135
+ const state = { config: { theme: 'dark', size: 14 } };
136
+ const result = produceDraft(state, (d) => {
137
+ d.config = { theme: 'blue', size: 20 };
138
+ d.config.theme = 'red';
139
+ });
140
+ expect(result).toEqual({ config: { theme: 'red', size: 20 } });
141
+ });
142
+
143
+ // ── Original immutability ───────────────────────────────────────
144
+
145
+ it('never mutates original state', () => {
146
+ const state = Object.freeze({ count: 0, name: 'test' });
147
+ const result = produceDraft(state, (d) => {
148
+ d.count = 5;
149
+ d.name = 'updated';
150
+ });
151
+ expect(result).toEqual({ count: 5, name: 'updated' });
152
+ expect(state.count).toBe(0);
153
+ expect(state.name).toBe('test');
154
+ });
155
+
156
+ it('never mutates original nested objects', () => {
157
+ const config = Object.freeze({ theme: 'dark', size: 14 });
158
+ const state = Object.freeze({ config });
159
+ const result = produceDraft(state, (d) => {
160
+ d.config.theme = 'light';
161
+ });
162
+ expect(result).toEqual({ config: { theme: 'light', size: 14 } });
163
+ expect(config.theme).toBe('dark');
164
+ });
165
+
166
+ // ── Non-POJO pass-through ───────────────────────────────────────
167
+
168
+ it('does not proxy Date objects', () => {
169
+ const date = new Date('2024-01-01');
170
+ const state = { created: date, name: 'test' };
171
+ const result = produceDraft(state, (d) => {
172
+ d.name = 'updated';
173
+ });
174
+ expect(result).toEqual({ name: 'updated' });
175
+ });
176
+
177
+ it('does not proxy class instances', () => {
178
+ class Foo {
179
+ value = 1;
180
+ }
181
+ const foo = new Foo();
182
+ const state = { foo, name: 'test' };
183
+
184
+ // Reading a class instance returns the original
185
+ produceDraft(state, (d) => {
186
+ expect(d.foo).toBe(foo);
187
+ });
188
+ });
189
+
190
+ it('does not proxy arrays (returns frozen copy in DEV)', () => {
191
+ const items = [1, 2, 3];
192
+ const state = { items, name: 'test' };
193
+
194
+ produceDraft(state, (d) => {
195
+ // In DEV, arrays are returned as frozen copies to catch mutation attempts
196
+ expect(d.items).toEqual(items);
197
+ expect(d.items).not.toBe(items);
198
+ expect(Object.isFrozen(d.items)).toBe(true);
199
+ });
200
+ });
201
+
202
+ it('handles array replacement via assignment', () => {
203
+ const state = { items: [1, 2, 3] };
204
+ const result = produceDraft(state, (d) => {
205
+ d.items = [...d.items, 4];
206
+ });
207
+ expect(result).toEqual({ items: [1, 2, 3, 4] });
208
+ expect(state.items).toEqual([1, 2, 3]); // Original unchanged
209
+ });
210
+
211
+ it('handles nested array replacement via assignment', () => {
212
+ const state = { config: { tags: ['a', 'b'] } };
213
+ const result = produceDraft(state, (d) => {
214
+ d.config.tags = [...(d.config as any).tags, 'c'];
215
+ });
216
+ expect(result!.config).toEqual({ tags: ['a', 'b', 'c'] });
217
+ });
218
+
219
+ // ── Spread and destructure ─────────────────────────────────────
220
+
221
+ it('Object.keys works on draft', () => {
222
+ const state = { a: 1, b: 2, c: 3 };
223
+ produceDraft(state, (d) => {
224
+ expect(Object.keys(d)).toEqual(['a', 'b', 'c']);
225
+ });
226
+ });
227
+
228
+ it('spread works on draft', () => {
229
+ const state = { a: 1, b: 2 };
230
+ produceDraft(state, (d) => {
231
+ const copy = { ...d };
232
+ expect(copy).toEqual({ a: 1, b: 2 });
233
+ });
234
+ });
235
+
236
+ it('destructure works on draft', () => {
237
+ const state = { a: 1, b: 2 };
238
+ produceDraft(state, (d) => {
239
+ const { a, b } = d;
240
+ expect(a).toBe(1);
241
+ expect(b).toBe(2);
242
+ });
243
+ });
244
+
245
+ it('"in" operator works on draft', () => {
246
+ const state = { a: 1, b: 2 };
247
+ produceDraft(state, (d) => {
248
+ expect('a' in d).toBe(true);
249
+ expect('c' in d).toBe(false);
250
+ });
251
+ });
252
+
253
+ // ── Null and undefined values ───────────────────────────────────
254
+
255
+ it('handles null values in state', () => {
256
+ const state = { user: null as string | null, count: 0 };
257
+ const result = produceDraft(state, (d) => {
258
+ d.user = 'alice';
259
+ });
260
+ expect(result).toEqual({ user: 'alice' });
261
+ });
262
+
263
+ it('handles setting value to null', () => {
264
+ const state = { user: 'alice' as string | null };
265
+ const result = produceDraft(state, (d) => {
266
+ d.user = null;
267
+ });
268
+ expect(result).toEqual({ user: null });
269
+ });
270
+
271
+ it('handles undefined values in state', () => {
272
+ const state = { value: undefined as string | undefined, count: 0 };
273
+ const result = produceDraft(state, (d) => {
274
+ d.value = 'hello';
275
+ });
276
+ expect(result).toEqual({ value: 'hello' });
277
+ });
278
+
279
+ // ── Mixed scenarios ─────────────────────────────────────────────
280
+
281
+ it('handles mix of flat and nested mutations', () => {
282
+ const state = { count: 0, config: { theme: 'dark' } };
283
+ const result = produceDraft(state, (d) => {
284
+ d.count = 1;
285
+ d.config.theme = 'light';
286
+ });
287
+ expect(result).toEqual({ count: 1, config: { theme: 'light' } });
288
+ });
289
+
290
+ it('returns null when nested writes result in same values', () => {
291
+ const state = { config: { theme: 'dark', size: 14 } };
292
+ const result = produceDraft(state, (d) => {
293
+ d.config.theme = 'light';
294
+ d.config.theme = 'dark'; // revert
295
+ });
296
+ // Config was copied (write happened) but final values are same as state.config
297
+ // However, the copy is a new reference, so it's detected as changed
298
+ // This is expected — same-ref check is at the produceDraft level
299
+ expect(result).toEqual({ config: { theme: 'dark', size: 14 } });
300
+ });
301
+
302
+ it('structural sharing: unchanged top-level objects keep identity', () => {
303
+ const obj1 = { x: 1 };
304
+ const obj2 = { y: 2 };
305
+ const state = { a: obj1, b: obj2, c: 3 };
306
+ const result = produceDraft(state, (d) => {
307
+ d.c = 4;
308
+ });
309
+ // Only 'c' changed — 'a' and 'b' are not in the result at all
310
+ expect(result).toEqual({ c: 4 });
311
+ expect(result).not.toHaveProperty('a');
312
+ expect(result).not.toHaveProperty('b');
313
+ });
314
+
315
+ it('structural sharing: nested unchanged siblings keep same reference', () => {
316
+ const sibling = { data: [1, 2, 3] };
317
+ const state = { parent: { changed: { value: 1 }, unchanged: sibling } };
318
+ const result = produceDraft(state, (d) => {
319
+ d.parent.changed.value = 2;
320
+ });
321
+ // The parent object is new (child was modified), but unchanged sibling kept reference
322
+ expect((result!.parent as any).unchanged).toBe(sibling);
323
+ });
324
+
325
+ // ── Object.create(null) support ─────────────────────────────────
326
+
327
+ it('handles objects with null prototype', () => {
328
+ const config = Object.create(null);
329
+ config.theme = 'dark';
330
+ config.size = 14;
331
+ const state = { config, count: 0 };
332
+
333
+ const result = produceDraft(state, (d) => {
334
+ d.config.theme = 'light';
335
+ });
336
+ expect(result!.config).toEqual({ theme: 'light', size: 14 });
337
+ });
338
+
339
+ // ── DEV array guard ─────────────────────────────────────────────
340
+
341
+ it('DEV: array mutation methods throw instead of silently mutating', () => {
342
+ const state = { items: [1, 2, 3] };
343
+ produceDraft(state, (d) => {
344
+ const items = d.items;
345
+ expect(() => (items as number[]).push(4)).toThrow();
346
+ });
347
+ // Original was never mutated
348
+ expect(state.items).toEqual([1, 2, 3]);
349
+ });
350
+
351
+ it('DEV: nested array mutation methods throw', () => {
352
+ const state = { config: { tags: ['a', 'b'] } };
353
+ produceDraft(state, (d) => {
354
+ const tags = d.config.tags;
355
+ expect(() => (tags as string[]).push('c')).toThrow();
356
+ });
357
+ expect(state.config.tags).toEqual(['a', 'b']);
358
+ });
359
+ });
360
+
361
+ // ── resolveDraftUpdater ─────────────────────────────────────────
362
+
363
+ describe('resolveDraftUpdater', () => {
364
+ it('handles draft mode (void return)', () => {
365
+ const state = { count: 0, name: 'test' };
366
+ const result = resolveDraftUpdater(state, (d) => {
367
+ d.count = 5;
368
+ });
369
+ expect(result).toEqual({ count: 5 });
370
+ });
371
+
372
+ it('handles updater mode (explicit return)', () => {
373
+ const state = { count: 0, name: 'test' };
374
+ const result = resolveDraftUpdater(state, () => ({ count: 5 }));
375
+ expect(result).toEqual({ count: 5 });
376
+ });
377
+
378
+ it('returns null for no-op draft', () => {
379
+ const state = { count: 0 };
380
+ const result = resolveDraftUpdater(state, (d) => {
381
+ d.count = 0; // same value
382
+ });
383
+ expect(result).toBeNull();
384
+ });
385
+
386
+ it('explicit return takes precedence over draft mutations', () => {
387
+ const state = { count: 0, name: 'test' };
388
+ const result = resolveDraftUpdater(state, (d) => {
389
+ d.count = 99; // draft mutation — ignored
390
+ return { name: 'updated' }; // explicit return wins
391
+ });
392
+ expect(result).toEqual({ name: 'updated' });
393
+ });
394
+ });
@@ -0,0 +1,168 @@
1
+ const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
2
+
3
+ /**
4
+ * Checks if a value is a plain object (POJO).
5
+ * Returns false for arrays, Dates, class instances, null, etc.
6
+ */
7
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
8
+ if (value === null || typeof value !== 'object') return false;
9
+ const proto = Object.getPrototypeOf(value);
10
+ return proto === Object.prototype || proto === null;
11
+ }
12
+
13
+ interface DraftNode<T extends object = object> {
14
+ proxy: T;
15
+ changed(): boolean;
16
+ finalize(): T;
17
+ }
18
+
19
+ function createDraftNode<T extends object>(original: Readonly<T>): DraftNode<T> {
20
+ let copy: Record<string, unknown> | null = null;
21
+ const children = new Map<string, DraftNode>();
22
+
23
+ function ensureCopy(): Record<string, unknown> {
24
+ if (!copy) copy = { ...(original as Record<string, unknown>) };
25
+ return copy;
26
+ }
27
+
28
+ // Use empty object as proxy target to avoid invariant violations
29
+ // with frozen state objects. All reads/writes go through the handler.
30
+ const proxy = new Proxy({} as T, {
31
+ get(_, prop) {
32
+ if (typeof prop === 'symbol') return (original as any)[prop];
33
+
34
+ const key = prop as string;
35
+
36
+ // Return cached child draft proxy
37
+ if (children.has(key)) return children.get(key)!.proxy;
38
+
39
+ // Read from copy (if mutated) or original
40
+ const source: any = copy ?? original;
41
+ const value = source[key];
42
+
43
+ // Auto-draft nested plain objects
44
+ if (isPlainObject(value)) {
45
+ const child = createDraftNode(value as Record<string, unknown>);
46
+ children.set(key, child);
47
+ return child.proxy;
48
+ }
49
+
50
+ // DEV: freeze arrays so mutation methods (push, splice) throw immediately
51
+ // instead of silently mutating the original state
52
+ if (__DEV__ && Array.isArray(value)) {
53
+ return Object.freeze([...value]);
54
+ }
55
+
56
+ return value;
57
+ },
58
+
59
+ set(_, prop, value) {
60
+ if (typeof prop === 'symbol') return true;
61
+
62
+ const key = prop as string;
63
+ const source: any = copy ?? original;
64
+
65
+ if (source[key] !== value) {
66
+ ensureCopy();
67
+ copy![key] = value;
68
+ // Discard child draft — value was fully replaced
69
+ children.delete(key);
70
+ }
71
+ return true;
72
+ },
73
+
74
+ ownKeys() {
75
+ return Reflect.ownKeys((copy ?? original) as object);
76
+ },
77
+
78
+ getOwnPropertyDescriptor(_, prop) {
79
+ const source = (copy ?? original) as Record<string, unknown>;
80
+ if (Object.prototype.hasOwnProperty.call(source, prop)) {
81
+ return { value: source[prop as string], writable: true, enumerable: true, configurable: true };
82
+ }
83
+ return undefined;
84
+ },
85
+
86
+ has(_, prop) {
87
+ return prop in ((copy ?? original) as object);
88
+ },
89
+ });
90
+
91
+ return {
92
+ proxy,
93
+
94
+ changed(): boolean {
95
+ if (copy) return true;
96
+ for (const child of children.values()) {
97
+ if (child.changed()) return true;
98
+ }
99
+ return false;
100
+ },
101
+
102
+ finalize(): T {
103
+ // Merge child results bottom-up
104
+ for (const [key, child] of children) {
105
+ if (child.changed()) {
106
+ ensureCopy();
107
+ copy![key] = child.finalize();
108
+ }
109
+ }
110
+ return (copy ?? original) as T;
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Creates a copy-on-write draft proxy of the given state, runs the mutator,
117
+ * and returns only the changed top-level keys as a Partial.
118
+ * Returns null if nothing was modified.
119
+ *
120
+ * - Nested plain objects use copy-on-write structural sharing
121
+ * - Same-value assignments are no-ops
122
+ * - Reads reflect prior writes within the same draft
123
+ * - Only POJOs are proxied; class instances, arrays, Dates pass through as-is
124
+ * - Arrays must be replaced via assignment, not mutated in place
125
+ */
126
+ export function produceDraft<S extends object>(
127
+ state: Readonly<S>,
128
+ mutator: (draft: S) => void,
129
+ ): Partial<S> | null {
130
+ const root = createDraftNode(state);
131
+ mutator(root.proxy);
132
+
133
+ if (!root.changed()) return null;
134
+
135
+ const finalized = root.finalize();
136
+
137
+ // Extract only changed top-level keys
138
+ const partial: Record<string, unknown> = {};
139
+ let hasChanges = false;
140
+
141
+ for (const key of Object.keys(finalized)) {
142
+ if ((finalized as any)[key] !== (state as any)[key]) {
143
+ partial[key] = (finalized as any)[key];
144
+ hasChanges = true;
145
+ }
146
+ }
147
+
148
+ return hasChanges ? (partial as Partial<S>) : null;
149
+ }
150
+
151
+ /**
152
+ * Resolves a function-form updater through produceDraft.
153
+ * Handles both patterns: explicit return (existing updater) and void return (draft mode).
154
+ * Returns the partial to apply, or null if nothing changed.
155
+ */
156
+ export function resolveDraftUpdater<S extends object>(
157
+ state: Readonly<S>,
158
+ updater: (stateOrDraft: S) => Partial<S> | void,
159
+ ): Partial<S> | null {
160
+ let explicitReturn: Partial<S> | undefined;
161
+ const draftChanges = produceDraft<S>(state, (draft) => {
162
+ const result = updater(draft);
163
+ if (result !== undefined && result !== null && typeof result === 'object') {
164
+ explicitReturn = result;
165
+ }
166
+ });
167
+ return explicitReturn ?? draftChanges;
168
+ }
@@ -0,0 +1,97 @@
1
+ # CardList
2
+
3
+ Headless, unstyled list/grid component for rendering item collections.
4
+
5
+ ---
6
+
7
+ ## When to Use
8
+
9
+ Use CardList for non-tabular data displays — card grids, article lists, comment threads. It renders semantic HTML (`<ul role="list">`) with data attributes for styling hooks. Compose with `InfiniteScroll` for infinite loading.
10
+
11
+ ---
12
+
13
+ ## Basic Usage
14
+
15
+ ```tsx
16
+ import { CardList } from 'mvc-kit/react';
17
+
18
+ function PostList() {
19
+ const [, vm] = useLocal(PostsVM, {});
20
+ return (
21
+ <CardList
22
+ items={vm.items}
23
+ renderItem={post => <PostCard post={post} />}
24
+ layout="grid"
25
+ columns={3}
26
+ />
27
+ );
28
+ }
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Props
34
+
35
+ | Prop | Type | Default | Description |
36
+ |------|------|---------|-------------|
37
+ | `items` | `T[]` | *required* | Array of data items |
38
+ | `renderItem` | `(item: T, index: number) => ReactNode` | *required* | Item renderer |
39
+ | `keyOf` | `(item: T) => string \| number` | `item => item.id` | Key extractor |
40
+ | `layout` | `'list' \| 'grid'` | `'list'` | Layout mode |
41
+ | `columns` | `number` | `3` | Grid columns (grid mode only) |
42
+ | `gap` | `string` | `'1rem'` | Gap between items (grid mode only) |
43
+ | `loading` | `boolean` | — | Loading state |
44
+ | `error` | `string \| null` | — | Error message |
45
+ | `className` | `string` | — | Container class |
46
+ | `aria-label` | `string` | — | Accessibility label |
47
+
48
+ ### Render Slots
49
+
50
+ | Prop | Type | Description |
51
+ |------|------|-------------|
52
+ | `renderEmpty` | `() => ReactNode` | Empty state |
53
+ | `renderLoading` | `() => ReactNode` | Loading state |
54
+ | `renderError` | `(error: string) => ReactNode` | Error state |
55
+
56
+ ---
57
+
58
+ ## Grid Layout
59
+
60
+ Grid mode uses CSS Grid with custom properties for easy override:
61
+
62
+ ```css
63
+ [data-component="card-list"][data-layout="grid"] {
64
+ /* Override defaults */
65
+ --card-list-columns: 4;
66
+ --card-list-gap: 2rem;
67
+ }
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Data Attributes
73
+
74
+ | Attribute | Element | Description |
75
+ |-----------|---------|-------------|
76
+ | `data-component="card-list"` | `<ul>` | Component identifier |
77
+ | `data-layout="list\|grid"` | `<ul>` | Current layout mode |
78
+ | `data-index` | `<li>` | Item index in the array |
79
+
80
+ ---
81
+
82
+ ## Composition with InfiniteScroll
83
+
84
+ ```tsx
85
+ <InfiniteScroll
86
+ hasMore={vm.hasMore}
87
+ loading={vm.async.loadMore?.loading}
88
+ onLoadMore={() => vm.loadMore()}
89
+ >
90
+ <CardList
91
+ items={vm.items}
92
+ renderItem={post => <PostCard post={post} />}
93
+ layout="grid"
94
+ columns={2}
95
+ />
96
+ </InfiniteScroll>
97
+ ```