@lazily-hub/lazily-js 0.19.0 → 0.20.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/BENCHMARKS.md CHANGED
@@ -19,7 +19,7 @@ like-for-like single-process counterpart here and are intentionally omitted.
19
19
 
20
20
  <!-- benchmark-results:start -->
21
21
 
22
- Generated for package `@lazily-hub/lazily-js` version `0.6.0`.
22
+ Generated for package `@lazily-hub/lazily-js` version `0.19.0`.
23
23
 
24
24
  Environment: Node.js `26.4.0` on `linux x64`.
25
25
 
@@ -33,16 +33,16 @@ Mean wall-clock time per iteration; 95% CI half-width from the standard error.
33
33
 
34
34
  | Group | Case | Mean | 95% CI | p75 | p99 | Samples |
35
35
  |---|---|---:|---:|---:|---:|---:|
36
- | cached_reads | context | 57.798 ns | ± 20.941 ns | 46.398 ns | 124.744 ns | 100 |
37
- | cold_first_get | context | 669.220 ns | ± 8.630 ns | 670.000 ns | 802.610 ns | 100 |
38
- | dependency_fan_out | context / 32 | 7.448 us | ± 824.968 ns | 6.783 us | 34.205 us | 100 |
39
- | dependency_fan_out | context / 256 | 43.766 us | ± 1.064 us | 43.270 us | 49.513 us | 100 |
40
- | set_cell_invalidation | high_fan_out / 512 | 10.737 us | ± 376.524 ns | 10.113 us | 17.141 us | 100 |
41
- | memo_equality_suppression | context | 2.387 us | ± 193.801 ns | 2.310 us | 2.864 us | 100 |
42
- | effect_flushing | context | 211.187 ns | ± 20.150 ns | 221.482 ns | 524.491 ns | 100 |
43
- | batch_storms | context / 64 | 30.430 us | ± 1.295 us | 30.815 us | 49.775 us | 99 |
44
- | typed_cache_reads | context_cell | 31.325 ns | ± 1.052 ns | 30.255 ns | 58.102 ns | 100 |
45
- | typed_cache_reads | context_slot | 120.262 ns | ± 4.805 ns | 118.099 ns | 220.409 ns | 100 |
36
+ | cached_reads | context | 31.707 ns | ± 1.826 ns | 29.867 ns | 78.222 ns | 100 |
37
+ | cold_first_get | context | 698.810 ns | ± 46.591 ns | 600.000 ns | 1.410 us | 100 |
38
+ | dependency_fan_out | context / 32 | 5.263 us | ± 858.863 ns | 4.753 us | 16.289 us | 100 |
39
+ | dependency_fan_out | context / 256 | 47.575 us | ± 3.908 us | 45.595 us | 132.218 us | 100 |
40
+ | set_cell_invalidation | high_fan_out / 512 | 6.692 us | ± 3.311 us | 5.840 us | 111.143 us | 100 |
41
+ | memo_equality_suppression | context | 2.546 us | ± 256.417 ns | 2.553 us | 5.435 us | 100 |
42
+ | effect_flushing | context | 129.166 ns | ± 11.208 ns | 131.217 ns | 406.377 ns | 100 |
43
+ | batch_storms | context / 64 | 12.092 us | ± 123.522 ns | 12.286 us | 14.168 us | 100 |
44
+ | typed_cache_reads | context_cell | 33.701 ns | ± 2.005 ns | 32.755 ns | 55.480 ns | 100 |
45
+ | typed_cache_reads | context_slot | 42.440 ns | ± 2.855 ns | 40.074 ns | 106.268 ns | 100 |
46
46
 
47
47
  <!-- benchmark-results:end -->
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), sync/async/thread-safe keyed reactive maps (ReactiveMap/CellMap/SlotMap), the lazily-spec IPC wire types + C-ABI FFI boundary (isomorphic channel + Node native binding), keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, an FFI state-projection consumer, and an in-library instrumentation/benchmark API.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/reactive.js CHANGED
@@ -36,8 +36,7 @@ function defaultEqual(a, b) {
36
36
  const bKeys = Object.keys(b);
37
37
  return (
38
38
  aKeys.length === bKeys.length &&
39
- aKeys.every((k) => Object.is(aKeys[k], bKeys[k])) &&
40
- aKeys.every((k) => defaultEqual(a[k], b[k]))
39
+ aKeys.every((k) => k in b && defaultEqual(a[k], b[k]))
41
40
  );
42
41
  }
