@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 +102 -1
- package/README.md +79 -70
- package/Signal.d.ts +28 -9
- package/Signal.js +228 -84
- package/Watch.js +13 -13
- package/llms.txt +96 -42
- package/package.json +1 -1
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.
|
|
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 (
|
|
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.
|
|
24
|
+
| **@vue/reactivity** | **1.5x faster** |
|
|
25
25
|
| **Signia** | **1.7x faster** |
|
|
26
26
|
| **MobX** | **2.3x faster** |
|
|
27
|
-
| **@solidjs/signals** | **2.
|
|
28
|
-
| **SolidJS** | **
|
|
29
|
-
| Preact Signals |
|
|
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
|
|
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:
|
|
448
|
-
maxLinks:
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
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
|
|
478
|
+
A[allocator hits empty free-list] --> B{policy?}
|
|
474
479
|
B -- "throw" --> C[CapacityError]
|
|
475
|
-
B -- "grow" --> D[
|
|
476
|
-
D --> E{
|
|
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
|
|
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
|
|
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()` |
|
|
504
|
-
| `watch(source, (v, p, stop) => ...)` | observe until a condition | self-dispose via callback arg |
|
|
505
|
-
| `when(predicate, cb)` | one-shot trigger when condition first true | auto-dispose |
|
|
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)**,
|
|
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
|
-
| **
|
|
660
|
-
| **DYNAMIC DAG** | sqrt-layered, FAN=6, read flips each iter | **
|
|
661
|
-
| **
|
|
662
|
-
| **SMALL SELECTIVE** | 6 layers × 64 wide, 6 cand / 3 read | **
|
|
663
|
-
| **KAIROS** | 1 signal -> 1000 computeds -> 1 effect |
|
|
664
|
-
| **
|
|
665
|
-
| **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in
|
|
666
|
-
| **
|
|
667
|
-
| **DEEP CHAIN** | 256-deep computed chain -> 1 effect
|
|
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
|
-
|
|
675
|
+
*(lower time = faster; transient heap = average delta-heap per rep, lower = less GC pressure)*
|
|
672
676
|
|
|
673
|
-
|
|
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.
|
|
723
|
-
- **`25-devtools-real-boot_test.mjs`** -- Devtools/Studio contract (10 tests). Boots the actual `Devtools.js` against the 1.
|
|
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.
|
|
803
|
-
| Scenario | alien-signals | lite-signal (1.
|
|
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
|
-
| **
|
|
806
|
-
| **
|
|
807
|
-
| **SMALL SELECTIVE** (~ 64x6) |
|
|
808
|
-
| **LARGE WEB APP** (~ 1000x12) |
|
|
809
|
-
| **WIDE DENSE** (~ 1000x5) |
|
|
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.
|
|
812
|
-
> wins are on the **allocation-heavy** dynamic shapes (
|
|
813
|
-
> +
|
|
814
|
-
> allocates nothing. The cheaper wide-app/dense shapes land within a few percent
|
|
815
|
-
> way (host noise on this old machine).
|
|
816
|
-
>
|
|
817
|
-
>
|
|
818
|
-
>
|
|
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.
|
|
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, -
|
|
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** --
|
|
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
|
-
/**
|
|
85
|
+
/** Dependency links available in the pool (ledger-based: `linkPoolCapacity - activeLinks`). */
|
|
86
86
|
pooledLinks: number;
|
|
87
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
172
|
-
* - `"
|
|
173
|
-
*
|
|
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
|
|
304
|
+
export function destroy(): void;
|
|
286
305
|
|
|
287
306
|
/**
|
|
288
307
|
* Configuration options for the watch utility.
|