@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.
- package/README.md +2 -5
- package/Signal.js +114 -24
- 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
|
-
**
|
|
714
|
-
evaluated libraries — behind alien-signals (177)
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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.
|
|
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",
|