@zakkster/lite-signal 1.2.1 → 1.2.2

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/llms.txt CHANGED
@@ -9,7 +9,7 @@ The library exposes a small reactive primitives API (signal, computed, effect,
9
9
  batch, untrack, onCleanup) backed by pre-allocated pools. Built for animation
10
10
  loops, Twitch Extensions, game HUDs, and other contexts where GC pauses break
11
11
  the frame budget. Effects flush synchronously in the same call stack as the
12
- triggering write no `queueMicrotask`, no promises, no scheduler ticks.
12
+ triggering write -- no `queueMicrotask`, no promises, no scheduler ticks.
13
13
 
14
14
  ## Core concepts
15
15
 
@@ -21,7 +21,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
21
21
  - **Untrack**: `untrack(fn)` reads without subscribing.
22
22
  - **isTracking**: `isTracking()` returns true iff a read right now would record a dependency on this registry (for wrappers that lazily allocate signals).
23
23
  - **onCleanup**: registers teardown for the current computation; fires before each re-run and once on dispose. Works in effects and computeds.
24
- - **Observer-lifecycle introspection** (1.1.4; top-level + per-registry): `hasObservers(handle)` returns true iff a signal/computed has 1 live observer right now (O(1); a `peek` does not count). `observeObservers(handle, { onConnect?, onDisconnect? })` fires on the 01 and 10 observer transitions (after registration; transition-only) and returns an idempotent unobserve the auto-pause hook for tickers (lite-time/lite-raf start a source only while it's watched). `forEachObserver(handle, fn)` / `forEachSource(handle, fn)` walk the live graph in either direction, passing a `{ kind, value }` descriptor (`kind` is `"signal" | "computed" | "effect"`) for inspection (lite-devtools). The surface is gated by an internal counter: zero steady-state cost when nothing is observed. `hasObservers`/`forEach*` no-op on a non-handle; `observeObservers` throws `TypeError`. **Node identity (1.1.5):** `nodeId(handle)` returns the node's stable per-allocation id, `describe(handle)` returns the handle's own `{ id, kind, value }` descriptor, and `forEach*` descriptors now carry `id` too; a descriptor is **re-walkable** (pass it back into `forEachObserver`/`forEachSource`) the recursion primitive lite-devtools uses to walk the full DAG. **Owner-tree introspection (1.2.1):** `forEachOwned(handle, fn)` walks a node's owned children (lifetime-binding edges from the 1.2 owner tree invisible through dep/sub); `ownerOf(handle)` returns the owner's descriptor or `undefined` for top-level / stale handles. **Stale-handle guard (1.2.1):** the entire introspection surface plus `peek()`/`read()`/`set()` is now generation-checked. A handle held across the 1.2 owner-cascade auto-dispose used to resolve `NODE_PTR` ungated and silently report the recycled slot's NEW resident; 1.2.1 surfaces stale handles as `undefined` (or `TypeError`, for `observeObservers`) same ABA discipline `dispose()` always had. Descriptors are gen-stamped so the re-walkable contract survives the guard. **Push-based mutation hook (1.2.1):** `onGraphMutation(fn)` registers a single nullable listener invoked synchronously at every mutation point with three integers `(opcode, intA, intB)` opcodes `1` node-create / `2` node-dispose / `3` link-add / `4` link-remove / `5` recompute. Zero-cost gate when no listener is registered (one branch-predicted null check); allocation-free dispatch when registered. The connection point for push-based devtools/studio. Contract: observe only never throw, never mutate.
24
+ - **Observer-lifecycle introspection** (1.1.4; top-level + per-registry): `hasObservers(handle)` returns true iff a signal/computed has >=1 live observer right now (O(1); a `peek` does not count). `observeObservers(handle, { onConnect?, onDisconnect? })` fires on the 0->1 and 1->0 observer transitions (after registration; transition-only) and returns an idempotent unobserve -- the auto-pause hook for tickers (lite-time/lite-raf start a source only while it's watched). `forEachObserver(handle, fn)` / `forEachSource(handle, fn)` walk the live graph in either direction, passing a `{ kind, value }` descriptor (`kind` is `"signal" | "computed" | "effect"`) -- for inspection (lite-devtools). The surface is gated by an internal counter: zero steady-state cost when nothing is observed. `hasObservers`/`forEach*` no-op on a non-handle; `observeObservers` throws `TypeError`. **Node identity (1.1.5):** `nodeId(handle)` returns the node's stable per-allocation id, `describe(handle)` returns the handle's own `{ id, kind, value }` descriptor, and `forEach*` descriptors now carry `id` too; a descriptor is **re-walkable** (pass it back into `forEachObserver`/`forEachSource`) -- the recursion primitive lite-devtools uses to walk the full DAG. **Owner-tree introspection (1.2.1):** `forEachOwned(handle, fn)` walks a node's owned children (lifetime-binding edges from the 1.2 owner tree -- invisible through dep/sub); `ownerOf(handle)` returns the owner's descriptor or `undefined` for top-level / stale handles. **Stale-handle guard (1.2.1):** the entire introspection surface plus `peek()`/`read()`/`set()` is now generation-checked. A handle held across the 1.2 owner-cascade auto-dispose used to resolve `NODE_PTR` ungated and silently report the recycled slot's NEW resident; 1.2.1 surfaces stale handles as `undefined` (or `TypeError`, for `observeObservers`) -- same ABA discipline `dispose()` always had. Descriptors are gen-stamped so the re-walkable contract survives the guard. **Push-based mutation hook (1.2.1):** `onGraphMutation(fn)` registers a single nullable listener invoked synchronously at every mutation point with three integers `(opcode, intA, intB)` -- opcodes `1` node-create / `2` node-dispose / `3` link-add / `4` link-remove / `5` recompute. Zero-cost gate when no listener is registered (one branch-predicted null check); allocation-free dispatch when registered. The connection point for push-based devtools/studio. Contract: observe only -- never throw, never mutate.
25
25
  - **Registry**: `createRegistry({ maxNodes, maxLinks, onCapacityExceeded, maxFlushPasses })` creates an isolated reactive world with its own pools. Useful for tests, plugins, multi-tenant sandboxes. Top-level helpers use a default registry created at module load.
26
26
  - **CapacityError**: thrown when a fixed-size pool is exhausted under the `"throw"` policy, or when a `"grow"` policy hits the 16× starting-capacity ceiling on links.
27
27
 
@@ -30,27 +30,72 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
30
30
  - **Two object pools**: nodes (default 1024) and links (default 4×nodes). Singly-linked via `nextFree`, O(1) allocate/free.
31
31
  - **Doubly-linked edge lists**: each link is in both a dep-list (on the target/observer) and a sub-list (on the source/dependency). O(1) unlink during cursor reconciliation.
32
32
  - **Cursor reconciliation**: at the start of each computed/effect run, `activeObserverCurrentDep` points at the head of the existing dep list. As the body reads deps, matching links advance the cursor (zero alloc); non-matching links insert before the cursor and the displaced tail is severed at the end of the run.
33
- - **Iterative mark phase**: `markDownstream` uses a pre-allocated `markStack` array for DFS no recursion, no call stack growth on wide fan-out.
33
+ - **Iterative mark phase**: `markDownstream` uses a pre-allocated `markStack` array for DFS -- no recursion, no call stack growth on wide fan-out.
34
34
  - **Recursive computed pull**: `pullComputed` is call-stack recursive; deep chains beyond ~10,000 nodes will throw `RangeError`. Effects don't have this limit.
35
- - **Double-buffered effect queue**: `effectQueueA` / `effectQueueB` alternate each flush pass effects *scheduled by* the current pass (cross-effect cascades) drain in the next pass. A **self-write** (an effect writing a signal it reads) is the exception: `markDownstream`'s `FLAG_COMPUTING` guard does **not** re-queue the writing effect, so a self-cycle runs exactly once while the write still propagates to the signal's other observers, and the effect stays responsive to later *external* writes (its `evalVersion` is bumped in the `executeEffect` finally). Mutual cross-effect write loops (ABA) are not self-cycles and trip `CycleError: flush passes exceeded` via `maxFlushPasses`.
35
+ - **Double-buffered effect queue**: `effectQueueA` / `effectQueueB` alternate each flush pass -- effects *scheduled by* the current pass (cross-effect cascades) drain in the next pass. A **self-write** (an effect writing a signal it reads) is the exception: `markDownstream`'s `FLAG_COMPUTING` guard does **not** re-queue the writing effect, so a self-cycle runs exactly once -- while the write still propagates to the signal's other observers, and the effect stays responsive to later *external* writes (its `evalVersion` is bumped in the `executeEffect` finally). Mutual cross-effect write loops (A->B->A) are not self-cycles and trip `CycleError: flush passes exceeded` via `maxFlushPasses`.
36
36
  - **maxFlushPasses ceiling**: default 100. Exceeding this throws `CycleError`. Prevents runaway effect loops.
37
37
  - **32-bit modular versioning**: `globalVersion` and `node.version` are `(value + 1) | 0`, wrapping signed. Comparison via `((dep.version - evalVer) | 0) > 0` is wrap-safe and works in modular distance, not raw integer ordering. The engine never overflows.
38
- - **Generation counter**: each node has a `gen` field incremented on dispose and destroy. Scheduler trampolines capture the current gen and no-op if it changes prevents stale callbacks from re-firing after dispose.
38
+ - **Generation counter**: each node has a `gen` field incremented on dispose and destroy. Scheduler trampolines capture the current gen and no-op if it changes -- prevents stale callbacks from re-firing after dispose.
39
39
  - **destroy()**: resets all pools, rebuilds free lists, bumps every node's gen, resets globalVersion to 1. Safe to call mid-flight (in-flight effects finish their current invocation, scheduled ones become no-ops).
40
40
 
41
41
  ## Performance characteristics
42
42
 
43
- - `signal.set(v)` O(downstream observers), zero alloc after warm-up.
44
- - `signal.peek()` O(1), zero alloc.
45
- - `computed()` cache hit O(deps), zero alloc.
46
- - `computed()` cache miss O(deps + body), zero alloc if dep structure is stable.
47
- - Effect re-run with stable dep order zero alloc.
43
+ - `signal.set(v)` -- O(downstream observers), zero alloc after warm-up.
44
+ - `signal.peek()` -- O(1), zero alloc.
45
+ - `computed()` cache hit -- O(deps), zero alloc.
46
+ - `computed()` cache miss -- O(deps + body), zero alloc if dep structure is stable.
47
+ - Effect re-run with stable dep order -- zero alloc.
48
48
  - Stable read order: O(1) per dep via cursor reuse.
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).
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).
50
50
 
