@zakkster/lite-signal 1.1.4 → 1.1.5

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
@@ -104,6 +104,9 @@ Full type definitions ship in [`Signal.d.ts`](./Signal.d.ts) and are referenced
104
104
 
105
105
  ## The case for object pooling
106
106
 
107
+ <details>
108
+ <summary>Why pre-allocate: the GC math, and the per-op zero-allocation table.</summary>
109
+
107
110
  A naive reactive library allocates one object per dependency edge, one per subscription, one per queued effect. With 1000 computeds × 1 update / frame × 60 fps, that's 60,000 short-lived objects per second. The major GC will catch up with you.
108
111
 
109
112
  `lite-signal` solves this by pre-allocating two pools at startup — **nodes** (one per signal/computed/effect) and **links** (one per dependency edge) — and reusing them indefinitely. After the warm-up frames, the hot path performs zero allocations:
@@ -118,10 +121,15 @@ A naive reactive library allocates one object per dependency edge, one per subsc
118
121
 
119
122
  The free lists are singly-linked through a `nextFree` field on each pool object — `O(1)` pop, `O(1)` push, no fragmentation.
120
123
 
124
+ </details>
125
+
121
126
  ---
122
127
 
123
128
  ## Architecture in one diagram
124
129
 
130
+ <details>
131
+ <summary>Pools, the reactive graph, hot-path state, and the doubly-linked edge model.</summary>
132
+
125
133
  ```mermaid
126
134
  flowchart TB
127
135
  subgraph Pools[Pre-allocated object pools]
@@ -163,10 +171,15 @@ Every reactive entity is a `ReactiveNode` with bit flags (`COMPUTED`, `EFFECT`,
163
171
 
164
172
  Doubly-linked on both axes means `O(1)` unlink during the cursor-based reconciliation that happens at the end of every computed/effect re-run.
165
173
 
174
+ </details>
175
+
166
176
  ---
167
177
 
168
178
  ## How a write propagates
169
179
 
180
+ <details>
181
+ <summary>The set → mark → flush sequence, and why computeds stay pull-based.</summary>
182
+
170
183
  ```mermaid
171
184
  sequenceDiagram
172
185
  participant U as User code
@@ -197,6 +210,8 @@ The flush phase uses **two queue buffers** (`effectQueueA` / `effectQueueB`) alt
197
210
 
198
211
  Computeds are **pull-based** — they're not in the effect queue. Reading a computed walks its dep list, recursively pulls upstream computeds, and only re-runs if any dep's version is greater than its own `evalVersion`. The version comparison uses 32-bit modular arithmetic: `((dep.version - evalVer) | 0) > 0`. This is the trick that makes the engine immune to integer overflow during long-running sessions.
199
212
 
213
+ </details>
214
+
200
215
  ---
201
216
 
202
217
  ## API reference
@@ -315,11 +330,13 @@ forEachObserver(sum, d => console.log(d.kind, d.value)); // subscribers of `sum
315
330
  forEachSource(sum, d => console.log(d.kind, d.value)); // dependencies of `sum`
316
331
  ```
317
332
 
318
- Four functions (top-level + per-registry), added in 1.1.4, for auto-pausing wrappers and graph inspection:
333
+ Six functions (top-level + per-registry) four in 1.1.4, two more in 1.1.5 — for auto-pausing wrappers and graph inspection:
319
334
 
320
335
  - **`hasObservers(handle)` → `boolean`** — O(1) (`node.headSub !== null`). The auto-pause predicate.
321
336
  - **`observeObservers(handle, { onConnect?, onDisconnect? })` → `unobserve`** — fires on the 0→1 and 1→0 observer transitions *after* registration (transition-only — no immediate fire if already observed). Re-tracking a persistently-read source does **not** churn. This is the hook `lite-time` / `lite-raf` use to run a clock only while a derived value is watched. Throws `TypeError` on a non-handle.
322
- - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`** — walk subscribers / dependencies; `fn` gets a `{ kind, value }` descriptor (`kind` ∈ `"signal" | "computed" | "effect"`). No-op on a non-handle.
337
+ - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`** — walk subscribers / dependencies; `fn` gets a `{ id, kind, value }` descriptor (`kind` ∈ `"signal" | "computed" | "effect"`; `id` added in 1.1.5). No-op on a non-handle.
338
+ - **`nodeId(handle)` → `number | undefined`** *(1.1.5)* — the node's stable per-allocation id; the dedupe key for graph traversal. `undefined` on a non-handle.
339
+ - **`describe(handle)` → `{ id, kind, value } | undefined`** *(1.1.5)* — the handle's own descriptor. **Re-walkable**: pass it back into `forEachObserver`/`forEachSource` to recurse the graph. `undefined` on a non-handle.
323
340
 
324
341
  The surface is gated by an internal lifecycle counter: when nothing is being observed, the hot path adds a single branch-predicted `count !== 0` check in link alloc/free and nothing else — **zero steady-state cost when unused**.
325
342
 
@@ -373,6 +390,9 @@ r.destroy(); // reset all pools, invalidate generations
373
390
 
374
391
  ## Capacity, growth, and the link ceiling
375
392
 
393
+ <details>
394
+ <summary>Pool sizing, the grow policy, and why there is a 16× link ceiling.</summary>
395
+
376
396
  The engine has two pool sizes: **nodes** and **links**. Both are fixed at registry creation but can be configured to grow.
377
397
 
378
398
  ```mermaid
