@sigil-dev/runtime 0.7.5 → 0.7.7

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/index.test.ts CHANGED
@@ -5,10 +5,35 @@ import {
5
5
  claimComment,
6
6
  claimText,
7
7
  createEffect,
8
+ createEffectPre,
9
+ createInspectEffect,
8
10
  createMemo,
11
+ createProfilerEffect,
12
+ createRawSignal,
9
13
  createSignal,
14
+ easeInCubic,
15
+ easeInOutCubic,
16
+ easeInOutQuad,
17
+ easeInOutQuart,
18
+ easeInOutQuint,
19
+ easeInOutSine,
20
+ easeInQuad,
21
+ easeInQuart,
22
+ easeInQuint,
23
+ easeInSine,
24
+ easeOutCubic,
25
+ easeOutQuad,
26
+ easeOutQuart,
27
+ easeOutQuint,
28
+ easeOutSine,
10
29
  hydrateKeyedList,
30
+ linear,
31
+ ReactiveMap,
32
+ ReactiveSet,
11
33
  reconcile,
34
+ snapshot,
35
+ tick,
36
+ tracking,
12
37
  withEffectScope,
13
38
  } from "./index.ts";
14
39
 
@@ -84,7 +109,9 @@ describe("Runtime", () => {
84
109
  createEffect(() => {
85
110
  seen = count();
86
111
  });
87
- count.set(5);
112
+ batch(() => {
113
+ count.set(5);
114
+ });
88
115
  expect(seen).toBe(5);
89
116
  });
90
117
  });
@@ -99,7 +126,9 @@ describe("Runtime", () => {
99
126
  test("updates when dependency changes", () => {
100
127
  const count = createSignal(2);
101
128
  const doubled = createMemo(() => count() * 2);
102
- count.set(5);
129
+ batch(() => {
130
+ count.set(5);
131
+ });
103
132
  expect(doubled()).toBe(10);
104
133
  });
105
134
  });
@@ -320,7 +349,8 @@ describe("Runtime", () => {
320
349
  });
321
350
  count.set(1);
322
351
  count.set(2);
323
- expect(log).toEqual(["cleanup", "cleanup"]);
352
+ batch(() => {});
353
+ expect(log).toEqual(["cleanup"]);
324
354
  });
325
355
 
326
356
  test("disposer stops effect", () => {
@@ -330,9 +360,13 @@ describe("Runtime", () => {
330
360
  count();
331
361
  runs++;
332
362
  });
333
- count.set(1);
363
+ batch(() => {
364
+ count.set(1);
365
+ });
334
366
  stop();
335
- count.set(2);
367
+ batch(() => {
368
+ count.set(2);
369
+ });
336
370
  expect(runs).toBe(2); // initial + one update, not three
337
371
  });
338
372
 
@@ -382,7 +416,7 @@ describe("batch", () => {
382
416
  expect(runs).toBe(2); // one run after outer exits
383
417
  });
384
418
 
385
- test("outside batch runs immediately", () => {
419
+ test("outside batch auto-batches to microtask", () => {
386
420
  const count = createSignal(0);
387
421
  let runs = 0;
388
422
  createEffect(() => {
@@ -392,7 +426,11 @@ describe("batch", () => {
392
426
 
393
427
  count.set(1);
394
428
  count.set(2);
395
- expect(runs).toBe(3); // initial + two immediate runs
429
+ // Without batch, effects are auto-batched — no synchronous run
430
+ expect(runs).toBe(1); // initial only
431
+
432
+ batch(() => {});
433
+ expect(runs).toBe(2); // initial + one batched update (last write wins)
396
434
  });
397
435
  });
398
436
 
@@ -409,7 +447,9 @@ describe("createSignal with objects", () => {
409
447
  seen = state().name;
410
448
  });
411
449
  expect(seen).toBe("Alice");
412
- state().name = "Bob";
450
+ batch(() => {
451
+ state().name = "Bob";
452
+ });
413
453
  expect(seen).toBe("Bob");
414
454
  });
415
455
 
@@ -421,9 +461,13 @@ describe("createSignal with objects", () => {
421
461
  runs++;
422
462
  });
423
463
  expect(runs).toBe(1);
424
- state().age = 31;
464
+ batch(() => {
465
+ state().age = 31;
466
+ });
425
467
  expect(runs).toBe(1);
426
- state().name = "Bob";
468
+ batch(() => {
469
+ state().name = "Bob";
470
+ });
427
471
  expect(runs).toBe(2);
428
472
  });
