@zakkster/lite-signal 1.1.4 → 1.2.0

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/llms.txt CHANGED
@@ -21,7 +21,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
21
21
  - **Untrack**: `untrack(fn)` reads without subscribing.
22
22
  - **isTracking**: `isTracking()` returns true iff a read right now would record a dependency on this registry (for wrappers that lazily allocate signals).
23
23
  - **onCleanup**: registers teardown for the current computation; fires before each re-run and once on dispose. Works in effects and computeds.
24
- - **Observer-lifecycle introspection** (1.1.4; top-level + per-registry): `hasObservers(handle)` returns true iff a signal/computed has ≥1 live observer right now (O(1); a `peek` does not count). `observeObservers(handle, { onConnect?, onDisconnect? })` fires on the 0→1 and 1→0 observer transitions (after registration; transition-only) and returns an idempotent unobserve — the auto-pause hook for tickers (lite-time/lite-raf start a source only while it's watched). `forEachObserver(handle, fn)` / `forEachSource(handle, fn)` walk the live graph in either direction, passing a `{ kind, value }` descriptor (`kind` is `"signal" | "computed" | "effect"`) — for inspection (lite-devtools). The surface is gated by an internal counter: zero steady-state cost when nothing is observed. `hasObservers`/`forEach*` no-op on a non-handle; `observeObservers` throws `TypeError`.
24
+ - **Observer-lifecycle introspection** (1.1.4; top-level + per-registry): `hasObservers(handle)` returns true iff a signal/computed has ≥1 live observer right now (O(1); a `peek` does not count). `observeObservers(handle, { onConnect?, onDisconnect? })` fires on the 0→1 and 1→0 observer transitions (after registration; transition-only) and returns an idempotent unobserve — the auto-pause hook for tickers (lite-time/lite-raf start a source only while it's watched). `forEachObserver(handle, fn)` / `forEachSource(handle, fn)` walk the live graph in either direction, passing a `{ kind, value }` descriptor (`kind` is `"signal" | "computed" | "effect"`) — for inspection (lite-devtools). The surface is gated by an internal counter: zero steady-state cost when nothing is observed. `hasObservers`/`forEach*` no-op on a non-handle; `observeObservers` throws `TypeError`. **Node identity (1.1.5):** `nodeId(handle)` returns the node's stable per-allocation id, `describe(handle)` returns the handle's own `{ id, kind, value }` descriptor, and `forEach*` descriptors now carry `id` too; a descriptor is **re-walkable** (pass it back into `forEachObserver`/`forEachSource`) — the recursion primitive lite-devtools uses to walk the full DAG.
25
25
  - **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.
26
26
  - **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.
27
27
 
@@ -32,7 +32,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
32
32
  - **Cursor reconciliation**: at the start of each computed/effect run, `activeObserverCurrentDep` points at the head of the existing dep list. As the body reads deps, matching links advance the cursor (zero alloc); non-matching links insert before the cursor and the displaced tail is severed at the end of the run.
33
33
  - **Iterative mark phase**: `markDownstream` uses a pre-allocated `markStack` array for DFS — no recursion, no call stack growth on wide fan-out.
34
34
  - **Recursive computed pull**: `pullComputed` is call-stack recursive; deep chains beyond ~10,000 nodes will throw `RangeError`. Effects don't have this limit.
35
- - **Double-buffered effect queue**: `effectQueueA` / `effectQueueB` alternate each flush pass. An effect that writes during its own re-run gets re-queued into the alternate buffer.
35
+ - **Double-buffered effect queue**: `effectQueueA` / `effectQueueB` alternate each flush pass — effects *scheduled by* the current pass (cross-effect cascades) drain in the next pass. A **self-write** (an effect writing a signal it reads) is the exception: `markDownstream`'s `FLAG_COMPUTING` guard does **not** re-queue the writing effect, so a self-cycle runs exactly once — while the write still propagates to the signal's other observers, and the effect stays responsive to later *external* writes (its `evalVersion` is bumped in the `executeEffect` finally). Mutual cross-effect write loops (A→B→A) are not self-cycles and trip `CycleError: flush passes exceeded` via `maxFlushPasses`.
36
36
  - **maxFlushPasses ceiling**: default 100. Exceeding this throws `CycleError`. Prevents runaway effect loops.
37
37
  - **32-bit modular versioning**: `globalVersion` and `node.version` are `(value + 1) | 0`, wrapping signed. Comparison via `((dep.version - evalVer) | 0) > 0` is wrap-safe and works in modular distance, not raw integer ordering. The engine never overflows.
38
38
  - **Generation counter**: each node has a `gen` field incremented on dispose and destroy. Scheduler trampolines capture the current gen and no-op if it changes — prevents stale callbacks from re-firing after dispose.
@@ -50,7 +50,47 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
50
50
 
51
51
  ## Version notes
52
52
 
53
- - **1.1.4** (current): combined release a retracking rewrite plus an observer-lifecycle
53
+ - **1.2.0** (current): structural refactor (three named layers: graph topology /
54
+ ownership / propagation) plus four additive features built on top. Drop-in over
55
+ 1.1.5; no public surface removed. **Owner tree:** an effect or computed that
56
+ creates nested observers (effect/computed) now owns them — when the owner
57
+ re-runs or is disposed, the engine cascade-disposes those observers before
58
+ the new run. Plain signals are deliberately NOT owner-adopted so lazy-
59
+ allocation wrappers (lite-store keys, lite-form fields) continue to survive
60
+ re-runs of the computed that allocated them. **Pre-batch revert:** inside a
61
+ `batch(...)`, set X then set X back (under the signal's `equals`) reverts the
62
+ version bump — downstream effects/computeds do not fire. **AggregateError on
63
+ multi-throw:** two or more effects throwing in the same flush pass aggregate to
64
+ `AggregateError` at the trigger; single-throw is unchanged. **Scheduler thunk
65
+ caching:** the scheduler closure is cached on the node and gen-bound, so async
66
+ schedules that fire post-dispose against a recycled slot are guaranteed no-ops
67
+ (ABA safe). Internal split: `currentObserver` and `currentOwner` are now
68
+ distinct pointers (today they move together, no behavioural change). **Perf:**
69
+ shared `peek` (one closure per registry instead of per primitive) shaves
70
+ 10–14% off signal/computed creation on the `S:create*` micros, no hot-path or
71
+ behavioural change. 363 tests, 100% line / 98.62% branch coverage on
72
+ `Signal.js` + `Watch.js`. Differential fuzz vs the published 1.1.5: 0
73
+ disagreements over 30,000 writes. New `test/19-v12-additions.test.mjs`
74
+ (24 tests) and `test/20-axis-stress.test.mjs` (23 tests — eight orthogonal
75
+ engine-invariant axes plus permanent conformance pins). **Conformance fixes
76
+ in 1.2.0**: #141 (dispose during execution then continue: no re-run), and
77
+ #238 / #241 / #243 (cleanup ordering on cascade: inner-before-outer,
78
+ deepest-first, and the inner-only-re-run regression). #141 was a latent
79
+ crash present in 1.1.5 too; the v1.2 owner tree exercised it more
80
+ aggressively. Fix is two-fold — nullify the tracking cursor in `disposeNode`
81
+ when the disposed node is the active observer (plus a `gen`-snapshot guard
82
+ in `executeEffect`/`pullComputed`); and swap `runCleanup` to cascade
83
+ children first, then own.
84
+
85
+ - **1.1.5**: additive release in service of `@zakkster/lite-devtools` — stable
86
+ node identity on the introspection surface. `nodeId(handle)` -> the node's stable
87
+ per-allocation id (the dedupe key for graph walks); `describe(handle)` -> the handle's
88
+ own `{ id, kind, value }` descriptor, **re-walkable** (pass it back into
89
+ `forEachObserver`/`forEachSource` to walk the full DAG); `forEach*` descriptors now carry
90
+ `id`. One SMI write at allocation, node shape kept monomorphic — **zero steady-state
91
+ cost**. Drop-in over 1.1.4, no breaking changes. New `test/18-identity_test.mjs` (5 tests); plus `14-lifecycle-teardown`, `16-alien-parity`, and the `17-reactivity` behavioral suite.
92
+
93
+ - **1.1.4**: combined release — a retracking rewrite plus an observer-lifecycle
54
94
  introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
55
95
  reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
56
96
  O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
@@ -111,6 +151,8 @@ function observeObservers(
111
151
  ): () => void; // returns idempotent unobserve
112
152
  function forEachObserver(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
113
153
  function forEachSource(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
154
+ function nodeId(handle: Signal<any> | Computed<any>): number | undefined; // 1.1.5
155
+ function describe(handle: Signal<any> | Computed<any>): NodeDescriptor | undefined; // 1.1.5
114
156
  function onCleanup(fn: () => void): void;
115
157
  function stats(): RegistryStats;
116
158
 
@@ -134,7 +176,8 @@ interface Computed<T> {
134
176
 
135
177
  type Dispose = () => void;
136
178
 
137
- interface NodeDescriptor { // yielded by forEachObserver / forEachSource
179
+ interface NodeDescriptor { // yielded by forEachObserver / forEachSource / describe
180
+ id: number; // stable per-allocation id (1.1.5); dedupe + re-walk key
138
181
  kind: "signal" | "computed" | "effect";
139
182
  value: unknown; // node's current value
140
183
  }
@@ -153,8 +196,8 @@ interface RegistryStats {
153
196
  activeLinks: number;
154
197
  pooledLinks: number;
155
198
  linkPoolCapacity: number;
156
- nodePoolCapacity?: number;
157
- activeNodes?: number;
199
+ nodePoolCapacity: number;
200
+ activeNodes: number;
158
201
  }
159
202
 
160
203
  class CapacityError extends Error {
@@ -235,9 +278,9 @@ path is long rather than wide.
235
278
  These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
236
279
  high-fan-in shapes that were lite-signal's documented weakness — `dyn: large web app`
237
280
  and `dyn: wide dense` in the cross-framework reactivity suite — were closed by the
238
- 1.1.4 retracking rewrite and are now the fastest of five frameworks: 571ms / 912ms
239
- vs alien-signals' 623ms / 1001ms, with preact and vue 10–18× slower. See
240
- resultsReactive.txt for the full 34-test, 5-framework table.
281
+ 1.1.4 retracking rewrite and remain the fastest of five frameworks (re-confirmed on
282
+ 1.1.5, median-of-12): 555ms / 870ms vs alien-signals' 590ms / 933ms, with preact and
283
+ vue ~7–30× slower. See resultsReactive.txt for the full 34-test, 5-framework table.
241
284
 
242
285
  On allocation pressure, lite-signal is alone in the zero-Δheap band: ~15 KB of
243
286
  transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
@@ -287,20 +330,24 @@ sandbox.destroy(); // entire reactive world reset
287
330
 
288
331
  - `Signal.js` — full implementation, single file.
289
332
  - `Signal.d.ts` — TypeScript declarations for all public API.
290
- - `test/01-core.test.mjs` — signal/computed/effect basics, equality, untrack.
291
- - `test/02-topology.test.mjs` — diamonds, chains, fan-out/in, cycle detection.
292
- - `test/03-pool.test.mjs` — capacity errors, grow policy, pool reuse.
293
- - `test/04-zero-gc.test.mjs` — heap retention (run with --expose-gc).
294
- - `test/05-scheduler.test.mjs` — scheduler races, dispose, gen counter, version wrap.
295
- - `test/06-nested-objects.test.mjs` — nested-object & reference-identity behaviours.
296
- - `test/07-dispose.test.mjs` — universal disposal: registry.dispose(api).
297
- - `test/08-watch.test.mjs` — new watch reactivity tests.
298
- - `test/09-conformance.test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
333
+ - `test/01-core_test.mjs` — signal/computed/effect basics, equality, untrack.
334
+ - `test/02-topology_test.mjs` — diamonds, chains, fan-out/in, cycle detection.
335
+ - `test/03-pool_test.mjs` — capacity errors, grow policy, pool reuse.
336
+ - `test/04-zero-gc_test.mjs` — heap retention (run with --expose-gc).
337
+ - `test/05-scheduler_test.mjs` — scheduler races, dispose, gen counter, version wrap.
338
+ - `test/06-nested-objects_test.mjs` — nested-object & reference-identity behaviours.
339
+ - `test/07-dispose_test.mjs` — universal disposal: registry.dispose(api).
340
+ - `test/08-watch_test.mjs` — new watch reactivity tests.
341
+ - `test/09-conformance_test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
299
342
  - `test/10-is-tracking_test.mjs` — `isTracking()` across observer bodies, untracked windows, and outside any observer.
300
343
  - `test/11-adopted-reactive_test.mjs` — engine-agnostic edge cases adopted from the wider ecosystem (alien-signals link-integrity #226–228, equality-predicate corners, `update()`/`peek()`/`subscribe` contracts).
301
344
  - `test/12-coverage_test.mjs` — targeted public-surface + hot-path branch coverage (top-level routing, clean-read short-circuit, tail severance, scheduler ABA); owner-tree block capability-gated.
302
345
  - `test/13-introspection_test.mjs` — observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
303
346
  - `test/14-lifecycle-teardown_test.mjs` — effect-teardown guards: stopped effect doesn't re-subscribe to a signal read later in the same run, self-dispose leaves no orphan link, throwing setup leaves no live subscription (4 tests).
347
+ - `test/15-owner-lazy-alloc_test.mjs` — owner-adoption contract for the 1.2.0 owner tree (lazy signals never adopted; observers still auto-disposed); capability-gated, skipped on 1.1.x (4 tests).
348
+ - `test/16-alien-parity_test.mjs` — differential guards vs alien-signals@3.2.0 fixed-bug classes: cleanup reads create no deps, inner write doesn't block computed-chain propagation, dynamic dep-set correct under dirty-check (3 tests).
349
+ - `test/17-reactivity_test.mjs` — behavioral suite over universal signal-system bug classes (subscription lifecycle, cleanup ordering, stale-dep tracking, batching, equality, nested invalidation, memory, sync async-boundary, scheduler/loops, + differential-review additions); SSR is N/A (≈30 tests).
350
+ - `test/18-identity_test.mjs` — node identity (1.1.5): `nodeId`/`describe`, descriptor `id`, re-walkable descriptors, non-perturbing (5 tests).
304
351
  - `bench/benchmark.mjs` — anti-DCE throughput harness (ops/s; results.txt).
305
352
  - `bench/benchmarkReactive.mjs` — cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
306
353
  - `demo/index.html` — interactive visualization of the reactive graph.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
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",
@@ -22,7 +22,8 @@
22
22
  "Watch.js",
23
23
  "README.md",
24
24
  "llms.txt",
25
- "LICENSE.txt"
25
+ "LICENSE.txt",
26
+ "CHANGELOG.md"
26
27
  ],
27
28
  "scripts": {
28
29
  "test": "node --test --test-reporter=spec",