@zakkster/lite-signal 1.1.3 → 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
@@ -3,8 +3,9 @@
3
3
  > Zero-GC reactive graph for hot paths. Object-pooled nodes, versioned push-pull propagation, 32-bit modular epochs. Built for 16ms render budgets and 1MB extension bundles.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-signal.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-signal)
6
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
6
7
  ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
7
- [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-signa?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-signal)
8
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-signal?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-signal)
8
9
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal)
9
10
  [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal)
10
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
@@ -46,6 +47,7 @@ Synchronous, glitch-free, push-pull. No microtask queue, no allocations after wa
46
47
  - [Benchmarks](#benchmarks)
47
48
  - [Testing strategy](#testing-strategy)
48
49
  - [What this is not](#what-this-is-not)
50
+ - [Ecosystem](#ecosystem)
49
51
  - [Browser and runtime support](#browser-and-runtime-support)
50
52
  - [Integration recipes](#integration-recipes)
51
53
  - [Conformance](#conformance)
@@ -102,6 +104,9 @@ Full type definitions ship in [`Signal.d.ts`](./Signal.d.ts) and are referenced
102
104
 
103
105
  ## The case for object pooling
104
106
 
107
+ <details>
108
+ <summary>Why pre-allocate: the GC math, and the per-op zero-allocation table.</summary>
109
+
105
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.
106
111
 
107
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:
@@ -116,10 +121,15 @@ A naive reactive library allocates one object per dependency edge, one per subsc
116
121
 
117
122
  The free lists are singly-linked through a `nextFree` field on each pool object — `O(1)` pop, `O(1)` push, no fragmentation.
118
123
 
124
+ </details>
125
+
119
126
  ---
120
127
 
121
128
  ## Architecture in one diagram
122
129
 
130
+ <details>
131
+ <summary>Pools, the reactive graph, hot-path state, and the doubly-linked edge model.</summary>
132
+
123
133
  ```mermaid
124
134
  flowchart TB
125
135
  subgraph Pools[Pre-allocated object pools]
@@ -161,10 +171,15 @@ Every reactive entity is a `ReactiveNode` with bit flags (`COMPUTED`, `EFFECT`,
161
171
 
162
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.
163
173
 
174
+ </details>
175
+
164
176
  ---
165
177
 
166
178
  ## How a write propagates
167
179
 
180
+ <details>
181
+ <summary>The set → mark → flush sequence, and why computeds stay pull-based.</summary>
182
+
168
183
  ```mermaid
169
184
  sequenceDiagram
170
185
  participant U as User code
@@ -195,6 +210,8 @@ The flush phase uses **two queue buffers** (`effectQueueA` / `effectQueueB`) alt
195
210
 
196
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.
197
212
 
213
+ </details>
214
+
198
215
  ---
199
216
 
200
217
  ## API reference
@@ -296,6 +313,33 @@ Returns `true` iff a read right now would record a dependency on the current reg
296
313
 
297
314
  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
315
 
316
+ ### Observer-lifecycle introspection
317
+
318
+ ```ts
319
+ // Start a ticker only while something is actually watching a derived value.
320
+ const now = signal(performance.now());
321
+ const unobserve = observeObservers(now, {
322
+ onConnect: () => startRAF(), // 0 → 1 observers
323
+ onDisconnect: () => stopRAF(), // 1 → 0 observers
324
+ });
325
+
326
+ hasObservers(now); // O(1): is anyone subscribed right now? (a peek doesn't count)
327
+
328
+ // Walk the live graph in either direction (lite-devtools):
329
+ forEachObserver(sum, d => console.log(d.kind, d.value)); // subscribers of `sum`
330
+ forEachSource(sum, d => console.log(d.kind, d.value)); // dependencies of `sum`
331
+ ```
332
+
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:
334
+
335
+ - **`hasObservers(handle)` → `boolean`** — O(1) (`node.headSub !== null`). The auto-pause predicate.
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.
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.
340
+
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**.
342
+
299
343
  ### onCleanup
300
344
 
301
345
  ```ts
@@ -346,6 +390,9 @@ r.destroy(); // reset all pools, invalidate generations
346
390
 
347
391
  ## Capacity, growth, and the link ceiling
348
392
 
393
+ <details>
394
+ <summary>Pool sizing, the grow policy, and why there is a 16× link ceiling.</summary>
395
+
349
396
  The engine has two pool sizes: **nodes** and **links**. Both are fixed at registry creation but can be configured to grow.
350
397
 
351
398
  ```mermaid
@@ -370,6 +417,8 @@ Default sizing for a Twitch-extension-style budget:
370
417
 
371
418
  `stats()` reports `signals`, `computeds`, `effects`, `activeLinks`, `pooledLinks`, `linkPoolCapacity`. Drop it on screen for live observability.
372
419
 
420
+ </details>
421
+
373
422
  ---
374
423
 
375
424
  ## Watchers
@@ -505,6 +554,9 @@ All three primitives live in a separate module (`Watch.js`) and are re-exported
505
554
 
506
555
  ## Edge cases pinned down
507
556
 
557
+ <details>
558
+ <summary>Diamonds, self-feedback, NaN/±0, throwing bodies, 32-bit version wrap, deep-chain limits.</summary>
559
+
508
560
  These are the questions you'd ask in a code review, with the answers:
509
561
 
510
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.
@@ -518,26 +570,29 @@ These are the questions you'd ask in a code review, with the answers:
518
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.
519
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.
520
572
 
573
+ </details>
574
+
521
575
  ---
522
576
 
523
577
  ## Benchmarks
524
578
 
525
- 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 × 50+ outer invocations (median reported). Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters.
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.)*
526
580
 
527
- | Scenario | What it stresses | lite-signal | alien-signals | preact | solid-js |
528
- | ---------- | -------------------------------- | ----------- | ------------- | ---------- | --------- |
529
- | **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **249K ops/s** | 207K | 153K | 77K |
530
- | **BROADCAST** | 1 signal → 1000 effects (fan-out) | **24K** | 22K | 17K | 8K |
531
- | **KAIROS** | 1 signal → 1000 computeds → 1 effect | **14K** | 13K | 12K | 4K |
532
- | **DEEP CHAIN** | 256-deep computed chain → 1 effect | 51K | **60K** | 50K | 15K |
533
- | **Δheap MUX** | transient alloc pressure | **15 KB** | 3,920 KB | 4,325 KB | 2,816 KB |
534
- | **Retained MUX** | state surviving forced GC | **−20 KB** (none) | −2 KB | 6 KB | −3 KB |
581
+ | Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
582
+ | ---------- | -------------------------------- | ----------- | ------------- | ------------- |
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 | |
589
+ | **Retained MUX** | state surviving forced GC | **−7 KB** (none) | −1 KB | — |
535
590
 
536
- **Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+20%**, **BROADCAST** (fan-out) by **+9%**, and **KAIROS** (one source feeding a wide layer of memos) by **+8%** — three of the four scenarios. These are the patterns that dominate real UI workloads: dashboards, scoreboards, HUDs, leaderboards, and any view that aggregates many inputs into a single computed slice. `alien-signals` retains a **−15% 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.
537
592
 
538
- On allocation pressure, `lite-signal` is alone in the zero-Δheap band: ~15 KB of transient garbage across 20,000 iterations regardless of scenario. 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 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.
539
594
 
540
- > Note on the +71 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.
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.
541
596
 
542
597
  The benchmark harness is in [`bench/benchmark.mjs`](./bench/benchmark.mjs); a full methodology write-up — including the anti-DCE design, workload diagrams, variance discipline, reproducibility recipe, and a self-validation procedure for the harness itself — lives in [`bench/README.md`](./bench/README.md). It:
543
598
 
@@ -562,15 +617,23 @@ Three tiers, all reproducible.
562
617
 
563
618
  `npm test` runs the suite in `test/`, covering:
564
619
 
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.
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`).
567
- - **`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.
568
- - **`05-scheduler.test.mjs`** — scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
569
- - **`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.
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.
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.
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.
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).
574
637
 
575
638
  ```bash
576
639
  npm test
@@ -578,7 +641,7 @@ npm test
578
641
 
579
642
  ### Tier 2 — Memory (allocation-free verification)
580
643
 
581
- `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`:
582
645
 
583
646
  - 100,000 `set()` calls on a graph with effects retain **< 200 KB** of heap.
584
647
  - 1,000 create/dispose cycles retain **< 50 KB**.
@@ -593,7 +656,7 @@ npm run test:gc
593
656
 
594
657
  ### Tier 3 — Performance (comparative benchmark)
595
658
 
596
- `npm run bench` runs the four-scenario comparative benchmark from the previous section. Output is plain text — easy to copy into PRs and changelogs.
659
+ `npm run bench` runs the comparative benchmark (9 scenarios — stable fan-in/out + dynamic/layered DAGs) against alien-signals (results.txt). `npm run bench-reactive` runs the cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, and solid (resultsReactive.txt). Output is plain text — easy to copy into PRs and changelogs.
597
660
 
598
661
  ```bash
599
662
  npm run bench
@@ -609,29 +672,47 @@ npm run verify # test + test:gc + a sanity bench
609
672
 
610
673
  ## Performance Trade-offs & Topology Scaling
611
674
 
675
+ <details>
676
+ <summary>Stable vs dynamic topologies; Andrii Volynets' matrix, the 1.1.4 result, and the roadmap.</summary>
677
+
612
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.
613
679
 
614
- However, flat arrays come with a mathematical trade-off. While memory allocation is $O(1)$, modifying a flat array during dynamic dependency churn requires $O(N)$ linear scans.
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).
615
681
 
616
- **Andrii Volynets** (author of the phenomenal [Alien Signals](https://github.com/stackblitz/alien-signals)) generously ran `lite-signal` through his advanced topology matrix. The results clearly highlight where the zero-GC flat-array architecture excels, and where pointer-based graphs (like Alien/Reflex) take the lead:
682
+ **Andrii Volynets** (author of the phenomenal [Alien Signals](https://github.com/stackblitz/alien-signals)) generously ran `lite-signal` through his advanced topology matrix on the **v1.1.2** engine. Those numbers the *pre-rewrite baseline* are below, followed by the 1.1.4 result.
617
683
 
618
684
  #### 1. Stable Topologies (Fan-in / Fan-out / Broadcast)
619
- In stable environments (typical of game engines, particle systems, and visualizers), `lite-signal` is blisteringly fast and maintains a near-zero allocation profile, keeping frame times perfectly flat.
620
-
621
- #### 2. Dynamic Topologies (Web Apps / Layered DAGs)
622
- In highly chaotic graphs with branch switching, selective reads, and wide dense churn (typical of large DOM-based web frameworks like Vue or React), the $O(N)$ edge traversal cost of flat arrays becomes the dominant bottleneck.
685
+ In stable environments (game engines, particle systems, visualizers), `lite-signal` is blisteringly fast and maintains a near-zero allocation profile, keeping frame times perfectly flat — unchanged through 1.1.4.
623
686
 
624
- *Andrii's benchmark results for dynamic topologies:*
625
- | Scenario | alien-signals | reflex | lite-signal |
687
+ #### 2. Dynamic Topologies (Web Apps / Layered DAGs) — closed in 1.1.4
688
+ *Andrii's v1.1.2 baseline (his host) where the cursor retracking lost:*
689
+ | Scenario | alien-signals | reflex | lite-signal (1.1.2) |
626
690
  | :--- | :--- | :--- | :--- |
627
691
  | **1000x12 (4 sources, dynamic)** | 184ms | 194ms | 2031ms |
628
692
  | **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
629
693
  | **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
630
694
 
631
- **The Takeaway:** "Zero-GC" and "topology scalability" are orthogonal dimensions. If you are building a DOM framework with massive dynamic `v-if` churn, use Alien Signals. If you are building a 120fps Canvas game with a stable scene graph where any GC pause is a dropped frame, use `lite-signal`.
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 |
697
+ | :--- | :--- | :--- | :--- |
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%** |
701
+
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 conditions — so 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)).
703
+
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%).
707
+
708
+ ### Roadmap
709
+ - **1.1.5** — additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering).
710
+ - **1.2.0** — the **ownership hybrid**: an owner tree so nested effects/computeds auto-dispose with their parent (closes conformance #209 / #210, matching Solid's `createRoot` ergonomics).
711
+ - **1.3** — next engine work after the owner-tree validation.
712
+
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.
632
714
 
633
- ### Roadmap: v1.2
634
- v1.2 (in benchmark validation) — Andrii Volynets (alien-signals#117) extended the benchmark matrix to dynamic-topology workloads and identified retracking-cost asymptotics, not allocation pressure, as the dominant cost in those scenarios. v1.2 replaces the cursor-based retracking with per-source version-stamped reconciliation: O(1) per read regardless of read order or dep-set churn. v1.2 will be validated against the alien-signals topology matrix and the before/after numbers published on release.
715
+ </details>
635
716
 
636
717
  ---
637
718
 
@@ -639,13 +720,39 @@ v1.2 (in benchmark validation) — Andrii Volynets (alien-signals#117) extended
639
720
 
640
721
  - **A virtual DOM, JSX runtime, or rendering library.** It's the substrate. Plug it under whatever rendering layer you like.
641
722
  - **A general-purpose state container.** No time-travel, no devtools integration, no serialization. (Build those on top if you need them.)
642
- - **A perfect fit for every workload.** If your reactive graph is mostly long chains of memos with chaotic read order, `alien-signals` is genuinely faster on those shapes. `lite-signal` optimizes for *stable* read order — the same observer reading the same deps in the same order, frame after frame, which is the dominant pattern in animation loops and HUD overlays.
723
+ - **A perfect fit for every workload.** On *256-deep computed pipelines* (DEEP CHAIN) `alien-signals` is still a bit faster its flatter representation pays off when the propagation path is long rather than wide. (Through 1.1.2 this caveat also covered chaotic, high-fan-in read order; 1.1.4's retracking rewrite closed that those shapes are now parity-or-ahead.) `lite-signal` is at its best on the fan-in / fan-out / wide-memo and dynamic-churn patterns that dominate animation loops, HUDs, and dashboards.
643
724
  - **A library for the server.** It works in Node, but there's no SSR story. Use it on the client.
644
725
 
645
726
  ---
646
727
 
728
+ ## Ecosystem
729
+
730
+ A growing family of zero-GC, ESM-only, sub-2KB packages built on `lite-signal`. All MIT, all by [@zakkster](https://www.npmjs.com/~zakkster).
731
+
732
+ **State & data**
733
+ - [`@zakkster/lite-store`](https://www.npmjs.com/package/@zakkster/lite-store) — Fine-grained reactivity for objects & arrays via Proxy. Direct mutation; lazy per-key signals (allocated only on first tracked read); proxy identity preserved across reads; cycle-safe disposal walk.
734
+ - [`@zakkster/lite-resource`](https://www.npmjs.com/package/@zakkster/lite-resource) — Async state as a signal. `resource(source, fetcher)` exposes data/error/loading/state with race-safe commits (generation guard), AbortSignal, stale-while-revalidate, and optimistic mutate.
735
+ - [`@zakkster/lite-form`](https://www.npmjs.com/package/@zakkster/lite-form) — Headless reactive forms. One validator per keystroke, hoisted Zod/Yup schema, ~1.5M keystrokes/sec on a 100-field form (8× the hand-written pattern). No DOM, no VDOM, no compiler.
736
+ - [`@zakkster/lite-router`](https://www.npmjs.com/package/@zakkster/lite-router) — Zero-GC sub-2KB SPA router. URL pathname, query params, and route matches as fine-grained signals — components re-render only when their slice of the URL changes.
737
+ - [`@zakkster/lite-persist`](https://www.npmjs.com/package/@zakkster/lite-persist) — Zero-GC reactive persistence. Debounced, coalesced localStorage/sessionStorage sync with cross-tab mirroring — a burst of writes becomes one storage write.
738
+ - [`@zakkster/lite-channel`](https://www.npmjs.com/package/@zakkster/lite-channel) — Cross-tab synchronization over BroadcastChannel. Multiplexed per-key sync, last-writer-wins (Lamport clock + tab-id tiebreak), reactive presence (peers, status, leader election as signals).
739
+
740
+ **Rendering (DOM / Canvas)**
741
+ - [`@zakkster/lite-element`](https://www.npmjs.com/package/@zakkster/lite-element) — Zero-GC reactive Custom Elements, no virtual DOM or templating. Component state survives synchronous reparents (sort, drag-and-drop, `insertBefore`) — the moves that destroy React, Vue, and Lit components.
742
+ - [`@zakkster/lite-virtual`](https://www.npmjs.com/package/@zakkster/lite-virtual) — Thrash-free list/grid windowing. Integer-gated reactive indices + `Object.is` cutoff means scrolling within a row writes zero bytes to the DOM. ~3.6M sub-row scrolls/sec, bounded pool regardless of count, fixed and variable heights, 2-D grid.
743
+ - [`@zakkster/lite-scene`](https://www.npmjs.com/package/@zakkster/lite-scene) — Reactive retained-mode Canvas2D scene graph. Nodes (group/rect/circle/line/text/image/path) take signals as props; the renderer redraws only what changed. Hit testing, clip groups, pointerEvents, nested transforms.
744
+
745
+ **Time & scheduling**
746
+ - [`@zakkster/lite-raf`](https://www.npmjs.com/package/@zakkster/lite-raf) — Zero-GC frame-rate scheduling. One `requestAnimationFrame` loop; frameTime/frameDelta/frameCount as signals; `rafEffect()` — reactive effects that run at most once per frame. Built for canvas/WebGL render loops and games.
747
+ - [`@zakkster/lite-time`](https://www.npmjs.com/package/@zakkster/lite-time) — Reactive, drift-corrected wall-clock cadence. One 1s heartbeat; zero-GC relativeTime/countdown/every; deterministic for tests and SSR. Not a date library — `Intl` does formatting, you bring the dates.
748
+
749
+ ---
750
+
647
751
  ## Browser and runtime support
648
752
 
753
+ <details>
754
+ <summary>Support matrix (Chrome / Firefox / Safari / Node / Bun / Deno / Workers).</summary>
755
+
649
756
  Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
650
757
 
651
758
  | Target | Supported |
@@ -661,10 +768,15 @@ Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScri
661
768
 
662
769
  ESM-only. No CommonJS build — modern bundlers handle this; legacy consumers can use a wrapper.
663
770
 
771
+ </details>
772
+
664
773
  ---
665
774
 
666
775
  ## Integration recipes
667
776
 
777
+ <details>
778
+ <summary>Game HUD (rAF), Twitch config sync, per-tenant sandboxing.</summary>
779
+
668
780
  ### Reactive game HUD with requestAnimationFrame
669
781
 
670
782
  ```js
@@ -725,16 +837,21 @@ function spawnPlugin(pluginCode) {
725
837
  }
726
838
  ```
727
839
 
840
+ </details>
841
+
728
842
  ---
729
843
 
730
844
  ## Conformance
731
845
 
846
+ <details>
847
+ <summary>174/177 on the reactive-framework-test-suite; what lite-signal does and doesn't, by intent.</summary>
848
+
732
849
  lite-signal is evaluated against the
733
850
  [reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
734
851
  the most comprehensive behavioral test battery for JavaScript reactive
735
852
  libraries.
736
853
 
737
- As of **v1.1.2**, the conformance items that were open at v1.1.0 — batch revert
854
+ As of **v1.1.2** — and unchanged through **1.1.4** (the 1.1.4 retracking rewrite is verified behavior-preserving by `retracking.difftest.mjs`: 20,000 direct + 10,000 batched writes, 0 disagreements) — the conformance items that were open at v1.1.0 — batch revert
738
855
  detection (#123 / #132 / #147), throw isolation in flush (#121), and
739
856
  inner-write propagation through computed chains (#180 / #213) — are **closed**
740
857
  (landed in v1.1.1). The remaining gaps are one deliberate design choice (#179,
@@ -763,6 +880,10 @@ more useful to library users than a green checkmark.
763
880
  and most others by one re-evaluation per write.
764
881
  - **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
765
882
  solid. Half the field leaks the subscription.
883
+ - **Observer-lifecycle introspection** (`hasObservers` / `observeObservers`,
884
+ 1.1.4): the 0→1 and 1→0 observer transitions are first-class, zero-cost-when-
885
+ unused hooks — the basis for auto-pausing a clock or RAF loop only while a
886
+ derived value is watched. Few signal libraries expose this.
766
887
 
767
888
  ### What lite-signal does NOT do yet
768
889
 
@@ -790,10 +911,15 @@ The remaining open items, by intent.
790
911
  Per-test results, the runner adapter, and reproductions live in
791
912
  `/conformance/`.
792
913
 
914
+ </details>
915
+
793
916
  ---
794
917
 
795
918
  ## FAQ
796
919
 
920
+ <details>
921
+ <summary>Microtasks, dual capacities, Object.is, destroy(), framework integration, dep-order stability.</summary>
922
+
797
923
  **Why no microtask scheduler?**
798
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.
799
925
 
@@ -818,6 +944,8 @@ The cleanup runs *before* the next computeFn body, so the set's notification arr
818
944
  **Is the dep order stable across re-runs?**
819
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.
820
946
 
947
+ </details>
948
+
821
949
  ---
822
950
 
823
951
  ## npm scripts
@@ -825,8 +953,9 @@ Yes, if your computeFn reads its deps in the same order each invocation. The `cu
825
953
  ```bash
826
954
  npm test # behavior suite, ~1.3s
827
955
  npm run test:gc # zero-gc suite, requires --expose-gc, ~3s
828
- npm run bench # comparative benchmark, ~5min wall clock
829
- npm run verify # all of the above; gate for publish
956
+ npm run bench # comparative benchmark vs alien-signals (results.txt), ~5min
957
+ npm run bench-reactive # 5-framework reactivity suite (resultsReactive.txt)
958
+ npm run verify # test + test:gc + sanity bench; gate for publish
830
959
  ```
831
960
 
832
961
  ---
package/Signal.d.ts CHANGED
@@ -92,6 +92,35 @@ export interface RegistryStats {
92
92
  activeNodes: number;
93
93
  }
94
94
 
95
+ // ─── Observer-lifecycle introspection (1.1.4) ─────────────────────────────────
96
+
97
+ /** Whether a described node is a signal, a computed, or an effect. */
98
+ export type NodeKind = "signal" | "computed" | "effect";
99
+
100
+ /** Non-perturbing snapshot of a graph neighbour yielded by the `forEach*` walkers. */
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;
105
+ kind: NodeKind;
106
+ /** The node's current value (last stored/computed value; an effect's is its body return). */
107
+ value: unknown;
108
+ }
109
+
110
+ /** Transition callbacks for {@link Registry.observeObservers}. */
111
+ export interface ObserveObserversHooks {
112
+ /** Fired on the 0→1 observer transition (after registration). */
113
+ onConnect?: () => void;
114
+ /** Fired on the 1→0 observer transition. */
115
+ onDisconnect?: () => void;
116
+ }
117
+
118
+ /** Stops an {@link Registry.observeObservers} subscription. Idempotent. */
119
+ export type Unobserve = () => void;
120
+
121
+ /** Anything carrying a node identity that the introspection surface can read. */
122
+ export type ReactiveHandle = Signal<any> | Computed<any>;
123
+
95
124
  // ─── Errors ───────────────────────────────────────────────────────────────────
96
125
 
97
126
  /** Thrown when a pool ceiling is hit. */
@@ -137,6 +166,22 @@ export interface Registry {
137
166
  * False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and
138
167
  * outside any observer. Use for lazy-allocation wrappers like lite-store. */
139
168
  isTracking(): boolean;
169
+ /** O(1): does this source have at least one live observer right now? A `peek` does not count. */
170
+ hasObservers(handle: ReactiveHandle): boolean;
171
+ /** Auto-pause hook: fires `onConnect` on the 0→1 observer transition and `onDisconnect`
172
+ * on 1→0, after registration (transition-only — no immediate fire if already observed).
173
+ * Re-tracking a persistently-read source does not churn. Returns an idempotent unobserve.
174
+ * @throws TypeError if `handle` is not a reactive handle. */
175
+ observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
176
+ /** Walk the observers (subscribers) of `handle`, newest-first. No-op on a non-handle. */
177
+ forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
178
+ /** Walk the sources (dependencies) of `handle`. No-op on a non-handle. */
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;
140
185
  onCleanup(fn: () => void): void;
141
186
  stats(): RegistryStats;
142
187
  /** Reset everything: nodes, links, queues, global clock. Outstanding dispose
@@ -169,6 +214,18 @@ export function batch<T>(fn: () => T): T;
169
214
  export function untrack<T>(fn: () => T): T;
170
215
  /** Top-level binding of {@link Registry.isTracking} against the default registry. */
171
216
  export function isTracking(): boolean;
217
+ /** Top-level binding of {@link Registry.hasObservers}. */
218
+ export function hasObservers(handle: ReactiveHandle): boolean;
219
+ /** Top-level binding of {@link Registry.observeObservers}. */
220
+ export function observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
221
+ /** Top-level binding of {@link Registry.forEachObserver}. */
222
+ export function forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
223
+ /** Top-level binding of {@link Registry.forEachSource}. */
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;
172
229
  export function onCleanup(fn: () => void): void;
173
230
  export function stats(): RegistryStats;
174
231
  export declare function destroy(): void;