@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 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 (`src/watch.js`) and are re-exported from the main entry. If your bundle doesn't import them, they won't appear in the output — modern ESM tree-shaking (Vite, Rollup, esbuild) handles this reliably.
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/`. 131 tests across 43 suites covering:
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 v1.1.0 was evaluated against the
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
- **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
+ 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 v1.1.0 doesn't do yet
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
- - **Revert detection inside batches** (#147, #132, #123): writes inside a
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
- **Opt-in feature, deferred** (2 tests):
757
+ **Landing in v1.2** (2 tests):
767
758
 
768
759
  - **Solid-style cascading disposal of nested effects** (#209, #210):
769
- lite-signal does not maintain an owner tree of parent-child effects.
770
- This matches preact, vue, mobx, the TC39 polyfill, Angular, Svelte,
771
- tansu, and Solid 1.x. Solid 2 / @solidjs/signals, reatom, and anod
772
- implement it. If you need it, please open an issue.
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 `src/index.js`.
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 {@link CycleError} is thrown. Default: 100. */
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 || 1024;
140
- let currentLinkCapacity = config.maxLinks || currentNodesCapacity * 4;
141
- const policy = config.onCapacityExceeded || "throw";
142
- const maxFlushPasses = config.maxFlushPasses || 100;
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 | 0;
425
- markStack[stackLen] = startNode;
426
- stackLen = (stackLen + 1) | 0;
443
+ let stackLen = 0;
444
+ markStack[stackLen++] = startNode;
427
445
 
428
- while (stackLen > 0) {
429
- stackLen = (stackLen - 1) | 0;
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
- if ((flags & FLAG_QUEUED) === 0) {
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
- while (activeQueueLen > 0) {
477
- passes = (passes + 1) | 0;
478
- if (passes > maxFlushPasses) {
479
- isFlushing = false;
480
- throw new Error("CycleError: flush passes exceeded");
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
- 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
  /**
@@ -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
- node.equals = opts.equals !== undefined ? opts.equals : Object.is;
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) allocateLink(node, currentObserver);
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
- let captured;
701
- const invokeSub = () => fn(captured);
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
- captured = read();
704
- untrack(invokeSub);
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
- node.equals = opts.equals !== undefined ? opts.equals : Object.is;
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) allocateLink(node, currentObserver);
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
- let captured;
740
- const invokeSub = () => fn(captured);
849
+ // Single-closure subscription — see signal() subscribe for rationale and
850
+ // the untrack-inlining coupling note.
741
851
  return effect(() => {
742
- captured = read();
743
- untrack(invokeSub);
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
- node.scheduler = opts.scheduler;
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, ~500 lines, single file.
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/bench.mjs` — comparative benchmark vs alien-signals, preact, solid.
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.0",
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
- "types": "./Signal.d.ts",
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": [