@zakkster/lite-signal 1.1.1 → 1.1.3

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
@@ -90,6 +90,7 @@ No microtask between `B` and `I`. No promise, no `queueMicrotask`. Just call sta
90
90
  - **`dispose(api)`** — universal disposal for signals, computeds, and effect handles. Cross-registry calls are silent no-ops.
91
91
  - **`batch(fn)`** — defer effect flush until the outermost batch closes.
92
92
  - **`untrack(fn)`** — read without subscribing.
93
+ - **`isTracking()`** — `true` iff a read right now would subscribe (for lazy-allocation wrappers).
93
94
  - **`onCleanup(fn)`** — register teardown for the current computation. Works in effects *and* computeds.
94
95
  - **`createRegistry(config)`** — isolated pool for tests, plugins, sandboxing.
95
96
  - **`stats()`** — pool occupancy snapshot. Used by the demo and easy to wire into perf overlays.
@@ -273,6 +274,28 @@ const value = untrack(() => s()); // read without subscribing
273
274
 
274
275
  Useful inside computeds/effects when you need a current value but don't want it as a dependency.
275
276
 
277
+ ### isTracking
278
+
279
+ ```ts
280
+ function makeLazyField(initial) {
281
+ let s = null, value = initial;
282
+ return {
283
+ get() {
284
+ if (isTracking()) {
285
+ if (s === null) s = signal(value); // allocate only when subscribed
286
+ return s();
287
+ }
288
+ return value;
289
+ },
290
+ set(v) { value = v; if (s !== null) s.set(v); }
291
+ };
292
+ }
293
+ ```
294
+
295
+ Returns `true` iff a read right now would record a dependency on the current registry — an observer body is on the stack AND tracking is enabled. Mirrors the engine's own read-trap check (both flags), so it correctly returns `false` inside `untrack`, inside `subscribe` callbacks, inside `onCleanup` bodies, inside `watch` / `when` callbacks, and outside any observer.
296
+
297
+ For wrapper libraries (lite-store, lite-query, lite-form) gating lazy allocation on the read path. Per-registry — call `registry.isTracking()` if your signals live in a non-default registry.
298
+
276
299
  ### onCleanup
277
300
 
278
301
  ```ts
@@ -476,7 +499,7 @@ The "0 per fire" property for `watch` is deliberate engineering — the inner `u
476
499
 
477
500
  ### Tree-shaking
478
501
 
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.
502
+ 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
503
 
481
504
  ---
482
505
 
@@ -537,7 +560,7 @@ Three tiers, all reproducible.
537
560
 
538
561
  ### Tier 1 — Behavior (unit tests, fast)
539
562
 
540
- `npm test` runs the suite in `test/`. 131 tests across 43 suites covering:
563
+ `npm test` runs the suite in `test/`, covering:
541
564
 
542
565
  - **`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
566
  - **`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`).
@@ -547,6 +570,7 @@ Three tiers, all reproducible.
547
570
  - **`07-dispose.test.mjs`** — unified `dispose(api)` across signals, computeds and effect handles, idempotency, cross-registry isolation (per-registry Symbol prevents pool corruption), foreign-value safety, top-level helper routing, 500-cycle balanced churn leaving pool and stats stable.
548
571
  - **`08-watch.test.mjs`** — Validates the user-land observer utilities (watch, when, whenAsync). Covers lifecycle teardown, old/new value tracking, and Promise-based asynchronous state resolution.
549
572
  - **`09-conformance.test.mjs`** — Industry-standard conformance tests. Validates the engine against extreme edge cases from the johnsoncodehk reactive test suite, ensuring strict zero-GC invariants, correct cleanup isolation, and re-entrant stability.
573
+ - **`10-is-tracking.test.mjs`** — The `isTracking()` observer-context predicate. 11 tests across 5 describe blocks: true inside effect/computed bodies; false inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and `watch` callbacks (the untracked-window cases that catch an observer-only misimplementation); false outside any observer including at the call site of an unobserved computed read; state-restoration after a thrown body; per-registry isolation; top-level binding.
550
574
 
551
575
  ```bash