51
51
  ## Version notes
52
52
 
53
- - **1.2.1** (current): correctness-and-introspection patch. Drop-in over 1.2.0;
53
+ - **1.2.2** (current): `createNode` clean-free-list invariant audit. Removes
54
+ ten redundant field-writes that defended against a state the engine cannot
55
+ produce on a clean free-list: seven graph/batch fields (`headDep`, `tailDep`,
56
+ `headSub`, `tailSub`, `revertEpoch`, `preBatchValue`, `preBatchVersion`) and
57
+ three owner-tree fields (the non-adoption `firstOwned`, the adoption-path
58
+ `prevOwned`, and the else-branch `owner`). Both teardown paths
59
+ (`disposeNode` direct, `runCleanup` cascade) already null these fields, and
60
+ the `ReactiveNode` constructor initializes them to the same values on the
61
+ fresh-pool-growth path -- so the redundant writes in `createNode` were
62
+ defense against a state that cannot exist. **No public surface change, no
63
+ semantic change.** New `test/26-free-list-invariant_test.mjs` (3 invariant
64
+ tests + 1 targeted coverage test for the swallow-on-self-dispose-then-throw
65
+ branch in `pullComputed`) asserts the invariant directly by inspecting
66
+ freshly-allocated nodes via the documented `describe()` -> `NODE_PTR`
67
+ introspection protocol; new `test/25-devtools-real-boot_test.mjs` (10 tests)
68
+ boots the actual `Devtools.js` against the 1.2.2 engine and pins the 1.2.x
69
+ introspection contract for downstream tooling, including the 8-key
70
+ `stats()` shape (`signals`, `computeds`, `effects`, `activeNodes`,
71
+ `activeLinks`, `pooledLinks`, `nodePoolCapacity`, `linkPoolCapacity`).
72
+ 408 tests total: 398 pass, 10 skip, 0 fail (the 10 skips are 9
73
+ signalBox-staged-for-1.5.0 in `test/24-signalbox_test.mjs` plus 1
74
+ architecturally-N/A SSR case in `17-reactivity`). Coverage on `Signal.js`:
75
+ 100% statements / 98.43% branches / 100% functions / 100% lines; `Watch.js`
76
+ 100% across all four (c8@11, Node 22) -- a branch-coverage gain over the
77
+ 1.2.1 baseline (98.07%), from the targeted swallow-on-self-dispose-then-throw
78
+ test in `pullComputed`.
79
+ Version lineage:
80
+ this is the engine previously labeled `1.2.3` in dev -- renumbered to
81
+ `1.2.2` because the deletion is small, isolated, and intentionally
82
+ non-behavioral. Drop-in over 1.2.1. **Bench position** (third-party
83
+ [js-reactivity-benchmark](https://github.com/volynetstyle/js-reactivity-benchmark),
84
+ 15 frameworks, 47 tests): #4 by geomean (81.6) at 1.78× behind alien-signals
85
+ (1.00×), reflex (1.03×), @reactively (1.30×); within noise of Preact
86
+ Signals (83.0, lite 1.7% ahead). The only object-pooled zero-GC engine in
87
+ the field. Ahead of @vue/reactivity (1.6×), Signia (1.7×), MobX (2.3×),
88
+ @solidjs/signals (2.6×), SolidJS (3.8×). Top-3 on 18/47 tests; outright
89
+ winner on `manyEffectsFromOneSource` and
90
+ `manySourcesIntoOneComputedEffectWithDirect`. (The suite measures reactivity
91
+ libraries -- Vue reactivity core, MobX, Solid, Preact Signals -- not full UI
92
+ frameworks like React/Angular.)
93
+ In the community reactive suite (median-of-10), fastest of 5 frameworks
94
+ on the entire `dyn:*` family (the realistic large-DOM-framework shapes)
95
+ and the entire `S: updateComputations*` family. Behind on the
96
+ construction-cost path (`S: createComputations*`, `cellx`, `createDataSignals`).
97
+
98
+ - **1.2.1**: correctness-and-introspection patch. Drop-in over 1.2.0;
54
99
  no public surface removed. **Stale-handle introspection (ABA fix):** the v1.2.0
55
100
  owner tree made the engine recycle pool slots autonomously on owner re-run, so
56
101
  holding a stale handle stopped being a user error and became a routine
@@ -63,16 +108,16 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
63
108
  documented `TypeError`, for `observeObservers`). `describe()` descriptors are
