@zakkster/lite-signal 1.2.0 → 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.
Files changed (6) hide show
  1. package/CHANGELOG.md +331 -68
  2. package/README.md +244 -155
  3. package/Signal.d.ts +74 -20
  4. package/Signal.js +191 -85
  5. package/llms.txt +189 -66
  6. package/package.json +7 -3
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.
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,36 +30,118 @@ 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.0** (current): structural refactor (three named layers: graph topology /
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;
99
+ no public surface removed. **Stale-handle introspection (ABA fix):** the v1.2.0
100
+ owner tree made the engine recycle pool slots autonomously on owner re-run, so
101
+ holding a stale handle stopped being a user error and became a routine
102
+ occurrence. Pre-1.2.1, `nodeId`/`describe`/`forEachObserver`/`forEachSource`/
103
+ `hasObservers`/`observeObservers`/`peek()` would resolve `NODE_PTR` ungated
104
+ and happily report the recycled slot's NEW resident through an old handle
105
+ (wrong id, wrong value, wrong edges). All six entry points + `peek()` + `read()`
106
+ + `set()` now resolve through a generation check (the same ABA discipline
107
+ `dispose()` already had); stale handles read as `undefined` (or throw the
108
+ documented `TypeError`, for `observeObservers`). `describe()` descriptors are
109
+ gen-stamped alongside `NODE_PTR`, so the "descriptors are re-walkable handles"
110
+ contract survives the guard: a fresh descriptor walks, one held across a
111
+ recycle correctly goes stale. **Added -- `onGraphMutation(fn)`:** registry-level
112
+ (and top-level) graph-mutation hook for push-based tooling. Single nullable
113
+ listener; every fire point is `if (mutationHook !== null) mutationHook(opcode, intA, intB)`
114
+ -- zero-cost gate when absent, allocation-free dispatch when present. Opcodes:
115
+ `1` node-create `(id, flags)`, `2` node-dispose `(id, flags)` (cascades
116
+ included), `3` link-add `(source.id, target.id)`, `4` link-remove `(source.id, target.id)`,
117
+ `5` recompute `(id, 0)` before each effect re-run / computed re-eval.
118
+ Contract: observe only -- never throw, never mutate the graph from inside.
119
+ This is the connection point lite-devtools 1.1 / lite-studio 1.1 use to go
120
+ push-based. **Added -- `forEachOwned(handle, fn)` / `ownerOf(handle)`:** owner-
121
+ tree introspection. `forEachOwned` iterates a node's owned children as
122
+ re-walkable descriptors; `ownerOf` returns the owner's descriptor or
123
+ `undefined` (top-level or stale). Same descriptor conventions as
124
+ `forEachObserver`/`forEachSource`; garbage input is a no-op/`undefined`.
125
+ Also: `Object.is` hoisted to a module-level const (one IC entry instead of
126
+ per-call lookup in `signal()`/`computed()`). 404 tests, **100% line / 98.07%
127
+ branch coverage** on `Signal.js` + `Watch.js`. New
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,
130
+ the 5 opcodes, payload shape, registry isolation), and
131
+ `test/23-owner-introspection.test.mjs` (14 tests -- `forEachOwned`, `ownerOf`,
132
+ and gen-guarded behaviour across the introspection surface). Differential
133
+ fuzz vs the published 1.1.5: 0 disagreements over 30,000 writes.
134
+
135
+ - **1.2.0**: structural refactor (three named layers: graph topology /
54
136
  ownership / propagation) plus four additive features built on top. Drop-in over
55
137
  1.1.5; no public surface removed. **Owner tree:** an effect or computed that
56
- creates nested observers (effect/computed) now owns them when the owner
138
+ creates nested observers (effect/computed) now owns them -- when the owner
57
139
  re-runs or is disposed, the engine cascade-disposes those observers before
58
140
  the new run. Plain signals are deliberately NOT owner-adopted so lazy-
59
141
  allocation wrappers (lite-store keys, lite-form fields) continue to survive
60
142
  re-runs of the computed that allocated them. **Pre-batch revert:** inside a
