jotai-state-tree 0.1.0
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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/chunk-XXZK62DD.mjs +931 -0
- package/dist/index.d.mts +1109 -0
- package/dist/index.d.ts +1109 -0
- package/dist/index.js +3579 -0
- package/dist/index.mjs +2625 -0
- package/dist/react.d.mts +144 -0
- package/dist/react.d.ts +144 -0
- package/dist/react.js +1259 -0
- package/dist/react.mjs +372 -0
- package/package.json +77 -0
- package/src/__tests__/index.test.ts +1371 -0
- package/src/__tests__/memory.test.ts +681 -0
- package/src/__tests__/performance.test.ts +667 -0
- package/src/__tests__/react.react.test.tsx +811 -0
- package/src/__tests__/registry.test.ts +589 -0
- package/src/array.ts +335 -0
- package/src/compat.ts +294 -0
- package/src/index.ts +647 -0
- package/src/lifecycle.ts +580 -0
- package/src/map.ts +276 -0
- package/src/model.ts +832 -0
- package/src/primitives.ts +400 -0
- package/src/react.ts +626 -0
- package/src/registry.ts +741 -0
- package/src/tree.ts +1275 -0
- package/src/types.ts +520 -0
- package/src/undo.ts +566 -0
- package/src/utilities.ts +616 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Management Tests for jotai-state-tree
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that the library properly manages memory and doesn't leak:
|
|
5
|
+
* - Node registry cleanup
|
|
6
|
+
* - Identifier registry cleanup
|
|
7
|
+
* - Listener cleanup
|
|
8
|
+
* - Action recorder cleanup
|
|
9
|
+
* - WeakRef/WeakMap behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
types,
|
|
15
|
+
getSnapshot,
|
|
16
|
+
onSnapshot,
|
|
17
|
+
onPatch,
|
|
18
|
+
destroy,
|
|
19
|
+
getIdentifier,
|
|
20
|
+
isAlive,
|
|
21
|
+
clone,
|
|
22
|
+
detach,
|
|
23
|
+
} from "../index";
|
|
24
|
+
import {
|
|
25
|
+
getRegistryStats,
|
|
26
|
+
cleanupStaleEntries,
|
|
27
|
+
clearAllRegistries,
|
|
28
|
+
resetGlobalStore,
|
|
29
|
+
} from "../tree";
|
|
30
|
+
import { recordActions } from "../lifecycle";
|
|
31
|
+
|
|
32
|
+
describe("Memory Management", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
// Clear all registries before each test
|
|
35
|
+
clearAllRegistries();
|
|
36
|
+
resetGlobalStore();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
// Ensure cleanup after each test
|
|
41
|
+
clearAllRegistries();
|
|
42
|
+
resetGlobalStore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("Node Registry Cleanup", () => {
|
|
46
|
+
it("should remove nodes from registry on destroy()", () => {
|
|
47
|
+
const Model = types.model("TestModel", {
|
|
48
|
+
id: types.identifier,
|
|
49
|
+
name: types.string,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const instance = Model.create({ id: "1", name: "Test" });
|
|
53
|
+
|
|
54
|
+
// Verify node is registered
|
|
55
|
+
let stats = getRegistryStats();
|
|
56
|
+
expect(stats.nodeRegistrySize).toBeGreaterThan(0);
|
|
57
|
+
|
|
58
|
+
// Destroy the node
|
|
59
|
+
destroy(instance);
|
|
60
|
+
|
|
61
|
+
// Verify node is removed from registry
|
|
62
|
+
stats = getRegistryStats();
|
|
63
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should remove all child nodes from registry on parent destroy()", () => {
|
|
67
|
+
const Child = types.model("Child", {
|
|
68
|
+
value: types.number,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const Parent = types.model("Parent", {
|
|
72
|
+
children: types.array(Child),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const instance = Parent.create({
|
|
76
|
+
children: [{ value: 1 }, { value: 2 }, { value: 3 }],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const statsBefore = getRegistryStats();
|
|
80
|
+
const nodeCountBefore = statsBefore.nodeRegistrySize;
|
|
81
|
+
|
|
82
|
+
// Destroy parent - should destroy all children too
|
|
83
|
+
destroy(instance);
|
|
84
|
+
|
|
85
|
+
const statsAfter = getRegistryStats();
|
|
86
|
+
expect(statsAfter.liveNodeCount).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should handle rapid create/destroy cycles without leaking", () => {
|
|
90
|
+
const Model = types.model("CycleModel", {
|
|
91
|
+
id: types.identifier,
|
|
92
|
+
value: types.number,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Create and destroy many nodes
|
|
96
|
+
for (let i = 0; i < 100; i++) {
|
|
97
|
+
const instance = Model.create({ id: `id-${i}`, value: i });
|
|
98
|
+
destroy(instance);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const stats = getRegistryStats();
|
|
102
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("Identifier Registry Cleanup", () => {
|
|
107
|
+
it("should remove identifiers on destroy()", () => {
|
|
108
|
+
const Model = types.model("IdentifiedModel", {
|
|
109
|
+
id: types.identifier,
|
|
110
|
+
name: types.string,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const instance = Model.create({ id: "unique-id", name: "Test" });
|
|
114
|
+
|
|
115
|
+
// Verify identifier is registered
|
|
116
|
+
let stats = getRegistryStats();
|
|
117
|
+
expect(stats.identifierRegistrySize).toBeGreaterThan(0);
|
|
118
|
+
|
|
119
|
+
// Destroy
|
|
120
|
+
destroy(instance);
|
|
121
|
+
|
|
122
|
+
// Verify identifier is removed
|
|
123
|
+
stats = getRegistryStats();
|
|
124
|
+
expect(stats.identifierRegistrySize).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should clean up empty type maps in identifier registry", () => {
|
|
128
|
+
const ModelA = types.model("ModelA", {
|
|
129
|
+
id: types.identifier,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const ModelB = types.model("ModelB", {
|
|
133
|
+
id: types.identifier,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const a1 = ModelA.create({ id: "a1" });
|
|
137
|
+
const a2 = ModelA.create({ id: "a2" });
|
|
138
|
+
const b1 = ModelB.create({ id: "b1" });
|
|
139
|
+
|
|
140
|
+
let stats = getRegistryStats();
|
|
141
|
+
expect(stats.identifierTypeCount).toBe(2); // ModelA and ModelB
|
|
142
|
+
|
|
143
|
+
// Destroy all of ModelA
|
|
144
|
+
destroy(a1);
|
|
145
|
+
destroy(a2);
|
|
146
|
+
|
|
147
|
+
stats = getRegistryStats();
|
|
148
|
+
expect(stats.identifierTypeCount).toBe(1); // Only ModelB remains
|
|
149
|
+
|
|
150
|
+
// Destroy ModelB
|
|
151
|
+
destroy(b1);
|
|
152
|
+
|
|
153
|
+
stats = getRegistryStats();
|
|
154
|
+
expect(stats.identifierTypeCount).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should handle identifier reuse after destroy", () => {
|
|
158
|
+
const Model = types.model("ReuseModel", {
|
|
159
|
+
id: types.identifier,
|
|
160
|
+
value: types.number,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Create with ID
|
|
164
|
+
const instance1 = Model.create({ id: "reused-id", value: 1 });
|
|
165
|
+
expect(getIdentifier(instance1)).toBe("reused-id");
|
|
166
|
+
|
|
167
|
+
// Destroy
|
|
168
|
+
destroy(instance1);
|
|
169
|
+
|
|
170
|
+
// Reuse the same ID
|
|
171
|
+
const instance2 = Model.create({ id: "reused-id", value: 2 });
|
|
172
|
+
expect(getIdentifier(instance2)).toBe("reused-id");
|
|
173
|
+
expect(instance2.value).toBe(2);
|
|
174
|
+
|
|
175
|
+
destroy(instance2);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("Listener Cleanup", () => {
|
|
180
|
+
it("should clean up snapshot listeners on destroy()", () => {
|
|
181
|
+
const Model = types.model("ListenerModel", {
|
|
182
|
+
value: types.number,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const instance = Model.create({ value: 0 });
|
|
186
|
+
|
|
187
|
+
let callCount = 0;
|
|
188
|
+
const disposer = onSnapshot(instance, () => {
|
|
189
|
+
callCount++;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Trigger a change
|
|
193
|
+
(instance as any).value = 1;
|
|
194
|
+
expect(callCount).toBe(1);
|
|
195
|
+
|
|
196
|
+
// Destroy the node
|
|
197
|
+
destroy(instance);
|
|
198
|
+
|
|
199
|
+
// Listener should be cleared, no more calls
|
|
200
|
+
// (can't trigger changes on dead node, but internal state is cleared)
|
|
201
|
+
const stats = getRegistryStats();
|
|
202
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should properly dispose listeners when disposer is called", () => {
|
|
206
|
+
const Model = types.model("DisposerModel", {
|
|
207
|
+
value: types.number,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const instance = Model.create({ value: 0 });
|
|
211
|
+
|
|
212
|
+
let callCount = 0;
|
|
213
|
+
const disposer = onSnapshot(instance, () => {
|
|
214
|
+
callCount++;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Trigger a change
|
|
218
|
+
(instance as any).value = 1;
|
|
219
|
+
expect(callCount).toBe(1);
|
|
220
|
+
|
|
221
|
+
// Dispose the listener
|
|
222
|
+
disposer();
|
|
223
|
+
|
|
224
|
+
// Trigger another change - listener should not be called
|
|
225
|
+
(instance as any).value = 2;
|
|
226
|
+
expect(callCount).toBe(1); // Still 1, not incremented
|
|
227
|
+
|
|
228
|
+
destroy(instance);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("should clean up patch listeners on destroy()", () => {
|
|
232
|
+
const Model = types.model("PatchListenerModel", {
|
|
233
|
+
value: types.number,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const instance = Model.create({ value: 0 });
|
|
237
|
+
|
|
238
|
+
let patchCount = 0;
|
|
239
|
+
const disposer = onPatch(instance, () => {
|
|
240
|
+
patchCount++;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Trigger a change
|
|
244
|
+
(instance as any).value = 1;
|
|
245
|
+
expect(patchCount).toBe(1);
|
|
246
|
+
|
|
247
|
+
// Destroy
|
|
248
|
+
destroy(instance);
|
|
249
|
+
|
|
250
|
+
const stats = getRegistryStats();
|
|
251
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("should not accumulate listeners on re-subscription", () => {
|
|
255
|
+
const Model = types.model("ResubModel", {
|
|
256
|
+
value: types.number,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const instance = Model.create({ value: 0 });
|
|
260
|
+
|
|
261
|
+
// Subscribe and unsubscribe many times
|
|
262
|
+
for (let i = 0; i < 100; i++) {
|
|
263
|
+
const disposer = onSnapshot(instance, () => {});
|
|
264
|
+
disposer();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// One final subscription
|
|
268
|
+
let callCount = 0;
|
|
269
|
+
onSnapshot(instance, () => {
|
|
270
|
+
callCount++;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
(instance as any).value = 1;
|
|
274
|
+
expect(callCount).toBe(1); // Should only be called once, not 100 times
|
|
275
|
+
|
|
276
|
+
destroy(instance);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("Action Recorder Cleanup", () => {
|
|
281
|
+
it("should clean up recorder on stop()", () => {
|
|
282
|
+
const Model = types
|
|
283
|
+
.model("ActionModel", {
|
|
284
|
+
value: types.number,
|
|
285
|
+
})
|
|
286
|
+
.actions((self) => ({
|
|
287
|
+
increment() {
|
|
288
|
+
self.value += 1;
|
|
289
|
+
},
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
const instance = Model.create({ value: 0 });
|
|
293
|
+
|
|
294
|
+
const recorder = recordActions(instance);
|
|
295
|
+
|
|
296
|
+
instance.increment();
|
|
297
|
+
instance.increment();
|
|
298
|
+
|
|
299
|
+
expect(recorder.actions.length).toBe(2);
|
|
300
|
+
|
|
301
|
+
// Stop recording
|
|
302
|
+
recorder.stop();
|
|
303
|
+
|
|
304
|
+
// Further actions should not be recorded
|
|
305
|
+
instance.increment();
|
|
306
|
+
expect(recorder.actions.length).toBe(2); // Still 2
|
|
307
|
+
|
|
308
|
+
destroy(instance);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("should allow GC of nodes even with active recorders (WeakMap)", () => {
|
|
312
|
+
const Model = types
|
|
313
|
+
.model("WeakRecorderModel", {
|
|
314
|
+
id: types.identifier,
|
|
315
|
+
value: types.number,
|
|
316
|
+
})
|
|
317
|
+
.actions((self) => ({
|
|
318
|
+
increment() {
|
|
319
|
+
self.value += 1;
|
|
320
|
+
},
|
|
321
|
+
}));
|
|
322
|
+
|
|
323
|
+
// Create many instances with recorders
|
|
324
|
+
for (let i = 0; i < 50; i++) {
|
|
325
|
+
const instance = Model.create({ id: `rec-${i}`, value: 0 });
|
|
326
|
+
const recorder = recordActions(instance);
|
|
327
|
+
instance.increment();
|
|
328
|
+
recorder.stop();
|
|
329
|
+
destroy(instance);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const stats = getRegistryStats();
|
|
333
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
334
|
+
expect(stats.identifierRegistrySize).toBe(0);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("Deep Tree Cleanup", () => {
|
|
339
|
+
it("should clean up deeply nested structures", () => {
|
|
340
|
+
// Ensure clean state
|
|
341
|
+
clearAllRegistries();
|
|
342
|
+
resetGlobalStore();
|
|
343
|
+
|
|
344
|
+
const statsInitial = getRegistryStats();
|
|
345
|
+
expect(statsInitial.liveNodeCount).toBe(0);
|
|
346
|
+
|
|
347
|
+
const Leaf = types.model("Leaf", {
|
|
348
|
+
value: types.number,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const Branch = types.model("Branch", {
|
|
352
|
+
children: types.array(types.late(() => Branch)),
|
|
353
|
+
leaf: types.maybe(Leaf),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Create a deep tree
|
|
357
|
+
const createDeepTree = (depth: number): any => {
|
|
358
|
+
if (depth === 0) {
|
|
359
|
+
return { children: [], leaf: { value: 1 } };
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
children: [createDeepTree(depth - 1), createDeepTree(depth - 1)],
|
|
363
|
+
leaf: { value: depth },
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const tree = Branch.create(createDeepTree(5));
|
|
368
|
+
|
|
369
|
+
const statsBefore = getRegistryStats();
|
|
370
|
+
expect(statsBefore.liveNodeCount).toBeGreaterThan(50); // Many nodes created
|
|
371
|
+
|
|
372
|
+
destroy(tree);
|
|
373
|
+
|
|
374
|
+
const statsAfter = getRegistryStats();
|
|
375
|
+
// All nodes should be destroyed
|
|
376
|
+
expect(statsAfter.liveNodeCount).toBe(0);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("should clean up large arrays", () => {
|
|
380
|
+
const Item = types.model("Item", {
|
|
381
|
+
id: types.identifier,
|
|
382
|
+
value: types.number,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const List = types.model("List", {
|
|
386
|
+
items: types.array(Item),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Create a large array
|
|
390
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({
|
|
391
|
+
id: `item-${i}`,
|
|
392
|
+
value: i,
|
|
393
|
+
}));
|
|
394
|
+
|
|
395
|
+
const list = List.create({ items });
|
|
396
|
+
|
|
397
|
+
const statsBefore = getRegistryStats();
|
|
398
|
+
expect(statsBefore.nodeRegistrySize).toBeGreaterThan(1000);
|
|
399
|
+
|
|
400
|
+
destroy(list);
|
|
401
|
+
|
|
402
|
+
const statsAfter = getRegistryStats();
|
|
403
|
+
expect(statsAfter.liveNodeCount).toBe(0);
|
|
404
|
+
expect(statsAfter.identifierRegistrySize).toBe(0);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("Clone and Detach Memory", () => {
|
|
409
|
+
it("should properly manage memory for cloned nodes", () => {
|
|
410
|
+
const Model = types.model("CloneModel", {
|
|
411
|
+
id: types.identifier,
|
|
412
|
+
value: types.number,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const original = Model.create({ id: "original", value: 1 });
|
|
416
|
+
const cloned = clone(original);
|
|
417
|
+
|
|
418
|
+
// Both should be alive
|
|
419
|
+
expect(isAlive(original)).toBe(true);
|
|
420
|
+
expect(isAlive(cloned)).toBe(true);
|
|
421
|
+
|
|
422
|
+
const statsBefore = getRegistryStats();
|
|
423
|
+
|
|
424
|
+
// Destroy original
|
|
425
|
+
destroy(original);
|
|
426
|
+
expect(isAlive(original)).toBe(false);
|
|
427
|
+
expect(isAlive(cloned)).toBe(true);
|
|
428
|
+
|
|
429
|
+
// Destroy clone
|
|
430
|
+
destroy(cloned);
|
|
431
|
+
|
|
432
|
+
const statsAfter = getRegistryStats();
|
|
433
|
+
expect(statsAfter.liveNodeCount).toBe(0);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("should clean up detached nodes when destroyed", () => {
|
|
437
|
+
const Child = types.model("DetachChild", {
|
|
438
|
+
value: types.number,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const Parent = types.model("DetachParent", {
|
|
442
|
+
child: Child,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const instance = Parent.create({ child: { value: 1 } });
|
|
446
|
+
const child = instance.child;
|
|
447
|
+
|
|
448
|
+
// Detach child
|
|
449
|
+
detach(child);
|
|
450
|
+
|
|
451
|
+
// Child is still alive but detached
|
|
452
|
+
expect(isAlive(child)).toBe(true);
|
|
453
|
+
|
|
454
|
+
// Destroy both
|
|
455
|
+
destroy(child);
|
|
456
|
+
destroy(instance);
|
|
457
|
+
|
|
458
|
+
const stats = getRegistryStats();
|
|
459
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("Stale Entry Cleanup", () => {
|
|
464
|
+
it("should clean up stale entries with cleanupStaleEntries()", () => {
|
|
465
|
+
const Model = types.model("StaleModel", {
|
|
466
|
+
value: types.number,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Create some nodes
|
|
470
|
+
const nodes = Array.from({ length: 10 }, (_, i) =>
|
|
471
|
+
Model.create({ value: i }),
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Destroy half of them
|
|
475
|
+
nodes.slice(0, 5).forEach((n) => destroy(n));
|
|
476
|
+
|
|
477
|
+
// Run cleanup
|
|
478
|
+
const cleaned = cleanupStaleEntries();
|
|
479
|
+
|
|
480
|
+
// Should have cleaned up destroyed entries
|
|
481
|
+
expect(cleaned).toBeGreaterThanOrEqual(0);
|
|
482
|
+
|
|
483
|
+
// Destroy the rest
|
|
484
|
+
nodes.slice(5).forEach((n) => destroy(n));
|
|
485
|
+
|
|
486
|
+
const stats = getRegistryStats();
|
|
487
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("Map Type Cleanup", () => {
|
|
492
|
+
it("should clean up map entries on destroy", () => {
|
|
493
|
+
const Item = types.model("MapItem", {
|
|
494
|
+
id: types.identifier,
|
|
495
|
+
value: types.number,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const Store = types.model("MapStore", {
|
|
499
|
+
items: types.map(Item),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const store = Store.create({
|
|
503
|
+
items: {
|
|
504
|
+
a: { id: "a", value: 1 },
|
|
505
|
+
b: { id: "b", value: 2 },
|
|
506
|
+
c: { id: "c", value: 3 },
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const statsBefore = getRegistryStats();
|
|
511
|
+
expect(statsBefore.identifierRegistrySize).toBe(3);
|
|
512
|
+
|
|
513
|
+
destroy(store);
|
|
514
|
+
|
|
515
|
+
const statsAfter = getRegistryStats();
|
|
516
|
+
expect(statsAfter.liveNodeCount).toBe(0);
|
|
517
|
+
expect(statsAfter.identifierRegistrySize).toBe(0);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe("Stress Tests", () => {
|
|
523
|
+
beforeEach(() => {
|
|
524
|
+
clearAllRegistries();
|
|
525
|
+
resetGlobalStore();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
afterEach(() => {
|
|
529
|
+
clearAllRegistries();
|
|
530
|
+
resetGlobalStore();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("should handle 1000 create/destroy cycles efficiently", () => {
|
|
534
|
+
const Model = types.model("StressModel", {
|
|
535
|
+
id: types.identifier,
|
|
536
|
+
value: types.number,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const startTime = Date.now();
|
|
540
|
+
|
|
541
|
+
for (let i = 0; i < 1000; i++) {
|
|
542
|
+
const instance = Model.create({ id: `stress-${i}`, value: i });
|
|
543
|
+
destroy(instance);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const duration = Date.now() - startTime;
|
|
547
|
+
|
|
548
|
+
// Should complete in reasonable time (< 5 seconds)
|
|
549
|
+
expect(duration).toBeLessThan(5000);
|
|
550
|
+
|
|
551
|
+
const stats = getRegistryStats();
|
|
552
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
553
|
+
expect(stats.identifierRegistrySize).toBe(0);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("should handle deep nesting without stack overflow", () => {
|
|
557
|
+
// Ensure clean state
|
|
558
|
+
clearAllRegistries();
|
|
559
|
+
resetGlobalStore();
|
|
560
|
+
|
|
561
|
+
const statsInitial = getRegistryStats();
|
|
562
|
+
expect(statsInitial.liveNodeCount).toBe(0);
|
|
563
|
+
|
|
564
|
+
const Node = types.model("DeepNode", {
|
|
565
|
+
child: types.maybe(types.late(() => Node)),
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// Create a deeply nested structure (100 levels)
|
|
569
|
+
let snapshot: any = null;
|
|
570
|
+
for (let i = 0; i < 100; i++) {
|
|
571
|
+
snapshot = { child: snapshot };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const root = Node.create(snapshot);
|
|
575
|
+
|
|
576
|
+
expect(isAlive(root)).toBe(true);
|
|
577
|
+
|
|
578
|
+
const statsBeforeDestroy = getRegistryStats();
|
|
579
|
+
expect(statsBeforeDestroy.liveNodeCount).toBeGreaterThan(0);
|
|
580
|
+
|
|
581
|
+
destroy(root);
|
|
582
|
+
|
|
583
|
+
const stats = getRegistryStats();
|
|
584
|
+
// All nodes should be destroyed
|
|
585
|
+
expect(stats.liveNodeCount).toBe(0);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("should handle many concurrent subscriptions", () => {
|
|
589
|
+
const Model = types.model("SubModel", {
|
|
590
|
+
value: types.number,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const instance = Model.create({ value: 0 });
|
|
594
|
+
const disposers: (() => void)[] = [];
|
|
595
|
+
|
|
596
|
+
// Add many subscriptions
|
|
597
|
+
for (let i = 0; i < 100; i++) {
|
|
598
|
+
disposers.push(onSnapshot(instance, () => {}));
|
|
599
|
+
disposers.push(onPatch(instance, () => {}));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Dispose all
|
|
603
|
+
disposers.forEach((d) => d());
|
|
604
|
+
|
|
605
|
+
// Make a change
|
|
606
|
+
let callCount = 0;
|
|
607
|
+
onSnapshot(instance, () => callCount++);
|
|
608
|
+
(instance as any).value = 1;
|
|
609
|
+
|
|
610
|
+
expect(callCount).toBe(1); // Only the one active subscription
|
|
611
|
+
|
|
612
|
+
destroy(instance);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe("Property Atoms and Proxy Lifecycle", () => {
|
|
616
|
+
it("should not leak property atoms after destroy", () => {
|
|
617
|
+
// Property atoms are instance-scoped in the proxy closure.
|
|
618
|
+
// When the instance is destroyed and dereferenced, the atoms
|
|
619
|
+
// should be eligible for GC (Jotai uses WeakMap internally).
|
|
620
|
+
|
|
621
|
+
const Model = types.model("AtomModel", {
|
|
622
|
+
name: types.string,
|
|
623
|
+
count: types.number,
|
|
624
|
+
active: types.boolean,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const statsBefore = getRegistryStats();
|
|
628
|
+
|
|
629
|
+
// Create and destroy many instances
|
|
630
|
+
for (let i = 0; i < 100; i++) {
|
|
631
|
+
const instance = Model.create({
|
|
632
|
+
name: `item-${i}`,
|
|
633
|
+
count: i,
|
|
634
|
+
active: i % 2 === 0,
|
|
635
|
+
});
|
|
636
|
+
destroy(instance);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const statsAfter = getRegistryStats();
|
|
640
|
+
|
|
641
|
+
// All nodes should be cleaned up
|
|
642
|
+
expect(statsAfter.liveNodeCount).toBe(statsBefore.liveNodeCount);
|
|
643
|
+
expect(statsAfter.nodeRegistrySize).toBe(statsBefore.nodeRegistrySize);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("should not leak when rapidly creating and destroying models with complex properties", () => {
|
|
647
|
+
const Child = types.model("ChildModel", {
|
|
648
|
+
value: types.number,
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
const Parent = types.model("ParentModel", {
|
|
652
|
+
name: types.string,
|
|
653
|
+
child: types.maybe(Child),
|
|
654
|
+
items: types.array(types.number),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const statsBefore = getRegistryStats();
|
|
658
|
+
|
|
659
|
+
// Rapid create/destroy cycle
|
|
660
|
+
for (let i = 0; i < 50; i++) {
|
|
661
|
+
const instance = Parent.create({
|
|
662
|
+
name: `parent-${i}`,
|
|
663
|
+
child: i % 2 === 0 ? { value: i } : undefined,
|
|
664
|
+
items: [1, 2, 3, 4, 5],
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Access properties to ensure atoms are used
|
|
668
|
+
const _ = instance.name;
|
|
669
|
+
const __ = instance.child?.value;
|
|
670
|
+
const ___ = instance.items.length;
|
|
671
|
+
|
|
672
|
+
destroy(instance);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const statsAfter = getRegistryStats();
|
|
676
|
+
|
|
677
|
+
// All nodes should be cleaned up
|
|
678
|
+
expect(statsAfter.liveNodeCount).toBe(statsBefore.liveNodeCount);
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
});
|