@zakkster/lite-signal 1.2.2 → 1.3.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,108 @@ All notable changes to `@zakkster/lite-signal` are documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
- ## [1.2.2] -- 2026-06-16
7
+ ## [1.3.0] -- 2026-06-XX
8
+
9
+ The pool minor: the node and link pools become **growable and incrementally
10
+ populated**, the propagation mark phase moves to an **intrusive linked-list
11
+ stack**, and a small **registry config surface** (`prealloc`,
12
+ `onCapacityExceeded`, `maxFlushPasses`) is exposed. Drop-in over 1.2.2 -- the
13
+ hot paths and public callable API are byte-identical; everything here is pool
14
+ mechanics, construction-time behavior, and new opt-in config. Steady-state
15
+ zero-GC is unchanged: after warm-up the pools recycle exactly as before.
16
+
17
+ **Default behavior: `prealloc: "eager"`.** The pools are preallocated up front
18
+ by default, preserving 1.2.x's deterministic-latency profile (no allocation
19
+ inside a hot loop or render frame -- the contract that matters for the 16ms /
20
+ 120fps Twitch-overlay and canvas use cases this engine targets). Lazy population
21
+ is available as an opt-in (`prealloc: "lazy"`) for footprint-sensitive or
22
+ fast-cold-start consumers. See the tradeoff note under *Added -- registry config*.
23
+
24
+ ### Added -- growable pools (`onCapacityExceeded: "grow"`)
25
+
26
+ The node and link pools can now grow past their initial capacity instead of
27
+ only throwing. Growth is **chunked and incremental**, not a single doubling
28
+ burst:
29
+
30
+ - **Link pool** refills in contiguous runs of up to **1024** links per
31
+ free-list miss; **node pool** in runs of up to **256**. This bounds any
32
+ single growth pause to roughly `chunk x ~0.5us` and keeps the freshly
33
+ constructed slots contiguous in memory (better locality than scattered
34
+ one-at-a-time `new`).
35
+ - `onCapacityExceeded` (default `"throw"`) selects the policy: `"throw"` fails
36
+ fast with a `CapacityError` when a pool is full (the 1.2.x behavior, now
37
+ named); `"grow"` extends the pool on demand. Link growth is bounded by a hard
38
+ ceiling of `maxLinks * 16`.
39
+ - The growth path **no longer length-extends the effect queues or mark stack**.
40
+ Previously `arr.length = newCap` permanently converted those arrays from
41
+ PACKED to HOLEY elements-kind -- a silent flush-path tax. They now grow by
42
+ sequential `arr[len++] = x` appends, which keep them packed.
43
+
44
+ ### Added -- registry config surface
45
+
46
+ `createRegistry(config)` accepts three new options. All are additive and
47
+ non-breaking; omitting `config` reproduces 1.2.x behavior with the eager
48
+ default.
49
+
50
+ - **`prealloc`** (`"eager"` default | `"lazy"`). `"eager"` constructs the full
51
+ `maxNodes` / `maxLinks` pools up front -- deterministic latency, zero
52
+ allocation inside any subsequent hot path, at the cost of a larger resident
53
+ heap that every major GC must trace. `"lazy"` treats `maxNodes` / `maxLinks`
54
+ as capacity *ledgers*, constructs nodes/links on first demand, and recycles
55
+ through the free lists thereafter -- smaller heap, faster cold start, lighter
56
+ GC marking, identical zero-GC steady state after warm-up. **Choose eager for
57
+ hard-real-time (render loops, game ticks, extension frame budgets); choose
58
+ lazy for footprint-sensitive or short-lived registries.**
59
+ - **`onCapacityExceeded`** (`"throw"` default | `"grow"`) -- see above.
60
+ - **`maxFlushPasses`** (default `100`) -- cycle-protection ceiling: the maximum
61
+ number of effect-queue drain passes before the flush throws an `Error`
62
+ prefixed `"CycleError:"`. Exposes what was a fixed internal bound so
63
+ pathological-but-legitimate deep-cascade graphs can raise it.
64
+
65
+ ### Changed -- intrusive mark stack in `markDownstream`
66
+
67
+ The propagation mark phase now walks an **iterative DFS backed by an intrusive
68
+ linked-list stack** (a `nextMark` field on each node) instead of a separate
69
+ `markStack` container array. Because `nextMark` sits adjacent to the
70
+ `markEpoch` field that the same sweep reads, the stack write lands in an
71
+ already-hot cache line. Behaviorally identical -- same nodes marked in the same
72
+ order, same glitch-free guarantee -- and it removes the container array's growth
73
+ and HOLEY-conversion concerns entirely. The mark stack never grows the JS call
74
+ stack regardless of graph depth (the iterative property from 1.2.4 is retained).
75
+
76
+ ### Added -- `ReactiveNode.nextMark`
77
+
78
+ One field added to the node shape to back the intrusive mark stack. Initialized
79
+ to `null` on construction, cleared on pop during a sweep and defensively on
80
+ dispose, so the chain stays clean for reuse. This is the only node-shape change
81
+ in 1.3.0.
82
+
83
+ ### Verified
84
+
85
+ - **Full suite green** against the 1.3.0 engine: 424 tests, 414 pass, 0 fail,
86
+ 10 skip. The 10 skips are the 9 `{skip:true}` `signalBox` tests in
87
+ `24-signalbox` (those primitives land in 1.5.0) plus 1 architecturally-N/A
88
+ SSR case in `17-reactivity`. The eager-default flip changed no test outcome.
89
+ Four new tests in `03-pool` cover the 1.3.0 paths: lazy on-demand construction
90
+ reaching the same steady state as eager, a never-allocated lazy registry
91
+ surviving `destroy()`, `"grow"` extending both pool ledgers, and the
92
+ `maxLinks * 16` link ceiling.
93
+ - **Coverage** (c8@11, Node 22): `Signal.js` 100% statements / 98.26% branches /
94
+ 100% functions / 100% lines; `Watch.js` 100% across all four. The lazy-pool
95
+ `destroy()` paths added in 1.3.0 are covered by the new `03-pool` tests.
96
+ - **Behavior-preservation difftest**: 20,000 direct + 10,000 batched writes
97
+ against the published 1.1.5 reference, 0 disagreements. Pool growth, chunked
98
+ refill, and the intrusive mark stack do not alter observable propagation.
99
+ - **Zero-GC steady state holds**: after warm-up, writing through a built graph
100
+ allocates nothing and the pool does not grow (eager) / does not grow further
101
+ (lazy, post warm-up). Confirmed across deep-chain, wide fan-out, and batched
102
+ scenarios.
103
+ - **`stats()` shape unchanged from 1.2.x** (8 keys). The cumulative allocation
104
+ counters (`totalAllocations` / `totalDisposals` / `poolGrowths`) remain
105
+ reserved for 1.4.0 and are still absent here -- the introspection-contract
106
+ test continues to pin their absence.
107
+
108
+
8
109
 
