@zakkster/lite-signal 1.1.2 → 1.1.4

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)
@@ -90,6 +92,7 @@ No microtask between `B` and `I`. No promise, no `queueMicrotask`. Just call sta
90
92
  - **`dispose(api)`** — universal disposal for signals, computeds, and effect handles. Cross-registry calls are silent no-ops.
91
93
  - **`batch(fn)`** — defer effect flush until the outermost batch closes.
92
94
  - **`untrack(fn)`** — read without subscribing.
95
+ - **`isTracking()`** — `true` iff a read right now would subscribe (for lazy-allocation wrappers).
93
96
  - **`onCleanup(fn)`** — register teardown for the current computation. Works in effects *and* computeds.
94
97
  - **`createRegistry(config)`** — isolated pool for tests, plugins, sandboxing.
95
98
  - **`stats()`** — pool occupancy snapshot. Used by the demo and easy to wire into perf overlays.
@@ -273,6 +276,53 @@ const value = untrack(() => s()); // read without subscribing
273
276
 
274
277
  Useful inside computeds/effects when you need a current value but don't want it as a dependency.
275
278
 
279
+ ### isTracking
280
+
281
+ ```ts
282
+ function makeLazyField(initial) {
283
+ let s = null, value = initial;
284
+ return {
285
+ get() {
286
+ if (isTracking()) {
287
+ if (s === null) s = signal(value); // allocate only when subscribed
288
+ return s();
289
+ }
290
+ return value;
291
+ },
292
+ set(v) { value = v; if (s !== null) s.set(v); }
293
+ };
294
+ }
295
+ ```
296
+
297
+ Returns `true` iff a read right now would record a dependency on the current registry — an observer body is on the stack AND tracking is enabled. Mirrors the engine's own read-trap check (both flags), so it correctly returns `false` inside `untrack`, inside `subscribe` callbacks, inside `onCleanup` bodies, inside `watch` / `when` callbacks, and outside any observer.
298
+
299
+ 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.
300
+
301
+ ### Observer-lifecycle introspection
302
+
303
+ ```ts
304
+ // Start a ticker only while something is actually watching a derived value.
305
+ const now = signal(performance.now());
306
+ const unobserve = observeObservers(now, {
307
+ onConnect: () => startRAF(), // 0 → 1 observers
308
+ onDisconnect: () => stopRAF(), // 1 → 0 observers
309
+ });
310
+
311
+ hasObservers(now); // O(1): is anyone subscribed right now? (a peek doesn't count)
312
+
313
+ // Walk the live graph in either direction (lite-devtools):
314
+ forEachObserver(sum, d => console.log(d.kind, d.value)); // subscribers of `sum`
315
+ forEachSource(sum, d => console.log(d.kind, d.value)); // dependencies of `sum`
316
+ ```
317
+
318
+ Four functions (top-level + per-registry), added in 1.1.4, for auto-pausing wrappers and graph inspection:
319
+
320
+ - **`hasObservers(handle)` → `boolean`** — O(1) (`node.headSub !== null`). The auto-pause predicate.
321
+ - **`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.
323
+
324
+ 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
+
276
326
  ### onCleanup
277
327
 
278
328
  ```ts
@@ -499,22 +549,23 @@ These are the questions you'd ask in a code review, with the answers:
499
549
 
500
550
  ## Benchmarks
501
551
 
502
- 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.
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).
503
553
 
504
- | Scenario | What it stresses | lite-signal | alien-signals | preact | solid-js |
505
- | ---------- | -------------------------------- | ----------- | ------------- | ---------- | --------- |
506
- | **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **249K ops/s** | 207K | 153K | 77K |
507
- | **BROADCAST** | 1 signal → 1000 effects (fan-out) | **24K** | 22K | 17K | 8K |
508
- | **KAIROS** | 1 signal → 1000 computeds → 1 effect | **14K** | 13K | 12K | 4K |
509
- | **DEEP CHAIN** | 256-deep computed chain → 1 effect | 51K | **60K** | 50K | 15K |
510
- | **Δheap MUX** | transient alloc pressure | **15 KB** | 3,920 KB | 4,325 KB | 2,816 KB |
511
- | **Retained MUX** | state surviving forced GC | **−20 KB** (none) | −2 KB | −6 KB | −3 KB |
554
+ | Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
555
+ | ---------- | -------------------------------- | ----------- | ------------- | ------------- |
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 | |
562
+ | **Retained MUX** | state surviving forced GC | **−7 KB** (none) | −1 KB | — |
512
563
 
513
- **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.
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.
514
565
 
515
- 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.
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.
516
567
 
517
- > 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.
568
+ > 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.
518
569
 
519
570
  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:
520
571
 
@@ -547,6 +598,11 @@ Three tiers, all reproducible.
547
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.
548
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.
549
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.
550
606
 
551
607
  ```bash
552
608
  npm test
@@ -569,7 +625,7 @@ npm run test:gc
569
625
 
570
626
  ### Tier 3 — Performance (comparative benchmark)
571
627
 
572
- `npm run bench` runs the four-scenario comparative benchmark from the previous section. Output is plain text — easy to copy into PRs and changelogs.
628
+ `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.
573
629
 
574
630
  ```bash
575
631
  npm run bench
@@ -587,27 +643,38 @@ npm run verify # test + test:gc + a sanity bench
587
643
 
588
644
  `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.
589
645
 
590
- 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.
646
+ 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).
591
647
 
592
- **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:
648
+ **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.
593
649
 
594
650
  #### 1. Stable Topologies (Fan-in / Fan-out / Broadcast)
595
- 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.
651
+ 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.
596
652
 
597
- #### 2. Dynamic Topologies (Web Apps / Layered DAGs)
598
- 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.
599
-
600
- *Andrii's benchmark results for dynamic topologies:*
601
- | Scenario | alien-signals | reflex | lite-signal |
653
+ #### 2. Dynamic Topologies (Web Apps / Layered DAGs) — closed in 1.1.4
654
+ *Andrii's v1.1.2 baseline (his host) where the cursor retracking lost:*
655
+ | Scenario | alien-signals | reflex | lite-signal (1.1.2) |
602
656
  | :--- | :--- | :--- | :--- |
603
657
  | **1000x12 (4 sources, dynamic)** | 184ms | 194ms | 2031ms |
604
658
  | **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
605
659
  | **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
606
660
 