64
109
  gen-stamped alongside `NODE_PTR`, so the "descriptors are re-walkable handles"
65
110
  contract survives the guard: a fresh descriptor walks, one held across a
66
- recycle correctly goes stale. **Added `onGraphMutation(fn)`:** registry-level
111
+ recycle correctly goes stale. **Added -- `onGraphMutation(fn)`:** registry-level
67
112
  (and top-level) graph-mutation hook for push-based tooling. Single nullable
68
113
  listener; every fire point is `if (mutationHook !== null) mutationHook(opcode, intA, intB)`
69
- zero-cost gate when absent, allocation-free dispatch when present. Opcodes:
114
+ -- zero-cost gate when absent, allocation-free dispatch when present. Opcodes:
70
115
  `1` node-create `(id, flags)`, `2` node-dispose `(id, flags)` (cascades
71
116
  included), `3` link-add `(source.id, target.id)`, `4` link-remove `(source.id, target.id)`,
72
117
  `5` recompute `(id, 0)` before each effect re-run / computed re-eval.
73
- Contract: observe only never throw, never mutate the graph from inside.
118
+ Contract: observe only -- never throw, never mutate the graph from inside.
74
119
  This is the connection point lite-devtools 1.1 / lite-studio 1.1 use to go
75
- push-based. **Added `forEachOwned(handle, fn)` / `ownerOf(handle)`:** owner-
120
+ push-based. **Added -- `forEachOwned(handle, fn)` / `ownerOf(handle)`:** owner-
76
121
  tree introspection. `forEachOwned` iterates a node's owned children as