@@ -397,6 +417,8 @@ Default sizing for a Twitch-extension-style budget:
397
417
 
398
418
  `stats()` reports `signals`, `computeds`, `effects`, `activeLinks`, `pooledLinks`, `linkPoolCapacity`. Drop it on screen for live observability.
399
419
 
420
+ </details>
421
+
400
422
  ---
401
423
 
402
424
  ## Watchers
@@ -532,6 +554,9 @@ All three primitives live in a separate module (`Watch.js`) and are re-exported
532
554
 
533
555
  ## Edge cases pinned down
534
556
 
557
+ <details>
558
+ <summary>Diamonds, self-feedback, NaN/±0, throwing bodies, 32-bit version wrap, deep-chain limits.</summary>
559
+
535
560
  These are the questions you'd ask in a code review, with the answers:
536
561
 
537
562
  - **Diamond dependency.** Glitch-free. The mark phase walks the graph once; computeds are pulled lazily on read, so each one re-runs at most once per propagation regardless of how many paths reach it.
@@ -545,25 +570,27 @@ These are the questions you'd ask in a code review, with the answers:
545
570
  - **Deep chain depth.** Computed resolution is recursive in the JS call stack. Chains beyond ~10,000 deep risk `RangeError: Maximum call stack size exceeded`. Effects use an iterative mark phase, so signal → effect fan-out has no depth limit other than memory.
546
571
  - **`destroy()` after dispose.** `destroy()` bumps every node's generation, so any in-flight scheduled trampolines from before destruction are silently dropped. Closures returned to user code from disposed effects guard with `if (node.flags === 0) return;` — calling `dispose()` again is a no-op.
547
572
 
573
+ </details>
574
+
548
575
  ---
549
576
 
550
577
  ## Benchmarks
551
578
 
552
- Honest numbers, against the same workload, with anti-DCE sinks and verified effect execution. All measurements: Node 22, **2016-era Intel MacBook Pro (4 cores, ~10 yr old hardware)**, 20K iterations × 5 inner runs × 10 outer invocations (median reported). Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters. Numbers below are lite-signal **@1.1.4** vs alien-signals on the same loop; the full five-framework comparison (incl. preact, vue-reactivity, solid across 34 reactive-suite tests) is in [`resultsReactive.txt`](./resultsReactive.txt).
579
+ Honest numbers, against the same workload, with anti-DCE sinks and verified effect execution. All measurements: Node 22, **2016-era Intel MacBook Pro (4 cores, ~10 yr old hardware)**, 20K iterations × 5 inner runs × 11 outer invocations (median reported). Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters. Numbers below are lite-signal **@1.1.5** vs alien-signals on the same loop; the full five-framework comparison (incl. preact, vue-reactivity, solid across 34 reactive-suite tests) is in [`resultsReactive.txt`](./resultsReactive.txt). *(Both halves are now @1.1.5 — this throughput table median-of-11, the cross-framework reactivity suite median-of-12 in [`resultsReactive.txt`](./resultsReactive.txt). 1.1.5 is additive over 1.1.4 (node-identity only), so the engine numbers are perf-neutral vs 1.1.4.)*
553
580
 
554
581
  | Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
555
582
  | ---------- | -------------------------------- | ----------- | ------------- | ------------- |
556
- | **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **314K ops/s** | 194K | **+62%** |
557
- | **BROADCAST** | 1 signal → 1000 effects (fan-out) | **26K** | 22K | **+22%** |
558
- | **KAIROS** | 1 signal → 1000 computeds → 1 effect | **14K** | 12K | **+12%** |
559
- | **DEEP CHAIN** | 256-deep computed chain → 1 effect | 53K | **60K** | −11% |
560
- | **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | **7K** | 6K | **+6%** ⬆ |
561
- | **Δheap MUX** | transient alloc pressure, 20K iters | **0.3 KB** | 3,907 KB | — |
583
+ | **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **314K ops/s** | 198K | **+59%** |
584
+ | **BROADCAST** | 1 signal → 1000 effects (fan-out) | **27K** | 22K | **+20%** |
585
+ | **KAIROS** | 1 signal → 1000 computeds → 1 effect | **15K** | 13K | **+19%** |
586
+ | **DEEP CHAIN** | 256-deep computed chain → 1 effect | 53K | **60K** | −12% |
587
+ | **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | **7K** | 7K | **+6%** ⬆ |
588
+ | **Δheap MUX** | transient alloc pressure, 20K iters | **0.3 KB** | 6,120 KB | — |
562
589
  | **Retained MUX** | state surviving forced GC | **−7 KB** (none) | −1 KB | — |
563
590
 