607
- **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`.
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 |
663
+ | :--- | :--- | :--- | :--- |
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%** |
667
+
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.)*
669
+
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%).
671
+
672
+ ### Roadmap
673
+ - **1.1.5** — additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering).
674
+ - **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).
675
+ - **1.3** — next engine work after the owner-tree validation.
608
676
 
609
- ### Roadmap: v1.2
610
- 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.
677
+ > 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.
611
678
 
612
679
  ---
613
680
 
@@ -615,11 +682,34 @@ v1.2 (in benchmark validation) — Andrii Volynets (alien-signals#117) extended
615
682
 
616
683
  - **A virtual DOM, JSX runtime, or rendering library.** It's the substrate. Plug it under whatever rendering layer you like.
617
684
  - **A general-purpose state container.** No time-travel, no devtools integration, no serialization. (Build those on top if you need them.)
618
- - **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.
685
+ - **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.
619
686
  - **A library for the server.** It works in Node, but there's no SSR story. Use it on the client.
620
687
 
621
688
  ---
622
689
 
690
+ ## Ecosystem
691
+
692
+ 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).
693
+
694
+ **State & data**
695
+ - [`@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.
696
+ - [`@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.
697
+ - [`@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.
698
+ - [`@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.
699
+ - [`@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.
700
+ - [`@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).
701
+
702
+ **Rendering (DOM / Canvas)**
703
+ - [`@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.
704
+ - [`@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.
705
+ - [`@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.
706
+
707
+ **Time & scheduling**
708
+ - [`@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.
709
+ - [`@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.
710
+
711
+ ---
712
+
623
713
  ## Browser and runtime support
624
714
 
625
715
  Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
@@ -710,7 +800,7 @@ lite-signal is evaluated against the
710
800
  the most comprehensive behavioral test battery for JavaScript reactive
711
801
  libraries.
712
802
 
713
- As of **v1.1.2**, the conformance items that were open at v1.1.0 — batch revert
803
+ 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
714
804
  detection (#123 / #132 / #147), throw isolation in flush (#121), and
715
805
  inner-write propagation through computed chains (#180 / #213) — are **closed**
716
806
  (landed in v1.1.1). The remaining gaps are one deliberate design choice (#179,
@@ -739,6 +829,10 @@ more useful to library users than a green checkmark.
739
829
  and most others by one re-evaluation per write.
740
830
  - **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
741
831
  solid. Half the field leaks the subscription.
832
+ - **Observer-lifecycle introspection** (`hasObservers` / `observeObservers`,
833
+ 1.1.4): the 0→1 and 1→0 observer transitions are first-class, zero-cost-when-
834
+ unused hooks — the basis for auto-pausing a clock or RAF loop only while a
835
+ derived value is watched. Few signal libraries expose this.
742
836
 
743
837
  ### What lite-signal does NOT do yet
744
838
 
@@ -801,8 +895,9 @@ Yes, if your computeFn reads its deps in the same order each invocation. The `cu
801
895
  ```bash
802
896
  npm test # behavior suite, ~1.3s
803
897
  npm run test:gc # zero-gc suite, requires --expose-gc, ~3s
804
- npm run bench # comparative benchmark, ~5min wall clock
805
- npm run verify # all of the above; gate for publish
898
+ npm run bench # comparative benchmark vs alien-signals (results.txt), ~5min
899
+ npm run bench-reactive # 5-framework reactivity suite (resultsReactive.txt)
900
+ npm run verify # test + test:gc + sanity bench; gate for publish
806
901
  ```
807
902
 
808
903
  ---
package/Signal.d.ts CHANGED
@@ -92,6 +92,32 @@ 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
+ kind: NodeKind;
103
+ /** The node's current value (last stored/computed value; an effect's is its body return). */
104
+ value: unknown;
105
+ }
106
+
107
+ /** Transition callbacks for {@link Registry.observeObservers}. */
108
+ export interface ObserveObserversHooks {
109
+ /** Fired on the 0→1 observer transition (after registration). */
110
+ onConnect?: () => void;
111
+ /** Fired on the 1→0 observer transition. */
112
+ onDisconnect?: () => void;
113
+ }
114
+
115
+ /** Stops an {@link Registry.observeObservers} subscription. Idempotent. */
116
+ export type Unobserve = () => void;
117
+
118
+ /** Anything carrying a node identity that the introspection surface can read. */
119
+ export type ReactiveHandle = Signal<any> | Computed<any>;
120
+
95
121
  // ─── Errors ───────────────────────────────────────────────────────────────────
96
122
 
97
123
  /** Thrown when a pool ceiling is hit. */
@@ -133,6 +159,21 @@ export interface Registry {
133
159
  dispose(api: Disposable): void;
134
160
  batch<T>(fn: () => T): T;
135
161
  untrack<T>(fn: () => T): T;
162
+ /** True iff a read RIGHT NOW would record a dependency on this registry.
163
+ * False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and
164
+ * outside any observer. Use for lazy-allocation wrappers like lite-store. */
165
+ isTracking(): boolean;
166
+ /** O(1): does this source have at least one live observer right now? A `peek` does not count. */
167
+ hasObservers(handle: ReactiveHandle): boolean;
168
+ /** Auto-pause hook: fires `onConnect` on the 0→1 observer transition and `onDisconnect`
169
+ * on 1→0, after registration (transition-only — no immediate fire if already observed).
170
+ * Re-tracking a persistently-read source does not churn. Returns an idempotent unobserve.
171
+ * @throws TypeError if `handle` is not a reactive handle. */
172
+ observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
173
+ /** Walk the observers (subscribers) of `handle`, newest-first. No-op on a non-handle. */
174
+ forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
175
+ /** Walk the sources (dependencies) of `handle`. No-op on a non-handle. */
176
+ forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
136
177
  onCleanup(fn: () => void): void;
137
178
  stats(): RegistryStats;
138
179
  /** Reset everything: nodes, links, queues, global clock. Outstanding dispose
@@ -163,6 +204,16 @@ export function effect(fn: () => void, opts?: EffectOptions): Dispose;
163
204
  export function dispose(api: Disposable): void;
164
205
  export function batch<T>(fn: () => T): T;
165
206
  export function untrack<T>(fn: () => T): T;
207
+ /** Top-level binding of {@link Registry.isTracking} against the default registry. */
208
+ export function isTracking(): boolean;
209
+ /** Top-level binding of {@link Registry.hasObservers}. */
210
+ export function hasObservers(handle: ReactiveHandle): boolean;
211
+ /** Top-level binding of {@link Registry.observeObservers}. */
212
+ export function observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
213
+ /** Top-level binding of {@link Registry.forEachObserver}. */
214
+ export function forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
215
+ /** Top-level binding of {@link Registry.forEachSource}. */
216
+ export function forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
166
217
  export function onCleanup(fn: () => void): void;
167
218
  export function stats(): RegistryStats;
168
219
  export declare function destroy(): void;
package/Signal.js CHANGED
@@ -1,8 +1,33 @@
1
1
  /**
2
- * @zakkster/lite-signal v1.1.2
2
+ * @zakkster/lite-signal v1.1.4
3
3
  * --------------------
4
4
  * Zero-GC reactive graph.
5
5
  *
6
+ * CHANGES vs 1.1.2 (both perf-only; public surface and semantics unchanged):
7
+ * 1. pullComputed clean short-circuit. markDownstream already stamps markEpoch
8
+ * on every node in a changed signal's transitive cone; pullComputed now uses
9
+ * it to return a cached value in O(1) when no mark landed since the last eval,
10
+ * instead of walking (and recursively pulling) the whole dependency subtree.
11
+ * Erases the dynamic-graph regression (batched read-after-write workloads):
12
+ * "large web app" 4824ms -> 650ms, "wide dense" 4378ms -> 908ms locally.
13
+ * 2. allocateLink: the O(N) linear headDep scan on a cursor miss is replaced by
14
+ * an O(1) source.tailSub dedup. Dependency re-tracking is now O(N) regardless
15
+ * of read order, not O(N^2). Wide reordering nodes: ~50x locally (600-dep
16
+ * flip: 2810ms -> 46ms). First-track of a wide node: ~4.4x (48ms -> 10ms).
17
+ * SEVER-FIRST: on a cursor-miss divergence the unmatched dep tail is freed
18
+ * BEFORE any new link is allocated, so peak link usage never exceeds steady
19
+ * state (ZERO pool debt). A divergent re-track therefore cannot trigger a
20
+ * mid-compute pool growth under tight maxLinks + "throw" — the zero-GC budget
21
+ * holds. (The alternative "splice" variant is ~equal speed but transiently
22
+ * holds up to one node's fan-in of extra links until end-of-frame severTail.)
23
+ *
24
+ * EDGE NOTE (2): a node that reads the SAME source twice within one body, with a
25
+ * nested computed that also reads that source evaluated in between, retains ONE
26
+ * redundant dependency link for the node's lifetime. It is value-correct, bounded
27
+ * (does not grow across re-tracks), and reclaimed on dispose/destroy. The previous
28
+ * O(N) scan collapsed this to a single link; the O(1) dedup cannot see past a
29
+ * nested re-link of the same source. This is a deliberate cost/correctness trade.
30
+ *
6
31
  * Architecture: monomorphic object pool + versioned push-pull propagation
7
32
  * + SMI modular arithmetic for 32-bit version-wrap safety.
8
33
  *
@@ -79,6 +104,8 @@ class ReactiveNode {
79
104
 
80
105
  // Pool free-list pointer.
81
106
  this.nextFree = null;
107
+
108
+ this.schedulerThunk = undefined;
82
109
  }
83
110
  }
84
111
 
@@ -151,13 +178,19 @@ export function createRegistry(config = {}) {
151
178
 
152
179
  // --- ZERO-GC OBJECT POOLS ---
153
180
  const nodePool = [];
181
+
154
182
  for (let i = 0; i < currentNodesCapacity; i++) nodePool[i] = new ReactiveNode();
183
+
155
184
  let freeNodeHead = nodePool[0];
185
+
156
186
  for (let i = 0; i < currentNodesCapacity - 1; i++) nodePool[i].nextFree = nodePool[i + 1];
157
187
 
158
188
  const linkPool = [];
189
+
159
190
  for (let i = 0; i < currentLinkCapacity; i++) linkPool[i] = new ReactiveLink();
191
+
160
192
  let freeLinkHead = linkPool[0];
193
+
161
194
  for (let i = 0; i < currentLinkCapacity - 1; i++) linkPool[i].nextFree = linkPool[i + 1];
162
195
 
163
196
  let activeNodes = 0 | 0;
@@ -170,6 +203,7 @@ export function createRegistry(config = {}) {
170
203
  const effectQueueA = [];
171
204
  const effectQueueB = [];
172
205
  const markStack = [];
206
+
173
207
  for (let i = 0; i < currentNodesCapacity; i++) {
174
208
  effectQueueA[i] = null;
175
209
  effectQueueB[i] = null;
@@ -189,6 +223,48 @@ export function createRegistry(config = {}) {
189
223
  let isTrackingDeps = false;
190
224
  let isFlushing = false;
191
225
 
226
+ // ── Observer-lifecycle introspection (opt-in; zero steady-state cost) ──
227
+ // lifecycleCount gates the hot-path checks in allocateLink/freeLink: with no node
228
+ // registered it stays 0 and each check folds to one predicted-false compare.
229
+ // Callbacks live in a WeakMap, NOT on the node struct, so node creation/footprint
230
+ // are untouched.
231
+ let lifecycleCount = 0 | 0;
232
+ const lifecycleMap = new WeakMap();
233
+
234
+ function fireConnect(node) {
235
+ const e = lifecycleMap.get(node);
236
+
237
+ if (e === undefined || e.onConnect === undefined) return;
238
+
239
+ const po = currentObserver, pt = isTrackingDeps;
240
+ currentObserver = null;
241
+ isTrackingDeps = false;
242
+
243
+ try {
244
+ e.onConnect();
245
+ } finally {
246
+ currentObserver = po;
247
+ isTrackingDeps = pt;
248
+ }
249
+ }
250
+
251
+ function fireDisconnect(node) {
252
+ const e = lifecycleMap.get(node);
253
+
254
+ if (e === undefined || e.onDisconnect === undefined) return;
255
+
256
+ const po = currentObserver, pt = isTrackingDeps;
257
+ currentObserver = null;
258
+ isTrackingDeps = false;
259
+
260
+ try {
261
+ e.onDisconnect();
262
+ } finally {
263
+ currentObserver = po;
264
+ isTrackingDeps = pt;
265
+ }
266
+ }
267
+
192
268
  // Reused buffer for throw isolation during flush
193
269
  const flushErrorBuffer = [];
194
270
  let flushErrorCount = 0 | 0;
@@ -205,45 +281,68 @@ export function createRegistry(config = {}) {
205
281
  * @private
206
282
  */
207
283
  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).
290
+ if (target.flags === 0) return null;
208
291
  let expected = activeObserverCurrentDep;
292
+ // Dead on this build: the inline cursor fast-path inside every read
293
+ // consumes a cursor *hit* before allocateLink runs, so on entry the
294
+ // cursor never matches `source`. Kept for the direct-call contract.
295
+ /* c8 ignore start */
209
296
  if (expected !== null && expected.source === source) {
210
297
  activeObserverCurrentDep = expected.nextDep;
211
298
  return expected;
212
299
  }
300
+ /* c8 ignore stop */
301
+
302
+ // SEVER-FIRST (zero pool debt): on any divergence, free the entire
303
+ // unmatched tail BEFORE allocating, so peak link usage never exceeds
304
+ // steady-state. Trades a little extra free/alloc churn on small reorders
305
+ // for honoring the zero-GC budget under tight maxLinks + "throw".
306
+ if (expected !== null) {
307
+ let stale = expected;
308
+ let prev = stale.prevDep;
309
+
310
+ if (prev !== null) prev.nextDep = null; else target.headDep = null;
213
311
 
214
- let existing = target.headDep;
215
- let found = null;
216
- while (existing !== null) {
217
- if (existing.source === source) {
218
- found = existing;
219
- break;
312
+ target.tailDep = prev;
313
+
314
+ while (stale !== null) {
315
+ let next = stale.nextDep;
316
+ freeLink(stale, target, stale.source);
317
+ stale = next;
220
318
  }
221
- existing = existing.nextDep;
319
+
320
+ activeObserverCurrentDep = null;
222
321
  }
223
322
 
323
+ // O(1) same-pass dedup (covers double-read of a still-linked source).
324
+ const lastSub = source.tailSub;
325
+
326
+ if (lastSub !== null && lastSub.target === target) return lastSub;
327
+
224
328
  let link;
225
- if (found !== null) {
226
- link = found;
227
- let p = link.prevDep;
228
- let n = link.nextDep;
229
- if (p !== null) p.nextDep = n; else target.headDep = n;
230
- if (n !== null) n.prevDep = p; else target.tailDep = p;
231
- } else {
329
+ {
232
330
  if (freeLinkHead === null) {
233
331
  if (policy === "throw") throw new CapacityError("links", currentLinkCapacity);
332
+
234
333
  const newCap = currentLinkCapacity * 2;
334
+
235
335
  if (newCap > maxLinkLimit) throw new CapacityError("links", maxLinkLimit);
236
336
 
237
337
  const newLinks = new Array(newCap - currentLinkCapacity);
338
+
238
339
  for (let i = 0; i < newLinks.length; i++) newLinks[i] = new ReactiveLink();
239
340
  for (let i = 0; i < newLinks.length - 1; i++) newLinks[i].nextFree = newLinks[i + 1];
240
341
 
241
342
  const startIdx = linkPool.length;
242
343
  linkPool.length = newCap;
243
344
 
244
- for (let i = 0; i < newLinks.length; i++) {
245
- linkPool[startIdx + i] = newLinks[i];
246
- }
345
+ for (let i = 0; i < newLinks.length; i++) linkPool[startIdx + i] = newLinks[i];
247
346
 
248
347
  freeLinkHead = newLinks[0];
249
348
  currentLinkCapacity = newCap;
@@ -259,26 +358,24 @@ export function createRegistry(config = {}) {
259
358
 
260
359
  link.nextSub = null;
261
360
  link.prevSub = source.tailSub;
361
+ const _was0 = lifecycleCount !== 0 && source.headSub === null; // 0→1 detect (pre-link)
262
362
 
263
- if (source.tailSub !== null) source.tailSub.nextSub = link;
264
- else source.headSub = link;
363
+ if (source.tailSub !== null) source.tailSub.nextSub = link; else source.headSub = link;
265
364
 
266
365
  source.tailSub = link;
267
- }
268
366
 
269
- link.nextDep = expected;
270
- if (expected !== null) {
271
- let p = expected.prevDep;
272
- link.prevDep = p;
273
- expected.prevDep = link;
274
- if (p !== null) p.nextDep = link; else target.headDep = link;
275
- } else {
276
- let tail = target.tailDep;
277
- link.prevDep = tail;
278
- if (tail !== null) tail.nextDep = link; else target.headDep = link;
279
- target.tailDep = link;
367
+ if (_was0) fireConnect(source);
280
368
  }
281
369
 
370
+ // Append at the (post-sever) tail.
371
+ let tail = target.tailDep;
372
+ link.prevDep = tail;
373
+ link.nextDep = null;
374
+
375
+ if (tail !== null) tail.nextDep = link; else target.headDep = link;
376
+
377
+ target.tailDep = link;
378
+
282
379
  return link;
283
380
  }
284
381
 
@@ -289,6 +386,7 @@ export function createRegistry(config = {}) {
289
386
 
290
387
  if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
291
388
  if (nSub !== null) nSub.prevSub = pSub; else source.tailSub = pSub;
389
+ if (lifecycleCount !== 0 && source.headSub === null) fireDisconnect(source); // 1→0
292
390
 
293
391
  link.source = null;
294
392
  link.target = null;
@@ -305,12 +403,14 @@ export function createRegistry(config = {}) {
305
403
  // --- MANUAL MEMORY MANAGEMENT ---
306
404
 
307
405
  function disposeNode(node) {
406
+ /* c8 ignore next -- redundant: both callers (effect handle, dispose(api)) pre-check flags!==0; destroy() resets slots inline */
308
407
  if (node.flags === 0) return; // Already freed
309
408
 
310
409
  runCleanup(node);
311
410
 
312
411
  // 1. Unlink from dependencies
313
412
  let dLink = node.headDep;
413
+
314
414
  while (dLink !== null) {
315
415
  const next = dLink.nextDep;
316
416
  freeLink(dLink, node, dLink.source);
@@ -319,6 +419,7 @@ export function createRegistry(config = {}) {
319
419
 
320
420
  // 2. Unlink from subscribers
321
421
  let sLink = node.headSub;
422
+
322
423
  while (sLink !== null) {
323
424
  const target = sLink.target;
324
425
  const next = sLink.nextSub;
@@ -345,6 +446,7 @@ export function createRegistry(config = {}) {
345
446
  node.computeFn = undefined;
346
447
  node.cleanupFn = undefined;
347
448
  node.scheduler = undefined;
449
+ node.schedulerThunk = undefined;
348
450
  node.value = undefined;
349
451
  node.equals = undefined;
350
452
  node.flags = 0;
@@ -417,12 +519,14 @@ export function createRegistry(config = {}) {
417
519
  /** Invoke registered cleanup function(s) on `node` and clear. @private */
418
520
  function runCleanup(node) {
419
521
  const cleanup = node.cleanupFn;
522
+
420
523
  if (cleanup === undefined) return;
421
524
 
422
525
  const prevObserver = currentObserver;
423
526
  const prevTracking = isTrackingDeps;
424
527
  currentObserver = null;
425
528
  isTrackingDeps = false;
529
+
426
530
  try {
427
531
  if (typeof cleanup === "function") cleanup();
428
532
  else for (let i = 0; i < cleanup.length; i++) cleanup[i]();
@@ -449,6 +553,7 @@ export function createRegistry(config = {}) {
449
553
  let link = n.headSub;
450
554
  while (link !== null) {
451
555
  const t = link.target;
556
+
452
557
  if ((t.markEpoch | 0) !== (globalVersion | 0)) {
453
558
  t.markEpoch = globalVersion | 0;
454
559
  // flags read stays INSIDE the markEpoch guard on purpose: hoisting it
@@ -471,6 +576,7 @@ export function createRegistry(config = {}) {
471
576
  markStack[stackLen++] = t;
472
577
  }
473
578
  }
579
+
474
580
  link = link.nextSub;
475
581
  }
476
582
  }
@@ -483,7 +589,9 @@ export function createRegistry(config = {}) {
483
589
  */
484
590
  function safeExecute(node, gen) {
485
591
  if ((node.gen | 0) !== (gen | 0)) return;
592
+ /* c8 ignore next -- unreachable after the gen guard: every disposal bumps node.gen, so a non-effect slot fails the gen check above first */
486
593
  if ((node.flags & FLAG_EFFECT) === 0) return;
594
+
487
595
  executeEffect(node);
488
596
  }
489
597
 
@@ -515,11 +623,12 @@ export function createRegistry(config = {}) {
515
623
 
516
624
  for (let i = 0; i < toRun; i++) {
517
625
  const node = currentQueue[i];
626
+
518
627
  try {
519
628
  const scheduler = node.scheduler;
629
+
520
630
  if (scheduler) {
521
- const gen = node.gen | 0;
522
- scheduler(() => safeExecute(node, gen));
631
+ scheduler(node.schedulerThunk);
523
632
  } else {
524
633
  executeEffect(node);
525
634
  }
@@ -535,11 +644,13 @@ export function createRegistry(config = {}) {
535
644
  normalExit = true;
536
645
  } finally {
537
646
  isFlushing = false;
647
+
538
648
  if (!normalExit) {
539
649
  // Escaping via CycleError or any non-effect-body throw. Discard
540
650
  // buffered effect errors — the structural failure supersedes them
541
651
  // and prevents leaking stale errors to the next flush call.
542
652
  for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
653
+
543
654
  flushErrorCount = 0 | 0;
544
655
  }
545
656
  }
@@ -553,8 +664,11 @@ export function createRegistry(config = {}) {
553
664
  }
554
665
  // 2+ errors: snapshot into a fresh array for AggregateError, then clear.
555
666
  const errs = flushErrorBuffer.slice(0, flushErrorCount);
667
+
556
668
  for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
669
+
557
670
  flushErrorCount = 0 | 0;
671
+
558
672
  throw new AggregateError(errs, "Effects threw during flush");
559
673
  }
560
674
  }
@@ -566,9 +680,12 @@ export function createRegistry(config = {}) {
566
680
  */
567
681
  function severTail(node) {
568
682
  let stale = activeObserverCurrentDep;
683
+
569
684
  if (stale !== null) {
570
685
  let prev = stale.prevDep;
686
+
571
687
  if (prev !== null) prev.nextDep = null; else node.headDep = null;
688
+
572
689
  node.tailDep = prev;
573
690
 
574
691
  while (stale !== null) {
@@ -585,6 +702,7 @@ export function createRegistry(config = {}) {
585
702
  * @private
586
703
  */
587
704
  function executeEffect(node) {
705
+ /* c8 ignore next -- defense-in-depth: writes during a flush re-queue for the next pass, so an effect cannot synchronously re-enter executeEffect */
588
706
  if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Infinite effect loop detected.");
589
707
 
590
708
  const isFirst = node.evalVersion === 0;
@@ -593,8 +711,10 @@ export function createRegistry(config = {}) {
593
711
  let link = node.headDep;
594
712
  const evalVer = node.evalVersion | 0;
595
713
  let needsRun = false;
714
+
596
715
  while (link !== null) {
597
716
  const dep = link.source;
717
+
598
718
  if ((dep.flags & FLAG_COMPUTED) !== 0) pullComputed(dep);
599
719
 
600
720
  // Overflow-safe modular arithmetic version check
@@ -659,24 +779,40 @@ export function createRegistry(config = {}) {
659
779
  return node.value;
660
780
  }
661
781
 
782
+ // CLEAN SHORT-CIRCUIT: markDownstream stamps markEpoch=globalVersion on
783
+ // every node in a changed signal's transitive cone. If no mark landed
784
+ // at-or-after our last eval, nothing we depend on changed -> cached value
785
+ // is still valid; skip the (recursive) dependency walk entirely. O(1).
786
+ if (node.evalVersion !== 0 && ((node.markEpoch - node.evalVersion) | 0) <= 0) {
787
+ node.evalVersion = globalVersion | 0;
788
+
789
+ if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
790
+
791
+ return node.value;
792
+ }
793
+
662
794
  let shouldRun = node.evalVersion === 0;
663
795
  if (!shouldRun) {
664
796
  let link = node.headDep;
665
797
  const evalVer = node.evalVersion | 0;
798
+
666
799
  while (link !== null) {
667
800
  const dep = link.source;
801
+
668
802
  if ((dep.flags & FLAG_COMPUTED) !== 0) pullComputed(dep);
669
803
  // Modular Arithmetic 32-bit Wrap Check
670
804
  if (((dep.version - evalVer) | 0) > 0) {
671
805
  shouldRun = true;
672
806
  break;
673
807
  }
808
+
674
809
  link = link.nextDep;
675
810
  }
676
811
  }
677
812
 
678
813
  if (shouldRun) {
679
814
  if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Circular dependency detected.");
815
+
680
816
  node.flags = node.flags | FLAG_COMPUTING;
681
817
 
682
818
  // Run cleanups registered during the previous compute pass before re-tracking.
@@ -694,6 +830,7 @@ export function createRegistry(config = {}) {
694
830
  try {
695
831
  const newValue = node.computeFn();
696
832
  const eq = node.equals;
833
+
697
834
  if (node.evalVersion === 0 || !eq || !eq(node.value, newValue)) {
698
835
  node.value = newValue;
699
836
  node.version = globalVersion | 0;
@@ -716,7 +853,9 @@ export function createRegistry(config = {}) {
716
853
  }
717
854
 
718
855
  node.evalVersion = globalVersion | 0;
856
+
719
857
  if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
858
+
720
859
  return node.value;
721
860
  }
722
861
 
@@ -748,6 +887,7 @@ export function createRegistry(config = {}) {
748
887
  // frame entirely. Only a cursor miss falls through to the cold path,
749
888
  // where allocateLink re-reads the cursor for its relink logic.
750
889
  const expected = activeObserverCurrentDep;
890
+
751
891
  if (expected !== null && expected.source === node) {
752
892
  activeObserverCurrentDep = expected.nextDep;
753
893
  } else {
@@ -756,9 +896,11 @@ export function createRegistry(config = {}) {
756
896
  }
757
897
  return node.value;
758
898
  };
899
+
759
900
  read.peek = () => node.value;
760
901
  read.set = (value) => {
761
902
  const eq = node.equals;
903
+
762
904
  if (eq && eq(node.value, value)) return;
763
905
 
764
906
  // Revert capture: first .set() of this signal inside the current batch.
@@ -798,6 +940,7 @@ export function createRegistry(config = {}) {
798
940
  const val = read();
799
941
  const prevTracking = isTrackingDeps;
800
942
  isTrackingDeps = false;
943
+
801
944
  try {
802
945
  fn(val);
803
946
  } finally {
@@ -809,6 +952,7 @@ export function createRegistry(config = {}) {
809
952
  // Secret pointer for safe, isolated disposal without allocating closures
810
953
  read[NODE_PTR] = node;
811
954
  read[NODE_GEN] = node.gen | 0;
955
+
812
956
  return read;
813
957
  }
814
958
 
@@ -835,6 +979,7 @@ export function createRegistry(config = {}) {
835
979
  if (isTrackingDeps && currentObserver !== null) {
836
980
  // Inlined cursor fast-path — see signal() read for rationale.
837
981
  const expected = activeObserverCurrentDep;
982
+
838
983
  if (expected !== null && expected.source === node) {
839
984
  activeObserverCurrentDep = expected.nextDep;
840
985
  } else {
@@ -843,6 +988,7 @@ export function createRegistry(config = {}) {
843
988
  }
844
989
  return pullComputed(node);
845
990
  };
991
+
846
992
  read.peek = () => pullComputed(node);
847
993
 
848
994
  read.subscribe = (fn) => {
@@ -852,6 +998,7 @@ export function createRegistry(config = {}) {
852
998
  const val = read();
853
999
  const prevTracking = isTrackingDeps;
854
1000
  isTrackingDeps = false;
1001
+
855
1002
  try {
856
1003
  fn(val);
857
1004
  } finally {
@@ -862,6 +1009,7 @@ export function createRegistry(config = {}) {
862
1009
 
863
1010
  read[NODE_PTR] = node;
864
1011
  read[NODE_GEN] = node.gen | 0;
1012
+
865
1013
  return read;
866
1014
  }
867
1015
 
@@ -891,9 +1039,11 @@ export function createRegistry(config = {}) {
891
1039
  statEffects = (statEffects + 1) | 0;
892
1040
 
893
1041
  let firstRunError = null;
1042
+
894
1043
  if (scheduler) {
895
1044
  const gen = node.gen | 0;
896
- scheduler(() => safeExecute(node, gen));
1045
+ node.schedulerThunk = () => safeExecute(node, gen);
1046
+ scheduler(node.schedulerThunk);
897
1047
  } else {
898
1048
  try {
899
1049
  executeEffect(node);
@@ -906,6 +1056,7 @@ export function createRegistry(config = {}) {
906
1056
 
907
1057
  let disposed = false;
908
1058
  const birthGen = node.gen | 0;
1059
+
909
1060
  const disposeFn = function dispose() {
910
1061
  if (disposed) return;
911
1062
  disposed = true;
@@ -926,6 +1077,7 @@ export function createRegistry(config = {}) {
926
1077
  disposeFn();
927
1078
  throw firstRunError;
928
1079
  }
1080
+
929
1081
  return disposeFn;
930
1082
  }
931
1083
 
@@ -974,9 +1126,12 @@ export function createRegistry(config = {}) {
974
1126
  function batch(fn) {
975
1127
  if (batchDepth === 0) {
976
1128
  batchEpoch = (batchEpoch + 1) | 0;
1129
+ /* c8 ignore next -- 2^32 wraparound sentinel; unreachable without ~4e9 batches */
977
1130
  if (batchEpoch === 0) batchEpoch = 1 | 0; // preserve the 0 sentinel
978
1131
  }
1132
+
979
1133
  batchDepth = (batchDepth + 1) | 0;
1134
+
980
1135
  try {
981
1136
  return fn();
982
1137
  } finally {
@@ -993,9 +1148,23 @@ export function createRegistry(config = {}) {
993
1148
  * @param {() => T} fn
994
1149
  * @returns {T}
995
1150
  */
1151
+ /**
1152
+ * Returns true iff a read RIGHT NOW would record a dependency on this
1153
+ * registry. Mirrors the engine's own read-trap predicate (both flags).
1154
+ * False inside untrack(), subscribe callbacks, onCleanup bodies,
1155
+ * watch/when callbacks, and outside any observer. For wrapper libraries
1156
+ * (lite-store, lite-query, lite-form) that lazily allocate signals on
1157
+ * property reads. Per-registry. ~1-2 ns.
1158
+ * @returns {boolean}
1159
+ */
1160
+ function isTracking() {
1161
+ return isTrackingDeps && currentObserver !== null;
1162
+ }
1163
+
996
1164
  function untrack(fn) {
997
1165
  const prev = isTrackingDeps;
998
1166
  isTrackingDeps = false;
1167
+
999
1168
  try {
1000
1169
  return fn();
1001
1170
  } finally {
@@ -1013,6 +1182,7 @@ export function createRegistry(config = {}) {
1013
1182
  function onCleanup(fn) {
1014
1183
  if (currentObserver !== null) {
1015
1184
  const existing = currentObserver.cleanupFn;
1185
+
1016
1186
  if (existing === undefined) currentObserver.cleanupFn = fn;
1017
1187
  else if (typeof existing === "function") currentObserver.cleanupFn = [existing, fn];
1018
1188
  else existing.push(fn);
@@ -1049,6 +1219,7 @@ export function createRegistry(config = {}) {
1049
1219
  n.cleanupFn = undefined;
1050
1220
  n.equals = undefined;
1051
1221
  n.scheduler = undefined;
1222
+ n.schedulerThunk = undefined;
1052
1223
  n.flags = 0;
1053
1224
  n.headDep = null;
1054
1225
  n.tailDep = null;
@@ -1063,11 +1234,15 @@ export function createRegistry(config = {}) {
1063
1234
  n.preBatchVersion = 0;
1064
1235
  // Bump gen so any scheduler trampolines holding a stale node ref bail.
1065
1236
  n.gen = (n.gen + 1) | 0;
1237
+
1066
1238
  if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
1067
1239
  }
1240
+
1068
1241
  nodePool[currentNodesCapacity - 1].nextFree = null;
1069
1242
  freeNodeHead = nodePool[0];
1070
1243
 
1244
+ // ... remainder of destroy() stays exactly the same
1245
+
1071
1246
  for (let i = 0; i < currentLinkCapacity; i++) {
1072
1247
  const l = linkPool[i];
1073
1248
  l.source = null;
@@ -1078,6 +1253,7 @@ export function createRegistry(config = {}) {
1078
1253
  l.nextSub = null;
1079
1254
  if (i < currentLinkCapacity - 1) l.nextFree = linkPool[i + 1];
1080
1255
  }
1256
+
1081
1257
  linkPool[currentLinkCapacity - 1].nextFree = null;
1082
1258
  freeLinkHead = linkPool[0];
1083
1259
 
@@ -1101,7 +1277,76 @@ export function createRegistry(config = {}) {
1101
1277
  flushErrorBuffer.length = 0; // release the backing array
1102
1278
  }
1103
1279
 
1104
- return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy};
1280
+ function hasObservers(handle) {
1281
+ const node = handle != null ? handle[NODE_PTR] : undefined;
1282
+ return node !== undefined && node.headSub !== null;
1283
+ }
1284
+
1285
+ function observeObservers(handle, opts) {
1286
+ const node = handle != null ? handle[NODE_PTR] : undefined;
1287
+ if (node === undefined) throw new TypeError("observeObservers: argument is not a reactive handle");
1288
+ let e = lifecycleMap.get(node);
1289
+ if (e === undefined) {
1290
+ e = {onConnect: undefined, onDisconnect: undefined};
1291
+ lifecycleMap.set(node, e);
1292
+ lifecycleCount = (lifecycleCount + 1) | 0;
1293
+ }
1294
+ if (opts !== undefined) {
1295
+ if (opts.onConnect !== undefined) e.onConnect = opts.onConnect;
1296
+ if (opts.onDisconnect !== undefined) e.onDisconnect = opts.onDisconnect;
1297
+ }
1298
+ let live = true;
1299
+ return () => {
1300
+ if (!live) return;
1301
+ live = false;
1302
+ if (lifecycleMap.delete(node)) lifecycleCount = (lifecycleCount - 1) | 0;
1303
+ };
1304
+ }
1305
+
1306
+ function describeNode(node) {
1307
+ const fl = node.flags;
1308
+ const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
1309
+ return {kind, value: node.value};
1310
+ }
1311
+
1312
+ function forEachObserver(handle, fn) {
1313
+ const node = handle != null ? handle[NODE_PTR] : undefined;
1314
+ if (node === undefined) return;
1315
+ let l = node.headSub;
1316
+ while (l !== null) {
1317
+ const nx = l.nextSub;
1318
+ fn(describeNode(l.target));
1319
+ l = nx;
1320
+ }
1321
+ }
1322
+
1323
+ function forEachSource(handle, fn) {
1324
+ const node = handle != null ? handle[NODE_PTR] : undefined;
1325
+ if (node === undefined) return;
1326
+ let l = node.headDep;
1327
+ while (l !== null) {
1328
+ const nx = l.nextDep;
1329
+ fn(describeNode(l.source));
1330
+ l = nx;
1331
+ }
1332
+ }
1333
+
1334
+ return {
1335
+ signal,
1336
+ computed,
1337
+ effect,
1338
+ dispose,
1339
+ batch,
1340
+ untrack,
1341
+ onCleanup,
1342
+ stats,
1343
+ destroy,
1344
+ isTracking,
1345
+ hasObservers,
1346
+ observeObservers,
1347
+ forEachObserver,
1348
+ forEachSource
1349
+ };
1105
1350
  }
1106
1351
 
1107
1352
  // ─────────────────────────────────────────────────────────────────
@@ -1150,6 +1395,30 @@ export function untrack(fn) {
1150
1395
  return defaultRegistry.untrack(fn);
1151
1396
  }
1152
1397
 
1398
+ /**
1399
+ * True iff a read RIGHT NOW would record a dependency on the default registry.
1400
+ * See {@link createRegistry} for the per-registry version.
1401
+ */
1402
+ export function isTracking() {
1403
+ return defaultRegistry.isTracking();
1404
+ }
1405
+
1406
+ export function hasObservers(handle) {
1407
+ return defaultRegistry.hasObservers(handle);
1408
+ }
1409
+
1410
+ export function observeObservers(handle, opts) {
1411
+ return defaultRegistry.observeObservers(handle, opts);
1412
+ }
1413
+
1414
+ export function forEachObserver(handle, fn) {
1415
+ return defaultRegistry.forEachObserver(handle, fn);
1416
+ }
1417
+
1418
+ export function forEachSource(handle, fn) {
1419
+ return defaultRegistry.forEachSource(handle, fn);
1420
+ }
1421
+
1153
1422
  /** @type {Registry["onCleanup"]} */
1154
1423
  export function onCleanup(fn) {
1155
1424
  return defaultRegistry.onCleanup(fn);
package/Watch.js CHANGED
@@ -121,7 +121,10 @@ export function when(predicate, callback) {
121
121
 
122
122
  stopFn = effect(() => {
123
123
  // Defense-in-depth: even if dispose timing lets one more evaluation
124
- // through (e.g., during sync propagation), don't fire twice.
124
+ // through (e.g., during sync propagation), don't fire twice. In practice
125
+ // stop() disposes this effect before any re-entry, so the early return is
126
+ // unreachable under the engine's self-cycle no-re-run guard — hence ignored.
127
+ /* c8 ignore next -- unreachable defensive guard; see comment above */
125
128
  if (fired) return;
126
129
  if (predicate()) {
127
130
  fired = true;
package/llms.txt CHANGED
@@ -19,7 +19,9 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
19
19
  - **dispose(api)**: universal disposal. Accepts a signal, computed, or effect dispose handle; idempotent; cross-registry calls are silent no-ops (per-registry `Symbol("node_ptr")` keys the node-identity slot on the returned API function, foreign signals fail the lookup and fall through). Passing an unrelated value is also safe; passing an arbitrary function invokes it (effect-handle contract).
20
20
  - **Batch**: `batch(fn)` defers effect flush until the outermost batch closes. Nestable.
21
21
  - **Untrack**: `untrack(fn)` reads without subscribing.
22
+ - **isTracking**: `isTracking()` returns true iff a read right now would record a dependency on this registry (for wrappers that lazily allocate signals).
22
23
  - **onCleanup**: registers teardown for the current computation; fires before each re-run and once on dispose. Works in effects and computeds.
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`.
23
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.
24
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.
25
27
 
@@ -44,11 +46,33 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
44
46
  - `computed()` cache miss — O(deps + body), zero alloc if dep structure is stable.
45
47
  - Effect re-run with stable dep order — zero alloc.
46
48
  - Stable read order: O(1) per dep via cursor reuse.
47
- - Chaotic/randomized read order: degrades to O(N) per dep due to list re-insertion.
49
+ - Chaotic/randomized read order: 1.1.4 added version-stamped per-source reconciliation plus a `markEpoch` clean-read short-circuit on the pull, so the prior O(N)-per-dep degradation no longer dominates high-fan-in batched read-after-write — the `dyn: large web app` and `dyn: wide dense` shapes that were the v1.1.x weakness are now the fastest of the five benchmarked frameworks (see resultsReactive.txt). Correctness verified by `retracking.difftest.mjs` (20,000 direct + 10,000 batched writes, 0 disagreements).
48
50
 
49
51
  ## Version notes
50
52
 
51
- - **1.1.2** (current): hot-path micro-optimizations, no behavior/API change.
53
+ - **1.1.4** (current): combined release a retracking rewrite plus an observer-lifecycle
54
+ introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
55
+ reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
56
+ O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
57
+ order is unchanged (still O(1), still zero-alloc). The two documented v1.1.x losses flipped
58
+ to wins and are now fastest of five frameworks — `dyn: large web app` 6194ms→571ms, `dyn:
59
+ wide dense` 5115ms→912ms (verified by `retracking.difftest.mjs`: 20k direct + 10k batched, 0
60
+ disagreements; no regressions elsewhere). **Introspection:** `hasObservers`,
61
+ `observeObservers`, `forEachObserver`, `forEachSource` (top-level + per-registry), gated by
62
+ an internal counter so zero steady-state cost when unused. New `test/13-introspection_test.mjs`
63
+ (10 tests). Internally staged as 1.1.4 + 1.1.5; ships as a single 1.1.4.
64
+
65
+ - **1.1.3**: adds `isTracking()` (top-level + per-registry). Returns true iff a
66
+ read right now would record a dependency (`isTrackingDeps && currentObserver !== null`).
67
+ False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, `watch` /
68
+ `when` callbacks, and outside any observer. ~1–2 ns. For wrapper libraries
69
+ (lite-store, lite-query, lite-form) that allocate reactive primitives lazily
70
+ on property reads. Per-registry: wrappers operating against a non-default
71
+ registry must call THAT registry's `isTracking()`, not the top-level one.
72
+ No behavior or engine changes.
73
+
74
+
75
+ - **1.1.2**: hot-path micro-optimizations, no behavior/API change.
52
76
  Inlined cursor fast-path in `signal`/`computed` reads (stable-order reads skip
53
77
  the `allocateLink` call); zero-allocation creation path (`opts` read defensively
54
78
  instead of defaulting to `{}`); single-closure `subscribe`. Owner-tree
@@ -66,8 +90,8 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
66
90
  ## When NOT to use
67
91
 
68
92
  - Server-side rendering — no SSR story.
69
- - Workloads with mostly chaotic read order `alien-signals` performs better here.
70
- - If you want time-travel, devtools, or serialization — build those on top.
93
+ - Graph *construction* is allocation-heavy (per-node closures): on create-many micro-benchmarks `alien-signals` builds faster. Real apps build once and update forever — lite-signal leads the update + dynamic-topology rows. (The former "chaotic read order" caveat was closed in 1.1.4.)
94
+ - If you want time-travel or serialization — build those on top. (Graph-inspection devtools are now buildable on the 1.1.4 introspection surface; see lite-devtools.)
71
95
 
72
96
  ## API summary
73
97
 
@@ -79,6 +103,14 @@ function effect(fn: () => void, opts?: { scheduler?: (run: () => void) => void }
79
103
  function dispose(api: Signal<any> | Computed<any> | Dispose): void;
80
104
  function batch<T>(fn: () => T): T;
81
105
  function untrack<T>(fn: () => T): T;
106
+ function isTracking(): boolean;
107
+ function hasObservers(handle: Signal<any> | Computed<any>): boolean;
108
+ function observeObservers(
109
+ handle: Signal<any> | Computed<any>,
110
+ hooks?: { onConnect?: () => void; onDisconnect?: () => void }
111
+ ): () => void; // returns idempotent unobserve
112
+ function forEachObserver(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
113
+ function forEachSource(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
82
114
  function onCleanup(fn: () => void): void;
83
115
  function stats(): RegistryStats;
84
116
 
@@ -102,6 +134,11 @@ interface Computed<T> {
102
134
 
103
135
  type Dispose = () => void;
104
136
 
137
+ interface NodeDescriptor { // yielded by forEachObserver / forEachSource
138
+ kind: "signal" | "computed" | "effect";
139
+ value: unknown; // node's current value
140
+ }
141
+
105
142
  interface RegistryConfig {
106
143
  maxNodes?: number; // default 1024
107
144
  maxLinks?: number; // default maxNodes * 4
@@ -195,6 +232,13 @@ that dominate UI workloads. alien-signals retains a 15% lead on 256-deep compute
195
232
  pipelines, where its flatter internal representation pays off when the propagation
196
233
  path is long rather than wide.
197
234
 
235
+ These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
236
+ high-fan-in shapes that were lite-signal's documented weakness — `dyn: large web app`
237
+ 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.
241
+
198
242
  On allocation pressure, lite-signal is alone in the zero-Δheap band: ~15 KB of
199
243
  transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
200
244
  ~230 KB per loop; solid runs into single-digit megabytes; alien-signals — which
@@ -252,7 +296,13 @@ sandbox.destroy(); // entire reactive world reset
252
296
  - `test/07-dispose.test.mjs` — universal disposal: registry.dispose(api).
253
297
  - `test/08-watch.test.mjs` — new watch reactivity tests.
254
298
  - `test/09-conformance.test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
255
- - `bench/benchmark.mjs` comparative benchmark vs alien-signals, preact, solid.
299
+ - `test/10-is-tracking_test.mjs` `isTracking()` across observer bodies, untracked windows, and outside any observer.
300
+ - `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
+ - `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
+ - `test/13-introspection_test.mjs` — observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
303
+ - `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).
304
+ - `bench/benchmark.mjs` — anti-DCE throughput harness (ops/s; results.txt).
305
+ - `bench/benchmarkReactive.mjs` — cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
256
306
  - `demo/index.html` — interactive visualization of the reactive graph.
257
307
 
258
308
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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",
@@ -27,6 +27,7 @@
27
27
  "scripts": {
28
28
  "test": "node --test --test-reporter=spec",
29
29
  "test:gc": "node --expose-gc --test --test-reporter=spec",
30
+ "test:coverage": "c8 node --test --test-reporter=spec",
30
31
  "bench": "node --expose-gc bench/benchmark.mjs",
31
32
  "bench-reactive": "node --expose-gc bench/benchmarkReactive.mjs",
32
33
  "verify": "npm test && npm run bench"
@@ -57,5 +58,12 @@
57
58
  "engines": {
58
59
  "node": ">=18"
59
60
  },
60
- "sideEffects": false
61
+ "funding": {
62
+ "type": "github",
63
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
64
+ },
65
+ "sideEffects": false,
66
+ "devDependencies": {
67
+ "c8": "^11.0.0"
68
+ }
61
69
  }