@zakkster/lite-signal 1.1.0 → 1.1.1

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.
Files changed (3) hide show
  1. package/README.md +2 -5
  2. package/Signal.js +114 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -710,11 +710,8 @@ lite-signal v1.1.0 was evaluated against the
710
710
  the most comprehensive behavioral test battery for JavaScript reactive
711
711
  libraries.
712
712
 
713
- **167 of 177 tests pass (94.4%)**, placing lite-signal **fifth of sixteen**
714
- evaluated libraries — behind alien-signals (177), @preact/signals-core (174),
715
- @reatom/core (173), and @vue/reactivity (170), and ahead of anod, solid-js,
716
- tansu, @solidjs/signals, the TC39 signals polyfill, mobx, Angular signals,
717
- Svelte, S.js, and reactively.
713
+ **174 of 177 tests pass (98.3%)**, placing lite-signal **in a tie for second place of sixteen**
714
+ evaluated libraries — just behind alien-signals (177).
718
715
 
719
716
  We publish both passing and failing tests, because honesty about behavior is
720
717
  more useful to library users than a green checkmark.
package/Signal.js CHANGED
@@ -61,6 +61,13 @@ class ReactiveNode {
61
61
  /** Recycle generation: bumped on dispose, used to invalidate stale scheduler closures. */
62
62
  this.gen = 0;
63
63
 
64
+ /** Captured value at first .set() inside the current batch (revert detection). */
65
+ this.preBatchValue = undefined;
66
+ /** Captured version at first .set() inside the current batch. */
67
+ this.preBatchVersion = 0;
68
+ /** batchEpoch that owns the capture; 0 = no capture. */
69
+ this.revertEpoch = 0;
70
+
64
71
  // Doubly-linked dependency list (this node depends on these sources).
65
72
  this.headDep = null;
66
73
  this.tailDep = null;
@@ -175,12 +182,17 @@ export function createRegistry(config = {}) {
175
182
 
176
183
  // --- GLOBAL STATE ---
177
184
  let globalVersion = 1 | 0; // Forced 32-bit SMI
185
+ let batchEpoch = 1 | 0; // skip-zero sentinel; revertEpoch=0 means "no capture"
178
186
  let currentObserver = null;
179
187
  let activeObserverCurrentDep = null;
180
188
  let batchDepth = 0 | 0;
181
189
  let isTrackingDeps = false;
182
190
  let isFlushing = false;
183
191
 
192
+ // Reused buffer for throw isolation during flush
193
+ const flushErrorBuffer = [];
194
+ let flushErrorCount = 0 | 0;
195
+
184
196
  // --- ALLOCATORS ---
185
197
 
186
198
  /**
@@ -342,6 +354,10 @@ export function createRegistry(config = {}) {
342
354
  node.headSub = null;
343
355
  node.tailSub = null;
344
356
 
357
+ node.revertEpoch = 0;
358
+ node.preBatchValue = undefined;
359
+ node.preBatchVersion = 0;
360
+
345
361
  node.gen = (node.gen + 1) | 0;
346
362
  node.nextFree = freeNodeHead;
347
363
  freeNodeHead = node;
@@ -392,6 +408,9 @@ export function createRegistry(config = {}) {
392
408
  node.version = 0;
393
409
  node.evalVersion = 0;
394
410
  node.markEpoch = 0;
411
+ node.revertEpoch = 0;
412
+ node.preBatchValue = undefined;
413
+ node.preBatchVersion = 0;
395
414
  return node;
396
415
  }
397
416
 
@@ -437,7 +456,12 @@ export function createRegistry(config = {}) {
437
456
  const flags = t.flags | 0;
438
457
 
439
458
  if ((flags & FLAG_EFFECT) !== 0) {
440
- if ((flags & FLAG_QUEUED) === 0) {
459
+ // "No re-run" semantics for self-cycles: an effect that is currently
460
+ // executing on the call stack must not be re-queued by its own body's
461
+ // writes (directly or through computed chains). The effect's evalVersion
462
+ // is bumped to the post-write globalVersion in its executeEffect finally,
463
+ // so subsequent unrelated writes still propagate normally.
464
+ if ((flags & FLAG_QUEUED) === 0 && (flags & FLAG_COMPUTING) === 0) {
441
465
  t.flags = flags | FLAG_QUEUED;
442
466
  activeQueue[activeQueueLen] = t;
443
467
  activeQueueLen = (activeQueueLen + 1) | 0;
@@ -465,40 +489,74 @@ export function createRegistry(config = {}) {
465
489
 
466
490
  /**
467
491
  * Drain the effect queue. Double-buffered so new effects scheduled mid-flush
468
- * end up in the next pass.
492
+ * end up in the next pass. Individual effect throws are caught, buffered, and
493
+ * re-thrown at the end of the flush (or wrapped in AggregateError if multiple).
469
494
  * @private
470
495
  */
471
496
  function flushEffects() {
472
497
  if (isFlushing) return;
473
498
  isFlushing = true;
474
499
  let passes = 0 | 0;
500
+ let normalExit = false;
475
501
 
476
- while (activeQueueLen > 0) {
477
- passes = (passes + 1) | 0;
478
- if (passes > maxFlushPasses) {
479
- isFlushing = false;
480
- throw new Error("CycleError: flush passes exceeded");
481
- }
502
+ try {
503
+ while (activeQueueLen > 0) {
504
+ passes = (passes + 1) | 0;
505
+ if (passes > maxFlushPasses) {
506
+ throw new Error("CycleError: flush passes exceeded");
507
+ }
482
508
 
483
- const toRun = activeQueueLen | 0;
484
- const currentQueue = activeQueue;
485
-
486
- isQueueA = !isQueueA;
487
- activeQueue = isQueueA ? effectQueueA : effectQueueB;
488
- activeQueueLen = 0 | 0;
489
-
490
- for (let i = 0; i < toRun; i++) {
491
- const node = currentQueue[i];
492
- const scheduler = node.scheduler;
493
- if (scheduler) {
494
- const gen = node.gen | 0;
495
- scheduler(() => safeExecute(node, gen));
496
- } else {
497
- executeEffect(node);
509
+ const toRun = activeQueueLen | 0;
510
+ const currentQueue = activeQueue;
511
+
512
+ isQueueA = !isQueueA;
513
+ activeQueue = isQueueA ? effectQueueA : effectQueueB;
514
+ activeQueueLen = 0 | 0;
515
+
516
+ for (let i = 0; i < toRun; i++) {
517
+ const node = currentQueue[i];
518
+ try {
519
+ const scheduler = node.scheduler;
520
+ if (scheduler) {
521
+ const gen = node.gen | 0;
522
+ scheduler(() => safeExecute(node, gen));
523
+ } else {
524
+ executeEffect(node);
525
+ }
526
+ } catch (err) {
527
+ // Buffer and continue. Effect's own try/finally inside
528
+ // executeEffect already restored observer state and
529
+ // severed tail deps before the throw landed here.
530
+ flushErrorBuffer[flushErrorCount] = err;
531
+ flushErrorCount = (flushErrorCount + 1) | 0;
532
+ }
498
533
  }
499
534
  }
535
+ normalExit = true;
536
+ } finally {
537
+ isFlushing = false;
538
+ if (!normalExit) {
539
+ // Escaping via CycleError or any non-effect-body throw. Discard
540
+ // buffered effect errors — the structural failure supersedes them
541
+ // and prevents leaking stale errors to the next flush call.
542
+ for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
543
+ flushErrorCount = 0 | 0;
544
+ }
545
+ }
546
+
547
+ if (flushErrorCount > 0) {
548
+ if (flushErrorCount === 1) {
549
+ const err = flushErrorBuffer[0];
550
+ flushErrorBuffer[0] = null; // drop reference, retain backing store
551
+ flushErrorCount = 0 | 0;
552
+ throw err;
553
+ }
554
+ // 2+ errors: snapshot into a fresh array for AggregateError, then clear.
555
+ const errs = flushErrorBuffer.slice(0, flushErrorCount);
556
+ for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
557
+ flushErrorCount = 0 | 0;
558
+ throw new AggregateError(errs, "Effects threw during flush");
500
559
  }
501
- isFlushing = false;
502
560
  }
503
561
 
504
562
  /**
@@ -688,7 +746,26 @@ export function createRegistry(config = {}) {
688
746
  read.set = (value) => {
689
747
  const eq = node.equals;
690
748
  if (eq && eq(node.value, value)) return;
749
+
750
+ // Revert capture: first .set() of this signal inside the current batch.
751
+ // Guarded by batchDepth so out-of-batch writes pay zero added cost.
752
+ if (batchDepth > 0 && node.revertEpoch !== batchEpoch) {
753
+ node.preBatchValue = node.value;
754
+ node.preBatchVersion = node.version | 0;
755
+ node.revertEpoch = batchEpoch | 0;
756
+ }
757
+
691
758
  node.value = value;
759
+
760
+ // Revert detection: in-batch write whose value matches the pre-batch
761
+ // capture. Restore version (without bumping globalVersion) and skip
762
+ // propagation. Subscribers already queued by an earlier mid-batch set
763
+ // will dirty-check against this restored version at flush and bail.
764
+ if (batchDepth > 0 && node.revertEpoch === batchEpoch && eq && eq(node.preBatchValue, value)) {
765
+ node.version = node.preBatchVersion | 0;
766
+ return;
767
+ }
768
+
692
769
  globalVersion = (globalVersion + 1) | 0;
693
770
  node.version = globalVersion | 0;
694
771
  markDownstream(node);
@@ -854,6 +931,10 @@ export function createRegistry(config = {}) {
854
931
  * @returns {T}
855
932
  */
856
933
  function batch(fn) {
934
+ if (batchDepth === 0) {
935
+ batchEpoch = (batchEpoch + 1) | 0;
936
+ if (batchEpoch === 0) batchEpoch = 1 | 0; // preserve the 0 sentinel
937
+ }
857
938
  batchDepth = (batchDepth + 1) | 0;
858
939
  try {
859
940
  return fn();
@@ -936,6 +1017,9 @@ export function createRegistry(config = {}) {
936
1017
  n.version = 0;
937
1018
  n.evalVersion = 0;
938
1019
  n.markEpoch = 0;
1020
+ n.revertEpoch = 0;
1021
+ n.preBatchValue = undefined;
1022
+ n.preBatchVersion = 0;
939
1023
  // Bump gen so any scheduler trampolines holding a stale node ref bail.
940
1024
  n.gen = (n.gen + 1) | 0;
941
1025
  if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
@@ -965,9 +1049,15 @@ export function createRegistry(config = {}) {
965
1049
  activeObserverCurrentDep = null;
966
1050
  isTrackingDeps = false;
967
1051
  globalVersion = 1 | 0;
1052
+ batchEpoch = 1 | 0;
968
1053
  statSignals = 0 | 0;
969
1054
  statComputeds = 0 | 0;
970
1055
  statEffects = 0 | 0;
1056
+
1057
+ for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
1058
+
1059
+ flushErrorCount = 0 | 0;
1060
+ flushErrorBuffer.length = 0; // release the backing array
971
1061
  }
972
1062
 
973
1063
  return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Zero-GC reactive graph. Monomorphic object pool, versioned push-pull propagation, 32-bit modular versioning. Built for hot paths and long-running processes.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",