552
576
  npm test
@@ -705,11 +729,19 @@ function spawnPlugin(pluginCode) {
705
729
 
706
730
  ## Conformance
707
731
 
708
- lite-signal v1.1.0 was evaluated against the
732
+ lite-signal is evaluated against the
709
733
  [reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
710
734
  the most comprehensive behavioral test battery for JavaScript reactive
711
735
  libraries.
712
736
 
737
+ As of **v1.1.2**, the conformance items that were open at v1.1.0 — batch revert
738
+ detection (#123 / #132 / #147), throw isolation in flush (#121), and
739
+ inner-write propagation through computed chains (#180 / #213) — are **closed**
740
+ (landed in v1.1.1). The remaining gaps are one deliberate design choice (#179,
741
+ below) and the owner-tree items (#209 / #210), which land with the v1.2
742
+ ownership hybrid. The exact post-1.1.1 pass count is being re-run against the
743
+ upstream suite; per-test results and the runner adapter live in `/conformance/`.
744
+
713
745
  **174 of 177 tests pass (98.3%)**, placing lite-signal **in a tie for second place of sixteen**
714
746
  evaluated libraries — just behind alien-signals (177).
715
747
 
@@ -732,23 +764,9 @@ more useful to library users than a green checkmark.
732
764
  - **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
733
765
  solid. Half the field leaks the subscription.
734
766
 
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):
767
+ ### What lite-signal does NOT do yet
740
768
 
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.
769
+ The remaining open items, by intent.
752
770
 
753
771
  **Design choices we will not change** (2 tests):
754
772
 
@@ -760,13 +778,14 @@ more useful to library users than a green checkmark.
760
778
  effect's own implicit batching. Most libraries behave this way. Wrap the
761
779
  batch outside the effect for the intended semantics.
762
780
 
763
- **Opt-in feature, deferred** (2 tests):
781
+ **Landing in v1.2** (2 tests):
764
782
 
765
783
  - **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.
784
+ the baseline 1.1.x engine maintains no owner tree of parent-child
785
+ effects matching preact, vue, mobx, the TC39 polyfill, Angular,
786
+ Svelte, tansu, and Solid 1.x. The v1.2 ownership hybrid adds an
787
+ owner tree so nested effects/computeds auto-dispose with their parent;
788
+ the #209 / #210 conformance tests are wired and skipped until then.
770
789
 
771
790
  Per-test results, the runner adapter, and reproductions live in
772
791
  `/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
 
@@ -133,6 +133,10 @@ export interface Registry {
133
133
  dispose(api: Disposable): void;
134
134
  batch<T>(fn: () => T): T;
135
135
  untrack<T>(fn: () => T): T;
136
+ /** True iff a read RIGHT NOW would record a dependency on this registry.
137
+ * False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and
138
+ * outside any observer. Use for lazy-allocation wrappers like lite-store. */
139
+ isTracking(): boolean;
136
140
  onCleanup(fn: () => void): void;
137
141
  stats(): RegistryStats;
138
142
  /** Reset everything: nodes, links, queues, global clock. Outstanding dispose
@@ -163,6 +167,8 @@ export function effect(fn: () => void, opts?: EffectOptions): Dispose;
163
167
  export function dispose(api: Disposable): void;
164
168
  export function batch<T>(fn: () => T): T;
165
169
  export function untrack<T>(fn: () => T): T;
170
+ /** Top-level binding of {@link Registry.isTracking} against the default registry. */
171
+ export function isTracking(): boolean;
166
172
  export function onCleanup(fn: () => void): void;
167
173
  export function stats(): RegistryStats;
168
174
  export declare function destroy(): void;
@@ -177,24 +183,24 @@ export interface WatchOptions {
177
183
  immediate?: boolean;
178
184
  }
179
185
 
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
- */
186
+ /**
187
+ * Track a reactive source and run a callback whenever its projected value
188
+ * changes. The callback receives `(newValue, oldValue, stop)` — the third
189
+ * argument is a dispose function that can be called from inside the callback
190
+ * to terminate the watcher.
191
+ *
192
+ * Internal reads inside the callback are untracked.
193
+ *
194
+ * Uses `Object.is` to guard against the raw-getter case where a dep mutation
195
+ * fires the effect but the projected value is unchanged.
196
+ *
197
+ * @param source Reactive read function.
198
+ * @param callback Called with the new and previous values plus a stop handle.
199
+ * @param options `immediate: true` runs the callback once on registration
200
+ * with `oldValue = undefined`.
201
+ * @returns Dispose function. Idempotent and safe to call at any time, including
202
+ * synchronously during the immediate callback.
203
+ */
198
204
  export function watch<T>(
199
205
  source: () => T,
200
206
  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;
@@ -952,6 +993,32 @@ export function createRegistry(config = {}) {
952
993
  * @param {() => T} fn
953
994
  * @returns {T}
954
995
  */
996
+ /**
997
+ * Returns true iff a read RIGHT NOW would record a dependency in the current
998
+ * call stack: an observer body is on the stack AND tracking is enabled.
999
+ *
1000
+ * Mirrors the engine's own read-trap predicate (both flags checked). Returns
1001
+ * false inside `untrack()`, inside `signal.subscribe`'s callback (which
1002
+ * inlines the same untracked-notify pattern), inside `runCleanup` bodies,
1003
+ * and outside any observer.
1004
+ *
1005
+ * Use case: wrapper libraries (lite-store, lite-query, lite-form) that
1006
+ * lazily allocate reactive primitives on property reads — gating on
1007
+ * isTracking() lets them skip allocation when reads happen outside a
1008
+ * reactive context, preserving the zero-GC contract.
1009
+ *
1010
+ * Cost: two closure-variable loads, one AND, one return. ~1–2 ns.
1011
+ *
1012
+ * Note: per-registry. Wrappers operating against a non-default registry
1013
+ * MUST call that registry's `isTracking()`, not the top-level one — each
1014
+ * registry has its own tracking state.
1015
+ *
1016
+ * @returns {boolean}
1017
+ */
1018
+ function isTracking() {
1019
+ return isTrackingDeps && currentObserver !== null;
1020
+ }
1021
+
955
1022
  function untrack(fn) {
956
1023
  const prev = isTrackingDeps;
957
1024
  isTrackingDeps = false;
@@ -1060,7 +1127,7 @@ export function createRegistry(config = {}) {
1060
1127
  flushErrorBuffer.length = 0; // release the backing array
1061
1128
  }
1062
1129
 
1063
- return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy};
1130
+ return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking};
1064
1131
  }
1065
1132
 
1066
1133
  // ─────────────────────────────────────────────────────────────────
@@ -1109,6 +1176,15 @@ export function untrack(fn) {
1109
1176
  return defaultRegistry.untrack(fn);
1110
1177
  }
1111
1178
 
1179
+ /**
1180
+ * True iff a read RIGHT NOW would record a dependency on the default registry.
1181
+ * See {@link createRegistry} for the per-registry version. Wrappers operating
1182
+ * against a non-default registry MUST call THAT registry's `isTracking()`.
1183
+ */
1184
+ export function isTracking() {
1185
+ return defaultRegistry.isTracking();
1186
+ }
1187
+
1112
1188
  /** @type {Registry["onCleanup"]} */
1113
1189
  export function onCleanup(fn) {
1114
1190
  return defaultRegistry.onCleanup(fn);
package/llms.txt CHANGED
@@ -19,6 +19,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
19
19
  - **dispose(api)**: universal disposal. Accepts a signal, computed, or effect dispose handle; idempotent; cross-registry calls are silent no-ops (per-registry `Symbol("node_ptr")` keys the node-identity slot on the returned API function, foreign signals fail the lookup and fall through). Passing an unrelated value is also safe; passing an arbitrary function invokes it (effect-handle contract).
20
20
  - **Batch**: `batch(fn)` defers effect flush until the outermost batch closes. Nestable.
21
21
  - **Untrack**: `untrack(fn)` reads without subscribing.
22
+ - **isTracking**: `isTracking()` returns true iff a read right now would record a dependency on this registry (for wrappers that lazily allocate signals).
22
23
  - **onCleanup**: registers teardown for the current computation; fires before each re-run and once on dispose. Works in effects and computeds.
23
24
  - **Registry**: `createRegistry({ maxNodes, maxLinks, onCapacityExceeded, maxFlushPasses })` creates an isolated reactive world with its own pools. Useful for tests, plugins, multi-tenant sandboxes. Top-level helpers use a default registry created at module load.
24
25
  - **CapacityError**: thrown when a fixed-size pool is exhausted under the `"throw"` policy, or when a `"grow"` policy hits the 16× starting-capacity ceiling on links.
@@ -46,6 +47,25 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
46
47
  - Stable read order: O(1) per dep via cursor reuse.
47
48
  - Chaotic/randomized read order: degrades to O(N) per dep due to list re-insertion.
48
49
 
50
+ ## Version notes
51
+
52
+ - **1.1.3**: adds `isTracking()` (top-level + per-registry). Returns true iff a
53
+ read right now would record a dependency (`isTrackingDeps && currentObserver !== null`).
54
+ False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, `watch` /
55
+ `when` callbacks, and outside any observer. ~1–2 ns. For wrapper libraries
56
+ (lite-store, lite-query, lite-form) that allocate reactive primitives lazily
57
+ on property reads. Per-registry: wrappers operating against a non-default
58
+ registry must call THAT registry's `isTracking()`, not the top-level one.
59
+ No behavior or engine changes.
60
+
61
+
62
+ - **1.1.2** (current): hot-path micro-optimizations, no behavior/API change.
63
+ Inlined cursor fast-path in `signal`/`computed` reads (stable-order reads skip
64
+ the `allocateLink` call); zero-allocation creation path (`opts` read defensively
65
+ instead of defaulting to `{}`); single-closure `subscribe`. Owner-tree
66
+ conformance items #209/#210 are wired but skipped pending the v1.2 ownership
67
+ hybrid.
68
+
49
69
  ## When to use
50
70
 
51
71
  - Animation loops, game HUDs, scoreboards, telemetry overlays.
@@ -70,6 +90,7 @@ function effect(fn: () => void, opts?: { scheduler?: (run: () => void) => void }
70
90
  function dispose(api: Signal<any> | Computed<any> | Dispose): void;
71
91
  function batch<T>(fn: () => T): T;
72
92
  function untrack<T>(fn: () => T): T;
93
+ function isTracking(): boolean;
73
94
  function onCleanup(fn: () => void): void;
74
95
  function stats(): RegistryStats;
75
96
 
@@ -232,7 +253,7 @@ sandbox.destroy(); // entire reactive world reset
232
253
 
233
254
  ## File layout
234
255
 
235
- - `Signal.js` — full implementation, ~500 lines, single file.
256
+ - `Signal.js` — full implementation, single file.
236
257
  - `Signal.d.ts` — TypeScript declarations for all public API.
237
258
  - `test/01-core.test.mjs` — signal/computed/effect basics, equality, untrack.
238
259
  - `test/02-topology.test.mjs` — diamonds, chains, fan-out/in, cycle detection.
@@ -243,7 +264,7 @@ sandbox.destroy(); // entire reactive world reset
243
264
  - `test/07-dispose.test.mjs` — universal disposal: registry.dispose(api).
244
265
  - `test/08-watch.test.mjs` — new watch reactivity tests.
245
266
  - `test/09-conformance.test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
246
- - `bench/bench.mjs` — comparative benchmark vs alien-signals, preact, solid.
267
+ - `bench/benchmark.mjs` — comparative benchmark vs alien-signals, preact, solid.
247
268
  - `demo/index.html` — interactive visualization of the reactive graph.
248
269
 
249
270
  ## 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.3",
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": [