77
122
  re-walkable descriptors; `ownerOf` returns the owner's descriptor or
78
123
  `undefined` (top-level or stale). Same descriptor conventions as
@@ -80,23 +125,23 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
80
125
  Also: `Object.is` hoisted to a module-level const (one IC entry instead of
81
126
  per-call lookup in `signal()`/`computed()`). 404 tests, **100% line / 98.07%
82
127
  branch coverage** on `Signal.js` + `Watch.js`. New
83
- `test/21-perf-pins.test.mjs` (6 tests construction-shape + ABA-guard pins),
84
- `test/22-mutation-hook.test.mjs` (12 tests `onGraphMutation` registration,
128
+ `test/21-perf-pins.test.mjs` (6 tests -- construction-shape + ABA-guard pins),
129
+ `test/22-mutation-hook.test.mjs` (12 tests -- `onGraphMutation` registration,
85
130
  the 5 opcodes, payload shape, registry isolation), and
86
- `test/23-owner-introspection.test.mjs` (14 tests `forEachOwned`, `ownerOf`,
131
+ `test/23-owner-introspection.test.mjs` (14 tests -- `forEachOwned`, `ownerOf`,
87
132
  and gen-guarded behaviour across the introspection surface). Differential
88
133
  fuzz vs the published 1.1.5: 0 disagreements over 30,000 writes.
89
134
 
90
135
  - **1.2.0**: structural refactor (three named layers: graph topology /
91
136
  ownership / propagation) plus four additive features built on top. Drop-in over
92
137
  1.1.5; no public surface removed. **Owner tree:** an effect or computed that
93
- creates nested observers (effect/computed) now owns them when the owner
138
+ creates nested observers (effect/computed) now owns them -- when the owner
94
139
  re-runs or is disposed, the engine cascade-disposes those observers before
95
140
  the new run. Plain signals are deliberately NOT owner-adopted so lazy-
96
141
  allocation wrappers (lite-store keys, lite-form fields) continue to survive
97
142
  re-runs of the computed that allocated them. **Pre-batch revert:** inside a
98
143
  `batch(...)`, set X then set X back (under the signal's `equals`) reverts the
99
- version bump downstream effects/computeds do not fire. **AggregateError on
144
+ version bump -- downstream effects/computeds do not fire. **AggregateError on
100
145
  multi-throw:** two or more effects throwing in the same flush pass aggregate to
101
146
  `AggregateError` at the trigger; single-throw is unchanged. **Scheduler thunk
102
147
  caching:** the scheduler closure is cached on the node and gen-bound, so async
@@ -104,36 +149,36 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
104
149
  (ABA safe). Internal split: `currentObserver` and `currentOwner` are now
105
150
  distinct pointers (today they move together, no behavioural change). **Perf:**
106
151
  shared `peek` (one closure per registry instead of per primitive) shaves
107
- 1014% off signal/computed creation on the `S:create*` micros, no hot-path or
152
+ 10-14% off signal/computed creation on the `S:create*` micros, no hot-path or
108
153
  behavioural change. 363 tests, 100% line / 98.62% branch coverage on
109
154
  `Signal.js` + `Watch.js`. Differential fuzz vs the published 1.1.5: 0
110
155
  disagreements over 30,000 writes. New `test/19-v12-additions.test.mjs`
111
- (24 tests) and `test/20-axis-stress.test.mjs` (23 tests eight orthogonal
156
+ (24 tests) and `test/20-axis-stress.test.mjs` (23 tests -- eight orthogonal
112
157
  engine-invariant axes plus permanent conformance pins). **Conformance fixes
113
158
  in 1.2.0**: #141 (dispose during execution then continue: no re-run), and
114
159
  #238 / #241 / #243 (cleanup ordering on cascade: inner-before-outer,
115
160
  deepest-first, and the inner-only-re-run regression). #141 was a latent
116
161
  crash present in 1.1.5 too; the v1.2 owner tree exercised it more
117
- aggressively. Fix is two-fold nullify the tracking cursor in `disposeNode`
162
+ aggressively. Fix is two-fold -- nullify the tracking cursor in `disposeNode`
118
163
  when the disposed node is the active observer (plus a `gen`-snapshot guard
119
164
  in `executeEffect`/`pullComputed`); and swap `runCleanup` to cascade
120
165
  children first, then own.
121
166
 
122
- - **1.1.5**: additive release in service of `@zakkster/lite-devtools` stable
167
+ - **1.1.5**: additive release in service of `@zakkster/lite-devtools` -- stable
123
168
  node identity on the introspection surface. `nodeId(handle)` -> the node's stable
124
169
  per-allocation id (the dedupe key for graph walks); `describe(handle)` -> the handle's
125
170
  own `{ id, kind, value }` descriptor, **re-walkable** (pass it back into
126
171
  `forEachObserver`/`forEachSource` to walk the full DAG); `forEach*` descriptors now carry
127
- `id`. One SMI write at allocation, node shape kept monomorphic **zero steady-state
172
+ `id`. One SMI write at allocation, node shape kept monomorphic -- **zero steady-state
128
173
  cost**. Drop-in over 1.1.4, no breaking changes. New `test/18-identity_test.mjs` (5 tests); plus `14-lifecycle-teardown`, `16-alien-parity`, and the `17-reactivity` behavioral suite.
129
174
 
130
- - **1.1.4**: combined release a retracking rewrite plus an observer-lifecycle
175
+ - **1.1.4**: combined release -- a retracking rewrite plus an observer-lifecycle
131
176
  introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
132
177
  reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
133
178
  O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
134
179
  order is unchanged (still O(1), still zero-alloc). The two documented v1.1.x losses flipped
135
- to wins and are now fastest of five frameworks `dyn: large web app` 6194ms571ms, `dyn:
136
- wide dense` 5115ms912ms (verified by `retracking.difftest.mjs`: 20k direct + 10k batched, 0
180
+ to wins and are now fastest of five frameworks -- `dyn: large web app` 6194ms->571ms, `dyn:
181
+ wide dense` 5115ms->912ms (verified by `retracking.difftest.mjs`: 20k direct + 10k batched, 0
137
182
  disagreements; no regressions elsewhere). **Introspection:** `hasObservers`,
138
183
  `observeObservers`, `forEachObserver`, `forEachSource` (top-level + per-registry), gated by
139
184
  an internal counter so zero steady-state cost when unused. New `test/13-introspection_test.mjs`
@@ -142,7 +187,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
142
187
  - **1.1.3**: adds `isTracking()` (top-level + per-registry). Returns true iff a
143
188
  read right now would record a dependency (`isTrackingDeps && currentObserver !== null`).
144
189
  False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, `watch` /
145
- `when` callbacks, and outside any observer. ~12 ns. For wrapper libraries
190
+ `when` callbacks, and outside any observer. ~1-2 ns. For wrapper libraries
146
191
  (lite-store, lite-query, lite-form) that allocate reactive primitives lazily
147
192
  on property reads. Per-registry: wrappers operating against a non-default
148
193
  registry must call THAT registry's `isTracking()`, not the top-level one.
@@ -166,9 +211,9 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
166
211
 
167
212
  ## When NOT to use
168
213
 
169
- - Server-side rendering no SSR story.
170
- - 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.)
171
- - 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.)
214
+ - Server-side rendering -- no SSR story.
215
+ - 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.)
216
+ - 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.)
172
217
 