9
110
  A code-deletion ship: a `createNode` audit removes ten redundant field-writes
10
111
  that defended against a state the engine cannot produce on a clean free-list.
package/README.md CHANGED
@@ -15,21 +15,21 @@
15
15
 
16
16
  ## 4th of 15 on the community reactivity benchmark -- and the only zero-GC engine in the field
17
17
 
18
- On the independent [js-reactivity-benchmark](https://github.com/volynetstyle/js-reactivity-benchmark) (Andrii Volynets' fork; 15 reactive libraries, 47 tests), `lite-signal` places **4th overall by geomean (81.6)** -- within noise of 5th-place Preact Signals (83.0, a 1.7% gap), behind only three push-eager engines: alien-signals, reflex, and @reactively.
18
+ On the independent [js-reactivity-benchmark](https://github.com/volynetstyle/js-reactivity-benchmark) (Andrii Volynets' fork; 15 reactive libraries, 47 tests), `lite-signal` places **4th overall by geomean (75.5)** -- now clearing 5th-place Preact Signals (79.2) by ~5%, a gap that widened on the 1.3.0 run, behind only three push-eager engines: alien-signals, reflex, and @reactively.
19
19
 
20
20
  It is the **only object-pooled, zero-GC engine in the entire field**, and it gets that result without giving up glitch-freedom or lazy evaluation. Against the mainstream reactivity libraries it leads decisively:
21
21
 
22
22
  | vs | lite-signal is |
23
23
  | ---------------------- | -------------- |
24
- | **@vue/reactivity** | **1.6x faster** |
24
+ | **@vue/reactivity** | **1.5x faster** |
25
25
  | **Signia** | **1.7x faster** |
26
26
  | **MobX** | **2.3x faster** |
27
- | **@solidjs/signals** | **2.6x faster** |
28
- | **SolidJS** | **3.8x faster** |
29
- | Preact Signals | ~even (+1.7%) |
27
+ | **@solidjs/signals** | **2.7x faster** |
28
+ | **SolidJS** | **4.2x faster** |
29
+ | Preact Signals | **1.05x faster** (~5% ahead) |
30
30
  | alien-signals | 0.56x (the field leader) |
31
31
 
32
- `lite-signal` finishes **top-3 on 18 of the 47 tests** and is the **outright fastest of all 15** on `manyEffectsFromOneSource` (1 source -> many effects, fan-out) and `manySourcesIntoOneComputedEffectWithDirect` (many sources -> one computed, fan-in) -- the aggregation shapes that dominate live dashboards, scoreboards, and HUDs. The three engines ahead of it are all push-eager designs that allocate on the hot path; `lite-signal` is the only top-4 finisher that allocates **nothing** in steady state. (Note: this suite measures reactivity *libraries* -- Vue's reactivity core, MobX, Solid, Preact Signals, etc. -- not full UI frameworks like React or Angular.)
32
+ `lite-signal` finishes **top-3 on 21 of the 47 tests** and is the **outright fastest of all 15** on three wide aggregation shapes -- `manyEffectsFromOneSource` (1 source -> many effects, fan-out), plus `manySourcesIntoOneComputedEffect` and `manySourcesIntoOneComputedEffectWithDirect` (many sources -> one computed, fan-in) -- the patterns that dominate live dashboards, scoreboards, and HUDs. It also edges the field leader alien-signals on a fourth (the 1-source linear-chain pull). The three engines ahead of it are all push-eager designs that allocate on the hot path; `lite-signal` is the only top-4 finisher that allocates **nothing** in steady state. (Note: this suite measures reactivity *libraries* -- Vue's reactivity core, MobX, Solid, Preact Signals, etc. -- not full UI frameworks like React or Angular.)
33
33
 
34
34
  ```bash
35
35
  npm install @zakkster/lite-signal
@@ -444,10 +444,11 @@ The effect dispose handle (`const dispose = effect(...)`) is still a plain funct
444
444
 
445
445
  ```ts
446
446
  const r = createRegistry({
447
- maxNodes: 1024, // default
448
- maxLinks: 4 * 1024, // default = maxNodes * 4
449
- maxFlushPasses: 100, // default
450
- onCapacityExceeded: "throw" // default. Other: "grow"
447
+ maxNodes: 1024, // default (ledger)
448
+ maxLinks: 4 * 1024, // default = maxNodes * 4 (ledger)
449
+ prealloc: "eager", // default. Other: "lazy"
450
+ maxFlushPasses: 100, // default
451
+ onCapacityExceeded: "throw" // default. Other: "grow"
451
452
  });
452
453
 
453
454
  const s = r.signal(0);
@@ -466,29 +467,34 @@ r.destroy(); // reset all pools, invalidate generations
466
467
  <details>
467
468
  <summary>Pool sizing, the grow policy, and why there is a 16× link ceiling.</summary>
468
469
 
469
- The engine has two pool sizes: **nodes** and **links**. Both are fixed at registry creation but can be configured to grow.
470
+ The engine has two pools: **nodes** and **links**. Their capacities are set at registry creation. As of **1.3.0** you also choose *when* the pools are populated (`prealloc`) and *what happens* when a capacity is hit (`onCapacityExceeded`).
471
+
472
+ **`prealloc` (1.3.0)** -- `"eager"` (default) constructs the full `maxNodes` / `maxLinks` pools up front: deterministic latency, zero allocation inside any subsequent hot path (the contract that matters for 16ms render frames and 120fps canvas loops), at the cost of a larger resident heap. `"lazy"` treats the capacities as *ledgers*, constructs nodes/links on first demand, and recycles through the free lists thereafter: smaller heap, faster cold start, lighter GC marking, **identical zero-GC steady state after warm-up**. Choose eager for hard-real-time, lazy for footprint-sensitive or short-lived registries (per-viewer sandboxes, tests).
473
+
474
+ **`onCapacityExceeded`** -- `"throw"` (default) fails fast with a `CapacityError`. `"grow"` extends the pool on demand. Growth is **chunked and incremental** -- contiguous runs of up to **1024 links / 256 nodes** per free-list miss, not a single doubling burst -- so any one growth pause stays bounded (~chunk x ~0.5us) and freshly constructed slots stay contiguous in memory. The capacity *ledger* still doubles, so `stats()` semantics are unchanged.
470
475
 
471
476
  ```mermaid
472
477
  flowchart LR
473
- A[allocator hits empty pool] --> B{policy?}
478
+ A[allocator hits empty free-list] --> B{policy?}
474
479
  B -- "throw" --> C[CapacityError]
475
- B -- "grow" --> D[double pool size]
476
- D --> E{new size > 16× original?}
480
+ B -- "grow" --> D[construct a chunked run<br/>up to 1024 links / 256 nodes<br/>ledger doubles]
481
+ D --> E{ledger > 16x original links?}
477
482
  E -- yes --> F[CapacityError<br/>link ceiling]
478
483
  E -- no --> G[allocate, continue]
479
484
  ```
480
485
 
481
- Why a ceiling? Unbounded growth hides leaks. If your app reaches 16× its starting link capacity, something is wrong and you want to know -- `CapacityError` is louder than a slow OOM crash four hours later.
486
+ Why a ceiling? Unbounded growth hides leaks. If your app reaches 16x its starting link capacity, something is wrong and you want to know -- `CapacityError` is louder than a slow OOM crash four hours later.
482
487
 
483
488
  Default sizing for a Twitch-extension-style budget:
484
489
 
485
- | Workload | maxNodes | maxLinks | policy |
486
- | ----------------------------------- | -------- | -------- | -------- |
487
- | Tiny widget (<=50 reactive cells) | 256 | 1024 | `"throw"` |
488
- | Standard overlay (~500 cells) | 1024 | 4096 | `"throw"` |
489
- | Heavy dashboard (variable scale) | 2048 | 16384 | `"grow"` |
490
+ | Workload | maxNodes | maxLinks | prealloc | policy |
491
+ | ----------------------------------- | -------- | -------- | -------- | -------- |
492
+ | Tiny widget (<=50 reactive cells) | 256 | 1024 | `"eager"` | `"throw"` |
493
+ | Standard overlay (~500 cells) | 1024 | 4096 | `"eager"` | `"throw"` |
494
+ | Heavy dashboard (variable scale) | 2048 | 16384 | `"eager"` | `"grow"` |
495
+ | Per-viewer sandbox / short-lived | 512 | 2048 | `"lazy"` | `"throw"` |
490
496
 
491
- `stats()` reports `signals`, `computeds`, `effects`, `activeLinks`, `pooledLinks`, `linkPoolCapacity`. Drop it on screen for live observability.
497
+ `stats()` reports `signals`, `computeds`, `effects`, `activeNodes`, `activeLinks`, `pooledLinks`, `nodePoolCapacity`, `linkPoolCapacity` (8 keys; the capacity keys are ledgers under `"lazy"`). Drop it on screen for live observability.
492
498
 
493
499
  </details>
494
500
 
@@ -500,9 +506,9 @@ Default sizing for a Twitch-extension-style budget:
500
506
 
501
507
  | API | Use case | Lifecycle | Hot-path safe? |
502
508
  |---|---|---|---|
503
- | `watch(source, cb)` | observe value changes over time | manual `stop()` | zero-GC per fire |
504
- | `watch(source, (v, p, stop) => ...)` | observe until a condition | self-dispose via callback arg | zero-GC per fire |
505
- | `when(predicate, cb)` | one-shot trigger when condition first true | auto-dispose | zero-GC per check |
509
+ | `watch(source, cb)` | observe value changes over time | manual `stop()` | yes -- zero-GC per fire |
510
+ | `watch(source, (v, p, stop) => ...)` | observe until a condition | self-dispose via callback arg | yes -- zero-GC per fire |
511
+ | `when(predicate, cb)` | one-shot trigger when condition first true | auto-dispose | yes -- zero-GC per check |
506
512
  | `whenAsync(predicate)` | await a condition | auto-dispose | ! allocates Promise -- see below |
507
513
 
508
514
  ### `watch(source, callback, options?)`
@@ -652,25 +658,25 @@ These are the questions you'd ask in a code review, with the answers:
652
658
 
653
659
  ## Benchmarks
654
660
 
655
- 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, **one engine per cold process**, median of 10 isolated runs. Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters. Numbers below are lite-signal **@1.2.2** 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). *(These numbers use the corrected one-engine-per-process protocol -- the prior 1.2.0 table ran several engines in one process, which let nursery-allocating engines borrow a warm heap and polluted shared inline caches. 1.2.2 is drop-in over 1.2.0; the hot paths are byte-identical, so the table moves here are the measurement correction, not engine changes. The 1.2.0 single-process table is in git history.)*
661
+ 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)**, **one engine per cold process**, median across reps. Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters. Numbers below are lite-signal **@1.3.0** (default `prealloc: "eager"`) vs alien-signals on the same `benchmark.mjs` loop -- the harness now reports **median execution time + transient heap** rather than ops/s. 1.3.0's hot paths are byte-identical to 1.2.2, so these track the 1.2.2 measurement; the deltas are run-to-run noise on this old host, not engine changes.
656
662
 
657
- | Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
658
- | ---------- | -------------------------------- | ----------- | ------------- | ------------- |
659
- | **MUX** | 256 signals -> 1 sum -> 1 effect (fan-in) | **293K ops/s** | 190K | **+35%** |
660
- | **DYNAMIC DAG** | sqrt-layered, FAN=6, read flips each iter | **2K** | 1K | **+44%** |
661
- | **SELECTIVE DAG** | sqrt-layered, set churn, 2 read/iter | **4K** | 2K | **+48%** |
662
- | **SMALL SELECTIVE** | 6 layers × 64 wide, 6 cand / 3 read | **10K** | 7K | **+31%** |
663
- | **KAIROS** | 1 signal -> 1000 computeds -> 1 effect | 15K | 16K | -4% |
664
- | **BROADCAST** | 1 signal -> 1000 effects (fan-out) | 18K | 19K | -7% |
665
- | **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | 7K | 7K | -5% |
666
- | **LARGE WEB APP** | 12 layers × ~80 wide, conditional reads | 7K | 7K | -7% |
667
- | **DEEP CHAIN** | 256-deep computed chain -> 1 effect | 49K | **59K** | -19% |
668
- | **heap-delta MUX** | transient alloc pressure, 20K iters | **0.3 KB** | 7,780 KB | -- |
669
- | **Retained MUX** | state surviving forced GC | **-9 KB** (none) | -2 KB | -- |
663
+ | Scenario | What it stresses | lite-signal | alien-signals | lite vs alien | transient heap (lite / alien) |
664
+ | ---------- | -------------------------------- | ----------- | ------------- | ------------- | ----------------------------- |
665
+ | **SELECTIVE DAG** | sqrt-layered, set churn, 2 read/iter | **4744 ms** | 9176 ms | **+48% faster** | **1.5 MB / 37 MB** (24x less) |
666
+ | **DYNAMIC DAG** | sqrt-layered, FAN=6, read flips each iter | **9670 ms** | 16888 ms | **+43% faster** | **5.1 MB / 24 MB** (4.7x less) |
667
+ | **MUX** | 256 signals -> 1 sum -> 1 effect (fan-in) | **67 ms** | 108 ms | **+38% faster** | **38 KB / 3.9 MB** (104x less) |
668
+ | **SMALL SELECTIVE** | 6 layers × 64 wide, 6 cand / 3 read | **1937 ms** | 2744 ms | **+29% faster** | **0.3 KB / 23.9 MB** (>=23934x less) |
669
+ | **KAIROS** | 1 signal -> 1000 computeds -> 1 effect | 1339 ms | 1325 ms | -1% (parity) | 178 KB / 1.1 MB (6.4x less) |
670
+ | **LARGE WEB APP** | 12 layers × ~80 wide, conditional reads | 2801 ms | 2701 ms | -4% (parity) | **0.3 KB / 41 KB** (41x less) |
671
+ | **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | 2850 ms | 2740 ms | -4% (parity) | **0.3 KB / 11.6 MB** (>=11605x less) |
672
+ | **BROADCAST** | 1 signal -> 1000 effects (fan-out) | 1182 ms | 1074 ms | -10% | 72 KB / 21 KB |
673
+ | **DEEP CHAIN** | 256-deep computed chain -> 1 effect | 401 ms | **339 ms** | -18% | 18 KB / 905 KB (49.7x less) |
670
674
 
671
- **Reading the table:** lite-signal's wins cluster exactly where its zero-GC design pays off -- the **allocation-heavy dynamic shapes** (**DYNAMIC DAG +44%**, **SELECTIVE DAG +48%**, **SMALL SELECTIVE +31%**), where alien-signals churns the nursery and lite's object pool allocates nothing, plus **MUX +35%** (fan-in aggregation). These are the patterns that dominate live UI workloads under input churn: dashboards, scoreboards, HUDs, leaderboards. On the cheap, low-allocation **stable** shapes (KAIROS, BROADCAST, wide app/dense) lite runs at **parity** with alien -- within a 4-7% band that is inside this old host's run-to-run noise. The one structural loss is **DEEP CHAIN (-19%)**: on a 256-deep computed pipeline alien's flatter representation wins because the propagation path is long rather than wide.
675
+ *(lower time = faster; transient heap = average delta-heap per rep, lower = less GC pressure)*
672
676
 
673
- On allocation pressure, `lite-signal` is alone in the zero-alloc band: ~0.3 KB of transient garbage on stable shapes across 20,000 iterations. The contrast is starkest on the dynamic DAGs -- lite allocates 9-13 MB (genuine retracking re-links) where alien-signals allocates 39-42 MB on the same shapes, and that allocation gap is the mechanism behind lite's +44-48% wins there once each engine is measured in isolation. preact ranges from ~220 KB to low-single-digit MB per stable loop, solid runs into single-digit megabytes. Negative "retained" numbers mean V8 reclaimed memory below the pre-bench baseline during the post-run forced GC -- no leaks anywhere.
677
+ **Reading the table:** lite-signal's time wins cluster exactly where its zero-GC design pays off -- the **allocation-heavy dynamic shapes** (**SELECTIVE DAG +48%**, **DYNAMIC DAG +43%**, **SMALL SELECTIVE +29%**), where alien-signals churns the nursery and lite's object pool allocates near-nothing, plus **MUX +38%** (fan-in aggregation). These are the patterns that dominate live UI workloads under input churn: dashboards, scoreboards, HUDs, leaderboards. On the cheap, low-allocation **stable** shapes (KAIROS, large web app, wide dense) lite runs at **parity** with alien -- within a 1-4% band inside this old host's run-to-run noise. The two losses are **BROADCAST (-10%)** (pure fan-out, no retracking for the pool to amortize) and **DEEP CHAIN (-18%)**: on a 256-deep computed pipeline alien's flatter representation wins because the propagation path is long rather than wide.
678
+
679
+ On allocation pressure, `lite-signal` wins **8 of 9 scenarios** and is alone in the zero-alloc band: **~0.3 KB** of transient garbage on the stable app shapes (large web app, wide dense, small selective) across the whole run. The contrast is starkest on the dynamic DAGs -- lite allocates 1.5-5 MB (genuine retracking re-links) where alien-signals allocates 24-37 MB on the same shapes, and that allocation gap is the mechanism behind lite's +43-48% wins there once each engine is measured in isolation. The one scenario where lite allocates more is **BROADCAST** (72 KB vs alien's 21 KB), a pure fan-out with no retracking. `prealloc: "lazy"` trades a little extra first-construction allocation on the dynamic shapes for a smaller resident heap and faster cold start; the steady state is identical. preact and solid trail both engines on the dynamic shapes by 1-2 orders of magnitude on transient heap.
674
680
 
675
681
  > 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.
676
682
 
@@ -699,7 +705,7 @@ Three tiers, all reproducible.
699
705
 
700
706
  - **`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.
701
707
  - **`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`).
702
- - **`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.
708
+ - **`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, and (1.3.0) the lazy-prealloc paths: on-demand construction reaching the same steady state as eager, a never-allocated lazy registry surviving `destroy()`, and `"grow"` extending both pool ledgers past their initial capacity.
703
709
  - **`05-scheduler_test.mjs`** -- scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
704
710
  - **`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.
705
711
  - **`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.
@@ -719,8 +725,8 @@ Three tiers, all reproducible.
719
725
  - **`21-perf-pins_test.mjs`** -- v1.2.1 construction-shape pins (6 tests). Locks the canonical handle shapes (`signal` 6 own props: peek/set/update/subscribe + NODE_PTR/NODE_GEN; `computed` 4: peek/subscribe + NODE_PTR/NODE_GEN) so a future "let's unify them" change has to be explicit. Locks the 1.2.1 ABA guards: detached `const {set} = signal()` keeps working on a LIVE signal; `read()` returns `undefined` and skips dep-tracking on a stale handle (no phantom subscription to the recycled slot); `set()` on a stale handle is a no-op across three corruption tiers (disposed slot, recycled slot, downstream propagation); `peek()` returns `undefined` for stale signal and computed handles.
720
726
  - **`22-mutation-hook_test.mjs`** -- 1.2.1 `onGraphMutation` semantics (12 tests across 2 suites). Registration: unsubscribe returns a function; `null` argument clears and the unsub restores the prior listener; non-function/non-null throws `TypeError`; multiple registrations stack LIFO; registries are isolated (no cross-talk). Opcode emission: `1` node-create fires with `(id, flags)` for signal (32) / computed (1) / effect (2); `2` node-dispose fires for cascade-disposed owned children; `3` link-add fires with `(source.id, target.id)` on dependency record; `4` link-remove fires when a dep-set flip severs the tail; `5` recompute fires on initial eval AND re-eval; the hook fires synchronously inside the mutation (listener sees its own event before the caller returns); payload is always three plain numbers -- no objects, no closures.
721
727
  - **`23-owner-introspection_test.mjs`** -- 1.2.1 owner-tree introspection + effect-disposer regression (22 tests across 4 suites). `ownerOf`: undefined for top-level / garbage input / stale handle; returns the enclosing effect's descriptor for a child created inside an effect body. `forEachOwned`: no-op for handles with no owned children / garbage input / stale handle; iterates owned children as `{id, kind, value}` descriptors. Gen-guarded introspection (ABA fix): `nodeId` / `describe` / `hasObservers` return undefined / false for stale handles; `observeObservers` throws `TypeError`; `forEachObserver` / `forEachSource` are no-ops; descriptors returned by `describeNode` are themselves gen-stamped so a descriptor obtained pre-recycle correctly walks as a no-op post-recycle (the "descriptors are re-walkable handles" contract survives the guard). Plus the 1.2.1 effect-dispose-handle fix: passing the effect's disposer directly to `describe` / `nodeId` / `forEachSource` / `forEachOwned` / `ownerOf` / `hasObservers` works as a first-class introspection handle (pre-fix it was a bare closure and returned `undefined` for a *live* effect); after `fx()` dispose the same handle correctly goes stale on every entry point; the disposer's `NODE_GEN` mirrors the effect node's birthGen exactly.
722
- - **`24-signalbox_test.mjs`** -- staged for v1.5.0; all 9 tests `{skip: true}` on 1.2.x. The `signalBox` / `computedBox` allocation-light handle API lands in 1.5.0; the suite is committed early so the surface is pinned and the skips are visible in the test count (the 10 skips on 1.2.2 are these 9 plus 1 architecturally-N/A SSR case in `17-reactivity`).
723
- - **`25-devtools-real-boot_test.mjs`** -- Devtools/Studio contract (10 tests). Boots the actual `Devtools.js` against the 1.2.2 engine and exercises all 19 Devtools exports plus the 10 symbols Studio imports from Devtools. Pins the ghost contract: heavy introspection (graph walk, owner-tree, observer descriptors) adds **zero** nodes to the live graph. Catches the real-rig failure mode where importing the package by its own name from a repo whose `package.json` declares `name: "@zakkster/lite-signal"` resolves to the published build instead of the local engine.
728
+ - **`24-signalbox_test.mjs`** -- staged for v1.5.0; all 9 tests `{skip: true}` on 1.3.x. The `signalBox` / `computedBox` allocation-light handle API lands in 1.5.0; the suite is committed early so the surface is pinned and the skips are visible in the test count (the 10 skips on 1.3.0 are these 9 plus 1 architecturally-N/A SSR case in `17-reactivity`).
729
+ - **`25-devtools-real-boot_test.mjs`** -- Devtools/Studio contract (10 tests). Boots the actual `Devtools.js` against the 1.3.0 engine and exercises all 19 Devtools exports plus the 10 symbols Studio imports from Devtools. Pins the ghost contract: heavy introspection (graph walk, owner-tree, observer descriptors) adds **zero** nodes to the live graph. Catches the real-rig failure mode where importing the package by its own name from a repo whose `package.json` declares `name: "@zakkster/lite-signal"` resolves to the published build instead of the local engine.
724
730
  - **`26-free-list-invariant_test.mjs`** -- the 1.2.2 audit's cleanliness pins (3 invariant tests + 1 targeted coverage test). Asserts directly -- by inspecting freshly-allocated nodes through the documented `describe()` -> `NODE_PTR` introspection protocol -- that the `ReactiveNode` constructor and the fresh-pool-growth path initialize the ten fields the audit removed from `createNode` to identical values, so the deleted writes were defending against a state the engine cannot produce on a clean free list. The 4th test covers the swallow-on-self-dispose-then-throw branch in `pullComputed` (the path that lifted branch coverage from 98.07% to 98.43%).
725
731
 
726
732
  ```bash
@@ -780,7 +786,7 @@ npm run verify # test + test:gc + a sanity bench
780
786
  ## Performance Trade-offs & Topology Scaling
781
787
 
782
788
  <details>
783
- <summary>Stable vs dynamic topologies; Andrii Volynets' matrix, the 1.1.4 result, and the roadmap.</summary>
789
+ <summary>Stable vs dynamic topologies; Andrii Volynets' matrix, the 1.1.4 result, the 1.3.0 ranking, and the roadmap.</summary>
784
790
 
785
791
  `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.
786
792
 
@@ -788,6 +794,8 @@ Through **v1.1.2**, that came with a mathematical trade-off: while memory alloca
788
794
 
789
795
  **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.
790
796
 
797
+ **1.3.0 on the official [js-reactivity-benchmark](https://github.com/volynetstyle/js-reactivity-benchmark) (15 libraries, 47 tests):** `lite-signal` holds **4th overall by geomean (75.5ms)**, behind only alien-signals (42.6, the field leader at 0.56x), reflex (48.9), and @reactively (59.3), and now **ahead of 5th-place Preact Signals (79.2, ~5%)** -- a gap that widened from 1.2.x. It finishes **top-3 on 21 of 47 tests** and is the **outright fastest of all 15 on three wide-aggregation shapes** -- `manyEffectsFromOneSource`, `manySourcesIntoOneComputedEffect`, and `manySourcesIntoOneComputedEffectWithDirect` -- even edging the leader alien-signals on those plus the 1-source linear-chain pull. It remains the only object-pooled, zero-GC engine in the field.
798
+
791
799
  #### 1. Stable Topologies (Fan-in / Fan-out / Broadcast)
792
800
  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.
793
801
 
@@ -799,32 +807,33 @@ In stable environments (game engines, particle systems, visualizers), `lite-sign
799
807
  | **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
800
808
  | **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
801
809
 
802
- *1.2.2 on the local harness (slow 2016 MacBook, one engine per cold process -- compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
803
- | Scenario | alien-signals | lite-signal (1.2.2) | result |
810
+ *1.3.0 (default eager) on the local harness (slow 2016 MacBook, one engine per cold process -- compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
811
+ | Scenario | alien-signals | lite-signal (1.3.0) | result |
804
812
  | :--- | :--- | :--- | :--- |
805
- | **DYNAMIC DAG** (sqrt-layered, FAN=6) | 17558ms | 9821ms | **lite +44%** |
806
- | **SELECTIVE DAG** (sqrt-layered, set churn) | 9229ms | 4797ms | **lite +48%** |
807
- | **SMALL SELECTIVE** (~ 64x6) | 2780ms | 1918ms | **lite +31%** |
808
- | **LARGE WEB APP** (~ 1000x12) | 2671ms | 2846ms | alien +7% |
809
- | **WIDE DENSE** (~ 1000x5) | 2729ms | 2876ms | alien +5% |
813
+ | **SELECTIVE DAG** (sqrt-layered, set churn) | 9176ms | 4744ms | **lite +48%** |
814
+ | **DYNAMIC DAG** (sqrt-layered, FAN=6) | 16888ms | 9670ms | **lite +43%** |
815
+ | **SMALL SELECTIVE** (~ 64x6) | 2744ms | 1937ms | **lite +29%** |
816
+ | **LARGE WEB APP** (~ 1000x12) | 2701ms | 2801ms | alien +4% |
817
+ | **WIDE DENSE** (~ 1000x5) | 2740ms | 2850ms | alien +4% |
810
818
 
811
- > **Honest note (1.2.2 isolated run):** measured one-engine-per-process, lite-signal's
812
- > wins are on the **allocation-heavy** dynamic shapes (DYNAMIC DAG +44%, SELECTIVE DAG
813
- > +48%, SMALL SELECTIVE +31%) -- exactly where alien churns the nursery and lite's pool
814
- > allocates nothing. The cheaper wide-app/dense shapes land within a few percent either
815
- > way (host noise on this old machine). The prior 1.2.0 table ran all engines in one
816
- > process, which understated these dynamic-shape gaps (alien borrowed lite's warm heap);
817
- > the isolated numbers here are the correct comparison. lite remains the only zero-alloc
818
- > library on every stable scenario (see [`results.txt`](./results.txt)).
819
+ > **Honest note (1.3.0 isolated run):** measured one-engine-per-process, lite-signal's
820
+ > wins are on the **allocation-heavy** dynamic shapes (SELECTIVE DAG +48%, DYNAMIC DAG
821
+ > +43%, SMALL SELECTIVE +29%) -- exactly where alien churns the nursery and lite's pool
822
+ > allocates near-nothing. The cheaper wide-app/dense shapes land within a few percent
823
+ > either way (host noise on this old machine). 1.3.0's hot paths are byte-identical to
824
+ > 1.2.2, so these match the 1.2.2 isolated numbers -- the deltas are run-to-run noise,
825
+ > not engine changes. lite remains the only zero-alloc library on every stable scenario
826
+ > (see [`results.txt`](./results.txt)).
819
827
 
820
- The cross-framework reactivity suite agrees independently, re-run on **1.2.2** (median-of-10, isolated): `dyn: large web app` **555ms** (+7% vs alien-signals' 600ms) and `dyn: wide dense` **922ms** (+0.4% vs 926ms) are wins there too -- lite-signal is the fastest of five frameworks on both, with preact ~14-19× slower and vue ~14-31× slower (see [`resultsReactive.txt`](./resultsReactive.txt)). lite also leads alien on **every** `S: updateComputations` row (+5% to +22%) and all five `dyn` rows -- the steady-state hot path. The retracking is verified correct by `retracking.difftest.mjs` -- 20,000 direct + 10,000 batched writes, 0 disagreements against the **published 1.1.5** reference (re-pinned for v1.2).
828
+ The cross-framework reactivity suite agrees independently, re-run on **1.3.0** (default eager, median-of-10, isolated): lite-signal is the **fastest of five frameworks on all five `dyn` rows** -- `dyn: large web app` **544ms** (+7% vs alien-signals' 586ms) and `dyn: wide dense` **902ms** (+3% vs 926ms), plus simple component (+17%), dynamic component (+15%), and deep (+14%) -- with preact 6-19× slower and vue 15-32× slower on the app-shaped graphs (see the `run_*.txt` logs). On the tiny `S: updateComputations` micro-rows lite and alien trade the lead within a few percent (lite ahead on 3 of 7); these sub-60ms kernels are dominated by run-to-run noise on this old host. The retracking is verified correct by `retracking.difftest.mjs` -- 20,000 direct + 10,000 batched writes, 0 disagreements against the **published 1.1.5** reference (re-pinned for v1.2).
821
829
 
822
- **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** wins decisively on the high-churn dynamic and fan-in topologies that dominate live UI -- the shapes where zero allocation pays off most. It runs at parity with alien-signals on cheap stable shapes. The one shape where alien's flatter representation still leads is the 256-deep computed pipeline (DEEP CHAIN, -19% on the 1.2.2 isolated run).
830
+ **The Takeaway:** as of 1.1.4 you no longer have to choose, and 1.3.0 holds the line -- the engine still ranks **4th of 15** on the official js-reactivity-benchmark (the only zero-GC library in the field). `lite-signal` keeps the zero-GC, flat-arena profile for 120fps Canvas/WebGL **and** wins decisively on the high-churn dynamic and fan-in topologies that dominate live UI -- the shapes where zero allocation pays off most. It runs at parity with alien-signals on cheap stable shapes. The one shape where alien's flatter representation still leads is the 256-deep computed pipeline (DEEP CHAIN, -18% on the 1.3.0 isolated run).
823
831
 
824
832
  ### Roadmap
825
833
  - **1.1.5** -- additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering). *Shipped.*
826
834
  - **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). Plus three additive features built on the same internal split: pre-batch revert (`batch(() => { a.set(99); a.set(10); })` doesn't re-fire), multi-throw `AggregateError`, and scheduler-thunk caching with an ABA gen guard. *Shipped.*
827
- - **1.3** -- next engine work after the owner-tree validation. The pull-mode recursion depth limit (~5,000 chained computeds) is the main outstanding architectural item.
835
+ - **1.3.0** -- the **pool minor**: node and link pools become growable and incrementally populated. New `prealloc` config (`"eager"` default | `"lazy"`) chooses up-front vs on-demand construction; `onCapacityExceeded: "grow"` extends pools via chunked refill (runs of up to 1024 links / 256 nodes, ledger doubles) bounded by the 16x link ceiling; `maxFlushPasses` is now a public config. Internally the propagation mark phase moved to an intrusive linked-list stack (a `nextMark` field) -- the only node-shape change. The hot paths and public callable API are byte-identical to 1.2.2; steady-state zero-GC is unchanged. *Shipped.*
836
+ - **1.4** -- next engine work. The pull-mode recursion depth limit (~5,000 chained computeds) is the main outstanding architectural item; cumulative allocation counters (`totalAllocations` / `totalDisposals` / `poolGrowths`) are reserved for the `stats()` surface here.
828
837
 
829
838
  > 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.
830
839
 
@@ -873,14 +882,14 @@ Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScri
873
882
 
874
883
  | Target | Supported |
875
884
  | --------------------------------- | --------- |
876
- | Chrome / Edge (last 2 majors) | |
877
- | Firefox (last 2 majors) | |
878
- | Safari 14+ | |
879
- | Node.js 18+ | |
880
- | Bun | |
881
- | Twitch Extensions (1MB / 3s) | |
882
- | Cloudflare Workers | |
883
- | Deno | |
885
+ | Chrome / Edge (last 2 majors) | yes |
886
+ | Firefox (last 2 majors) | yes |
887
+ | Safari 14+ | yes |
888
+ | Node.js 18+ | yes |
889
+ | Bun | yes |
890
+ | Twitch Extensions (1MB / 3s) | yes |
891
+ | Cloudflare Workers | yes |
892
+ | Deno | yes |
884
893
 
885
894
  ESM-only. No CommonJS build -- modern bundlers handle this; legacy consumers can use a wrapper.
886
895
 
package/Signal.d.ts CHANGED
@@ -82,11 +82,13 @@ export interface RegistryStats {
82
82
  effects: number;
83
83
  /** Number of dependency links currently in use. */
84
84
  activeLinks: number;
85
- /** Number of dependency links available in the pool. */
85
+ /** Dependency links available in the pool (ledger-based: `linkPoolCapacity - activeLinks`). */
86
86
  pooledLinks: number;
87
- /** Total link-pool capacity (grows under `"grow"` policy). */
87
+ /** Link-pool capacity ledger. Doubles under the `"grow"` policy; under `"lazy"`
88
+ * prealloc it may exceed the count of physically constructed links. */
88
89
  linkPoolCapacity: number;
89
- /** Total node-pool capacity (grows under `"grow"` policy). */
90
+ /** Node-pool capacity ledger. Doubles under the `"grow"` policy; under `"lazy"`
91
+ * prealloc it may exceed the count of physically constructed nodes. */
90
92
  nodePoolCapacity: number;
91
93
  /** Number of nodes currently allocated (signals + computeds + alive effects). */
92
94
  activeNodes: number;
@@ -163,14 +165,31 @@ export class CapacityError extends Error {
163
165
  // --- Registry -----------------------------------------------------------------
164
166
 
165
167
  export interface RegistryConfig {
166
- /** Initial node-pool capacity. Default: 1024. */
168
+ /** Initial node-pool capacity (a ledger under `prealloc: "lazy"`). Default: 1024. */
167
169
  maxNodes?: number;
168
- /** Initial link-pool capacity. Default: `maxNodes * 4`. */
170
+ /** Initial link-pool capacity (a ledger under `prealloc: "lazy"`). Default: `maxNodes * 4`. */
169
171
  maxLinks?: number;
170
172
  /**
171
- * Behaviour when a pool is exhausted:
172
- * - `"throw"` (default): throw {@link CapacityError} immediately.
173
- * - `"grow"`: double the pool. Links are bounded by `maxLinks * 16`.
173
+ * Pool population strategy. Default: `"eager"`.
174
+ * - `"eager"`: construct the full `maxNodes` / `maxLinks` pools up front.
175
+ * Deterministic latency -- zero allocation inside any subsequent hot path
176
+ * (the contract for render loops, game ticks, and extension frame budgets),
177
+ * at the cost of a larger resident heap that every major GC traces.
178
+ * - `"lazy"`: treat `maxNodes` / `maxLinks` as capacity ledgers and construct
179
+ * nodes/links on first demand, recycling through the free lists thereafter.
180
+ * Smaller heap, faster cold start, lighter GC marking; identical zero-GC
181
+ * steady state after warm-up. Prefer for footprint-sensitive or short-lived
182
+ * registries.
183
+ */
184
+ prealloc?: "eager" | "lazy";
185
+ /**
186
+ * Behaviour when a pool is exhausted. Default: `"throw"`.
187
+ * - `"throw"`: throw {@link CapacityError} immediately when the pool is full.
188
+ * - `"grow"`: extend the pool on demand. Growth is chunked and incremental
189
+ * (contiguous runs of up to 1024 links / 256 nodes per free-list miss),
190
+ * not a single doubling burst, so any one growth pause stays bounded. The
191
+ * capacity ledger still doubles, keeping {@link RegistryStats} semantics
192
+ * unchanged. Link growth is bounded by a hard ceiling of `maxLinks * 16`.
174
193
  */
175
194
  onCapacityExceeded?: "throw" | "grow";
176
195
  /** Max effect-queue drain passes before a flush-cycle `Error` (message prefixed `"CycleError:"`) is thrown. Default: 100. */
@@ -282,7 +301,7 @@ export function describe(handle: ReactiveHandle): NodeDescriptor | undefined;
282
301
  export function onGraphMutation(fn: GraphMutationListener | null): GraphMutationUnsubscribe;
283
302
  export function onCleanup(fn: () => void): void;
284
303
  export function stats(): RegistryStats;
285
- export declare function destroy(): void;
304
+ export function destroy(): void;
286
305
 
287
306
  /**
288
307
  * Configuration options for the watch utility.