61
143
  `batch(...)`, set X then set X back (under the signal's `equals`) reverts the
62
- version bump downstream effects/computeds do not fire. **AggregateError on
144
+ version bump -- downstream effects/computeds do not fire. **AggregateError on
63
145
  multi-throw:** two or more effects throwing in the same flush pass aggregate to
64
146
  `AggregateError` at the trigger; single-throw is unchanged. **Scheduler thunk
65
147
  caching:** the scheduler closure is cached on the node and gen-bound, so async
@@ -67,36 +149,36 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
67
149
  (ABA safe). Internal split: `currentObserver` and `currentOwner` are now
68
150
  distinct pointers (today they move together, no behavioural change). **Perf:**
69
151
  shared `peek` (one closure per registry instead of per primitive) shaves
70
- 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
71
153
  behavioural change. 363 tests, 100% line / 98.62% branch coverage on
72
154
  `Signal.js` + `Watch.js`. Differential fuzz vs the published 1.1.5: 0
73
155
  disagreements over 30,000 writes. New `test/19-v12-additions.test.mjs`
74
- (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
75
157
  engine-invariant axes plus permanent conformance pins). **Conformance fixes
76
158
  in 1.2.0**: #141 (dispose during execution then continue: no re-run), and
77
159
  #238 / #241 / #243 (cleanup ordering on cascade: inner-before-outer,
78
160
  deepest-first, and the inner-only-re-run regression). #141 was a latent
79
161
  crash present in 1.1.5 too; the v1.2 owner tree exercised it more
80
- aggressively. Fix is two-fold nullify the tracking cursor in `disposeNode`
162
+ aggressively. Fix is two-fold -- nullify the tracking cursor in `disposeNode`
81
163
  when the disposed node is the active observer (plus a `gen`-snapshot guard
82
164
  in `executeEffect`/`pullComputed`); and swap `runCleanup` to cascade
83
165
  children first, then own.
84
166
 
85
- - **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
86
168
  node identity on the introspection surface. `nodeId(handle)` -> the node's stable
87
169
  per-allocation id (the dedupe key for graph walks); `describe(handle)` -> the handle's
88
170
  own `{ id, kind, value }` descriptor, **re-walkable** (pass it back into
89
171
  `forEachObserver`/`forEachSource` to walk the full DAG); `forEach*` descriptors now carry
90
- `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
91
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.
92
174
 
93
- - **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
94
176
  introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
95
177
  reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
96
178
  O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
97
179
  order is unchanged (still O(1), still zero-alloc). The two documented v1.1.x losses flipped
98
- to wins and are now fastest of five frameworks `dyn: large web app` 6194ms571ms, `dyn:
99
- 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
100
182
  disagreements; no regressions elsewhere). **Introspection:** `hasObservers`,
101
183
  `observeObservers`, `forEachObserver`, `forEachSource` (top-level + per-registry), gated by
102
184
  an internal counter so zero steady-state cost when unused. New `test/13-introspection_test.mjs`
@@ -105,7 +187,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
105
187
  - **1.1.3**: adds `isTracking()` (top-level + per-registry). Returns true iff a
106
188
  read right now would record a dependency (`isTrackingDeps && currentObserver !== null`).
107
189
  False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, `watch` /
108
- `when` callbacks, and outside any observer. ~12 ns. For wrapper libraries
190
+ `when` callbacks, and outside any observer. ~1-2 ns. For wrapper libraries
109
191
  (lite-store, lite-query, lite-form) that allocate reactive primitives lazily
110
192
  on property reads. Per-registry: wrappers operating against a non-default
111
193
  registry must call THAT registry's `isTracking()`, not the top-level one.
@@ -129,9 +211,9 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
129
211
 
130
212
  ## When NOT to use
131
213
 
132
- - Server-side rendering no SSR story.
133
- - 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.)
134
- - 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.)
135
217
 
136
218
  ## API summary
137
219
 
@@ -151,8 +233,13 @@ function observeObservers(
151
233
  ): () => void; // returns idempotent unobserve
