@zakkster/lite-signal 1.1.1 → 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,11 +705,19 @@ 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
+ 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
+
713
721
  **174 of 177 tests pass (98.3%)**, placing lite-signal **in a tie for second place of sixteen**
714
722
  evaluated libraries — just behind alien-signals (177).
715
723
 
@@ -732,23 +740,9 @@ more useful to library users than a green checkmark.
732
740
  - **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
733
741
  solid. Half the field leaks the subscription.
734
742
 
735
- ### What v1.1.0 doesn't do yet
736
-
737
- 10 tests fail. We've categorized them by intent.
738
-
739
- **Targeted for v1.2** (6 tests):
743
+ ### What lite-signal does NOT do yet
740
744
 
741
- - **Revert detection inside batches** (#147, #132, #123): writes inside a
742
- `batch()` that net to no change still mark dependents as dirty. Vue,
743
- Solid, Mobx, and roughly half the field share this behavior. v1.2 will
744
- capture pre-batch values per signal and skip propagation on revert.
745
- - **Throw isolation in batch flush** (#121): if an effect throws during
746
- flush, lite-signal currently halts the flush. v1.2 will collect errors,
747
- finish the flush, then re-throw as `AggregateError`.
748
- - **Inner-write propagation through computed chains** (#180, #213): two
749
- specific propagation paths where lite-signal disagrees with the field.
750
- Both are propagation-order bugs in the recursive computed resolver,
751
- not zero-GC tradeoffs.
745
+ The remaining open items, by intent.
752
746
 
753
747
  **Design choices we will not change** (2 tests):
754
748
 
@@ -760,13 +754,14 @@ more useful to library users than a green checkmark.
760
754
  effect's own implicit batching. Most libraries behave this way. Wrap the
761
755
  batch outside the effect for the intended semantics.
762
756
 
763
- **Opt-in feature, deferred** (2 tests):
757
+ **Landing in v1.2** (2 tests):
764
758
 
765
759
  - **Solid-style cascading disposal of nested effects** (#209, #210):
766
- lite-signal does not maintain an owner tree of parent-child effects.
767
- This matches preact, vue, mobx, the TC39 polyfill, Angular, Svelte,
768
- tansu, and Solid 1.x. Solid 2 / @solidjs/signals, reatom, and anod
769
- 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.
770
765
 
771
766
  Per-test results, the runner adapter, and reproductions live in
772
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
  *
@@ -143,10 +143,10 @@ export function createRegistry(config = {}) {
143
143
  const NODE_PTR = Symbol("node_ptr");
144
144
  const NODE_GEN = Symbol("node_gen");
145
145
 
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;
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;
150
150
  const maxLinkLimit = currentLinkCapacity * 16;
151
151
 
152
152
  // --- ZERO-GC OBJECT POOLS ---
@@ -440,19 +440,21 @@ export function createRegistry(config = {}) {
440
440
  * @private
441
441
  */
442
442
  function markDownstream(startNode) {
443
- let stackLen = 0 | 0;
444
- markStack[stackLen] = startNode;
445
- stackLen = (stackLen + 1) | 0;
443
+ let stackLen = 0;
444
+ markStack[stackLen++] = startNode;
446
445
 
447
- while (stackLen > 0) {
448
- stackLen = (stackLen - 1) | 0;
449
- const n = markStack[stackLen];
446
+ while (stackLen !== 0) {
447
+ const n = markStack[--stackLen];
450
448
 
451
449
  let link = n.headSub;
452
450
  while (link !== null) {
453
451
  const t = link.target;
454
452
  if ((t.markEpoch | 0) !== (globalVersion | 0)) {
455
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).
456
458
  const flags = t.flags | 0;
457
459
 
458
460
  if ((flags & FLAG_EFFECT) !== 0) {
@@ -461,14 +463,12 @@ export function createRegistry(config = {}) {
461
463
  // writes (directly or through computed chains). The effect's evalVersion
462
464
  // is bumped to the post-write globalVersion in its executeEffect finally,
463
465
  // so subsequent unrelated writes still propagate normally.
464
- if ((flags & FLAG_QUEUED) === 0 && (flags & FLAG_COMPUTING) === 0) {
466
+ if ((flags & (FLAG_QUEUED | FLAG_COMPUTING)) === 0) {
465
467
  t.flags = flags | FLAG_QUEUED;
466
- activeQueue[activeQueueLen] = t;
467
- activeQueueLen = (activeQueueLen + 1) | 0;
468
+ activeQueue[activeQueueLen++] = t;
468
469
  }
469
470
  } else {
470
- markStack[stackLen] = t;
471
- stackLen = (stackLen + 1) | 0;
471
+ markStack[stackLen++] = t;
472
472
  }
473
473
  }
474
474
  link = link.nextSub;
@@ -732,14 +732,28 @@ export function createRegistry(config = {}) {
732
732
  * Equality predicate. Returning true short-circuits notification.
733
733
  * @returns {Signal<T>}
734
734
  */
735
- function signal(initial, opts = {}) {
735
+ function signal(initial, opts) {
736
736
  const node = createNode(initial, FLAG_SIGNAL);
737
- 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;
738
741
  node.version = globalVersion | 0;
739
742
  statSignals = (statSignals + 1) | 0;
740
743
 
741
744
  const read = () => {
742
- 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
+ }
743
757
  return node.value;
744
758
  };
745
759
  read.peek = () => node.value;
@@ -774,11 +788,21 @@ export function createRegistry(config = {}) {
774
788
  read.update = (fn) => read.set(fn(node.value));
775
789
 
776
790
  read.subscribe = (fn) => {
777
- let captured;
778
- 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.
779
797
  return effect(() => {
780
- captured = read();
781
- untrack(invokeSub);
798
+ const val = read();
799
+ const prevTracking = isTrackingDeps;
800
+ isTrackingDeps = false;
801
+ try {
802
+ fn(val);
803
+ } finally {
804
+ isTrackingDeps = prevTracking;
805
+ }
782
806
  });
783
807
  };
784
808
 
@@ -800,24 +824,39 @@ export function createRegistry(config = {}) {
800
824
  * Equality predicate. Returning true blocks propagation downstream.
801
825
  * @returns {Computed<T>}
802
826
  */
803
- function computed(fn, opts = {}) {
827
+ function computed(fn, opts) {
804
828
  const node = createNode(undefined, FLAG_COMPUTED);
805
829
  node.computeFn = fn;
806
- 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;
807
832
  statComputeds = (statComputeds + 1) | 0;
808
833
 
809
834
  const read = () => {
810
- 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
+ }
811
844
  return pullComputed(node);
812
845
  };
813
846
  read.peek = () => pullComputed(node);
814
847
 
815
848
  read.subscribe = (fn) => {
816
- let captured;
817
- const invokeSub = () => fn(captured);
849
+ // Single-closure subscription — see signal() subscribe for rationale and
850
+ // the untrack-inlining coupling note.
818
851
  return effect(() => {
819
- captured = read();
820
- untrack(invokeSub);
852
+ const val = read();
853
+ const prevTracking = isTrackingDeps;
854
+ isTrackingDeps = false;
855
+ try {
856
+ fn(val);
857
+ } finally {
858
+ isTrackingDeps = prevTracking;
859
+ }
821
860
  });
822
861
  };
823
862
 
@@ -842,13 +881,15 @@ export function createRegistry(config = {}) {
842
881
  * @returns {() => void} Dispose function. Idempotent. Safe to call
843
882
  * after registry.destroy().
844
883
  */
845
- function effect(fn, opts = {}) {
884
+ function effect(fn, opts) {
846
885
  const node = createNode(undefined, FLAG_EFFECT);
847
886
  node.computeFn = fn;
848
- 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;
849
891
  statEffects = (statEffects + 1) | 0;
850
892
 
851
- const scheduler = opts.scheduler;
852
893
  let firstRunError = null;
853
894
  if (scheduler) {
854
895
  const gen = node.gen | 0;
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.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
- "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": [