173
218
  ## API summary
174
219
 
@@ -259,11 +304,11 @@ Fires callback on every change of the projected source value.
259
304
  - **Signature**: `watch<T>(source: () => T, callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void, options?: { immediate?: boolean }): () => void`
260
305
  - **Returns**: dispose function. Idempotent. Safe to call synchronously inside the callback.
261
306
  - **options.immediate**: when `true`, callback fires once on registration with `oldValue = undefined`. Default `false`.
262
- - **Equality guard**: uses `Object.is(newValue, oldValue)` internally to avoid firing when the projected value is unchanged across dep mutations. Critical for raw getter sources like `() => health() <= 0` many dep changes can produce the same boolean. Wrapping the source in `computed()` would achieve the same via the computed's own equality check.
307
+ - **Equality guard**: uses `Object.is(newValue, oldValue)` internally to avoid firing when the projected value is unchanged across dep mutations. Critical for raw getter sources like `() => health() <= 0` -- many dep changes can produce the same boolean. Wrapping the source in `computed()` would achieve the same via the computed's own equality check.
263
308
  - **UNINITIALIZED sentinel**: uses `Symbol("watch.uninitialized")` instead of `undefined` to detect first-run. This means `signal(undefined)` is correctly distinguished from "watcher hasn't fired yet".
264
309
  - **Callback reads are untracked**: the callback can read other signals without registering them as dependencies.
265
310
  - **Stop in callback**: third callback argument is a stop handle. Safe to call at any point including synchronously during the immediate fire (the `wantsStopEarly` flag defers dispose until after the effect is registered).
