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