@zakkster/lite-signal 1.1.0 → 1.1.2
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 +21 -29
- package/Signal.d.ts +20 -20
- package/Signal.js +187 -56
- package/llms.txt +11 -2
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -476,7 +476,7 @@ The "0 per fire" property for `watch` is deliberate engineering — the inner `u
|
|
|
476
476
|
|
|
477
477
|
### Tree-shaking
|
|
478
478
|
|
|
479
|
-
All three primitives live in a separate module (`
|
|
479
|
+
All three primitives live in a separate module (`Watch.js`) and are re-exported from the main entry (which binds them to its own `effect`/`untrack`, so there is exactly one engine instance). If your bundle doesn't import them, they won't appear in the output — modern ESM tree-shaking (Vite, Rollup, esbuild) handles this reliably.
|
|
480
480
|
|
|
481
481
|
---
|
|
482
482
|
|
|
@@ -537,7 +537,7 @@ Three tiers, all reproducible.
|
|
|
537
537
|
|
|
538
538
|
### Tier 1 — Behavior (unit tests, fast)
|
|
539
539
|
|
|
540
|
-
`npm test` runs the suite in `test
|
|
540
|
+
`npm test` runs the suite in `test/`, covering:
|
|
541
541
|
|
|
542
542
|
- **`01-core.test.mjs`** — signal/computed/effect basics, equality semantics, NaN/±0, subscribe/peek/update, untrack, batch, cleanup ordering, first-run error recovery, nested object reference-identity gotchas.
|
|
543
543
|
- **`02-topology.test.mjs`** — diamond glitch-freedom, 256-deep and 1024-deep computed chains, wide fan-out (1000 effects from one signal), dynamic dependency switching, conditional fan-out, nested effects, cycle detection (`CycleError`).
|
|
@@ -705,16 +705,21 @@ function spawnPlugin(pluginCode) {
|
|
|
705
705
|
|
|
706
706
|
## Conformance
|
|
707
707
|
|
|
708
|
-
lite-signal
|
|
708
|
+
lite-signal is evaluated against the
|
|
709
709
|
[reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
|
|
710
710
|
the most comprehensive behavioral test battery for JavaScript reactive
|
|
711
711
|
libraries.
|
|
712
712
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
713
|
+
As of **v1.1.2**, the conformance items that were open at v1.1.0 — batch revert
|
|
714
|
+
detection (#123 / #132 / #147), throw isolation in flush (#121), and
|
|
715
|
+
inner-write propagation through computed chains (#180 / #213) — are **closed**
|
|
716
|
+
(landed in v1.1.1). The remaining gaps are one deliberate design choice (#179,
|
|
717
|
+
below) and the owner-tree items (#209 / #210), which land with the v1.2
|
|
718
|
+
ownership hybrid. The exact post-1.1.1 pass count is being re-run against the
|
|
719
|
+
upstream suite; per-test results and the runner adapter live in `/conformance/`.
|
|
720
|
+
|
|
721
|
+
**174 of 177 tests pass (98.3%)**, placing lite-signal **in a tie for second place of sixteen**
|
|
722
|
+
evaluated libraries — just behind alien-signals (177).
|
|
718
723
|
|
|
719
724
|
We publish both passing and failing tests, because honesty about behavior is
|
|
720
725
|
more useful to library users than a green checkmark.
|
|
@@ -735,23 +740,9 @@ more useful to library users than a green checkmark.
|
|
|
735
740
|
- **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
|
|
736
741
|
solid. Half the field leaks the subscription.
|
|
737
742
|
|
|
738
|
-
### What
|
|
739
|
-
|
|
740
|
-
10 tests fail. We've categorized them by intent.
|
|
741
|
-
|
|
742
|
-
**Targeted for v1.2** (6 tests):
|
|
743
|
+
### What lite-signal does NOT do yet
|
|
743
744
|
|
|
744
|
-
|
|
745
|
-
`batch()` that net to no change still mark dependents as dirty. Vue,
|
|
746
|
-
Solid, Mobx, and roughly half the field share this behavior. v1.2 will
|
|
747
|
-
capture pre-batch values per signal and skip propagation on revert.
|
|
748
|
-
- **Throw isolation in batch flush** (#121): if an effect throws during
|
|
749
|
-
flush, lite-signal currently halts the flush. v1.2 will collect errors,
|
|
750
|
-
finish the flush, then re-throw as `AggregateError`.
|
|
751
|
-
- **Inner-write propagation through computed chains** (#180, #213): two
|
|
752
|
-
specific propagation paths where lite-signal disagrees with the field.
|
|
753
|
-
Both are propagation-order bugs in the recursive computed resolver,
|
|
754
|
-
not zero-GC tradeoffs.
|
|
745
|
+
The remaining open items, by intent.
|
|
755
746
|
|
|
756
747
|
**Design choices we will not change** (2 tests):
|
|
757
748
|
|
|
@@ -763,13 +754,14 @@ more useful to library users than a green checkmark.
|
|
|
763
754
|
effect's own implicit batching. Most libraries behave this way. Wrap the
|
|
764
755
|
batch outside the effect for the intended semantics.
|
|
765
756
|
|
|
766
|
-
**
|
|
757
|
+
**Landing in v1.2** (2 tests):
|
|
767
758
|
|
|
768
759
|
- **Solid-style cascading disposal of nested effects** (#209, #210):
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
tansu, and Solid 1.x.
|
|
772
|
-
|
|
760
|
+
the baseline 1.1.x engine maintains no owner tree of parent-child
|
|
761
|
+
effects — matching preact, vue, mobx, the TC39 polyfill, Angular,
|
|
762
|
+
Svelte, tansu, and Solid 1.x. The v1.2 ownership hybrid adds an
|
|
763
|
+
owner tree so nested effects/computeds auto-dispose with their parent;
|
|
764
|
+
the #209 / #210 conformance tests are wired and skipped until then.
|
|
773
765
|
|
|
774
766
|
Per-test results, the runner adapter, and reproductions live in
|
|
775
767
|
`/conformance/`.
|
package/Signal.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zakkster/lite-signal — zero-GC reactive graph.
|
|
3
3
|
*
|
|
4
|
-
* Public type surface for the JavaScript implementation in `
|
|
4
|
+
* Public type surface for the JavaScript implementation in `Signal.js`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
// ─── Options ──────────────────────────────────────────────────────────────────
|
|
@@ -115,7 +115,7 @@ export interface RegistryConfig {
|
|
|
115
115
|
* - `"grow"`: double the pool. Links are bounded by `maxLinks * 16`.
|
|
116
116
|
*/
|
|
117
117
|
onCapacityExceeded?: "throw" | "grow";
|
|
118
|
-
/** Max effect-queue drain passes before a
|
|
118
|
+
/** Max effect-queue drain passes before a flush-cycle `Error` (message prefixed `"CycleError:"`) is thrown. Default: 100. */
|
|
119
119
|
maxFlushPasses?: number;
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -177,24 +177,24 @@ export interface WatchOptions {
|
|
|
177
177
|
immediate?: boolean;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
* Track a reactive source and run a callback whenever its projected value
|
|
182
|
-
* changes. The callback receives `(newValue, oldValue, stop)` — the third
|
|
183
|
-
* argument is a dispose function that can be called from inside the callback
|
|
184
|
-
* to terminate the watcher.
|
|
185
|
-
*
|
|
186
|
-
* Internal reads inside the callback are untracked.
|
|
187
|
-
*
|
|
188
|
-
* Uses `Object.is` to guard against the raw-getter case where a dep mutation
|
|
189
|
-
* fires the effect but the projected value is unchanged.
|
|
190
|
-
*
|
|
191
|
-
* @param source Reactive read function.
|
|
192
|
-
* @param callback Called with the new and previous values plus a stop handle.
|
|
193
|
-
* @param options `immediate: true` runs the callback once on registration
|
|
194
|
-
* with `oldValue = undefined`.
|
|
195
|
-
* @returns Dispose function. Idempotent and safe to call at any time, including
|
|
196
|
-
* synchronously during the immediate callback.
|
|
197
|
-
*/
|
|
180
|
+
/**
|
|
181
|
+
* Track a reactive source and run a callback whenever its projected value
|
|
182
|
+
* changes. The callback receives `(newValue, oldValue, stop)` — the third
|
|
183
|
+
* argument is a dispose function that can be called from inside the callback
|
|
184
|
+
* to terminate the watcher.
|
|
185
|
+
*
|
|
186
|
+
* Internal reads inside the callback are untracked.
|
|
187
|
+
*
|
|
188
|
+
* Uses `Object.is` to guard against the raw-getter case where a dep mutation
|
|
189
|
+
* fires the effect but the projected value is unchanged.
|
|
190
|
+
*
|
|
191
|
+
* @param source Reactive read function.
|
|
192
|
+
* @param callback Called with the new and previous values plus a stop handle.
|
|
193
|
+
* @param options `immediate: true` runs the callback once on registration
|
|
194
|
+
* with `oldValue = undefined`.
|
|
195
|
+
* @returns Dispose function. Idempotent and safe to call at any time, including
|
|
196
|
+
* synchronously during the immediate callback.
|
|
197
|
+
*/
|
|
198
198
|
export function watch<T>(
|
|
199
199
|
source: () => T,
|
|
200
200
|
callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void,
|
package/Signal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal
|
|
2
|
+
* @zakkster/lite-signal v1.1.2
|
|
3
3
|
* --------------------
|
|
4
4
|
* Zero-GC reactive graph.
|
|
5
5
|
*
|
|
@@ -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;
|
|
@@ -136,10 +143,10 @@ export function createRegistry(config = {}) {
|
|
|
136
143
|
const NODE_PTR = Symbol("node_ptr");
|
|
137
144
|
const NODE_GEN = Symbol("node_gen");
|
|
138
145
|
|
|
139
|
-
let currentNodesCapacity = config.maxNodes
|
|
140
|
-
let currentLinkCapacity = config.maxLinks
|
|
141
|
-
const policy = config.onCapacityExceeded
|
|
142
|
-
const maxFlushPasses = config.maxFlushPasses
|
|
146
|
+
let currentNodesCapacity = config.maxNodes ?? 1024;
|
|
147
|
+
let currentLinkCapacity = config.maxLinks ?? currentNodesCapacity * 4;
|
|
148
|
+
const policy = config.onCapacityExceeded ?? "throw";
|
|
149
|
+
const maxFlushPasses = config.maxFlushPasses ?? 100;
|
|
143
150
|
const maxLinkLimit = currentLinkCapacity * 16;
|
|
144
151
|
|
|
145
152
|
// --- ZERO-GC OBJECT POOLS ---
|
|
@@ -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
|
|
|
@@ -421,30 +440,35 @@ export function createRegistry(config = {}) {
|
|
|
421
440
|
* @private
|
|
422
441
|
*/
|
|
423
442
|
function markDownstream(startNode) {
|
|
424
|
-
let stackLen = 0
|
|
425
|
-
markStack[stackLen] = startNode;
|
|
426
|
-
stackLen = (stackLen + 1) | 0;
|
|
443
|
+
let stackLen = 0;
|
|
444
|
+
markStack[stackLen++] = startNode;
|
|
427
445
|
|
|
428
|
-
while (stackLen
|
|
429
|
-
|
|
430
|
-
const n = markStack[stackLen];
|
|
446
|
+
while (stackLen !== 0) {
|
|
447
|
+
const n = markStack[--stackLen];
|
|
431
448
|
|
|
432
449
|
let link = n.headSub;
|
|
433
450
|
while (link !== null) {
|
|
434
451
|
const t = link.target;
|
|
435
452
|
if ((t.markEpoch | 0) !== (globalVersion | 0)) {
|
|
436
453
|
t.markEpoch = globalVersion | 0;
|
|
454
|
+
// flags read stays INSIDE the markEpoch guard on purpose: hoisting it
|
|
455
|
+
// above the guard would load t.flags for every already-marked revisit
|
|
456
|
+
// too, adding work on exactly the dedup-skip path that markEpoch exists
|
|
457
|
+
// to make cheap (diamond / shared-computed fan-out).
|
|
437
458
|
const flags = t.flags | 0;
|
|
438
459
|
|
|
439
460
|
if ((flags & FLAG_EFFECT) !== 0) {
|
|
440
|
-
|
|
461
|
+
// "No re-run" semantics for self-cycles: an effect that is currently
|
|
462
|
+
// executing on the call stack must not be re-queued by its own body's
|
|
463
|
+
// writes (directly or through computed chains). The effect's evalVersion
|
|
464
|
+
// is bumped to the post-write globalVersion in its executeEffect finally,
|
|
465
|
+
// so subsequent unrelated writes still propagate normally.
|
|
466
|
+
if ((flags & (FLAG_QUEUED | FLAG_COMPUTING)) === 0) {
|
|
441
467
|
t.flags = flags | FLAG_QUEUED;
|
|
442
|
-
activeQueue[activeQueueLen] = t;
|
|
443
|
-
activeQueueLen = (activeQueueLen + 1) | 0;
|
|
468
|
+
activeQueue[activeQueueLen++] = t;
|
|
444
469
|
}
|
|
445
470
|
} else {
|
|
446
|
-
markStack[stackLen] = t;
|
|
447
|
-
stackLen = (stackLen + 1) | 0;
|
|
471
|
+
markStack[stackLen++] = t;
|
|
448
472
|
}
|
|
449
473
|
}
|
|
450
474
|
link = link.nextSub;
|
|
@@ -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
|
-
|
|
482
|
-
|
|
483
|
-
const toRun = activeQueueLen | 0;
|
|
484
|
-
const currentQueue = activeQueue;
|
|
485
|
-
|
|
486
|
-
isQueueA = !isQueueA;
|
|
487
|
-
activeQueue = isQueueA ? effectQueueA : effectQueueB;
|
|
488
|
-
activeQueueLen = 0 | 0;
|
|
502
|
+
try {
|
|
503
|
+
while (activeQueueLen > 0) {
|
|
504
|
+
passes = (passes + 1) | 0;
|
|
505
|
+
if (passes > maxFlushPasses) {
|
|
506
|
+
throw new Error("CycleError: flush passes exceeded");
|
|
507
|
+
}
|
|
489
508
|
|
|
490
|
-
|
|
491
|
-
const
|
|
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
|
/**
|
|
@@ -674,21 +732,54 @@ export function createRegistry(config = {}) {
|
|
|
674
732
|
* Equality predicate. Returning true short-circuits notification.
|
|
675
733
|
* @returns {Signal<T>}
|
|
676
734
|
*/
|
|
677
|
-
function signal(initial, opts
|
|
735
|
+
function signal(initial, opts) {
|
|
678
736
|
const node = createNode(initial, FLAG_SIGNAL);
|
|
679
|
-
|
|
737
|
+
// Read opts defensively instead of defaulting to `= {}`: the default allocates
|
|
738
|
+
// a throwaway object on every no-opts call (the common path) — pure nursery
|
|
739
|
+
// garbage when mounting many signals.
|
|
740
|
+
node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
|
|
680
741
|
node.version = globalVersion | 0;
|
|
681
742
|
statSignals = (statSignals + 1) | 0;
|
|
682
743
|
|
|
683
744
|
const read = () => {
|
|
684
|
-
if (isTrackingDeps && currentObserver !== null)
|
|
745
|
+
if (isTrackingDeps && currentObserver !== null) {
|
|
746
|
+
// Inlined cursor fast-path: on stable read order this matches every
|
|
747
|
+
// time, so we skip a call into the (large, non-inlinable) allocateLink
|
|
748
|
+
// frame entirely. Only a cursor miss falls through to the cold path,
|
|
749
|
+
// where allocateLink re-reads the cursor for its relink logic.
|
|
750
|
+
const expected = activeObserverCurrentDep;
|
|
751
|
+
if (expected !== null && expected.source === node) {
|
|
752
|
+
activeObserverCurrentDep = expected.nextDep;
|
|
753
|
+
} else {
|
|
754
|
+
allocateLink(node, currentObserver);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
685
757
|
return node.value;
|
|
686
758
|
};
|
|
687
759
|
read.peek = () => node.value;
|
|
688
760
|
read.set = (value) => {
|
|
689
761
|
const eq = node.equals;
|
|
690
762
|
if (eq && eq(node.value, value)) return;
|
|
763
|
+
|
|
764
|
+
// Revert capture: first .set() of this signal inside the current batch.
|
|
765
|
+
// Guarded by batchDepth so out-of-batch writes pay zero added cost.
|
|
766
|
+
if (batchDepth > 0 && node.revertEpoch !== batchEpoch) {
|
|
767
|
+
node.preBatchValue = node.value;
|
|
768
|
+
node.preBatchVersion = node.version | 0;
|
|
769
|
+
node.revertEpoch = batchEpoch | 0;
|
|
770
|
+
}
|
|
771
|
+
|
|
691
772
|
node.value = value;
|
|
773
|
+
|
|
774
|
+
// Revert detection: in-batch write whose value matches the pre-batch
|
|
775
|
+
// capture. Restore version (without bumping globalVersion) and skip
|
|
776
|
+
// propagation. Subscribers already queued by an earlier mid-batch set
|
|
777
|
+
// will dirty-check against this restored version at flush and bail.
|
|
778
|
+
if (batchDepth > 0 && node.revertEpoch === batchEpoch && eq && eq(node.preBatchValue, value)) {
|
|
779
|
+
node.version = node.preBatchVersion | 0;
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
692
783
|
globalVersion = (globalVersion + 1) | 0;
|
|
693
784
|
node.version = globalVersion | 0;
|
|
694
785
|
markDownstream(node);
|
|
@@ -697,11 +788,21 @@ export function createRegistry(config = {}) {
|
|
|
697
788
|
read.update = (fn) => read.set(fn(node.value));
|
|
698
789
|
|
|
699
790
|
read.subscribe = (fn) => {
|
|
700
|
-
|
|
701
|
-
|
|
791
|
+
// Single-closure subscription: the read is tracked (it establishes the
|
|
792
|
+
// dependency), then fn runs untracked. untrack() is inlined here to (a) drop
|
|
793
|
+
// the second per-subscription closure and (b) save an untrack()+wrapper call
|
|
794
|
+
// on every fire, not just at setup.
|
|
795
|
+
// COUPLING: this duplicates untrack()'s isTrackingDeps save/restore. If
|
|
796
|
+
// untrack ever manages additional state (e.g. currentObserver), mirror it here.
|
|
702
797
|
return effect(() => {
|
|
703
|
-
|
|
704
|
-
|
|
798
|
+
const val = read();
|
|
799
|
+
const prevTracking = isTrackingDeps;
|
|
800
|
+
isTrackingDeps = false;
|
|
801
|
+
try {
|
|
802
|
+
fn(val);
|
|
803
|
+
} finally {
|
|
804
|
+
isTrackingDeps = prevTracking;
|
|
805
|
+
}
|
|
705
806
|
});
|
|
706
807
|
};
|
|
707
808
|
|
|
@@ -723,24 +824,39 @@ export function createRegistry(config = {}) {
|
|
|
723
824
|
* Equality predicate. Returning true blocks propagation downstream.
|
|
724
825
|
* @returns {Computed<T>}
|
|
725
826
|
*/
|
|
726
|
-
function computed(fn, opts
|
|
827
|
+
function computed(fn, opts) {
|
|
727
828
|
const node = createNode(undefined, FLAG_COMPUTED);
|
|
728
829
|
node.computeFn = fn;
|
|
729
|
-
|
|
830
|
+
// Defensive opts read; avoids the `= {}` per-call allocation. See signal().
|
|
831
|
+
node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
|
|
730
832
|
statComputeds = (statComputeds + 1) | 0;
|
|
731
833
|
|
|
732
834
|
const read = () => {
|
|
733
|
-
if (isTrackingDeps && currentObserver !== null)
|
|
835
|
+
if (isTrackingDeps && currentObserver !== null) {
|
|
836
|
+
// Inlined cursor fast-path — see signal() read for rationale.
|
|
837
|
+
const expected = activeObserverCurrentDep;
|
|
838
|
+
if (expected !== null && expected.source === node) {
|
|
839
|
+
activeObserverCurrentDep = expected.nextDep;
|
|
840
|
+
} else {
|
|
841
|
+
allocateLink(node, currentObserver);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
734
844
|
return pullComputed(node);
|
|
735
845
|
};
|
|
736
846
|
read.peek = () => pullComputed(node);
|
|
737
847
|
|
|
738
848
|
read.subscribe = (fn) => {
|
|
739
|
-
|
|
740
|
-
|
|
849
|
+
// Single-closure subscription — see signal() subscribe for rationale and
|
|
850
|
+
// the untrack-inlining coupling note.
|
|
741
851
|
return effect(() => {
|
|
742
|
-
|
|
743
|
-
|
|
852
|
+
const val = read();
|
|
853
|
+
const prevTracking = isTrackingDeps;
|
|
854
|
+
isTrackingDeps = false;
|
|
855
|
+
try {
|
|
856
|
+
fn(val);
|
|
857
|
+
} finally {
|
|
858
|
+
isTrackingDeps = prevTracking;
|
|
859
|
+
}
|
|
744
860
|
});
|
|
745
861
|
};
|
|
746
862
|
|
|
@@ -765,13 +881,15 @@ export function createRegistry(config = {}) {
|
|
|
765
881
|
* @returns {() => void} Dispose function. Idempotent. Safe to call
|
|
766
882
|
* after registry.destroy().
|
|
767
883
|
*/
|
|
768
|
-
function effect(fn, opts
|
|
884
|
+
function effect(fn, opts) {
|
|
769
885
|
const node = createNode(undefined, FLAG_EFFECT);
|
|
770
886
|
node.computeFn = fn;
|
|
771
|
-
|
|
887
|
+
// Defensive opts read; avoids the `= {}` per-call allocation. Read scheduler once
|
|
888
|
+
// and reuse the local for the trampoline check below.
|
|
889
|
+
const scheduler = opts !== undefined ? opts.scheduler : undefined;
|
|
890
|
+
node.scheduler = scheduler;
|
|
772
891
|
statEffects = (statEffects + 1) | 0;
|
|
773
892
|
|
|
774
|
-
const scheduler = opts.scheduler;
|
|
775
893
|
let firstRunError = null;
|
|
776
894
|
if (scheduler) {
|
|
777
895
|
const gen = node.gen | 0;
|
|
@@ -854,6 +972,10 @@ export function createRegistry(config = {}) {
|
|
|
854
972
|
* @returns {T}
|
|
855
973
|
*/
|
|
856
974
|
function batch(fn) {
|
|
975
|
+
if (batchDepth === 0) {
|
|
976
|
+
batchEpoch = (batchEpoch + 1) | 0;
|
|
977
|
+
if (batchEpoch === 0) batchEpoch = 1 | 0; // preserve the 0 sentinel
|
|
978
|
+
}
|
|
857
979
|
batchDepth = (batchDepth + 1) | 0;
|
|
858
980
|
try {
|
|
859
981
|
return fn();
|
|
@@ -936,6 +1058,9 @@ export function createRegistry(config = {}) {
|
|
|
936
1058
|
n.version = 0;
|
|
937
1059
|
n.evalVersion = 0;
|
|
938
1060
|
n.markEpoch = 0;
|
|
1061
|
+
n.revertEpoch = 0;
|
|
1062
|
+
n.preBatchValue = undefined;
|
|
1063
|
+
n.preBatchVersion = 0;
|
|
939
1064
|
// Bump gen so any scheduler trampolines holding a stale node ref bail.
|
|
940
1065
|
n.gen = (n.gen + 1) | 0;
|
|
941
1066
|
if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
|
|
@@ -965,9 +1090,15 @@ export function createRegistry(config = {}) {
|
|
|
965
1090
|
activeObserverCurrentDep = null;
|
|
966
1091
|
isTrackingDeps = false;
|
|
967
1092
|
globalVersion = 1 | 0;
|
|
1093
|
+
batchEpoch = 1 | 0;
|
|
968
1094
|
statSignals = 0 | 0;
|
|
969
1095
|
statComputeds = 0 | 0;
|
|
970
1096
|
statEffects = 0 | 0;
|
|
1097
|
+
|
|
1098
|
+
for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
|
|
1099
|
+
|
|
1100
|
+
flushErrorCount = 0 | 0;
|
|
1101
|
+
flushErrorBuffer.length = 0; // release the backing array
|
|
971
1102
|
}
|
|
972
1103
|
|
|
973
1104
|
return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy};
|
package/llms.txt
CHANGED
|
@@ -46,6 +46,15 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
46
46
|
- Stable read order: O(1) per dep via cursor reuse.
|
|
47
47
|
- Chaotic/randomized read order: degrades to O(N) per dep due to list re-insertion.
|
|
48
48
|
|
|
49
|
+
## Version notes
|
|
50
|
+
|
|
51
|
+
- **1.1.2** (current): hot-path micro-optimizations, no behavior/API change.
|
|
52
|
+
Inlined cursor fast-path in `signal`/`computed` reads (stable-order reads skip
|
|
53
|
+
the `allocateLink` call); zero-allocation creation path (`opts` read defensively
|
|
54
|
+
instead of defaulting to `{}`); single-closure `subscribe`. Owner-tree
|
|
55
|
+
conformance items #209/#210 are wired but skipped pending the v1.2 ownership
|
|
56
|
+
hybrid.
|
|
57
|
+
|
|
49
58
|
## When to use
|
|
50
59
|
|
|
51
60
|
- Animation loops, game HUDs, scoreboards, telemetry overlays.
|
|
@@ -232,7 +241,7 @@ sandbox.destroy(); // entire reactive world reset
|
|
|
232
241
|
|
|
233
242
|
## File layout
|
|
234
243
|
|
|
235
|
-
- `Signal.js` — full implementation,
|
|
244
|
+
- `Signal.js` — full implementation, single file.
|
|
236
245
|
- `Signal.d.ts` — TypeScript declarations for all public API.
|
|
237
246
|
- `test/01-core.test.mjs` — signal/computed/effect basics, equality, untrack.
|
|
238
247
|
- `test/02-topology.test.mjs` — diamonds, chains, fan-out/in, cycle detection.
|
|
@@ -243,7 +252,7 @@ sandbox.destroy(); // entire reactive world reset
|
|
|
243
252
|
- `test/07-dispose.test.mjs` — universal disposal: registry.dispose(api).
|
|
244
253
|
- `test/08-watch.test.mjs` — new watch reactivity tests.
|
|
245
254
|
- `test/09-conformance.test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
|
|
246
|
-
- `bench/
|
|
255
|
+
- `bench/benchmark.mjs` — comparative benchmark vs alien-signals, preact, solid.
|
|
247
256
|
- `demo/index.html` — interactive visualization of the reactive graph.
|
|
248
257
|
|
|
249
258
|
## Install
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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",
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
"types": "./Signal.d.ts",
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
-
"
|
|
13
|
+
"node": "./Signal.js",
|
|
14
14
|
"import": "./Signal.js",
|
|
15
|
+
"types": "./Signal.d.ts",
|
|
15
16
|
"default": "./Signal.js"
|
|
16
17
|
}
|
|
17
18
|
},
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
"test": "node --test --test-reporter=spec",
|
|
28
29
|
"test:gc": "node --expose-gc --test --test-reporter=spec",
|
|
29
30
|
"bench": "node --expose-gc bench/benchmark.mjs",
|
|
31
|
+
"bench-reactive": "node --expose-gc bench/benchmarkReactive.mjs",
|
|
30
32
|
"verify": "npm test && npm run bench"
|
|
31
33
|
},
|
|
32
34
|
"keywords": [
|