266
- - **Allocation profile**: 3 closures at registration (stop, effect body, hoisted untrack body). **Zero allocations per fire** the untrack body is hoisted with `currentNewValue` as shared mutable state. Hot-path safe at 120fps.
311
+ - **Allocation profile**: 3 closures at registration (stop, effect body, hoisted untrack body). **Zero allocations per fire** -- the untrack body is hoisted with `currentNewValue` as shared mutable state. Hot-path safe at 120fps.
267
312
 
268
313
  ### when(predicate, callback)
269
314
 
@@ -275,7 +320,7 @@ Fires callback exactly once when predicate first returns truthy, then auto-dispo
275
320
  - **One-shot guarantee**: internal `fired` flag protects against double-fire even if dispose timing lets one more evaluation through.
276
321
  - **Callback reads are untracked.**
277
322
  - **Truthy/falsy semantics**: standard JS truthy check. `0`, `""`, `null`, `undefined`, `false`, `NaN` do not trigger; everything else does.
278
- - **Allocation profile**: 2 closures at registration (stop, effect body). **Zero allocations per check** `untrack(callback)` passes the user's callback directly without wrapping. Hot-path safe at 120fps.
323
+ - **Allocation profile**: 2 closures at registration (stop, effect body). **Zero allocations per check** -- `untrack(callback)` passes the user's callback directly without wrapping. Hot-path safe at 120fps.
279
324
 
280
325
  ### whenAsync(predicate)
281
326
 
@@ -285,11 +330,11 @@ Promise variant of `when`.
285
330
  - **Returns**: Promise that resolves when predicate first becomes truthy.
286
331
  - **Implementation**: `return new Promise((resolve) => when(predicate, resolve))`.
287
332
  - **Foot-gun**: promise never rejects. If predicate never becomes truthy, promise never settles. Use `Promise.race` for timeout: `Promise.race([whenAsync(p), timeoutPromise])`.
288
- - **⚠️ HOT-PATH WARNING**: `new Promise(...)` is a heap allocation. Each call allocates 1 Promise + 1 executor closure + Promise infrastructure (resolve fn, microtask state) + 2 closures from internal `when` call. This is unavoidable Promises require heap allocation by spec. **Do NOT call per frame.** Use for high-level orchestration (boot sequences, scene transitions, awaiting user input). For 60/120fps logic, use `when(predicate, callback)` directly it's zero-GC.
333
+ - **! HOT-PATH WARNING**: `new Promise(...)` is a heap allocation. Each call allocates 1 Promise + 1 executor closure + Promise infrastructure (resolve fn, microtask state) + 2 closures from internal `when` call. This is unavoidable -- Promises require heap allocation by spec. **Do NOT call per frame.** Use for high-level orchestration (boot sequences, scene transitions, awaiting user input). For 60/120fps logic, use `when(predicate, callback)` directly -- it's zero-GC.
289
334
 
290
335
  ### Architecture note
291
336
 
292
- None of these touch the reactive engine no `FLAG_WATCHER` on `ReactiveNode`, no extension to the object pool, no new internal primitive. They compose entirely from public API. This is the test that the core engine is structurally complete: when a higher-level pattern can be built without extending the engine, the engine has enough.
337
+ None of these touch the reactive engine -- no `FLAG_WATCHER` on `ReactiveNode`, no extension to the object pool, no new internal primitive. They compose entirely from public API. This is the test that the core engine is structurally complete: when a higher-level pattern can be built without extending the engine, the engine has enough.
293
338
 
294
339
  ### Allocation profile summary
295
340
 
@@ -299,35 +344,35 @@ None of these touch the reactive engine — no `FLAG_WATCHER` on `ReactiveNode`,
299
344
  | `when` | 2 closures | 0 |
300
345
  | `whenAsync` | 1 Promise + executor + Promise internals + 2 closures (from `when`) | 0 |
301
346
 
302
- The deliberate engineering for `watch`'s "0 per fire" is the hoisted `untrackedFire` closure with `currentNewValue` shared mutable state see source comment in `watch.js`. Inline arrow function inside the effect body would allocate per fire and break the zero-GC contract.
347
+ The deliberate engineering for `watch`'s "0 per fire" is the hoisted `untrackedFire` closure with `currentNewValue` shared mutable state -- see source comment in `watch.js`. Inline arrow function inside the effect body would allocate per fire and break the zero-GC contract.
303
348
 
304
349
  ## Benchmark snapshot (Node 22, 2016-era Intel MacBook Pro, 20K iter × 5 runs × 50+ invocations)
305
350
 
306
351
  | Scenario | lite-signal | alien-signals | preact | solid |
307
352
  | --------------------------------------- | ----------- | ------------- | -------- | -------- |
308
- | MUX (256 sigs sum effect) | **249K ops/s** | 207K | 153K | 77K |
309
- | BROADCAST (1 sig 1000 effects) | **24K** | 22K | 17K | 8K |
310
- | KAIROS (1 sig 1000 computeds eff) | **14K** | 13K | 12K | 4K |
311
- | DEEP CHAIN (256-deep memos eff) | 51K | **60K** | 50K | 15K |
353
+ | MUX (256 sigs -> sum -> effect) | **249K ops/s** | 207K | 153K | 77K |
354
+ | BROADCAST (1 sig -> 1000 effects) | **24K** | 22K | 17K | 8K |
355
+ | KAIROS (1 sig -> 1000 computeds -> eff) | **14K** | 13K | 12K | 4K |
356
+ | DEEP CHAIN (256-deep memos -> eff) | 51K | **60K** | 50K | 15K |
312
357
 