429
473
 
@@ -434,7 +478,9 @@ describe("createSignal with objects", () => {
434
478
  seen = state().user.address.city;
435
479
  });
436
480
  expect(seen).toBe("NYC");
437
- state().user.address.city = "LA";
481
+ batch(() => {
482
+ state().user.address.city = "LA";
483
+ });
438
484
  expect(seen).toBe("LA");
439
485
  });
440
486
 
@@ -449,7 +495,9 @@ describe("createSignal with objects", () => {
449
495
  seen = state().address.city;
450
496
  });
451
497
  expect(seen).toBe("Agartha");
452
- state().address.city = "Hyperborea";
498
+ batch(() => {
499
+ state().address.city = "Hyperborea";
500
+ });
453
501
  expect(seen).toBe("Hyperborea");
454
502
  });
455
503
 
@@ -460,7 +508,9 @@ describe("createSignal with objects", () => {
460
508
  seen = state().name;
461
509
  });
462
510
  expect(seen).toBe("Alice");
463
- state.set({ name: "Charlie", age: 25 });
511
+ batch(() => {
512
+ state.set({ name: "Charlie", age: 25 });
513
+ });
464
514
  expect(seen).toBe("Charlie");
465
515
  });
466
516
 
@@ -472,7 +522,9 @@ describe("createSignal with objects", () => {
472
522
  runs++;
473
523
  });
474
524
  expect(runs).toBe(1);
475
- state().name = "Bob";
525
+ batch(() => {
526
+ state().name = "Bob";
527
+ });
476
528
  expect(runs).toBe(1);
477
529
  });
478
530
 
@@ -501,7 +553,9 @@ describe("createSignal with arrays", () => {
501
553
  len = items().length;
502
554
  });
503
555
  expect(len).toBe(3);
504
- items().push(4);
556
+ batch(() => {
557
+ items().push(4);
558
+ });
505
559
  expect(len).toBe(4);
506
560
  });
507
561
 
@@ -513,7 +567,9 @@ describe("createSignal with arrays", () => {
513
567
  first = items()[0];
514
568
  });
515
569
  expect(first).toBe(1);
516
- items()[0] = 99;
570
+ batch(() => {
571
+ items()[0] = 99;
572
+ });
517
573
  expect(first).toBe(99);
518
574
  });
519
575
 
@@ -524,9 +580,13 @@ describe("createSignal with arrays", () => {
524
580
  seen = [...items()];
525
581
  });
526
582
  expect(seen).toEqual([]);
527
- items().push(1);
583
+ batch(() => {
584
+ items().push(1);
585
+ });
528
586
  expect(seen).toEqual([1]);
529
- items().push(2);
587
+ batch(() => {
588
+ items().push(2);
589
+ });
530
590
  expect(seen).toEqual([1, 2]);
531
591
  });
532
592
 
@@ -537,7 +597,9 @@ describe("createSignal with arrays", () => {
537
597
  len = items().length;
538
598
  });
539
599
  expect(len).toBe(3);
540
- items().pop();
600
+ batch(() => {
601
+ items().pop();
602
+ });
541
603
  expect(len).toBe(2);
542
604
  });
543
605
 
@@ -547,7 +609,9 @@ describe("createSignal with arrays", () => {
547
609
  createEffect(() => {
548
610
  seen = [...items()];
549
611
  });
550
- items().splice(1, 2);
612
+ batch(() => {
613
+ items().splice(1, 2);
614
+ });
551
615
  expect(seen).toEqual([1, 4]);
552
616
  });
553
617
 
@@ -555,7 +619,9 @@ describe("createSignal with arrays", () => {
555
619
  const items = createSignal([1, 2, 3]);
556
620
  const doubled = createMemo(() => items().map((x) => x * 2));
557
621
  expect(doubled()).toEqual([2, 4, 6]);
558
- items().push(4);
622
+ batch(() => {
623
+ items().push(4);
624
+ });
559
625
  expect(doubled()).toEqual([2, 4, 6, 8]);
560
626
  });
561
627
  });
@@ -574,7 +640,9 @@ describe("proxy edge cases", () => {
574
640
  seen = [...state().items];
575
641
  });
576
642
  expect(seen).toEqual([1, 2, 3]);