43
42
 
@@ -74,10 +73,31 @@ export class SignalHandle {
74
73
 
75
74
  // -- Context -----------------------------------------------------------------
76
75
 
76
+ function edgeInsert(edges, id) {
77
+ for (let i = 0; i < edges.length; i++) {
78
+ if (edges[i] === id) {
79
+ return false;
80
+ }
81
+ }
82
+ edges.push(id);
83
+ return true;
84
+ }
85
+
86
+ function edgeRemove(edges, id) {
87
+ for (let i = 0; i < edges.length; i++) {
88
+ if (edges[i] === id) {
89
+ edges[i] = edges[edges.length - 1];
90
+ edges.pop();
91
+ return true;
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+
77
97
  class CellNode {
78
98
  constructor(value) {
79
99
  this.value = value;
80
- this.dependents = new Set();
100
+ this.dependents = [];
81
101
  }
82
102
  }
83
103
 
@@ -87,8 +107,8 @@ class SlotNode {
87
107
  this.hasValue = false;
88
108
  this.memo = memo;
89
109
  this.compute = compute;
90
- this.dependencies = new Set();
91
- this.dependents = new Set();
110
+ this.dependencies = [];
111
+ this.dependents = [];
92
112
  this.dirty = false;
93
113
  this.forceRecompute = false;
94
114
  this.inProgress = false;
@@ -98,18 +118,19 @@ class SlotNode {
98
118
  class EffectNode {
99
119
  constructor(run) {
100
120
  this.run = run;
101
- this.dependencies = new Set();
121
+ this.dependencies = [];
102
122
  this.cleanup = null;
103
123
  this.forceRun = false;
104
124
  }
105
125
  }
106
126
 
107
127
  export class Context {
108
- #nodes = new Map();
128
+ #nodes = [];
109
129
  #nextId = 1;
110
130
  #freeIds = [];
111
131
  #trackingStack = [];
112
132
  #pendingEffects = [];
133
+ #pendingHead = 0;
113
134
  #scheduledEffects = new Set();
114
135
  #flushingEffects = false;
115
136
  #batchDepth = 0;
@@ -167,7 +188,7 @@ export class Context {
167
188
 
168
189
  #cellAny(value) {
169
190
  const id = this.#allocId();
170
- this.#nodes.set(id, new CellNode(value));
191
+ this.#nodes[id] = new CellNode(value);
171
192
  return id;
172
193
  }
173
194
 
@@ -185,7 +206,7 @@ export class Context {
185
206
 
186
207
  #slotAny(memo, compute) {
187
208
  const id = this.#allocId();
188
- this.#nodes.set(id, new SlotNode(compute, memo));
209
+ this.#nodes[id] = new SlotNode(compute, memo);
189
210
  return id;
190
211
  }
191
212
 
@@ -206,7 +227,7 @@ export class Context {
206
227
  const id = this.#allocId();
207
228
  const node = new EffectNode(run);
208
229
  node.forceRun = true; // force the initial run on registration
209
- this.#nodes.set(id, node);
230
+ this.#nodes[id] = node;
210
231
  this.#scheduleEffect(id, false);
211
232
  this.#flushEffects();
212
233
  return id;
@@ -232,7 +253,7 @@ export class Context {
232
253
  this.#registerDependency(id, frame);
233
254
  }
234
255
  this.#refreshSlot(id);
235
- const node = this.#nodes.get(id);
256
+ const node = this.#nodes[id];
236
257
  if (!(node instanceof SlotNode) || !node.hasValue) {
237
258
  throw new Error(`slot ${id} has no value`);
238
259
  }
@@ -244,7 +265,7 @@ export class Context {
244
265
  if (frame !== undefined) {
245
266
  this.#registerDependency(id, frame);
246
267
  }
247
- const node = this.#nodes.get(id);
268
+ const node = this.#nodes[id];
248
269
  if (!(node instanceof CellNode)) {
249
270
  throw new Error(`get_cell on non-cell id ${id}`);
250
271
  }
@@ -258,7 +279,7 @@ export class Context {
258
279
  }
259
280
 
260
281
  #setCellAny(id, value) {
261
- const node = this.#nodes.get(id);
282
+ const node = this.#nodes[id];
262
283
  if (!(node instanceof CellNode)) {
263
284
  throw new Error(`set_cell on non-cell id ${id}`);
264
285
  }
@@ -302,10 +323,20 @@ export class Context {
302
323
  }
303
324
 
304
325
  #flushBatched() {
305
- const cells = [...this.#batchedCells];
306
- this.#batchedCells.clear();
326
+ const cells = this.#batchedCells;
327
+ this.#batchedCells = new Set();
328
+ const roots = [];
307
329
  for (const id of cells) {
308
- this.#invalidateCellDependentsNow(id);
330
+ const node = this.#nodes[id];
331
+ if (node instanceof CellNode) {
332
+ for (const d of node.dependents) {
333
+ roots.push(d);
334
+ }
335
+ }
336
+ }
337
+ const effects = this.#markFrontier(roots);
338
+ for (let i = 0; i < effects.length; i++) {
339
+ this.#scheduleEffect(effects[i][0], effects[i][1]);
309
340
  }
310
341
  this.#flushEffects();
311
342
  }
@@ -318,18 +349,18 @@ export class Context {
318
349
 
319
350
  disposeEffect(handle) {
320
351
  const id = handle.id;
321
- const idx = this.#pendingEffects.indexOf(id);
352
+ const idx = this.#pendingEffects.indexOf(id, this.#pendingHead);
322
353
  if (idx !== -1) {
323
354
  this.#pendingEffects.splice(idx, 1);
324
355
  }
325
356
  this.#scheduledEffects.delete(id);
326
- const node = this.#nodes.get(id);
357
+ const node = this.#nodes[id];
327
358
  if (!(node instanceof EffectNode)) {
328
359
  return;
329
360
  }
330
- this.#nodes.delete(id);
361
+ this.#nodes[id] = undefined;
331
362
  this.#freeIds.push(id);
332
- for (const dep of [...node.dependencies]) {
363
+ for (const dep of node.dependencies) {
333
364
  this.#removeDependentEdge(dep, id);
334
365
  }
335
366
  if (node.cleanup) {
@@ -338,7 +369,7 @@ export class Context {
338
369
  }
339
370
 
340
371
  isEffectActive(handle) {
341
- return this.#nodes.get(handle.id) instanceof EffectNode;
372
+ return this.#nodes[handle.id] instanceof EffectNode;
342
373
  }
343
374
 
344
375
  disposeSignal(handle) {
@@ -350,7 +381,7 @@ export class Context {
350
381
  }
351
382
 
352
383
  isSet(handle) {
353
- const node = this.#nodes.get(handle.id);
384
+ const node = this.#nodes[handle.id];
354
385
  if (!(node instanceof SlotNode)) {
355
386
  return false;
356
387
  }
@@ -371,36 +402,39 @@ export class Context {
371
402
  }
372
403
 
373
404
  #registerDependency(depId, parentId) {
374
- const dep = this.#nodes.get(depId);
405
+ const dep = this.#nodes[depId];
375
406
  if (dep instanceof CellNode || dep instanceof SlotNode) {
376
- if (this.#instrument && !dep.dependents.has(parentId)) {
407
+ const added = edgeInsert(dep.dependents, parentId);
408
+ if (this.#instrument && added) {
377
409
  this.#counters.dependencyEdgesAdded++;
378
410
  }
379
- dep.dependents.add(parentId);
380
411
  }
381
- const parent = this.#nodes.get(parentId);
412
+ const parent = this.#nodes[parentId];
382
413
  if (parent instanceof SlotNode || parent instanceof EffectNode) {
383
- parent.dependencies.add(depId);
414
+ edgeInsert(parent.dependencies, depId);
384
415
  }
385
416
  }
386
417
 
387
418
  #removeDependentEdge(depId, parentId) {
388
- const dep = this.#nodes.get(depId);
419
+ const dep = this.#nodes[depId];
389
420
  if (dep instanceof CellNode || dep instanceof SlotNode) {
390
- if (this.#instrument && dep.dependents.has(parentId)) {
421
+ const removed = edgeRemove(dep.dependents, parentId);
422
+ if (this.#instrument && removed) {
391
423
  this.#counters.dependencyEdgesRemoved++;
392
424
  }
393
- dep.dependents.delete(parentId);
394
425
  }
395
426
  }
396
427
 
397
428
  // -- Internals: refresh / recompute (pull-based, glitch-free) ----------
398
429
 
399
430
  #refreshSlot(id) {
400
- const node = this.#nodes.get(id);
431
+ const node = this.#nodes[id];
401
432
  if (!(node instanceof SlotNode)) {
402
433
  return false;
403
434
  }
435
+ if (node.hasValue && !node.dirty && !node.forceRecompute) {
436
+ return false;
437
+ }
404
438
  if (node.inProgress) {
405
439
  throw new Error(
406
440
  `lazily: circular dependency detected at slot ${id}; a computed/memo slot depends on itself`,
@@ -409,8 +443,8 @@ export class Context {
409
443
  node.inProgress = true;
410
444
  try {
411
445
  let dependencyChanged = false;
412
- for (const dep of [...node.dependencies]) {
413
- if (this.#nodes.get(dep) instanceof SlotNode && this.#refreshSlot(dep)) {
446
+ for (const dep of node.dependencies) {
447
+ if (this.#nodes[dep] instanceof SlotNode && this.#refreshSlot(dep)) {
414
448
  dependencyChanged = true;
415
449
  }
416
450
  }
@@ -430,10 +464,11 @@ export class Context {
430
464
  if (this.#instrument) {
431
465
  this.#counters.slotRecomputes++;
432
466
  }
433
- for (const dep of [...node.dependencies]) {
467
+ const oldDeps = node.dependencies;
468
+ node.dependencies = [];
469
+ for (const dep of oldDeps) {
434
470
  this.#removeDependentEdge(dep, id);
435
471
  }
436
- node.dependencies.clear();
437
472
  this.#trackingStack.push(id);
438
473
  let result;
439
474
  try {
@@ -457,13 +492,7 @@ export class Context {
457
492
  }
458
493
 
459
494
  #notifySlotValueChanged(id) {
460
- const node = this.#nodes.get(id);
461
- if (!(node instanceof SlotNode)) {
462
- return;
463
- }
464
- for (const d of [...node.dependents]) {
465
- this.#invalidateDependentFromChangedValue(d);
466
- }
495
+ this.#invalidateDependentsNow(id);
467
496
  }
468
497
 
469
498
  // -- Internals: invalidation propagation ------------------------------
@@ -475,58 +504,67 @@ export class Context {
475
504
  * @returns {boolean}
476
505
  */
477
506
  #invalidateCellDependentsNow(id) {
478
- const node = this.#nodes.get(id);
479
- if (!(node instanceof CellNode) || node.dependents.size === 0) {
480
- return false;
481
- }
482
- let scheduled = false;
483
- for (const d of [...node.dependents]) {
484
- if (this.#invalidateDependentFromChangedValue(d)) {
485
- scheduled = true;
486
- }
487
- }
488
- return scheduled;
507
+ return this.#invalidateDependentsNow(id);
489
508
  }
490
509
 
491
- /** @returns {boolean} whether an Effect was scheduled. */
492
- #invalidateDependentFromChangedValue(id) {
493
- if (this.#nodes.get(id) instanceof EffectNode) {
494
- this.#scheduleEffect(id, true);
495
- return true;
496
- }
497
- return this.#markSlotDirty(id, true);
498
- }
499
-
500
- /** @returns {boolean} whether an Effect was scheduled during this walk. */
501
- #markSlotDirty(id, force) {
502
- const node = this.#nodes.get(id);
503
- if (!(node instanceof SlotNode)) {
504
- return false;
505
- }
506
- const shouldPropagate = !node.dirty || (force && !node.forceRecompute);
507
- node.dirty = true;
508
- if (force) {
509
- node.forceRecompute = true;
510
- }
511
- if (!shouldPropagate) {
510
+ #invalidateDependentsNow(id) {
511
+ const node = this.#nodes[id];
512
+ let roots;
513
+ if (node instanceof CellNode) {
514
+ if (node.dependents.length === 0) {
515
+ return false;
516
+ }
517
+ roots = node.dependents;
518
+ } else if (node instanceof SlotNode) {
519
+ if (node.dependents.length === 0) {
520
+ return false;
521
+ }
522
+ roots = node.dependents;
523
+ } else {
512
524
  return false;
513
525
  }
514
- let scheduled = false;
515
- for (const d of [...node.dependents]) {
516
- if (this.#nodes.get(d) instanceof EffectNode) {
517
- this.#scheduleEffect(d, false);
518
- scheduled = true;
519
- } else if (this.#markSlotDirty(d, false)) {
520
- scheduled = true;
526
+ const effects = this.#markFrontier(roots);
527
+ for (let i = 0; i < effects.length; i++) {
528
+ this.#scheduleEffect(effects[i][0], effects[i][1]);
529
+ }
530
+ return effects.length > 0;
531
+ }
532
+
533
+ #markFrontier(roots) {
534
+ const effects = [];
535
+ const stack = [];
536
+ const forceStack = [];
537
+ for (const root of roots) {
538
+ stack.push(root);
539
+ forceStack.push(true);
540
+ }
541
+ while (stack.length > 0) {
542
+ const id = stack.pop();
543
+ const force = forceStack.pop();
544
+ const node = this.#nodes[id];
545
+ if (node instanceof SlotNode) {
546
+ const shouldPropagate = !node.dirty || (force && !node.forceRecompute);
547
+ node.dirty = true;
548
+ if (force) {
549
+ node.forceRecompute = true;
550
+ }
551
+ if (shouldPropagate) {
552
+ for (const depId of node.dependents) {
553
+ stack.push(depId);
554
+ forceStack.push(false);
555
+ }
556
+ }
557
+ } else if (node instanceof EffectNode) {
558
+ effects.push([id, force]);
521
559
  }
522
560
  }
523
- return scheduled;
561
+ return effects;
524
562
  }
525
563
 
526
564
  // -- Internals: effect scheduling / flush ------------------------------
527
565
 
528
566
  #scheduleEffect(id, force) {
529
- const node = this.#nodes.get(id);
567
+ const node = this.#nodes[id];
530
568
  if (!(node instanceof EffectNode)) {
531
569
  return;
532
570
  }
@@ -537,8 +575,9 @@ export class Context {
537
575
  this.#pendingEffects.push(id);
538
576
  if (this.#instrument) {
539
577
  this.#counters.effectQueuePushes++;
540
- if (this.#pendingEffects.length > this.#counters.maxEffectQueueDepth) {
541
- this.#counters.maxEffectQueueDepth = this.#pendingEffects.length;
578
+ const depth = this.#pendingEffects.length - this.#pendingHead;
579
+ if (depth > this.#counters.maxEffectQueueDepth) {
580
+ this.#counters.maxEffectQueueDepth = depth;
542
581
  }
543
582
  }
544
583
  }
@@ -551,8 +590,10 @@ export class Context {
551
590
  this.#flushingEffects = true;
552
591
  try {
553
592
  while (true) {
554
- const id = this.#pendingEffects.shift();
593
+ const id = this.#pendingEffects[this.#pendingHead++];
555
594
  if (id === undefined) {
595
+ this.#pendingEffects = [];
596
+ this.#pendingHead = 0;
556
597
  return;
557
598
  }
558
599
  this.#scheduledEffects.delete(id);
@@ -567,12 +608,12 @@ export class Context {
567
608
  if (!this.#effectShouldRun(id)) {
568
609
  return;
569
610
  }
570
- const node = this.#nodes.get(id);
611
+ const node = this.#nodes[id];
571
612
  if (!(node instanceof EffectNode)) {
572
613
  return;
573
614
  }
574
- const oldDeps = [...node.dependencies];
575
- node.dependencies.clear();
615
+ const oldDeps = node.dependencies;
616
+ node.dependencies = [];
576
617
  const cleanup = node.cleanup;
577
618
  node.cleanup = null;
578
619
  node.forceRun = false;
@@ -589,7 +630,7 @@ export class Context {
589
630
  } finally {
590
631
  this.#trackingStack.pop();
591
632
  }
592
- const current = this.#nodes.get(id);
633
+ const current = this.#nodes[id];
593
634
  if (current instanceof EffectNode) {
594
635
  current.cleanup = typeof nextCleanup === "function" ? nextCleanup : null;
595
636
  } else if (typeof nextCleanup === "function") {
@@ -598,7 +639,7 @@ export class Context {
598
639
  }
599
640
 
600
641
  #effectShouldRun(id) {
601
- const node = this.#nodes.get(id);
642
+ const node = this.#nodes[id];
602
643
  if (!(node instanceof EffectNode)) {
603
644
  return false;
604
645
  }
@@ -606,7 +647,7 @@ export class Context {
606
647
  return true;
607
648
  }
608
649
  for (const dep of node.dependencies) {
609
- if (this.#nodes.get(dep) instanceof SlotNode && this.#refreshSlot(dep)) {
650
+ if (this.#nodes[dep] instanceof SlotNode && this.#refreshSlot(dep)) {
610
651
  return true;
611
652
  }
612
653
  }