152
234
  function forEachObserver(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
153
235
  function forEachSource(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
236
+ function forEachOwned(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void; // 1.2.1
237
+ function ownerOf(handle: Signal<any> | Computed<any>): NodeDescriptor | undefined; // 1.2.1
154
238
  function nodeId(handle: Signal<any> | Computed<any>): number | undefined; // 1.1.5
155
239
  function describe(handle: Signal<any> | Computed<any>): NodeDescriptor | undefined; // 1.1.5
240
+ function onGraphMutation( // 1.2.1
241
+ fn: ((opcode: 1|2|3|4|5, intA: number, intB: number) => void) | null
242
+ ): () => void; // unsub restores prior listener
156
243
  function onCleanup(fn: () => void): void;
157
244
  function stats(): RegistryStats;
158
245
 
@@ -217,11 +304,11 @@ Fires callback on every change of the projected source value.
217
304
  - **Signature**: `watch<T>(source: () => T, callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void, options?: { immediate?: boolean }): () => void`
218
305
  - **Returns**: dispose function. Idempotent. Safe to call synchronously inside the callback.
219
306
  - **options.immediate**: when `true`, callback fires once on registration with `oldValue = undefined`. Default `false`.
220
- - **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.
221
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".
222
309
  - **Callback reads are untracked**: the callback can read other signals without registering them as dependencies.
223
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).
224
- - **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.
225
312
 
226
313
  ### when(predicate, callback)
227
314
 
@@ -233,7 +320,7 @@ Fires callback exactly once when predicate first returns truthy, then auto-dispo
233
320
  - **One-shot guarantee**: internal `fired` flag protects against double-fire even if dispose timing lets one more evaluation through.
234
321
  - **Callback reads are untracked.**
235
322
  - **Truthy/falsy semantics**: standard JS truthy check. `0`, `""`, `null`, `undefined`, `false`, `NaN` do not trigger; everything else does.
236
- - **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.
237
324
 
238
325
  ### whenAsync(predicate)
239
326
 
@@ -243,11 +330,11 @@ Promise variant of `when`.
243
330
  - **Returns**: Promise that resolves when predicate first becomes truthy.
244
331
  - **Implementation**: `return new Promise((resolve) => when(predicate, resolve))`.
245
332
  - **Foot-gun**: promise never rejects. If predicate never becomes truthy, promise never settles. Use `Promise.race` for timeout: `Promise.race([whenAsync(p), timeoutPromise])`.
246
- - **⚠️ 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.
247
334
 
248
335
  ### Architecture note
249
336
 
250
- 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.
251
338
 
252
339
  ### Allocation profile summary
253
340
 
@@ -257,35 +344,35 @@ None of these touch the reactive engine — no `FLAG_WATCHER` on `ReactiveNode`,
257
344
  | `when` | 2 closures | 0 |
258
345
  | `whenAsync` | 1 Promise + executor + Promise internals + 2 closures (from `when`) | 0 |
259
346
 
260
- 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.
261
348
 
262
349
  ## Benchmark snapshot (Node 22, 2016-era Intel MacBook Pro, 20K iter × 5 runs × 50+ invocations)
263
350
 
264
351
  | Scenario | lite-signal | alien-signals | preact | solid |
265
352
  | --------------------------------------- | ----------- | ------------- | -------- | -------- |
266
- | MUX (256 sigs sum effect) | **249K ops/s** | 207K | 153K | 77K |
267
- | BROADCAST (1 sig 1000 effects) | **24K** | 22K | 17K | 8K |
268
- | KAIROS (1 sig 1000 computeds eff) | **14K** | 13K | 12K | 4K |
269
- | 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 |
270
357
 
271
358
  lite-signal wins three of four scenarios against current published versions of the
272
- 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
273
360
  broadcast, and one-source-to-wide-memo-layer respectively. These are the patterns
274
361
  that dominate UI workloads. alien-signals retains a 15% lead on 256-deep computed
275
362
  pipelines, where its flatter internal representation pays off when the propagation
276
363
  path is long rather than wide.
277
364
 
278
365
  These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
279
- high-fan-in shapes that were lite-signal's documented weakness `dyn: large web app`
280
- 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
281
368
  1.1.4 retracking rewrite and remain the fastest of five frameworks (re-confirmed on
282
369
  1.1.5, median-of-12): 555ms / 870ms vs alien-signals' 590ms / 933ms, with preact and
283
- 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.
284
371
 
285
- 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
286
373
  transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
287
- ~230 KB per loop; solid runs into single-digit megabytes; alien-signals which
288
- 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
289
376
  current published version (not a leak; retained heap is near zero everywhere,
290
377
  but it is real GC pressure on the hot path).
291
378
 
@@ -324,33 +411,69 @@ const sandbox = createRegistry({ maxNodes: 256, onCapacityExceeded: "throw" });
324
411
  plugin(sandbox.signal, sandbox.computed, sandbox.effect);
325
412
  // later:
326
413
  sandbox.destroy(); // entire reactive world reset
414
+
415
+ // 1.2.1: push-based devtools via onGraphMutation
416
+ // Single listener; opcodes 1=create, 2=dispose, 3=link-add, 4=link-remove, 5=recompute.
417
+ // Allocation-free dispatch with just three integers.
418
+ const unsubscribe = onGraphMutation((opcode, intA, intB) => {
419
+ switch (opcode) {
420
+ case 1: devtools.onNodeCreate(intA, intB); break; // intA=id, intB=flags
421
+ case 2: devtools.onNodeDispose(intA); break;
422
+ case 3: devtools.onLinkAdd(intA, intB); break; // intA=source.id, intB=target.id
423
+ case 4: devtools.onLinkRemove(intA, intB); break;
424
+ case 5: devtools.onRecompute(intA); break;
425
+ }
426
+ });
427
+ // Stop listening -- restores prior listener (or null), engine returns to zero-cost state.
428
+ unsubscribe();
429
+
430
+ // 1.2.1: walk the owner tree (cascade-disposal domains)
431
+ // forEachOwned + ownerOf complement forEachObserver + forEachSource:
432
+ // the dep/sub edges show DATA FLOW; the owner edges show LIFETIME BINDING.
433
+ function dumpOwnerTree(handle, depth = 0) {
434
+ const d = describe(handle);
435
+ if (!d) return;
436
+ console.log(" ".repeat(depth * 2), d.kind, d.id);
437
+ forEachOwned(d, (child) => dumpOwnerTree(child, depth + 1));
438
+ }
327
439
  ```
328
440
 
329
441
  ## File layout
330
442
 
331
- - `Signal.js` full implementation, single file.
332
- - `Signal.d.ts` TypeScript declarations for all public API.
333
- - `test/01-core_test.mjs` signal/computed/effect basics, equality, untrack.
334
- - `test/02-topology_test.mjs` diamonds, chains, fan-out/in, cycle detection.
335
- - `test/03-pool_test.mjs` capacity errors, grow policy, pool reuse.
336
- - `test/04-zero-gc_test.mjs` heap retention (run with --expose-gc).
337
- - `test/05-scheduler_test.mjs` scheduler races, dispose, gen counter, version wrap.
338
- - `test/06-nested-objects_test.mjs` nested-object & reference-identity behaviours.
339
- - `test/07-dispose_test.mjs` universal disposal: registry.dispose(api).
340
- - `test/08-watch_test.mjs` new watch reactivity tests.
341
- - `test/09-conformance_test.mjs` johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
342
- - `test/10-is-tracking_test.mjs` `isTracking()` across observer bodies, untracked windows, and outside any observer.
343
- - `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).
344
- - `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.
345
- - `test/13-introspection_test.mjs` observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
346
- - `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).
347
- - `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).
348
- - `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).
349
- - `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).
350
- - `test/18-identity_test.mjs` node identity (1.1.5): `nodeId`/`describe`, descriptor `id`, re-walkable descriptors, non-perturbing (5 tests).
351
- - `bench/benchmark.mjs` anti-DCE throughput harness (ops/s; results.txt).
352
- - `bench/benchmarkReactive.mjs` cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
353
- - `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.
354
477
 
355
478
  ## Install
356
479
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal",
3
- "version": "1.2.0",
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",
@@ -45,7 +45,10 @@
45
45
  "object-pool",
46
46
  "fine-grained",
47
47
  "twitch-extension",
48
- "performance"
48
+ "performance",
49
+ "devtools",
50
+ "introspection",
51
+ "owner-tree"
49
52
  ],
50
53
  "homepage": "https://github.com/PeshoVurtoleta/lite-signal#readme",
51
54
  "repository": {
@@ -65,6 +68,7 @@
65
68
  },
66
69
  "sideEffects": false,
67
70
  "devDependencies": {
68
- "c8": "^11.0.0"
71
+ "c8": "^11.0.0",
72
+ "@zakkster/lite-devtools": "^1.1.0"
69
73
  }
70
74
  }