313
358
  lite-signal wins three of four scenarios against current published versions of the
314
- alternatives: MUX +20%, BROADCAST +9%, KAIROS +8% fan-in aggregation, fan-out
359
+ alternatives: MUX +20%, BROADCAST +9%, KAIROS +8% -- fan-in aggregation, fan-out
315
360
  broadcast, and one-source-to-wide-memo-layer respectively. These are the patterns
316
361
  that dominate UI workloads. alien-signals retains a 15% lead on 256-deep computed
317
362
  pipelines, where its flatter internal representation pays off when the propagation
318
363
  path is long rather than wide.
319
364
 
320
365
  These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
321
- high-fan-in shapes that were lite-signal's documented weakness `dyn: large web app`
322
- and `dyn: wide dense` in the cross-framework reactivity suite were closed by the
366
+ high-fan-in shapes that were lite-signal's documented weakness -- `dyn: large web app`
367
+ and `dyn: wide dense` in the cross-framework reactivity suite -- were closed by the
323
368
  1.1.4 retracking rewrite and remain the fastest of five frameworks (re-confirmed on
324
369
  1.1.5, median-of-12): 555ms / 870ms vs alien-signals' 590ms / 933ms, with preact and
325
- vue ~730× slower. See resultsReactive.txt for the full 34-test, 5-framework table.
370
+ vue ~7-30× slower. See resultsReactive.txt for the full 34-test, 5-framework table.
326
371
 
327
- On allocation pressure, lite-signal is alone in the zero-Δheap band: ~15 KB of
372
+ On allocation pressure, lite-signal is alone in the zero-alloc band: ~15 KB of
328
373
  transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
329
- ~230 KB per loop; solid runs into single-digit megabytes; alien-signals which
330
- earlier shared the zero-GC band now allocates 0.9-3.9 MB per scenario in its
374
+ ~230 KB per loop; solid runs into single-digit megabytes; alien-signals -- which
375
+ earlier shared the zero-GC band -- now allocates 0.9-3.9 MB per scenario in its
331
376
  current published version (not a leak; retained heap is near zero everywhere,
332
377
  but it is real GC pressure on the hot path).
333
378
 
@@ -379,7 +424,7 @@ const unsubscribe = onGraphMutation((opcode, intA, intB) => {
379
424
  case 5: devtools.onRecompute(intA); break;
380
425
  }
381
426
  });
382
- // Stop listening restores prior listener (or null), engine returns to zero-cost state.
427
+ // Stop listening -- restores prior listener (or null), engine returns to zero-cost state.
383
428
  unsubscribe();
384
429
 
385
430
  // 1.2.1: walk the owner tree (cascade-disposal domains)