564
- **Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+62%**, **BROADCAST** (fan-out) by **+22%**, and **KAIROS** (one source feeding a wide layer of memos) by **+12%** — the patterns that dominate real UI workloads: dashboards, scoreboards, HUDs, leaderboards, and any view that aggregates many inputs into a single computed slice. The **WIDE DENSE** row (⬆) is the headline of 1.1.4: at 1.1.2 lite-signal *lost* this dense, high-fan-in shape by −16%; the 1.1.4 retracking rewrite flips it to **+6%**. `alien-signals` now retains only a **−11% lead on DEEP CHAIN** (256-deep computed pipelines), where a flatter internal representation pays off when the propagation path is long rather than wide.
591
+ **Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+59%**, **BROADCAST** (fan-out) by **+20%**, and **KAIROS** (one source feeding a wide layer of memos) by **+19%** — the patterns that dominate real UI workloads: dashboards, scoreboards, HUDs, leaderboards, and any view that aggregates many inputs into a single computed slice. The **WIDE DENSE** row (⬆) is the headline of 1.1.4: at 1.1.2 lite-signal *lost* this dense, high-fan-in shape by −16%; the 1.1.4 retracking rewrite flips it to **+6%**. `alien-signals` now retains only a **−12% lead on DEEP CHAIN** (256-deep computed pipelines), where a flatter internal representation pays off when the propagation path is long rather than wide.
565
592
 
566
- On allocation pressure, `lite-signal` is alone in the zero-Δheap band: ~0.3 KB of transient garbage on stable shapes across 20,000 iterations. The contrast is starkest on SMALL SELECTIVE — lite-signal 0.3 KB vs alien-signals **29 MB** in the same loop. preact runs ~230 KB per loop, solid runs into single-digit megabytes, and alien-signals — which earlier shared the zero-GC band with lite-signal — now allocates 0.9-3.9 MB per stable scenario in current published versions. Negative "retained" numbers mean V8 reclaimed memory below the pre-bench baseline during the post-run forced GC — no leaks anywhere.
593
+ On allocation pressure, `lite-signal` is alone in the zero-Δheap band: ~0.3 KB of transient garbage on stable shapes across 20,000 iterations. The contrast is starkest on SMALL SELECTIVE — lite-signal 0.3 KB vs alien-signals **~15 MB** in the same loop. preact ranges from ~220 KB to low-single-digit MB per loop, solid runs into single-digit megabytes, and alien-signals — which earlier shared the zero-GC band with lite-signal — allocates from near-zero (BROADCAST) up to ~6 MB (MUX) per stable scenario in this run. Negative "retained" numbers mean V8 reclaimed memory below the pre-bench baseline during the post-run forced GC — no leaks anywhere.
567
594
 
568
595
  > Note on the +70.8 KB retained that lite-signal shows on KAIROS specifically: that's the pre-allocated pool sitting in memory holding the live graph (1002 nodes + ~2000 links). The pool *is* the working memory — see the [Case for object pooling](#case-for-object-pooling) section. On the other benches the graph is small enough that the same pool floats below baseline after GC.
569
596
 
@@ -590,19 +617,23 @@ Three tiers, all reproducible.
590
617
 
591
618
  `npm test` runs the suite in `test/`, covering:
592
619
 
