mvc-kit 2.12.0 → 2.12.1
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 +10 -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,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
|
+
```
|