@@ -395,29 +440,40 @@ function dumpOwnerTree(handle, depth = 0) {
395
440
 
396
441
  ## File layout
397
442
 
398
- - `Signal.js` full implementation, single file.
399
- - `Signal.d.ts` TypeScript declarations for all public API.
400
- - `test/01-core_test.mjs` signal/computed/effect basics, equality, untrack.
401
- - `test/02-topology_test.mjs` diamonds, chains, fan-out/in, cycle detection.
402
- - `test/03-pool_test.mjs` capacity errors, grow policy, pool reuse.
403
- - `test/04-zero-gc_test.mjs` heap retention (run with --expose-gc).
404
- - `test/05-scheduler_test.mjs` scheduler races, dispose, gen counter, version wrap.
405
- - `test/06-nested-objects_test.mjs` nested-object & reference-identity behaviours.
406
- - `test/07-dispose_test.mjs` universal disposal: registry.dispose(api).
407
- - `test/08-watch_test.mjs` new watch reactivity tests.
408
- - `test/09-conformance_test.mjs` johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
409
- - `test/10-is-tracking_test.mjs` `isTracking()` across observer bodies, untracked windows, and outside any observer.
410
- - `test/11-adopted-reactive_test.mjs` engine-agnostic edge cases adopted from the wider ecosystem (alien-signals link-integrity #226228, equality-predicate corners, `update()`/`peek()`/`subscribe` contracts).
411
- - `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.
412
- - `test/13-introspection_test.mjs` observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
413
- - `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).
414
- - `test/15-owner-lazy-alloc_test.mjs` owner-adoption contract for the 1.2.0 owner tree (lazy signals never adopted; observers still auto-disposed); capability-gated, skipped on 1.1.x (4 tests).
415
- - `test/16-alien-parity_test.mjs` differential guards vs alien-signals@3.2.0 fixed-bug classes: cleanup reads create no deps, inner write doesn't block computed-chain propagation, dynamic dep-set correct under dirty-check (3 tests).
416
- - `test/17-reactivity_test.mjs` behavioral suite over universal signal-system bug classes (subscription lifecycle, cleanup ordering, stale-dep tracking, batching, equality, nested invalidation, memory, sync async-boundary, scheduler/loops, + differential-review additions); SSR is N/A (30 tests).
417
- - `test/18-identity_test.mjs` node identity (1.1.5): `nodeId`/`describe`, descriptor `id`, re-walkable descriptors, non-perturbing (5 tests).
418
- - `bench/benchmark.mjs` anti-DCE throughput harness (ops/s; results.txt).
419
- - `bench/benchmarkReactive.mjs` cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
420
- - `demo/index.html` interactive visualization of the reactive graph.
443
+ - `Signal.js` -- full implementation, single file.
444
+ - `Signal.d.ts` -- TypeScript declarations for all public API.
445
+ - `test/01-core_test.mjs` -- signal/computed/effect basics, equality, untrack.
446
+ - `test/02-topology_test.mjs` -- diamonds, chains, fan-out/in, cycle detection.
447
+ - `test/03-pool_test.mjs` -- capacity errors, grow policy, pool reuse.
448
+ - `test/04-zero-gc_test.mjs` -- heap retention (run with --expose-gc).
449
+ - `test/05-scheduler_test.mjs` -- scheduler races, dispose, gen counter, version wrap.
450
+ - `test/06-nested-objects_test.mjs` -- nested-object & reference-identity behaviours.
451
+ - `test/07-dispose_test.mjs` -- universal disposal: registry.dispose(api).
452
+ - `test/08-watch_test.mjs` -- new watch reactivity tests.
453
+ - `test/09-conformance_test.mjs` -- johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
454
+ - `test/10-is-tracking_test.mjs` -- `isTracking()` across observer bodies, untracked windows, and outside any observer.
455
+ - `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).
456
+ - `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.
457
+ - `test/13-introspection_test.mjs` -- observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
458
+ - `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).
459
+ - `test/15-owner-lazy-alloc_test.mjs` -- owner-adoption contract for the 1.2.0 owner tree (lazy signals never adopted; observers still auto-disposed); capability-gated, skipped on 1.1.x (4 tests).
460
+ - `test/16-alien-parity_test.mjs` -- differential guards vs alien-signals@3.2.0 fixed-bug classes: cleanup reads create no deps, inner write doesn't block computed-chain propagation, dynamic dep-set correct under dirty-check (3 tests).
461
+ - `test/17-reactivity_test.mjs` -- behavioral suite over universal signal-system bug classes (subscription lifecycle, cleanup ordering, stale-dep tracking, batching, equality, nested invalidation, memory, sync async-boundary, scheduler/loops, + differential-review additions); SSR is N/A (~30 tests).
462
+ - `test/18-identity_test.mjs` -- node identity (1.1.5): `nodeId`/`describe`, descriptor `id`, re-walkable descriptors, non-perturbing (5 tests).
463
+ - `test/19-v12-additions_test.mjs` -- 1.2.0 surface coverage: shared-peek, owner-adoption rule, pre-batch revert (24 tests).
464
+ - `test/20-axis-stress_test.mjs` -- eight orthogonal stress axes (batch-under-exception, connect/disconnect re-entrancy, untrack vs owner cascade, etc.) (23 tests).
465
+ - `test/21-perf-pins_test.mjs` -- 1.2.1 construction-shape + ABA-guard pins (6 tests).
466
+ - `test/22-mutation-hook_test.mjs` -- 1.2.1 `onGraphMutation` registration + opcode emission (12 tests).
467
+ - `test/23-owner-introspection_test.mjs` -- `ownerOf`, `forEachOwned`, gen-guarded introspection (14 tests).
468
+ - `test/24-signalbox_test.mjs` -- staged for 1.5.0; all tests `{skip: true}` on 1.2.x.
469
+ - `test/25-devtools-real-boot_test.mjs` -- 1.2.2: real `Devtools.js` boot against the engine, ghost-contract pin, `stats()` shape pin (10 tests).
470
+ - `test/26-free-list-invariant_test.mjs` -- 1.2.2: the audit's clean-free-list invariant, asserted by inspecting freshly-allocated nodes (4 tests).
471
+ - `bench/benchmark.mjs` -- anti-DCE throughput harness (ops/s; results.txt).
472
+ - `bench/benchmarkReactive.mjs` -- cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
473
+ - `bench/dispose-recycle.mjs` -- 1.2.2 targeted microbench: create/dispose/recreate cycle (the free-list invariant audit target).
474
+ - `bench/torture/` -- crash-detection soaks: `graph-fuzzer`, `scheduler-bench`, `torture-soak`. Exit 0 iff zero errors AND post-teardown pool clean.
475
+ - `bench/run-all.sh` + `bench/aggregate.mjs` -- cold-process-per-engine protocol + per-scenario aggregation.
476
+ - `demo/index.html` -- interactive visualization of the reactive graph.
421
477
 
422
478
  ## Install
423
479
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
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",
@@ -68,6 +68,7 @@
68
68
  },
69
69
  "sideEffects": false,
70
70
  "devDependencies": {
71
- "c8": "^11.0.0"
71
+ "c8": "^11.0.0",
72
+ "@zakkster/lite-devtools": "^1.1.0"
72
73
  }
73
74
  }