577
- state().items.push(4);
643
+ batch(() => {
644
+ state().items.push(4);
645
+ });
578
646
  expect(seen).toEqual([1, 2, 3, 4]);
579
647
  });
580
648
 
@@ -595,13 +663,17 @@ describe("proxy edge cases", () => {
595
663
  expect(xRuns).toBe(1);
596
664
  expect(yRuns).toBe(1);
597
665
 
598
- state().x = 5;
666
+ batch(() => {
667
+ state().x = 5;
668
+ });
599
669
  expect(xSeen).toBe(5);
600
670
  expect(ySeen).toBe(0);
601
671
  expect(xRuns).toBe(2);
602
672
  expect(yRuns).toBe(1);
603
673
 
604
- state().y = 10;
674
+ batch(() => {
675
+ state().y = 10;
676
+ });
605
677
  expect(xSeen).toBe(5);
606
678
  expect(ySeen).toBe(10);
607
679
  expect(xRuns).toBe(2);
@@ -771,11 +843,15 @@ describe("Scary reactivity", () => {
771
843
  expect(runs).toBe(1);
772
844
 
773
845
  // flip condition, effect should now track b, not a
774
- condition.set(false);
846
+ batch(() => {
847
+ condition.set(false);
848
+ });
775
849
  expect(runs).toBe(2);
776
850
 
777
851
  // updating a should NOT trigger the effect anymore
778
- a.set(99);
852
+ batch(() => {
853
+ a.set(99);
854
+ });
779
855
  expect(runs).toBe(2); // ← this will fail with current implementation
780
856
  });
781
857
  });
@@ -791,7 +867,9 @@ describe("withEffectScope", () => {
791
867
  });
792
868
  });
793
869
  expect(runs).toBe(1);
794
- count.set(1);
870
+ batch(() => {
871
+ count.set(1);
872
+ });
795
873
  expect(runs).toBe(2);
796
874
  });
797
875
 
@@ -805,12 +883,16 @@ describe("withEffectScope", () => {
805
883
  });
806
884
  });
807
885
  expect(runs).toBe(1);
808
- count.set(1);
886
+ batch(() => {
887
+ count.set(1);
888
+ });
809
889
  expect(runs).toBe(2);
810
890
 
811
891
  dispose();
812
892
 
813
- count.set(2);
893
+ batch(() => {
894
+ count.set(2);
895
+ });
814
896
  expect(runs).toBe(2); // stopped — no further runs
815
897
  });
816
898
 
@@ -829,15 +911,19 @@ describe("withEffectScope", () => {
829
911
  runsB++;
830
912
  });
831
913
  });
832
- a.set(1);
833
- b.set(1);
914
+ batch(() => {
915
+ a.set(1);
916
+ b.set(1);
917
+ });
834
918
  expect(runsA).toBe(2);
835
919
  expect(runsB).toBe(2);
836
920
 
837
921
  dispose();
838
922
 
839
- a.set(2);
840
- b.set(2);
923
+ batch(() => {
924
+ a.set(2);
925
+ b.set(2);
926
+ });
841
927
  expect(runsA).toBe(2);
842
928
  expect(runsB).toBe(2);
843
929
  });
@@ -859,13 +945,17 @@ describe("withEffectScope", () => {
859
945
  });
860
946
  });
861
947
 
862
- count.set(1);
948
+ batch(() => {
949
+ count.set(1);
950
+ });
863
951
  expect(outsideRuns).toBe(2);
864
952
  expect(insideRuns).toBe(2);
865
953
 
866
954
  dispose();
867
955
 
868
- count.set(2);
956
+ batch(() => {
957
+ count.set(2);
958
+ });
869
959
  expect(outsideRuns).toBe(3); // still running
870
960
  expect(insideRuns).toBe(2); // stopped
871
961
  });
@@ -890,12 +980,16 @@ describe("withEffectScope", () => {
890
980
  disposeInner();
891
981
  });
892
982
 
893
- count.set(1);
983
+ batch(() => {
984
+ count.set(1);
985
+ });
894
986
  expect(outerRuns).toBe(2);
895
987
  expect(innerRuns).toBe(1); // stopped on inner dispose
896
988
 
897
989
  disposeOuter();
898
- count.set(2);
990
+ batch(() => {
991
+ count.set(2);
992
+ });
899
993
  expect(outerRuns).toBe(2); // stopped on outer dispose
900
994
  });
901
995
 
