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,667 @@
1
+ /**
2
+ * Performance and stress tests for jotai-state-tree
3
+ * These tests ensure the library performs well under load and doesn't have memory issues
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
7
+ import {
8
+ types,
9
+ destroy,
10
+ getSnapshot,
11
+ applySnapshot,
12
+ onSnapshot,
13
+ onPatch,
14
+ clone,
15
+ clearAllRegistries,
16
+ resetGlobalStore,
17
+ getRegistryStats,
18
+ } from "../index";
19
+
20
+ // ============================================================================
21
+ // Test Setup
22
+ // ============================================================================
23
+
24
+ beforeEach(() => {
25
+ clearAllRegistries();
26
+ resetGlobalStore();
27
+ });
28
+
29
+ afterEach(() => {
30
+ clearAllRegistries();
31
+ resetGlobalStore();
32
+ });
33
+
34
+ // ============================================================================
35
+ // Performance Benchmarks
36
+ // ============================================================================
37
+
38
+ describe("Performance", () => {
39
+ describe("Creation Performance", () => {
40
+ it("should create 10,000 simple models efficiently", () => {
41
+ const SimpleModel = types.model("Simple", {
42
+ id: types.identifier,
43
+ name: types.string,
44
+ value: types.number,
45
+ });
46
+
47
+ const start = performance.now();
48
+
49
+ const instances = Array.from({ length: 10000 }, (_, i) =>
50
+ SimpleModel.create({
51
+ id: `item-${i}`,
52
+ name: `Item ${i}`,
53
+ value: i,
54
+ })
55
+ );
56
+
57
+ const elapsed = performance.now() - start;
58
+
59
+ expect(instances.length).toBe(10000);
60
+ // Should complete in reasonable time (less than 5 seconds on most machines)
61
+ expect(elapsed).toBeLessThan(5000);
62
+
63
+ // Cleanup
64
+ instances.forEach((i) => destroy(i));
65
+ });
66
+
67
+ it("should create deeply nested models efficiently", () => {
68
+ const Leaf = types.model("Leaf", { value: types.number });
69
+ const Branch = types.model("Branch", {
70
+ left: types.maybe(types.late(() => Branch)),
71
+ right: types.maybe(types.late(() => Branch)),
72
+ leaf: types.maybe(Leaf),
73
+ });
74
+
75
+ const createTree = (depth: number): any => {
76
+ if (depth === 0) return { leaf: { value: 1 } };
77
+ return {
78
+ left: createTree(depth - 1),
79
+ right: createTree(depth - 1),
80
+ };
81
+ };
82
+
83
+ const start = performance.now();
84
+ const tree = Branch.create(createTree(10)); // 2^10 = 1024 leaf nodes
85
+ const elapsed = performance.now() - start;
86
+
87
+ expect(elapsed).toBeLessThan(5000);
88
+
89
+ destroy(tree);
90
+ });
91
+
92
+ it("should create large arrays efficiently", () => {
93
+ const Item = types.model("Item", {
94
+ id: types.identifier,
95
+ data: types.string,
96
+ });
97
+
98
+ const List = types.model("List", {
99
+ items: types.array(Item),
100
+ });
101
+
102
+ const items = Array.from({ length: 10000 }, (_, i) => ({
103
+ id: `id-${i}`,
104
+ data: `data-${i}`,
105
+ }));
106
+
107
+ const start = performance.now();
108
+ const list = List.create({ items });
109
+ const elapsed = performance.now() - start;
110
+
111
+ expect(list.items.length).toBe(10000);
112
+ expect(elapsed).toBeLessThan(5000);
113
+
114
+ destroy(list);
115
+ });
116
+ });
117
+
118
+ describe("Update Performance", () => {
119
+ it("should handle rapid updates efficiently", () => {
120
+ const Counter = types
121
+ .model("Counter", {
122
+ value: types.number,
123
+ })
124
+ .actions((self) => ({
125
+ increment() {
126
+ self.value += 1;
127
+ },
128
+ }));
129
+
130
+ const counter = Counter.create({ value: 0 });
131
+
132
+ const start = performance.now();
133
+
134
+ for (let i = 0; i < 10000; i++) {
135
+ counter.increment();
136
+ }
137
+
138
+ const elapsed = performance.now() - start;
139
+
140
+ expect(counter.value).toBe(10000);
141
+ expect(elapsed).toBeLessThan(2000);
142
+
143
+ destroy(counter);
144
+ });
145
+
146
+ it("should handle array mutations efficiently", () => {
147
+ const Item = types.model("Item", {
148
+ id: types.identifier,
149
+ value: types.number,
150
+ });
151
+
152
+ const List = types
153
+ .model("List", {
154
+ items: types.array(Item),
155
+ })
156
+ .actions((self) => ({
157
+ addItem(id: string, value: number) {
158
+ self.items.push({ id, value });
159
+ },
160
+ removeFirst() {
161
+ if (self.items.length > 0) {
162
+ self.items.splice(0, 1);
163
+ }
164
+ },
165
+ }));
166
+
167
+ const list = List.create({ items: [] });
168
+
169
+ const start = performance.now();
170
+
171
+ // Add 1000 items
172
+ for (let i = 0; i < 1000; i++) {
173
+ list.addItem(`id-${i}`, i);
174
+ }
175
+
176
+ // Remove 500 items
177
+ for (let i = 0; i < 500; i++) {
178
+ list.removeFirst();
179
+ }
180
+
181
+ const elapsed = performance.now() - start;
182
+
183
+ expect(list.items.length).toBe(500);
184
+ expect(elapsed).toBeLessThan(5000);
185
+
186
+ destroy(list);
187
+ });
188
+
189
+ it("should handle applySnapshot efficiently", () => {
190
+ const Model = types.model("Model", {
191
+ items: types.array(
192
+ types.model("Item", {
193
+ id: types.identifier,
194
+ value: types.number,
195
+ })
196
+ ),
197
+ });
198
+
199
+ const instance = Model.create({
200
+ items: Array.from({ length: 1000 }, (_, i) => ({
201
+ id: `id-${i}`,
202
+ value: i,
203
+ })),
204
+ });
205
+
206
+ const newSnapshot = {
207
+ items: Array.from({ length: 1000 }, (_, i) => ({
208
+ id: `id-${i}`,
209
+ value: i * 2,
210
+ })),
211
+ };
212
+
213
+ const start = performance.now();
214
+
215
+ for (let i = 0; i < 100; i++) {
216
+ applySnapshot(instance, newSnapshot);
217
+ }
218
+
219
+ const elapsed = performance.now() - start;
220
+
221
+ expect(elapsed).toBeLessThan(5000);
222
+
223
+ destroy(instance);
224
+ });
225
+ });
226
+
227
+ describe("Snapshot Performance", () => {
228
+ it("should generate snapshots efficiently", () => {
229
+ const Item = types.model("Item", {
230
+ id: types.identifier,
231
+ name: types.string,
232
+ value: types.number,
233
+ });
234
+
235
+ const Store = types.model("Store", {
236
+ items: types.array(Item),
237
+ });
238
+
239
+ const store = Store.create({
240
+ items: Array.from({ length: 5000 }, (_, i) => ({
241
+ id: `id-${i}`,
242
+ name: `Item ${i}`,
243
+ value: i,
244
+ })),
245
+ });
246
+
247
+ const start = performance.now();
248
+
249
+ for (let i = 0; i < 100; i++) {
250
+ getSnapshot(store);
251
+ }
252
+
253
+ const elapsed = performance.now() - start;
254
+
255
+ expect(elapsed).toBeLessThan(2000);
256
+
257
+ destroy(store);
258
+ });
259
+ });
260
+
261
+ describe("Listener Performance", () => {
262
+ it("should handle many snapshot listeners efficiently", () => {
263
+ const Model = types
264
+ .model("Model", {
265
+ value: types.number,
266
+ })
267
+ .actions((self) => ({
268
+ setValue(v: number) {
269
+ self.value = v;
270
+ },
271
+ }));
272
+
273
+ const instance = Model.create({ value: 0 });
274
+
275
+ // Add many listeners
276
+ const disposers: (() => void)[] = [];
277
+ let callCount = 0;
278
+
279
+ for (let i = 0; i < 100; i++) {
280
+ disposers.push(
281
+ onSnapshot(instance, () => {
282
+ callCount++;
283
+ })
284
+ );
285
+ }
286
+
287
+ const start = performance.now();
288
+
289
+ // Trigger many updates
290
+ for (let i = 0; i < 100; i++) {
291
+ instance.setValue(i);
292
+ }
293
+
294
+ const elapsed = performance.now() - start;
295
+
296
+ expect(callCount).toBe(10000); // 100 listeners * 100 updates
297
+ expect(elapsed).toBeLessThan(2000);
298
+
299
+ // Cleanup
300
+ disposers.forEach((d) => d());
301
+ destroy(instance);
302
+ });
303
+
304
+ it("should handle many patch listeners efficiently", () => {
305
+ const Model = types
306
+ .model("Model", {
307
+ value: types.number,
308
+ })
309
+ .actions((self) => ({
310
+ setValue(v: number) {
311
+ self.value = v;
312
+ },
313
+ }));
314
+
315
+ const instance = Model.create({ value: 0 });
316
+
317
+ const disposers: (() => void)[] = [];
318
+ let patchCount = 0;
319
+
320
+ for (let i = 0; i < 100; i++) {
321
+ disposers.push(
322
+ onPatch(instance, () => {
323
+ patchCount++;
324
+ })
325
+ );
326
+ }
327
+
328
+ const start = performance.now();
329
+
330
+ for (let i = 0; i < 100; i++) {
331
+ instance.setValue(i);
332
+ }
333
+
334
+ const elapsed = performance.now() - start;
335
+
336
+ expect(patchCount).toBe(10000);
337
+ expect(elapsed).toBeLessThan(2000);
338
+
339
+ disposers.forEach((d) => d());
340
+ destroy(instance);
341
+ });
342
+ });
343
+
344
+ describe("Clone Performance", () => {
345
+ it("should clone large structures efficiently", () => {
346
+ const Item = types.model("Item", {
347
+ id: types.identifier,
348
+ data: types.string,
349
+ });
350
+
351
+ const Store = types.model("Store", {
352
+ items: types.array(Item),
353
+ });
354
+
355
+ const original = Store.create({
356
+ items: Array.from({ length: 1000 }, (_, i) => ({
357
+ id: `id-${i}`,
358
+ data: `data-${i}`,
359
+ })),
360
+ });
361
+
362
+ const start = performance.now();
363
+
364
+ const clones = [];
365
+ for (let i = 0; i < 10; i++) {
366
+ clones.push(clone(original));
367
+ }
368
+
369
+ const elapsed = performance.now() - start;
370
+
371
+ expect(clones.length).toBe(10);
372
+ expect(elapsed).toBeLessThan(3000);
373
+
374
+ // Cleanup
375
+ destroy(original);
376
+ clones.forEach((c) => destroy(c));
377
+ });
378
+ });
379
+ });
380
+
381
+ // ============================================================================
382
+ // Stress Tests
383
+ // ============================================================================
384
+
385
+ describe("Stress Tests", () => {
386
+ describe("Memory Stress", () => {
387
+ it("should handle create/destroy cycles without memory growth", () => {
388
+ const Model = types.model("Model", {
389
+ id: types.identifier,
390
+ value: types.number,
391
+ });
392
+
393
+ const statsBefore = getRegistryStats();
394
+
395
+ // Create and destroy many times
396
+ for (let cycle = 0; cycle < 100; cycle++) {
397
+ const instances = Array.from({ length: 100 }, (_, i) =>
398
+ Model.create({ id: `cycle${cycle}-item${i}`, value: i })
399
+ );
400
+
401
+ instances.forEach((i) => destroy(i));
402
+ }
403
+
404
+ const statsAfter = getRegistryStats();
405
+
406
+ // Registry should not have grown
407
+ expect(statsAfter.liveNodeCount).toBe(statsBefore.liveNodeCount);
408
+ expect(statsAfter.identifierRegistrySize).toBe(
409
+ statsBefore.identifierRegistrySize
410
+ );
411
+ });
412
+
413
+ it("should handle listener add/remove cycles", () => {
414
+ const Model = types
415
+ .model("Model", {
416
+ value: types.number,
417
+ })
418
+ .actions((self) => ({
419
+ setValue(v: number) {
420
+ self.value = v;
421
+ },
422
+ }));
423
+
424
+ const instance = Model.create({ value: 0 });
425
+
426
+ // Add and remove listeners many times
427
+ for (let cycle = 0; cycle < 100; cycle++) {
428
+ const disposers: (() => void)[] = [];
429
+
430
+ for (let i = 0; i < 50; i++) {
431
+ disposers.push(onSnapshot(instance, () => {}));
432
+ disposers.push(onPatch(instance, () => {}));
433
+ }
434
+
435
+ // Trigger some updates
436
+ for (let i = 0; i < 10; i++) {
437
+ instance.setValue(i);
438
+ }
439
+
440
+ // Remove all listeners
441
+ disposers.forEach((d) => d());
442
+ }
443
+
444
+ // Should complete without issues
445
+ expect(instance.value).toBe(9);
446
+
447
+ destroy(instance);
448
+ });
449
+ });
450
+
451
+ describe("Concurrent Operations", () => {
452
+ it("should handle interleaved operations on multiple stores", () => {
453
+ const Counter = types
454
+ .model("Counter", {
455
+ id: types.identifier,
456
+ value: types.number,
457
+ })
458
+ .actions((self) => ({
459
+ increment() {
460
+ self.value += 1;
461
+ },
462
+ }));
463
+
464
+ const counters = Array.from({ length: 100 }, (_, i) =>
465
+ Counter.create({ id: `counter-${i}`, value: 0 })
466
+ );
467
+
468
+ // Interleaved operations
469
+ for (let round = 0; round < 100; round++) {
470
+ counters.forEach((c) => c.increment());
471
+ }
472
+
473
+ // Verify all counters have correct value
474
+ counters.forEach((c) => {
475
+ expect(c.value).toBe(100);
476
+ });
477
+
478
+ // Cleanup
479
+ counters.forEach((c) => destroy(c));
480
+ });
481
+ });
482
+
483
+ describe("Edge Cases Under Load", () => {
484
+ it("should handle rapid snapshot subscriptions during updates", () => {
485
+ const Model = types
486
+ .model("Model", {
487
+ value: types.number,
488
+ })
489
+ .actions((self) => ({
490
+ setValue(v: number) {
491
+ self.value = v;
492
+ },
493
+ }));
494
+
495
+ const instance = Model.create({ value: 0 });
496
+ const disposers: (() => void)[] = [];
497
+ let snapshotCount = 0;
498
+
499
+ // Add listeners while updating
500
+ for (let i = 0; i < 100; i++) {
501
+ instance.setValue(i);
502
+
503
+ if (i % 10 === 0) {
504
+ disposers.push(
505
+ onSnapshot(instance, () => {
506
+ snapshotCount++;
507
+ })
508
+ );
509
+ }
510
+ }
511
+
512
+ // More updates after all listeners added
513
+ for (let i = 100; i < 200; i++) {
514
+ instance.setValue(i);
515
+ }
516
+
517
+ expect(snapshotCount).toBeGreaterThan(0);
518
+
519
+ disposers.forEach((d) => d());
520
+ destroy(instance);
521
+ });
522
+
523
+ it("should handle destroy during iteration", () => {
524
+ const Item = types.model("Item", {
525
+ id: types.identifier,
526
+ value: types.number,
527
+ });
528
+
529
+ const List = types
530
+ .model("List", {
531
+ items: types.array(Item),
532
+ })
533
+ .actions((self) => ({
534
+ clearAll() {
535
+ self.items.length = 0;
536
+ },
537
+ }));
538
+
539
+ const list = List.create({
540
+ items: Array.from({ length: 100 }, (_, i) => ({
541
+ id: `id-${i}`,
542
+ value: i,
543
+ })),
544
+ });
545
+
546
+ // Get items for reference
547
+ const itemsRef = [...list.items];
548
+
549
+ // Clear the list
550
+ list.clearAll();
551
+
552
+ expect(list.items.length).toBe(0);
553
+
554
+ destroy(list);
555
+ });
556
+ });
557
+
558
+ describe("Identifier Registry Stress", () => {
559
+ it("should handle massive identifier churn", () => {
560
+ const Item = types.model("Item", {
561
+ id: types.identifier,
562
+ value: types.number,
563
+ });
564
+
565
+ const List = types
566
+ .model("List", {
567
+ items: types.array(Item),
568
+ })
569
+ .actions((self) => ({
570
+ addItem(id: string, value: number) {
571
+ self.items.push({ id, value });
572
+ },
573
+ removeFirst() {
574
+ self.items.splice(0, 1);
575
+ },
576
+ }));
577
+
578
+ const list = List.create({ items: [] });
579
+
580
+ // Add and remove many items
581
+ for (let i = 0; i < 5000; i++) {
582
+ list.addItem(`item-${i}`, i);
583
+
584
+ // Remove items periodically to test cleanup
585
+ if (i > 100 && i % 2 === 0) {
586
+ list.removeFirst();
587
+ }
588
+ }
589
+
590
+ const stats = getRegistryStats();
591
+
592
+ // Should have proper cleanup
593
+ expect(stats.identifierRegistrySize).toBeLessThan(5000);
594
+
595
+ destroy(list);
596
+
597
+ const statsAfter = getRegistryStats();
598
+ expect(statsAfter.identifierRegistrySize).toBe(0);
599
+ });
600
+ });
601
+ });
602
+
603
+ // ============================================================================
604
+ // Regression Tests
605
+ // ============================================================================
606
+
607
+ describe("Regression Tests", () => {
608
+ it("should not leak nodes when using maybe types", () => {
609
+ const Child = types.model("Child", { value: types.number });
610
+ const Parent = types
611
+ .model("Parent", {
612
+ child: types.maybe(Child),
613
+ })
614
+ .actions((self) => ({
615
+ setChild(value: number | null) {
616
+ self.child = value !== null ? { value } : undefined;
617
+ },
618
+ }));
619
+
620
+ const parent = Parent.create({ child: { value: 1 } });
621
+
622
+ const statsBefore = getRegistryStats();
623
+
624
+ // Toggle child many times
625
+ for (let i = 0; i < 100; i++) {
626
+ parent.setChild(i % 2 === 0 ? i : null);
627
+ }
628
+
629
+ destroy(parent);
630
+
631
+ const statsAfter = getRegistryStats();
632
+ expect(statsAfter.liveNodeCount).toBe(0);
633
+ });
634
+
635
+ it("should not leak nodes when using late types in arrays", () => {
636
+ const Node = types.model("Node", {
637
+ id: types.identifier,
638
+ children: types.array(types.late(() => Node)),
639
+ });
640
+
641
+ const root = Node.create({
642
+ id: "root",
643
+ children: [
644
+ {
645
+ id: "child1",
646
+ children: [
647
+ { id: "grandchild1", children: [] },
648
+ { id: "grandchild2", children: [] },
649
+ ],
650
+ },
651
+ {
652
+ id: "child2",
653
+ children: [],
654
+ },
655
+ ],
656
+ });
657
+
658
+ const statsBefore = getRegistryStats();
659
+ expect(statsBefore.liveNodeCount).toBeGreaterThan(0);
660
+
661
+ destroy(root);
662
+
663
+ const statsAfter = getRegistryStats();
664
+ expect(statsAfter.liveNodeCount).toBe(0);
665
+ expect(statsAfter.identifierRegistrySize).toBe(0);
666
+ });
667
+ });