593
- - **`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.
594
- - **`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`).
595
- - **`03-pool.test.mjs`** — `CapacityError` under both `"throw"` and `"grow"` policies, the 16× link ceiling, stable pool reuse across thousands of create/dispose cycles, registry isolation.
596
- - **`05-scheduler.test.mjs`** — scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
597
- - **`06-nested-objects.test.mjs`** — array mutation patterns (push/splice/spread), deep nested paths, Map/Set/Date inside signals, custom structural equality, computed memoisation cutoffs over object slices, signal-of-signals composition, high-frequency object updates, batched immutable updates.
598
- - **`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.
599
- - **`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.
600
- - **`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.
601
- - **`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.
602
- - **`11-adopted-reactive.test.mjs`** — 24 engine-agnostic edge cases adopted from across the ecosystem: alien-signals' parent-child link-integrity regression (#226–228), equality-predicate corners (preact/solid/vue), `signal.update(fn)` functional setter (vue/solid), `peek()` non-subscription depth (preact/vue), and the `subscribe` behavioral contract (preact/mobx).
603
- - **`12-coverage.test.mjs`** — 18 targeted exercises for public surface and hot-path branches the behavioral suites don't incidentally hit: top-level routing to the default registry, the computed clean-read short-circuit (`markEpoch` O(1) skip), dependency-set shrink severing the stale tail, error/structural edge paths, and scheduler ABA across a recycled pool slot. The owner-tree block is capability-gated (runs on 1.2.0+, skipped on 1.1.x).
604
- - **`13-introspection.test.mjs`** — The observer-lifecycle surface (1.1.4). 10 tests across 3 describe blocks: `hasObservers` (live observation reflects; a peek doesn't count), `observeObservers` auto-pause lifecycle (start-on-first / stop-on-last, no extra connect for a 2nd observer, re-observe fires again, no churn on re-track, conditional reads toggle honestly, transition-only registration, works for computeds), and `forEachObserver`/`forEachSource` enumeration (both directions; descriptor carries kind + value).
605
- - **`14-lifecycle-teardown.test.mjs`** — Effect-teardown guards (1.1.4 fix). A stopped effect must not re-subscribe to a signal read later in the same run; self-dispose must leave no orphaned link; a throwing effect setup must leave no live subscription; normal and dynamic re-tracking stay unaffected.
620
+ - **`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.
621
+ - **`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`).
622
+ - **`03-pool_test.mjs`** — `CapacityError` under both `"throw"` and `"grow"` policies, the 16× link ceiling, stable pool reuse across thousands of create/dispose cycles, registry isolation.
623
+ - **`05-scheduler_test.mjs`** — scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
624
+ - **`06-nested-objects_test.mjs`** — array mutation patterns (push/splice/spread), deep nested paths, Map/Set/Date inside signals, custom structural equality, computed memoisation cutoffs over object slices, signal-of-signals composition, high-frequency object updates, batched immutable updates.
625
+ - **`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.
626
+ - **`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.
627
+ - **`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.
628
+ - **`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.
629
+ - **`11-adopted-reactive_test.mjs`** — 24 engine-agnostic edge cases adopted from across the ecosystem: alien-signals' parent-child link-integrity regression (#226–228), equality-predicate corners (preact/solid/vue), `signal.update(fn)` functional setter (vue/solid), `peek()` non-subscription depth (preact/vue), and the `subscribe` behavioral contract (preact/mobx).
630
+ - **`12-coverage_test.mjs`** — 18 targeted exercises for public surface and hot-path branches the behavioral suites don't incidentally hit: top-level routing to the default registry, the computed clean-read short-circuit (`markEpoch` O(1) skip), dependency-set shrink severing the stale tail, error/structural edge paths, and scheduler ABA across a recycled pool slot. The owner-tree block is capability-gated (runs on 1.2.0+, skipped on 1.1.x).
631
+ - **`13-introspection_test.mjs`** — The observer-lifecycle surface (1.1.4). 10 tests across 3 describe blocks: `hasObservers` (live observation reflects; a peek doesn't count), `observeObservers` auto-pause lifecycle (start-on-first / stop-on-last, no extra connect for a 2nd observer, re-observe fires again, no churn on re-track, conditional reads toggle honestly, transition-only registration, works for computeds), and `forEachObserver`/`forEachSource` enumeration (both directions; descriptor carries kind + value).
632
+ - **`14-lifecycle-teardown_test.mjs`** — Effect-teardown guards against the alien-signals@3.2.1 regressions (4 tests). A stopped effect must not re-subscribe to a signal read later in the same run; self-dispose must leave no orphaned link (clean `activeLinks`); a throwing setup must leave no live subscription; normal and dynamic re-tracking stay unaffected by the `allocateLink` eligibility gate.
633
+ - **`15-owner-lazy-alloc_test.mjs`** — Owner-adoption contract for the 1.2.0 owner tree (4 tests, capability-gated — skipped on 1.1.x). A signal allocated lazily *inside* a computed/effect must **not** be owner-adopted (it survives the owner's re-run — the lite-store/lite-form lazy-field shape) and sibling lazy signals must not cross-wire, while observers *are* still auto-disposed. Runs on 1.2.0+.
634
+ - **`16-alien-parity_test.mjs`** — Differential regression guards (3 tests) reproducing the *properties* behind alien-signals@3.2.0 fixed bugs: reads inside a cleanup create no spurious dependencies (the dispose-cleanup fix); an inner-effect write does not block later propagation through a computed chain (#112); a dynamic dependency-set change stays correct under dirty-check (#109/#110).
635
+ - **`17-reactivity_test.mjs`** — Behavioral suite (≈30 tests across 11 groups) mirroring universal signal-system bug classes: subscription lifecycle, cleanup ordering, stale-dependency tracking, batching/timing (incl. set-then-revert), equality cutoff (NaN/±0/custom), nested invalidation + glitch-free diamond, memory/retained nodes, the synchronous async-boundary, scheduler & loops (self-write termination, self-reading computed), and differential-review additions (cached computed errors, mid-batch pull, self-disposing getter, pooled-slot return). SSR hydration is a documented N/A — lite has no DOM layer.
636
+ - **`18-identity_test.mjs`** — Node identity (1.1.5; 5 tests). Unique/stable ids; `nodeId`/`describe` return `undefined` for a non-handle; the descriptor's visible shape is `{ id, kind, value }`; `forEach*` descriptors carry `id` and are **re-walkable** (`nodeId`/`forEachSource` accept a descriptor); identity walks are non-perturbing (add no observers).
606
637
 
607
638
  ```bash
608
639
  npm test
@@ -610,7 +641,7 @@ npm test
610
641
 
611
642
  ### Tier 2 — Memory (allocation-free verification)
612
643
 
613
- `npm run test:gc` runs `test/04-zero-gc.test.mjs` with `--expose-gc`:
644
+ `npm run test:gc` runs `test/04-zero-gc_test.mjs` with `--expose-gc`:
614
645
 
615
646
  - 100,000 `set()` calls on a graph with effects retain **< 200 KB** of heap.
616
647
  - 1,000 create/dispose cycles retain **< 50 KB**.
@@ -641,6 +672,9 @@ npm run verify # test + test:gc + a sanity bench
641
672
 
642
673
  ## Performance Trade-offs & Topology Scaling
643
674
 
675
+ <details>
676
+ <summary>Stable vs dynamic topologies; Andrii Volynets' matrix, the 1.1.4 result, and the roadmap.</summary>
677
+
644
678
  `lite-signal` was built with a strict mandate: **absolute zero garbage collection**. By packing the dependency graph into a flat, pre-allocated memory arena, we eliminate the Scavenger GC pauses that plague 120fps Canvas/WebGL loops.
645
679
 
646
680
  Through **v1.1.2**, that came with a mathematical trade-off: while memory allocation is $O(1)$, the cursor-based retracking degraded to $O(N)$ linear scans under chaotic, high-fan-in, batched read-after-write — the shape of large DOM-style apps with heavy branch switching. **v1.1.4 closed that gap.** A version-stamped $O(1)$ reconciliation plus a `markEpoch` clean-read short-circuit on the pull replaced the cursor degradation; stable read order is unchanged (still $O(1)$, still zero-alloc).
@@ -658,16 +692,18 @@ In stable environments (game engines, particle systems, visualizers), `lite-sign
658
692
  | **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
659
693
  | **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
660
694
 
661
- *1.1.4 on the local harness (slow 2016 MacBook — compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
662
- | Scenario | alien-signals | lite-signal (1.1.4) | result |
695
+ *1.1.5 on the local harness (slow 2016 MacBook — compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
696
+ | Scenario | alien-signals | lite-signal (1.1.5) | result |
663
697
  | :--- | :--- | :--- | :--- |
664
- | **LARGE WEB APP** (≈ 1000x12) | 3026ms | 2736ms | **lite +11%** |
665
- | **WIDE DENSE** (≈ 1000x5) | 2960ms | 2802ms | **lite +6%** |
666
- | **SMALL SELECTIVE** (≈ 64x6) | 2985ms | 1868ms | **lite +60%** |
698
+ | **LARGE WEB APP** (≈ 1000x12) | 2846ms | 2686ms | **lite +6%** |
699
+ | **WIDE DENSE** (≈ 1000x5) | 2826ms | 2671ms | **lite +6%** |
700
+ | **SMALL SELECTIVE** (≈ 64x6) | 2000ms | 1857ms | **lite +8%** |
667
701
 
668
- The cross-framework reactivity suite agrees independently: `dyn: large web app` and `dyn: wide dense` are now wins there too, and lite-signal is the fastest of five frameworks on both (see [`resultsReactive.txt`](./resultsReactive.txt)). The new retracking is verified correct by `retracking.difftest.mjs`20,000 direct + 10,000 batched writes, 0 disagreements. *(A fresh run of Andrii's exact matrix on 1.1.4 is the definitive external confirmation and is pending; the two local suites above are the current evidence.)*
702
+ > **Honest note (1.1.5 run):** alien-signals is much faster on these three shapes than in the 1.1.4 run above (SMALL SELECTIVE 2985ms 2000ms, LARGE WEB APP 3026ms 2846ms) a newer alien build and/or host conditionsso lite-signal's margins narrowed from +60%/+11%/+6% to **+8%/+6%/+6%**. Within this run lite still leads all three and remains the only zero-Δheap library (see [`results.txt`](./results.txt)).
669
703
 
670
- **The Takeaway:** as of 1.1.4 you no longer have to choose. `lite-signal` keeps the zero-GC, flat-arena profile for 120fps Canvas/WebGL **and** holds parity-or-ahead of alien-signals on dynamic, high-fan-in web-app topologies. The one shape where alien's flatter representation still leads is the 256-deep computed pipeline (DEEP CHAIN, −11%).
704
+ The cross-framework reactivity suite agrees independently and was re-run on **1.1.5** (median-of-12): `dyn: large web app` **555ms** (+6% vs alien-signals' 590ms) and `dyn: wide dense` **870ms** (+7% vs 933ms) are wins there too — lite-signal is the fastest of five frameworks on both, with preact and vue ~7–30× slower (see [`resultsReactive.txt`](./resultsReactive.txt)). The retracking is verified correct by `retracking.difftest.mjs` — 20,000 direct + 10,000 batched writes, 0 disagreements. *(A fresh run of Andrii's exact matrix on 1.1.5 is the definitive external confirmation and is pending; the two local suites above are the current evidence.)*
705
+
706
+ **The Takeaway:** as of 1.1.4 you no longer have to choose. `lite-signal` keeps the zero-GC, flat-arena profile for 120fps Canvas/WebGL **and** holds parity-or-ahead of alien-signals on dynamic, high-fan-in web-app topologies. The one shape where alien's flatter representation still leads is the 256-deep computed pipeline (DEEP CHAIN, −12%).
671
707
 
672
708
  ### Roadmap
673
709
  - **1.1.5** — additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering).
@@ -676,6 +712,8 @@ The cross-framework reactivity suite agrees independently: `dyn: large web app`
676
712
 
677
713
  > Note: the retracking rewrite that closes the dynamic-topology gap shipped in **1.1.4**, not a future release. The earlier roadmap that listed it under "v1.2" is superseded.
678
714
 
715
+ </details>
716
+
679
717
  ---
680
718
 
681
719
  ## What this is not
@@ -712,6 +750,9 @@ A growing family of zero-GC, ESM-only, sub-2KB packages built on `lite-signal`.
712
750
 
713
751
  ## Browser and runtime support
714
752
 
753
+ <details>
754
+ <summary>Support matrix (Chrome / Firefox / Safari / Node / Bun / Deno / Workers).</summary>
755
+
715
756
  Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
716
757
 
717
758
  | Target | Supported |
@@ -727,10 +768,15 @@ Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScri
727
768
 
728
769
  ESM-only. No CommonJS build — modern bundlers handle this; legacy consumers can use a wrapper.
729
770
 
771
+ </details>
772
+
730
773
  ---
731
774
 
732
775
  ## Integration recipes
733
776
 
777
+ <details>
778
+ <summary>Game HUD (rAF), Twitch config sync, per-tenant sandboxing.</summary>
779
+
734
780
  ### Reactive game HUD with requestAnimationFrame
735
781
 
736
782
  ```js
@@ -791,10 +837,15 @@ function spawnPlugin(pluginCode) {
791
837
  }
792
838
  ```
793
839
 
840
+ </details>
841
+
794
842
  ---
795
843
 
796
844
  ## Conformance
797
845
 
846
+ <details>
847
+ <summary>174/177 on the reactive-framework-test-suite; what lite-signal does and doesn't, by intent.</summary>
848
+
798
849
  lite-signal is evaluated against the
799
850
  [reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
800
851
  the most comprehensive behavioral test battery for JavaScript reactive
@@ -860,10 +911,15 @@ The remaining open items, by intent.
860
911
  Per-test results, the runner adapter, and reproductions live in
861
912
  `/conformance/`.
862
913
 
914
+ </details>
915
+
863
916
  ---
864
917
 
865
918
  ## FAQ
866
919
 
920
+ <details>
921
+ <summary>Microtasks, dual capacities, Object.is, destroy(), framework integration, dep-order stability.</summary>
922
+
867
923
  **Why no microtask scheduler?**
868
924
  Microtask schedulers solve a real problem (deduplicating multiple `set()`s into one effect run) but introduce a worse one: causal opacity. When `signal.set(x)` returns, you don't know whether your effect has run yet. `lite-signal` chooses synchronous flush + explicit `batch()` for the same deduplication outcome with predictable timing.
869
925
 
@@ -888,6 +944,8 @@ The cleanup runs *before* the next computeFn body, so the set's notification arr
888
944
  **Is the dep order stable across re-runs?**
889
945
  Yes, if your computeFn reads its deps in the same order each invocation. The `currentDep` cursor walks the existing dep list and tries to match; matches reuse the existing link (zero alloc), mismatches insert/remove. Stable order = stable performance.
890
946
 
947
+ </details>
948
+
891
949
  ---
892
950
 
893
951
  ## npm scripts
package/Signal.d.ts CHANGED
@@ -99,6 +99,9 @@ export type NodeKind = "signal" | "computed" | "effect";
99
99
 
100
100
  /** Non-perturbing snapshot of a graph neighbour yielded by the `forEach*` walkers. */
101
101
  export interface NodeDescriptor {
102
+ /** Stable per-allocation node id (1.1.5+). Dedupe key for graph traversal, and the
103
+ * re-walk handle: a descriptor may be passed back into forEachObserver/forEachSource. */
104
+ id: number;
102
105
  kind: NodeKind;
103
106
  /** The node's current value (last stored/computed value; an effect's is its body return). */
104
107
  value: unknown;
@@ -174,6 +177,11 @@ export interface Registry {
174
177
  forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
175
178
  /** Walk the sources (dependencies) of `handle`. No-op on a non-handle. */
176
179
  forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
180
+ /** Stable node id of `handle` (1.1.5+), or undefined for a non-handle. */
181
+ nodeId(handle: ReactiveHandle): number | undefined;
182
+ /** The own descriptor of `handle` (1.1.5+), or undefined for a non-handle. Re-walkable:
183
+ * the returned descriptor may be passed back into forEachObserver/forEachSource. */
184
+ describe(handle: ReactiveHandle): NodeDescriptor | undefined;
177
185
  onCleanup(fn: () => void): void;
178
186
  stats(): RegistryStats;
179
187
  /** Reset everything: nodes, links, queues, global clock. Outstanding dispose
@@ -214,6 +222,10 @@ export function observeObservers(handle: ReactiveHandle, hooks?: ObserveObserver
214
222
  export function forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
215
223
  /** Top-level binding of {@link Registry.forEachSource}. */
216
224
  export function forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
225
+ /** Top-level binding of {@link Registry.nodeId}. */
226
+ export function nodeId(handle: ReactiveHandle): number | undefined;
227
+ /** Top-level binding of {@link Registry.describe}. */
228
+ export function describe(handle: ReactiveHandle): NodeDescriptor | undefined;
217
229
  export function onCleanup(fn: () => void): void;
218
230
  export function stats(): RegistryStats;
219
231
  export declare function destroy(): void;
package/Signal.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal v1.1.4
2
+ * @zakkster/lite-signal v1.1.5
3
3
  * --------------------
4
4
  * Zero-GC reactive graph.
5
5
  *
@@ -85,6 +85,8 @@ class ReactiveNode {
85
85
  this.markEpoch = 0;
86
86
  /** Recycle generation: bumped on dispose, used to invalidate stale scheduler closures. */
87
87
  this.gen = 0;
88
+ /** Stable per-allocation id for introspection/devtools (1.1.5). Reassigned on each allocate-from-pool. */
89
+ this.id = 0;
88
90
 
89
91
  /** Captured value at first .set() inside the current batch (revert detection). */
90
92
  this.preBatchValue = undefined;
@@ -194,6 +196,7 @@ export function createRegistry(config = {}) {
194
196
  for (let i = 0; i < currentLinkCapacity - 1; i++) linkPool[i].nextFree = linkPool[i + 1];
195
197
 
196
198
  let activeNodes = 0 | 0;
199
+ let nodeSeq = 1 | 0; // monotonic node-id source for introspection (1.1.5+); wraps modular | 0
197
200
  let activeLinks = 0 | 0;
198
201
  let statSignals = 0 | 0;
199
202
  let statComputeds = 0 | 0;
@@ -239,7 +242,6 @@ export function createRegistry(config = {}) {
239
242
  const po = currentObserver, pt = isTrackingDeps;
240
243
  currentObserver = null;
241
244
  isTrackingDeps = false;
242
-
243
245
  try {
244
246
  e.onConnect();
245
247
  } finally {
@@ -256,7 +258,6 @@ export function createRegistry(config = {}) {
256
258
  const po = currentObserver, pt = isTrackingDeps;
257
259
  currentObserver = null;
258
260
  isTrackingDeps = false;
259
-
260
261
  try {
261
262
  e.onDisconnect();
262
263
  } finally {
@@ -281,12 +282,13 @@ export function createRegistry(config = {}) {
281
282
  * @private
282
283
  */
283
284
  function allocateLink(source, target) {
284
- // Eligibility gate (lite's analogue of alien-signals' shouldTrack): if the observer was
285
- // disposed mid-run — self-dispose, or an outer observer torn down while suspended — its
286
- // flags are cleared to 0. Linking would splice a dead, pool-bound node back into `source`'s
287
- // subscriber list: a phantom edge that mis-targets the instant the slot is recycled. Skip it.
288
- // Cold path only (the inline cursor-hit fast path never reaches here) -> steady-state reads
289
- // pay nothing. Legit tracking always passes a live observer (flags != 0).
285
+ // Eligibility gate (lite's analogue of alien-signals' shouldTrack): if the observer
286
+ // was disposed mid-run — self-dispose, or an outer observer torn down while suspended —
287
+ // its flags are cleared to 0. Linking would splice a dead, pool-bound node back into
288
+ // `source`'s subscriber list: a phantom edge that dangles into the pool and mis-targets
289
+ // the instant that slot is recycled (the next allocation). Skip it. This sits on the COLD
290
+ // path only the inline cursor-hit fast path never reaches allocateLink — so steady-state
291
+ // tracked reads pay nothing. Legit tracking always passes a live observer (flags != 0).
290
292
  if (target.flags === 0) return null;
291
293
  let expected = activeObserverCurrentDep;
292
294
  // Dead on this build: the inline cursor fast-path inside every read
@@ -513,6 +515,8 @@ export function createRegistry(config = {}) {
513
515
  node.revertEpoch = 0;
514
516
  node.preBatchValue = undefined;
515
517
  node.preBatchVersion = 0;
518
+ node.id = nodeSeq;
519
+ nodeSeq = (nodeSeq + 1) | 0; // fresh identity per allocation (1.1.5)
516
520
  return node;
517
521
  }
518
522
 
@@ -1306,7 +1310,22 @@ export function createRegistry(config = {}) {
1306
1310
  function describeNode(node) {
1307
1311
  const fl = node.flags;
1308
1312
  const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
1309
- return {kind, value: node.value};
1313
+ const d = {id: node.id, kind, value: node.value};
1314
+ // Carry the node so the descriptor can be passed BACK into forEachObserver/forEachSource —
1315
+ // this is what lets lite-devtools BFS the full graph. Non-enumerable so {id,kind,value}
1316
+ // stays the visible shape (clean logs / JSON).
1317
+ Object.defineProperty(d, NODE_PTR, {value: node, enumerable: false});
1318
+ return d;
1319
+ }
1320
+
1321
+ function nodeId(handle) {
1322
+ const node = handle != null ? handle[NODE_PTR] : undefined;
1323
+ return node !== undefined ? node.id : undefined;
1324
+ }
1325
+
1326
+ function describe(handle) {
1327
+ const node = handle != null ? handle[NODE_PTR] : undefined;
1328
+ return node !== undefined ? describeNode(node) : undefined;
1310
1329
  }
1311
1330
 
1312
1331
  function forEachObserver(handle, fn) {
@@ -1345,7 +1364,9 @@ export function createRegistry(config = {}) {
1345
1364
  hasObservers,
1346
1365
  observeObservers,
1347
1366
  forEachObserver,
1348
- forEachSource
1367
+ forEachSource,
1368
+ nodeId,
1369
+ describe
1349
1370
  };
1350
1371
  }
1351
1372
 
@@ -1419,6 +1440,14 @@ export function forEachSource(handle, fn) {
1419
1440
  return defaultRegistry.forEachSource(handle, fn);
1420
1441
  }
1421
1442
 
1443
+ export function nodeId(handle) {
1444
+ return defaultRegistry.nodeId(handle);
1445
+ }
1446
+
1447
+ export function describe(handle) {
1448
+ return defaultRegistry.describe(handle);
1449
+ }
1450
+
1422
1451
  /** @type {Registry["onCleanup"]} */
1423
1452
  export function onCleanup(fn) {
1424
1453
  return defaultRegistry.onCleanup(fn);
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
 
@@ -50,7 +50,15 @@ 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.1.5** (current): additive release in service of `@zakkster/lite-devtools` stable
54
+ node identity on the introspection surface. `nodeId(handle)` -> the node's stable
55
+ per-allocation id (the dedupe key for graph walks); `describe(handle)` -> the handle's
56
+ own `{ id, kind, value }` descriptor, **re-walkable** (pass it back into
57
+ `forEachObserver`/`forEachSource` to walk the full DAG); `forEach*` descriptors now carry
58
+ `id`. One SMI write at allocation, node shape kept monomorphic — **zero steady-state
59
+ 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.
60
+
61
+ - **1.1.4**: combined release — a retracking rewrite plus an observer-lifecycle
54
62
  introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
55
63
  reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
56
64
  O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
@@ -111,6 +119,8 @@ function observeObservers(
111
119
  ): () => void; // returns idempotent unobserve
112
120
  function forEachObserver(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
113
121
  function forEachSource(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
122
+ function nodeId(handle: Signal<any> | Computed<any>): number | undefined; // 1.1.5
123
+ function describe(handle: Signal<any> | Computed<any>): NodeDescriptor | undefined; // 1.1.5
114
124
  function onCleanup(fn: () => void): void;
115
125
  function stats(): RegistryStats;
116
126
 
@@ -134,7 +144,8 @@ interface Computed<T> {
134
144
 
135
145
  type Dispose = () => void;
136
146
 
137
- interface NodeDescriptor { // yielded by forEachObserver / forEachSource
147
+ interface NodeDescriptor { // yielded by forEachObserver / forEachSource / describe
148
+ id: number; // stable per-allocation id (1.1.5); dedupe + re-walk key
138
149
  kind: "signal" | "computed" | "effect";
139
150
  value: unknown; // node's current value
140
151
  }
@@ -153,8 +164,8 @@ interface RegistryStats {
153
164
  activeLinks: number;
154
165
  pooledLinks: number;
155
166
  linkPoolCapacity: number;
156
- nodePoolCapacity?: number;
157
- activeNodes?: number;
167
+ nodePoolCapacity: number;
168
+ activeNodes: number;
158
169
  }
159
170
 
160
171
  class CapacityError extends Error {
@@ -235,9 +246,9 @@ path is long rather than wide.
235
246
  These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
236
247
  high-fan-in shapes that were lite-signal's documented weakness — `dyn: large web app`
237
248
  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.
249
+ 1.1.4 retracking rewrite and remain the fastest of five frameworks (re-confirmed on
250
+ 1.1.5, median-of-12): 555ms / 870ms vs alien-signals' 590ms / 933ms, with preact and
251
+ vue ~7–30× slower. See resultsReactive.txt for the full 34-test, 5-framework table.
241
252
 
242
253
  On allocation pressure, lite-signal is alone in the zero-Δheap band: ~15 KB of
243
254
  transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
@@ -287,20 +298,24 @@ sandbox.destroy(); // entire reactive world reset
287
298
 
288
299
  - `Signal.js` — full implementation, single file.
289
300
  - `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.
301
+ - `test/01-core_test.mjs` — signal/computed/effect basics, equality, untrack.
302
+ - `test/02-topology_test.mjs` — diamonds, chains, fan-out/in, cycle detection.
303
+ - `test/03-pool_test.mjs` — capacity errors, grow policy, pool reuse.
304
+ - `test/04-zero-gc_test.mjs` — heap retention (run with --expose-gc).
305
+ - `test/05-scheduler_test.mjs` — scheduler races, dispose, gen counter, version wrap.
306
+ - `test/06-nested-objects_test.mjs` — nested-object & reference-identity behaviours.
307
+ - `test/07-dispose_test.mjs` — universal disposal: registry.dispose(api).
308
+ - `test/08-watch_test.mjs` — new watch reactivity tests.
309
+ - `test/09-conformance_test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
299
310
  - `test/10-is-tracking_test.mjs` — `isTracking()` across observer bodies, untracked windows, and outside any observer.
300
311
  - `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
312
  - `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
313
  - `test/13-introspection_test.mjs` — observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
303
314
  - `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).
315
+ - `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).
316
+ - `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).
317
+ - `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).
318
+ - `test/18-identity_test.mjs` — node identity (1.1.5): `nodeId`/`describe`, descriptor `id`, re-walkable descriptors, non-perturbing (5 tests).
304
319
  - `bench/benchmark.mjs` — anti-DCE throughput harness (ops/s; results.txt).
305
320
  - `bench/benchmarkReactive.mjs` — cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
306
321
  - `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.1.5",
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",