@@ -911,11 +1005,15 @@ describe("withEffectScope", () => {
911
1005
  expect(doubled()).toBe(0);
912
1006
  });
913
1007
  expect(memoRuns).toBe(1);
914
- count.set(5);
1008
+ batch(() => {
1009
+ count.set(5);
1010
+ });
915
1011
  expect(memoRuns).toBe(2);
916
1012
 
917
1013
  dispose();
918
- count.set(10);
1014
+ batch(() => {
1015
+ count.set(10);
1016
+ });
919
1017
  expect(memoRuns).toBe(2); // memo's internal effect stopped
920
1018
  });
921
1019
 
@@ -930,7 +1028,9 @@ describe("withEffectScope", () => {
930
1028
  });
931
1029
  dispose();
932
1030
  dispose(); // second call must not throw or double-fire
933
- count.set(1);
1031
+ batch(() => {
1032
+ count.set(1);
1033
+ });
934
1034
  expect(runs).toBe(1);
935
1035
  });
936
1036
  });
@@ -1063,3 +1163,450 @@ describe("hydrateKeyedList", () => {
1063
1163
  expect(result.keyMap.size).toBe(0);
1064
1164
  });
1065
1165
  });
1166
+
1167
+ describe("A1 createRawSignal", () => {
1168
+ test("skips proxy for objects", () => {
1169
+ const state = createRawSignal({ name: "Alice" });
1170
+ expect(state()).toEqual({ name: "Alice" });
1171
+ // Raw signal — direct object, not proxied
1172
+ const obj = state();
1173
+ state.set({ name: "Bob" });
1174
+ expect(state()).toEqual({ name: "Bob" });
1175
+ });
1176
+
1177
+ test("works with primitives", () => {
1178
+ const count = createRawSignal(0);
1179
+ expect(count()).toBe(0);
1180
+ count.set(5);
1181
+ expect(count()).toBe(5);
1182
+ });
1183
+
1184
+ test("is reactive", () => {
1185
+ const count = createRawSignal(0);
1186
+ let runs = 0;
1187
+ createEffect(() => {
1188
+ count();
1189
+ runs++;
1190
+ });
1191
+ expect(runs).toBe(1);
1192
+ batch(() => {
1193
+ count.set(1);
1194
+ });
1195
+ expect(runs).toBe(2);
1196
+ });
1197
+
1198
+ test(".peek() reads without tracking", () => {
1199
+ const count = createRawSignal(0);
1200
+ let runs = 0;
1201
+ createEffect(() => {
1202
+ count.peek();
1203
+ runs++;
1204
+ });
1205
+ expect(runs).toBe(1);
1206
+ batch(() => {
1207
+ count.set(1);
1208
+ });
1209
+ expect(runs).toBe(1);
1210
+ });
1211
+ });
1212
+
1213
+ describe("A2 snapshot", () => {
1214
+ test("returns primitive values as-is", () => {
1215
+ expect(snapshot(42)).toBe(42);
1216
+ expect(snapshot("hello")).toBe("hello");
1217
+ expect(snapshot(null)).toBe(null);
1218
+ expect(snapshot(undefined)).toBe(undefined);
1219
+ });
1220
+
1221
+ test("unwraps proxy to plain object", () => {
1222
+ const state = createSignal({ name: "Alice", age: 30 });
1223
+ const snap = snapshot(state());
1224
+ expect(snap).toEqual({ name: "Alice", age: 30 });
1225
+ expect(snap).not.toBe(state()); // distinct reference
1226
+ });
1227
+
1228
+ test("unwraps proxy to plain array", () => {
1229
+ const items = createSignal([1, 2, 3]);
1230
+ const snap = snapshot(items());
1231
+ expect(snap).toEqual([1, 2, 3]);
1232
+ expect(Array.isArray(snap)).toBe(true);
1233
+ expect(snap).not.toBe(items()); // distinct reference
1234
+ });
1235
+
1236
+ test("non-proxy objects are returned as-is", () => {
1237
+ const obj = { plain: true };
1238
+ expect(snapshot(obj)).toBe(obj);
1239
+ });
1240
+
1241
+ test("deeply nested snapshot is fully un-proxied", () => {
1242
+ const obj = createSignal({ a: { b: { c: 1 } } });
1243
+ const s = snapshot(obj());
1244
+ expect(s.a.b.c).toBe(1);
1245
+ // s.a.b should NOT be a proxy — it's a plain object from the deep copy
1246
+ const proxyTargetCheck = (val: object) =>
1247
+ typeof val === "object" &&
1248
+ val !== null &&
1249
+ !Object.getPrototypeOf(val)?.constructor?.name?.includes("Map");
1250
+ // Verify no nested proxy: accessing s.a.b.c should not trigger tracking
1251
+ let tracked = false;
1252
+ const prev = (globalThis as any).__sigil_tracking_test;
1253
+ (globalThis as any).__sigil_tracking_test = () => (tracked = true);
1254
+ const raw = s.a.b;
1255
+ // No reactive tracking should happen on plain objects
1256
+ expect(raw.c).toBe(1);
1257
+ // Deep compare confirms it's a full independent copy
1258
+ expect(s).toEqual({ a: { b: { c: 1 } } });
1259
+ expect(s.a).not.toBe(obj().a);
1260
+ expect(s.a.b).not.toBe(obj().a.b);
1261
+ });
1262
+ });
1263
+
1264
+ describe("A3 tracking", () => {
1265
+ test("returns false outside effect", () => {
1266
+ expect(tracking()).toBe(false);
1267
+ });
1268
+
1269
+ test("returns true inside effect", () => {
1270
+ let inside = false;
1271
+ createEffect(() => {
1272
+ inside = tracking();
1273
+ });
1274
+ expect(inside).toBe(true);
1275
+ });
1276
+
1277
+ test("returns false after effect completes", () => {
1278
+ createEffect(() => {
1279
+ tracking();
1280
+ });
1281
+ expect(tracking()).toBe(false);
1282
+ });
1283
+ });
1284
+
1285
+ describe("A4 createInspectEffect", () => {
1286
+ test("does not throw", () => {
1287
+ const count = createSignal(0);
1288
+ expect(() => {
1289
+ createInspectEffect(() => [count()]);
1290
+ }).not.toThrow();
1291
+ });
1292
+
1293
+ test("accepts custom callback", () => {
1294
+ const count = createSignal(0);
1295
+ const logged: unknown[][] = [];
1296
+ createInspectEffect(
1297
+ () => [count()],
1298
+ (vals) => logged.push(vals),
1299
+ );
1300
+ expect(logged.length).toBe(1);
1301
+ expect(logged[0]).toEqual([0]);
1302
+ });
1303
+ });
1304
+
1305
+ describe("A5 createEffectPre", () => {
1306
+ test("runs immediately like createEffect", () => {
1307
+ const count = createSignal(0);
1308
+ let seen = -1;
1309
+ createEffectPre(() => {
1310
+ seen = count();
1311
+ });
1312
+ expect(seen).toBe(0);
1313
+ });
1314
+
1315
+ test("reruns on signal change", () => {
1316
+ const count = createSignal(0);
1317
+ let seen = -1;
1318
+ createEffectPre(() => {
1319
+ seen = count();
1320
+ });
1321
+ batch(() => {
1322
+ count.set(5);
1323
+ });
1324
+ expect(seen).toBe(5);
1325
+ });
1326
+ });
1327
+
1328
+ describe("A7 ReactiveMap", () => {
1329
+ test("basic set/get", () => {
1330
+ const map = new ReactiveMap<string, number>();
1331
+ map.set("a", 1);
1332
+ expect(map.get("a")).toBe(1);
1333
+ });
1334
+
1335
+ test("reactivity: get tracks reads", () => {
1336
+ const map = new ReactiveMap<string, number>();
1337
+ let size = 0;
1338
+ createEffect(() => {
1339
+ size = map.size;
1340
+ });
1341
+ expect(size).toBe(0);
1342
+ batch(() => {
1343
+ map.set("x", 1);
1344
+ });
1345
+ expect(size).toBe(1);
1346
+ });
1347
+
1348
+ test("deleted key triggers reactivity", () => {
1349
+ const map = new ReactiveMap<string, number>();
1350
+ map.set("a", 1);
1351
+ let accessed: number | undefined;
1352
+ createEffect(() => {
1353
+ accessed = map.get("a");
1354
+ });
1355
+ expect(accessed).toBe(1);
1356
+ batch(() => {
1357
+ map.delete("a");
1358
+ });
1359
+ expect(accessed).toBe(undefined);
1360
+ });
1361
+
1362
+ test("clear triggers reactivity", () => {
1363
+ const map = new ReactiveMap<string, number>();
1364
+ map.set("a", 1);
1365
+ let size = 0;
1366
+ createEffect(() => {
1367
+ size = map.size;
1368
+ });
1369
+ batch(() => {
1370
+ map.clear();
1371
+ });
1372
+ expect(size).toBe(0);
1373
+ });
1374
+
1375
+ test("forEach tracks reads", () => {
1376
+ const map = new ReactiveMap<string, number>();
1377
+ map.set("a", 1);
1378
+ let count = 0;
1379
+ createEffect(() => {
1380
+ count = 0;
1381
+ map.forEach(() => count++);
1382
+ });
1383
+ expect(count).toBe(1);
1384
+ batch(() => {
1385
+ map.set("b", 2);
1386
+ });
1387
+ expect(count).toBe(2);
1388
+ });
1389
+
1390
+ test("per-key granularity: setting key X does not re-trigger effect reading key Y", () => {
1391
+ const map = new ReactiveMap<string, number>();
1392
+ map.set("a", 1);
1393
+ map.set("b", 2);
1394
+ let aRuns = 0;
1395
+ let bRuns = 0;
1396
+ createEffect(() => {
1397
+ map.get("a");
1398
+ aRuns++;
1399
+ });
1400
+ createEffect(() => {
1401
+ map.get("b");
1402
+ bRuns++;
1403
+ });
1404
+ expect(aRuns).toBe(1);
1405
+ expect(bRuns).toBe(1);
1406
+
1407
+ batch(() => {
1408
+ map.set("a", 10); // only a-effect should rerun
1409
+ });
1410
+ expect(aRuns).toBe(2);
1411
+ expect(bRuns).toBe(1);
1412
+
1413
+ batch(() => {
1414
+ map.set("b", 20); // only b-effect should rerun
1415
+ });
1416
+ expect(aRuns).toBe(2);
1417
+ expect(bRuns).toBe(2);
1418
+ });
1419
+
1420
+ test("per-key: adding new key triggers struct but not existing key effects", () => {
1421
+ const map = new ReactiveMap<string, number>();
1422
+ map.set("a", 1);
1423
+ let aRuns = 0;
1424
+ let sizeRuns = 0;
1425
+ createEffect(() => {
1426
+ map.get("a");
1427
+ aRuns++;
1428
+ });
1429
+ createEffect(() => {
1430
+ map.size;
1431
+ sizeRuns++;
1432
+ });
1433
+ expect(aRuns).toBe(1);
1434
+ expect(sizeRuns).toBe(1);
1435
+
1436
+ batch(() => {
1437
+ map.set("b", 2); // new key — struct changes, a's value doesn't
1438
+ });
1439
+ expect(aRuns).toBe(1);
1440
+ expect(sizeRuns).toBe(2);
1441
+ });
1442
+ });
1443
+
1444
+ describe("A7 ReactiveSet", () => {
1445
+ test("basic add/has", () => {
1446
+ const set = new ReactiveSet<number>();
1447
+ set.add(1);
1448
+ expect(set.has(1)).toBe(true);
1449
+ });
1450
+
1451
+ test("reactivity: has tracks reads", () => {
1452
+ const set = new ReactiveSet<number>();
1453
+ let hasIt = false;
1454
+ createEffect(() => {
1455
+ hasIt = set.has(1);
1456
+ });
1457
+ expect(hasIt).toBe(false);
1458
+ batch(() => {
1459
+ set.add(1);
1460
+ });
1461
+ expect(hasIt).toBe(true);
1462
+ });
1463
+
1464
+ test("delete triggers reactivity", () => {
1465
+ const set = new ReactiveSet<number>();
1466
+ set.add(1);
1467
+ let size = 0;
1468
+ createEffect(() => {
1469
+ size = set.size;
1470
+ });
1471
+ expect(size).toBe(1);
1472
+ batch(() => {
1473
+ set.delete(1);
1474
+ });
1475
+ expect(size).toBe(0);
1476
+ });
1477
+
1478
+ test("clear triggers reactivity", () => {
1479
+ const set = new ReactiveSet<number>();
1480
+ set.add(1);
1481
+ let size = 0;
1482
+ createEffect(() => {
1483
+ size = set.size;
1484
+ });
1485
+ batch(() => {
1486
+ set.clear();
1487
+ });
1488
+ expect(size).toBe(0);
1489
+ });
1490
+
1491
+ test("forEach tracks reads", () => {
1492
+ const set = new ReactiveSet<string>();
1493
+ set.add("a");
1494
+ let count = 0;
1495
+ createEffect(() => {
1496
+ count = 0;
1497
+ set.forEach(() => count++);
1498
+ });
1499
+ expect(count).toBe(1);
1500
+ batch(() => {
1501
+ set.add("b");
1502
+ });
1503
+ expect(count).toBe(2);
1504
+ });
1505
+ });
1506
+
1507
+ describe("A8 easing functions", () => {
1508
+ test("linear is identity", () => {
1509
+ expect(linear(0)).toBe(0);
1510
+ expect(linear(0.5)).toBe(0.5);
1511
+ expect(linear(1)).toBe(1);
1512
+ });
1513
+
1514
+ test("easeInQuad is quadratic", () => {
1515
+ expect(easeInQuad(0)).toBe(0);
1516
+ expect(easeInQuad(0.5)).toBe(0.25);
1517
+ expect(easeInQuad(1)).toBe(1);
1518
+ });
1519
+
1520
+ test("easeOutQuad completes at 1", () => {
1521
+ expect(easeOutQuad(0)).toBe(0);
1522
+ expect(easeOutQuad(1)).toBe(1);
1523
+ });
1524
+
1525
+ test("all easing functions return 0 at t=0 and 1 at t=1", () => {
1526
+ const fns = [
1527
+ easeInQuad,
1528
+ easeOutQuad,
1529
+ easeInOutQuad,
1530
+ easeInCubic,
1531
+ easeOutCubic,
1532
+ easeInOutCubic,
1533
+ easeInQuart,
1534
+ easeOutQuart,
1535
+ easeInOutQuart,
1536
+ easeInQuint,
1537
+ easeOutQuint,
1538
+ easeInOutQuint,
1539
+ easeInSine,
1540
+ easeOutSine,
1541
+ easeInOutSine,
1542
+ ];
1543
+ for (const fn of fns) {
1544
+ expect(fn(0)).toBeCloseTo(0, 10);
1545
+ expect(fn(1)).toBeCloseTo(1, 10);
1546
+ }
1547
+ });
1548
+ });
1549
+
1550
+ describe("A9 createProfilerEffect", () => {
1551
+ test("runs the effect", () => {
1552
+ let ran = false;
1553
+ createProfilerEffect("test", () => {
1554
+ ran = true;
1555
+ });
1556
+ expect(ran).toBe(true);
1557
+ });
1558
+
1559
+ test("reacts to signal changes", () => {
1560
+ const count = createSignal(0);
1561
+ let seen = -1;
1562
+ createProfilerEffect("test", () => {
1563
+ seen = count();
1564
+ });
1565
+ expect(seen).toBe(0);
1566
+ batch(() => {
1567
+ count.set(5);
1568
+ });
1569
+ expect(seen).toBe(5);
1570
+ });
1571
+ });
1572
+
1573
+ describe("Array reactivity", () => {
1574
+ test("push on $state array triggers reactivity", async () => {
1575
+ const items = createSignal([] as string[]);
1576
+ let count = 0;
1577
+ createEffect(() => {
1578
+ items(); // now auto-tracks length
1579
+ count++;
1580
+ });
1581
+ await tick();
1582
+ expect(count).toBe(1); // initial run
1583
+ items().push("a");
1584
+ await tick();
1585
+ expect(count).toBe(2); // re-ran after push
1586
+ expect(items().length).toBe(1);
1587
+ });
1588
+
1589
+ test("pop on $state array triggers reactivity", async () => {
1590
+ const items = createSignal(["a", "b"] as string[]);
1591
+ let len = 0;
1592
+ createEffect(() => {
1593
+ len = items().length; // reads length, subscribes
1594
+ });
1595
+ await tick();
1596
+ expect(len).toBe(2);
1597
+ items().pop();
1598
+ await tick();
1599
+ expect(len).toBe(1);
1600
+ });
1601
+
1602
+ test("splice on $state array triggers reactivity", async () => {
1603
+ const items = createSignal(["a", "b", "c"] as string[]);
1604
+ let len = 0;
1605
+ createEffect(() => { len = items().length; });
1606
+ await tick();
1607
+ expect(len).toBe(3);
1608
+ items().splice(1, 1);
1609
+ await tick();
1610
+ expect(len).toBe(2);
1611
+ });
1612
+ })