@zakkster/lite-signal 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,125 @@ All notable changes to `@zakkster/lite-signal` are documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.2.1] — 2026-06-12
8
+
9
+ A correctness-and-pauses patch in two halves: the pool allocator stops paying
10
+ for growth in unbounded bursts, and the introspection surface stops lying about
11
+ handles the 1.2.0 owner tree disposed behind your back. Plus the graph-mutation
12
+ hook — the keystone that lets lite-devtools 1.1 / lite-studio 1.1 go push-based.
13
+ Drop-in over 1.2.0: 404-test suite green, 177/178 on
14
+ johnsoncodehk/reactive-framework-test-suite (same single open cell, Inner
15
+ Write #179), hot-path regression gate flat on two hosts.
16
+
17
+ ### Fixed — bounded pool growth (no more construction bursts)
18
+ - Under `onCapacityExceeded: "grow"`, exhausting a pool used to double it by
19
+ synchronously constructing `currentCapacity` fresh nodes/links — at a
20
+ 524,288-node pool that is a quarter-million 25-field allocations in one
21
+ pause, in whatever frame triggered it. Growth is now incremental: **one**
22
+ node/link constructed per free-list miss, pushed into the pool, recycled
23
+ forever after. The capacity **ledger** still doubles, so `stats()`
24
+ (`nodePoolCapacity` / `linkPoolCapacity` / `pooledLinks`), the
25
+ `maxLinks × 16` ceiling, and every `CapacityError` are bit-identical to
26
+ 1.2.0 — only the construction schedule changed. Locked by the existing
27
+ `test/03-pool` capacity/ceiling/recycle contracts.
28
+ - Benchmark effect (volynetstyle/js-reactivity-benchmark, same host as the
29
+ 1.2.0 baseline run): creation group 489 → 423 ms (−13.5%), with the burst
30
+ cases roughly halved (`1to2` 112 → 58, `1to8` 113 → 55, `1to4` 81 → 54).
31
+ Honest redistribution note: rows that previously *fit inside the doubling
32
+ overshoot* (`createDataSignals` 12.8 → 71.9, `1to1` 17.8 → 43.2) now pay
33
+ their construction inside the measured window — 1.2.0's overshoot was an
34
+ accidental prefetch, and the same mechanism produced the pathological
35
+ bursts. Bounded pauses are the right trade for real applications; the
36
+ group total still improves.
37
+ - Steady-state hot paths are untouched (update / dynamic-retracking /
38
+ effect-recycle measured flat on both benchmark hosts).
39
+
40
+ ### Fixed — effect queues / mark stack stay PACKED
41
+ - Pool growth used to pre-size `effectQueueA/B` and the mark stack with
42
+ `arr.length = newCap` — which permanently converts a PACKED V8 array to
43
+ HOLEY elements, a silent tax on every subsequent flush read. The queues now
44
+ grow by sequential append (packed-preserving, auto-amortised) and
45
+ `destroy()` truncates instead of null-filling to capacity.
46
+
47
+ ### Fixed — `destroy()` iterates physical pools
48
+ - `destroy()` walked `currentNodesCapacity` slots by index; with incremental
49
+ growth (and any future lazy population) the ledger can exceed the physical
50
+ pool. It now walks `nodePool.length` / `linkPool.length` and is safe on an
51
+ empty pool.
52
+
53
+ ### Fixed — stale-handle introspection (the owner-tree follow-up)
54
+ - 1.2.0's owner tree made the engine recycle pool slots **autonomously**: an
55
+ owner re-run cascade-disposes its owned observers, so holding a stale handle
56
+ stopped being a user error and became a routine occurrence. The
57
+ introspection surface — `nodeId` / `describe` / `forEachObserver` /
58
+ `forEachSource` / `hasObservers` / `observeObservers` — still resolved
59
+ `NODE_PTR` without a generation check and would happily report the
60
+ **recycled slot's new resident** (wrong id, wrong value, wrong edges)
61
+ through an old handle. All six entry points now resolve through a
62
+ gen-guarded `liveNode()` and report stale handles as `undefined` (or throw
63
+ the existing `TypeError`, for `observeObservers`) — the same ABA discipline
64
+ `read()` / `set()` / `dispose()` already had.
65
+ - `describe()` descriptors are now **gen-stamped** alongside the node
66
+ reference, so the documented "descriptors are re-walkable handles" contract
67
+ survives the guard: a fresh descriptor walks; one held across a recycle
68
+ correctly goes stale. Pinned by the existing
69
+ "forEach* descriptors carry id and are re-walkable" test.
70
+ - **Effect dispose handles are now first-class introspection handles.** On
71
+ every prior version, `effect()` returned a bare closure carrying neither
72
+ `NODE_PTR` nor `NODE_GEN` — so `describe` / `nodeId` / `forEachSource`
73
+ returned `undefined`/empty for a **live** effect handle, and
74
+ `observeObservers(effectHandle)` threw. The dispose function is now stamped
75
+ with the same symbol pair as signal/computed handles (`NODE_GEN` mirrors the
76
+ disposer's own `birthGen`, so introspection validity agrees exactly with its
77
+ stale-guard). After explicit dispose, slot recycle, or owner-cascade the
78
+ handle correctly reads stale. Measured cost: two property stores per effect
79
+ creation (~50 ns on a create/dispose churn microbench) — symmetric with
80
+ what signal/computed handles already pay, create-path only. Found by the
81
+ lite-devtools 1.1 cross-probe campaign (`track(effectHandle)` threw).
82
+ - `peek()` had the same hole: `sharedSignalPeek` / `sharedComputedPeek`
83
+ resolved the slot ungated, so a stale handle's `peek()` returned the new
84
+ resident's value. Both now gen-check first and return `undefined` when
85
+ stale — closing the last unguarded entry point in the probe-c1 ABA family.
86
+ Measured cost: 4M peeks 7.1 → 7.4 ms (≈0.08 ns/op).
87
+
88
+ ### Added — `onGraphMutation(fn)`: the graph-mutation hook
89
+ - Registry-level (and default-registry module export) debug hook, the
90
+ connection point for push-based tooling. Single nullable listener; every
91
+ fire point is one `if (mutationHook !== null)` branch and the dispatch is
92
+ allocation-free — `(opcode, intA, intB)`:
93
+ - `1` node create — `(id, flags)`, end of `createNode`
94
+ - `2` node dispose — `(id, flags)`, top of `disposeNode` (cascades included)
95
+ - `3` link add — `(source.id, target.id)`
96
+ - `4` link remove — `(source.id, target.id)`
97
+ - `5` recompute — `(id, 0)`, before an effect re-run / computed re-eval
98
+ - Cost: **zero when unregistered** (hot-path gate flat); registered, the
99
+ worst case measured is +29% on a dynamic-retracking torture loop (11.4M
100
+ events for 400K writes) — a debug-mode tax paid only while a consumer is
101
+ attached, proportional to event volume.
102
+ - **Listener contract: observe only — never throw, never mutate the graph.**
103
+ The hook fires synchronously inside mutation points; lite-devtools 1.1
104
+ multiplexes all of its consumers behind one registration, isolates their
105
+ exceptions, and unregisters when the last consumer stops (returning the
106
+ engine to the zero-cost state). `onGraphMutation` returns an unsubscribe
107
+ that restores the previously registered listener.
108
+
109
+ ### Added — owner-tree introspection: `forEachOwned` / `ownerOf`
110
+ - The 1.2.0 owner tree finally gets a (read-only, gen-guarded) window:
111
+ `forEachOwned(handle, fn)` iterates a node's owned children as standard
112
+ re-walkable descriptors; `ownerOf(handle)` returns the owner's descriptor
113
+ or `undefined` (top-level or stale). Same descriptor conventions as
114
+ `forEachObserver` / `forEachSource`; garbage input is a no-op /
115
+ `undefined`. This is what lite-devtools 1.1 builds `ownerTree()` and the
116
+ `graph({owners: true})` ownership edges on
117
+ (`capabilities().owners === true` from this release).
118
+
119
+ ### Compatibility
120
+ - No behavioural change for live handles; stale handles now read as stale
121
+ everywhere instead of as the slot's next tenant. Allocation strategy is
122
+ unobservable through the public API. Tooling floor: lite-devtools ≥ 1.1.0
123
+ detects `onGraphMutation` / `forEachOwned` at load and degrades to its
124
+ 1.0 polling behaviour on older engines.
125
+
7
126
  ## [1.2.0] — 2026-06-11
8
127
 
9
128
  A structural refactor that internally splits the engine into three named layers
package/README.md CHANGED
@@ -220,10 +220,21 @@ Computeds are **pull-based** — they're not in the effect queue. Reading a comp
220
220
 
221
221
  ```ts
222
222
  import {
223
+ // Core
223
224
  signal, computed, effect,
224
- batch, untrack, onCleanup,
225
- createRegistry, setDefaultRegistry,
226
- stats, CapacityError
225
+ batch, untrack, onCleanup, isTracking,
226
+ // Registry / lifecycle
227
+ createRegistry, setDefaultRegistry, dispose, destroy,
228
+ stats, CapacityError,
229
+ // Introspection (1.1.4 / 1.1.5 / 1.2.1)
230
+ hasObservers, observeObservers,
231
+ forEachObserver, forEachSource,
232
+ nodeId, describe,
233
+ forEachOwned, ownerOf, // 1.2.1
234
+ // Debug hook (1.2.1)
235
+ onGraphMutation,
236
+ // Watchers
237
+ watch, when, whenAsync,
227
238
  } from "@zakkster/lite-signal";
228
239
  ```
229
240
 
@@ -328,18 +339,61 @@ hasObservers(now); // O(1): is anyone subscribed right now? (a
328
339
  // Walk the live graph in either direction (lite-devtools):
329
340
  forEachObserver(sum, d => console.log(d.kind, d.value)); // subscribers of `sum`
330
341
  forEachSource(sum, d => console.log(d.kind, d.value)); // dependencies of `sum`
342
+
343
+ // 1.2.1: walk the owner tree (cascade-disposal domains)
344
+ forEachOwned(effectHandle, d => console.log(d.kind, d.id)); // observers this one will cascade-dispose
345
+ ownerOf(innerComputedDesc); // descriptor of the enclosing effect/computed
331
346
  ```
332
347
 
333
- Six functions (top-level + per-registry) — four in 1.1.4, two more in 1.1.5 — for auto-pausing wrappers and graph inspection:
348
+ Eight functions (top-level + per-registry) — four in 1.1.4, two in 1.1.5, two more in 1.2.1 — for auto-pausing wrappers and graph inspection:
334
349
 
335
350
  - **`hasObservers(handle)` → `boolean`** — O(1) (`node.headSub !== null`). The auto-pause predicate.
336
351
  - **`observeObservers(handle, { onConnect?, onDisconnect? })` → `unobserve`** — fires on the 0→1 and 1→0 observer transitions *after* registration (transition-only — no immediate fire if already observed). Re-tracking a persistently-read source does **not** churn. This is the hook `lite-time` / `lite-raf` use to run a clock only while a derived value is watched. Throws `TypeError` on a non-handle.
337
352
  - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`** — walk subscribers / dependencies; `fn` gets a `{ id, kind, value }` descriptor (`kind` ∈ `"signal" | "computed" | "effect"`; `id` added in 1.1.5). No-op on a non-handle.
338
353
  - **`nodeId(handle)` → `number | undefined`** *(1.1.5)* — the node's stable per-allocation id; the dedupe key for graph traversal. `undefined` on a non-handle.
339
- - **`describe(handle)` → `{ id, kind, value } | undefined`** *(1.1.5)* — the handle's own descriptor. **Re-walkable**: pass it back into `forEachObserver`/`forEachSource` to recurse the graph. `undefined` on a non-handle.
354
+ - **`describe(handle)` → `{ id, kind, value } | undefined`** *(1.1.5)* — the handle's own descriptor. **Re-walkable**: pass it back into any `forEach*` to recurse the graph. `undefined` on a non-handle.
355
+ - **`forEachOwned(handle, fn)`** *(1.2.1)* — walk this node's owned children (lifetime-binding edges from the 1.2 owner tree). The dep/sub edges show DATA FLOW; the owner edges show LIFETIME BINDING — when this handle re-runs or is disposed, every owned child is cascade-disposed. No-op on a non-handle, top-level handle with no children, or stale handle.
356
+ - **`ownerOf(handle)` → `{ id, kind, value } | undefined`** *(1.2.1)* — descriptor of `handle`'s owner, or `undefined` for top-level / stale handles. The inverse of `forEachOwned`: walks UP the owner tree.
340
357
 
341
358
  The surface is gated by an internal lifecycle counter: when nothing is being observed, the hot path adds a single branch-predicted `count !== 0` check in link alloc/free and nothing else — **zero steady-state cost when unused**.
342
359
 
360
+ #### Stale-handle guard (1.2.1)
361
+
362
+ The 1.2.0 owner tree makes the engine recycle pool slots autonomously: when an effect or computed re-runs, every observer it created in its previous body is cascade-disposed. **Holding a stale handle stopped being a user error and became routine.** Pre-1.2.1, the introspection surface plus `peek()` resolved `NODE_PTR` ungated and would happily report the recycled slot's NEW resident — wrong id, wrong value, wrong edges.
363
+
364
+ 1.2.1 generation-checks every entry point that resolves a handle (the same ABA discipline `dispose()` always had):
365
+
366
+ - `nodeId`, `describe`, `hasObservers`, `forEachObserver`, `forEachSource`, `forEachOwned`, `ownerOf`, `signal.peek()`, `computed.peek()`, `signal()`/`computed()` read, `signal.set()` → return `undefined` / are no-ops on stale handles.
367
+ - `observeObservers` throws `TypeError` (matching the existing non-handle contract).
368
+
369
+ Descriptors returned by `describe()` and the `forEach*` walkers are themselves gen-stamped, so the documented "descriptors are re-walkable handles" contract survives the guard: a fresh descriptor walks, one held across a recycle correctly goes stale.
370
+
371
+ ### onGraphMutation (1.2.1)
372
+
373
+ ```ts
374
+ // Push-based devtools / studio integration. Single listener, allocation-free dispatch.
375
+ const unsub = onGraphMutation((opcode, intA, intB) => {
376
+ switch (opcode) {
377
+ case 1: devtools.onNodeCreate(intA, intB); break; // intA = node.id, intB = node.flags
378
+ case 2: devtools.onNodeDispose(intA); break; // ditto (cascade-disposed children included)
379
+ case 3: devtools.onLinkAdd(intA, intB); break; // intA = source.id, intB = target.id
380
+ case 4: devtools.onLinkRemove(intA, intB); break;
381
+ case 5: devtools.onRecompute(intA); break; // before each effect re-run / computed re-eval
382
+ }
383
+ });
384
+
385
+ // Stop listening — restores the previous registration (or null), engine returns to zero-cost state.
386
+ unsub();
387
+ ```
388
+
389
+ A registry-level (and top-level) debug hook for push-based tooling — the connection point lite-devtools 1.1 and lite-studio 1.1 use to walk away from polling. Single nullable listener; every fire point in the engine is one `if (mutationHook !== null) mutationHook(opcode, intA, intB)`:
390
+
391
+ - **Zero cost when unregistered** — branch-predicted null check per mutation point, same as the lifecycle counter pattern.
392
+ - **Allocation-free when registered** — three integers, no objects, no closures. Worst-case measured cost on a dynamic-retracking torture loop (11.4M events over 400K writes) is +29% — a debug-mode tax proportional to event volume, paid only while a consumer is attached.
393
+ - **LIFO stacking** — `onGraphMutation(a); onGraphMutation(b); unsubB()` restores `a`. Used by lite-devtools 1.1 to multiplex multiple consumers behind one engine registration.
394
+
395
+ **Listener contract: observe only — never throw, never mutate the graph from inside.** The hook fires synchronously inside mutation points; mutating from the callback corrupts the in-flight operation. Wrap any downstream work that could touch the registry in a microtask.
396
+
343
397
  ### onCleanup
344
398
 
345
399
  ```ts
@@ -641,6 +695,9 @@ Three tiers, all reproducible.
641
695
  - **`18-identity_test.mjs`** — Node identity (1.1.5; 5 tests). Unique/stable ids; `nodeId`/`describe` return `undefined` for a non-handle; the descriptor's visible shape is `{ id, kind, value }`; `forEach*` descriptors carry `id` and are **re-walkable** (`nodeId`/`forEachSource` accept a descriptor); identity walks are non-perturbing (add no observers).
642
696
  - **`19-v12-additions_test.mjs`** — v1.2.0 release-prep regressions (24 tests across 8 suites). Shared `peek` (one closure per registry, identical reference across primitives, no tracking, two registries hold independent peeks). Owner-adoption rule (signals not adopted, computeds/effects adopted, cascade drains correctly). Pre-batch revert (signal-level, propagates through computeds, respects custom `equals`, nested batches, final-different-value still fires). Multi-throw aggregation (`AggregateError` with both errors carried, single-throw unwrapped, engine survives). `CycleError` via `maxFlushPasses` (default + custom). `maxLinks` config branch under `throw` and `grow`. Documented disposed-signal semantics (read undefined, set silent no-op, dispose idempotent). Scheduler-thunk ABA guard across a recycled pool slot.
643
697
  - **`20-axis-stress_test.mjs`** — engine-invariant regression guards along eight orthogonal "axes" (16 tests across 9 suites). Pins lite-signal's actual contract on: batch semantics under exception (writes commit; pre-batch revert holds; effects see the post-throw value), connect/disconnect lifecycle re-entrancy (`observeObservers` from inside an `onConnect`, transition-only registration), untrack does NOT suppress owner adoption (a nested effect created via `untrack` is still owner-cascaded), untrack inside a computed body (no hidden dep leaks; tracked source re-evaluates), queue safety under self-dispose mid-flush (no UAF), value-dependent cycle detection (computed graph closes a cycle, `CycleError` thrown), nested-effect creation order (effects run synchronously on creation; immediately-stopped one still ran), synchronous flush (no scheduler in the default path; batch coalesces). Plus a bonus suite: 1,000 effect-create-then-dispose cycles return pool to baseline; `dispose()` idempotent; `dispose()` on foreign values safe.
698
+ - **`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.
699
+ - **`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.
700
+ - **`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.
644
701
 
645
702
  ```bash
646
703
  npm test
package/Signal.d.ts CHANGED
@@ -121,6 +121,35 @@ export type Unobserve = () => void;
121
121
  /** Anything carrying a node identity that the introspection surface can read. */
122
122
  export type ReactiveHandle = Signal<any> | Computed<any>;
123
123
 
124
+ // ─── Graph-mutation hook (1.2.1) ──────────────────────────────────────────────
125
+
126
+ /**
127
+ * Opcode passed as the first argument to a {@link GraphMutationListener}.
128
+ *
129
+ * - `1` node create. `(intA, intB) = (node.id, node.flags)`.
130
+ * - `2` node dispose. `(intA, intB) = (node.id, node.flags)` — fires for every node
131
+ * disposed, including cascaded owner-tree children.
132
+ * - `3` link add. `(intA, intB) = (source.id, target.id)`.
133
+ * - `4` link remove. `(intA, intB) = (source.id, target.id)` — `-1` if the link
134
+ * was already nulled (defensive, rare).
135
+ * - `5` recompute. `(intA, intB) = (node.id, 0)` — fires just before an effect
136
+ * re-run or a computed re-eval.
137
+ */
138
+ export type GraphMutationOpcode = 1 | 2 | 3 | 4 | 5;
139
+
140
+ /**
141
+ * Listener registered with {@link Registry.onGraphMutation}. Called synchronously
142
+ * inside each mutation point with three integers — no objects allocated.
143
+ *
144
+ * **Contract: observe only.** Listeners MUST NOT throw and MUST NOT mutate the
145
+ * graph from inside the callback. Both will corrupt the engine's state. Wrap any
146
+ * downstream work in a microtask if it could touch the registry.
147
+ */
148
+ export type GraphMutationListener = (opcode: GraphMutationOpcode, intA: number, intB: number) => void;
149
+
150
+ /** Idempotent unsubscriber returned by {@link Registry.onGraphMutation}. */
151
+ export type GraphMutationUnsubscribe = () => void;
152
+
124
153
  // ─── Errors ───────────────────────────────────────────────────────────────────
125
154
 
126
155
  /** Thrown when a pool ceiling is hit. */
@@ -177,16 +206,35 @@ export interface Registry {
177
206
  forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
178
207
  /** Walk the sources (dependencies) of `handle`. No-op on a non-handle. */
179
208
  forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
180
- /** Stable node id of `handle` (1.1.5+), or undefined for a non-handle. */
209
+ /** Walk the owned children of `handle` -- nodes whose lifetime is bound to this
210
+ * one by the 1.2 owner tree (1.2.1+). Top-level handles and signals have no
211
+ * owned children; effects/computeds may own nested observers created inside
212
+ * their bodies. No-op on a non-handle or stale handle. */
213
+ forEachOwned(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
214
+ /** Descriptor of `handle`'s owner, or `undefined` for top-level handles, stale
215
+ * handles, or non-handles (1.2.1+). The owner is the effect or computed inside
216
+ * whose body `handle` was created -- the node that will cascade-dispose it
217
+ * on re-run or explicit dispose. */
218
+ ownerOf(handle: ReactiveHandle): NodeDescriptor | undefined;
219
+ /** Stable node id of `handle` (1.1.5+), or undefined for a non-handle or stale handle. */
181
220
  nodeId(handle: ReactiveHandle): number | undefined;
182
- /** The own descriptor of `handle` (1.1.5+), or undefined for a non-handle. Re-walkable:
183
- * the returned descriptor may be passed back into forEachObserver/forEachSource. */
221
+ /** The own descriptor of `handle` (1.1.5+), or undefined for a non-handle or stale handle.
222
+ * Re-walkable: the returned descriptor may be passed back into forEachObserver/
223
+ * forEachSource/forEachOwned/ownerOf. Descriptors are gen-stamped (1.2.1+): a
224
+ * descriptor obtained pre-recycle goes stale and walks as undefined post-recycle. */
184
225
  describe(handle: ReactiveHandle): NodeDescriptor | undefined;
226
+ /** Register a single graph-mutation listener (1.2.1+). Replaces any existing
227
+ * listener and returns an unsubscribe that restores the previous one on call.
228
+ * Listener is invoked synchronously at each mutation point with three integers:
229
+ * `(opcode, intA, intB)` -- see {@link GraphMutationOpcode}. Cost when no
230
+ * listener is registered: one branch-predicted `null` check per mutation point.
231
+ * @throws TypeError if `fn` is not a function or null. */
232
+ onGraphMutation(fn: GraphMutationListener | null): GraphMutationUnsubscribe;
185
233
  onCleanup(fn: () => void): void;
186
234
  stats(): RegistryStats;
187
235
  /** Reset everything: nodes, links, queues, global clock. Outstanding dispose
188
236
  * closures become safe no-ops. Outstanding read/set closures still reference
189
- * pool slots they will silently misbehave; use a fresh registry afterwards. */
237
+ * pool slots -- they will silently misbehave; use a fresh registry afterwards. */
190
238
  destroy(): void;
191
239
  }
192
240
 
@@ -222,10 +270,16 @@ export function observeObservers(handle: ReactiveHandle, hooks?: ObserveObserver
222
270
  export function forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
223
271
  /** Top-level binding of {@link Registry.forEachSource}. */
224
272
  export function forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
273
+ /** Top-level binding of {@link Registry.forEachOwned} (1.2.1+). */
274
+ export function forEachOwned(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
275
+ /** Top-level binding of {@link Registry.ownerOf} (1.2.1+). */
276
+ export function ownerOf(handle: ReactiveHandle): NodeDescriptor | undefined;
225
277
  /** Top-level binding of {@link Registry.nodeId}. */
226
278
  export function nodeId(handle: ReactiveHandle): number | undefined;
227
279
  /** Top-level binding of {@link Registry.describe}. */
228
280
  export function describe(handle: ReactiveHandle): NodeDescriptor | undefined;
281
+ /** Top-level binding of {@link Registry.onGraphMutation} (1.2.1+). */
282
+ export function onGraphMutation(fn: GraphMutationListener | null): GraphMutationUnsubscribe;
229
283
  export function onCleanup(fn: () => void): void;
230
284
  export function stats(): RegistryStats;
231
285
  export declare function destroy(): void;
package/Signal.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal v1.2.0
2
+ * @zakkster/lite-signal v1.2.1
3
3
  * --------------------
4
4
  * Hybrid Doubly-Linked-List Reactive Graph Engine — decoupled (Signal1_3) base
5
5
  * with the two 1.1.3 performance fixes ported in:
@@ -60,6 +60,11 @@ const FLAG_EFFECT = 1 << 1;
60
60
  const FLAG_QUEUED = 1 << 2;
61
61
  const FLAG_COMPUTING = 1 << 3;
62
62
  const FLAG_HAS_ERROR = 1 << 4;
63
+
64
+ // Hoisted equality default. Object.is lookup is fast under V8 IC but a module-
65
+ // scope const is monomorphic without ICs. Replaces the per-call lookup in
66
+ // signal() and computed() construction.
67
+ const OBJECT_IS = Object.is;
63
68
  const FLAG_SIGNAL = 1 << 5;
64
69
 
65
70
  /**
@@ -272,6 +277,20 @@ export function createRegistry(config) {
272
277
  *
273
278
  * @private
274
279
  */
280
+ // --- Graph-mutation hook (1.2.1 keystone prototype) ---------------------
281
+ // Single nullable listener; every fire point is `if (mutationHook !== null)`
282
+ // -- branch-predicted free when absent, allocation-free when present
283
+ // (opcode + two int args). Enables push-based devtools (watchGraph) and the
284
+ // recompute profiler. Opcodes: 1 node-create, 2 node-dispose, 3 link-add,
285
+ // 4 link-remove, 5 recompute.
286
+ let mutationHook = null;
287
+ function onGraphMutation(fn) {
288
+ if (fn !== null && typeof fn !== "function") throw new TypeError("onGraphMutation: listener must be a function or null");
289
+ const prev = mutationHook;
290
+ mutationHook = fn;
291
+ return () => { if (mutationHook === fn) mutationHook = prev; };
292
+ }
293
+
275
294
  function allocateLink(source, target) {
276
295
  // Eligibility gate (restored from 1.1.5): an observer disposed mid-run (self-dispose, or
277
296
  // an outer observer torn down while suspended) has flags cleared to 0. Linking would splice
@@ -338,10 +357,12 @@ export function createRegistry(config) {
338
357
  if (tail !== null) tail.nextDep = link;
339
358
  else target.headDep = link;
340
359
  target.tailDep = link;
360
+ if (mutationHook !== null) mutationHook(3, source.id, target.id);
341
361
  }
342
362
 
343
363
  /** Return a link to the free pool and unlink it from the source's sub list. @private */
344
364
  function freeLink(link, target, source) {
365
+ if (mutationHook !== null) mutationHook(4, link.source !== null ? link.source.id : -1, link.target !== null ? link.target.id : -1);
345
366
  const pSub = link.prevSub;
346
367
  const nSub = link.nextSub;
347
368
  if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
@@ -391,6 +412,7 @@ export function createRegistry(config) {
391
412
  // ─── LIFECYCLE & OWNERSHIP ───────────────────────────────────────
392
413
 
393
414
  function disposeNode(node) {
415
+ if (mutationHook !== null) mutationHook(2, node.id, node.flags | 0);
394
416
  if (node.flags === 0) return;
395
417
 
396
418
  // RACE WITH ACTIVE TRACKING: an effect/computed may call dispose on
@@ -556,6 +578,7 @@ export function createRegistry(config) {
556
578
  node.owner = null;
557
579
  }
558
580
 
581
+ if (mutationHook !== null) mutationHook(1, node.id, node.flags | 0);
559
582
  return node;
560
583
  }
561
584
 
@@ -763,6 +786,7 @@ export function createRegistry(config) {
763
786
  // dep-list / flag / version mutations in that case — they would
764
787
  // either crash on the freed link list or corrupt the new resident.
765
788
  const savedGen = node.gen;
789
+ if (mutationHook !== null) mutationHook(5, node.id, 0);
766
790
  try {
767
791
  node.computeFn();
768
792
  } finally {
@@ -839,6 +863,7 @@ export function createRegistry(config) {
839
863
 
840
864
  // Same self-dispose detection as executeEffect — see comment there.
841
865
  const savedGen = node.gen;
866
+ if (mutationHook !== null) mutationHook(5, node.id, 0);
842
867
  try {
843
868
  const newValue = node.computeFn();
844
869
  const eq = node.equals;
@@ -906,8 +931,16 @@ export function createRegistry(config) {
906
931
  // arrows. Method-invoked, so `this` is the read function and this[NODE_PTR]
907
932
  // is the node. Signal: direct value read. Computed: pull (still respects
908
933
  // the cached/short-circuit fast paths since pullComputed handles them).
909
- function sharedSignalPeek() { return this[NODE_PTR].value; }
910
- function sharedComputedPeek() { return pullComputed(this[NODE_PTR]); }
934
+ function sharedSignalPeek() {
935
+ const node = this[NODE_PTR];
936
+ if (this[NODE_GEN] !== node.gen) return undefined; // stale handle: slot recycled (ABA guard, matches read())
937
+ return node.value;
938
+ }
939
+ function sharedComputedPeek() {
940
+ const node = this[NODE_PTR];
941
+ if (this[NODE_GEN] !== node.gen) return undefined;
942
+ return pullComputed(node);
943
+ }
911
944
 
912
945
  /**
913
946
  * Create a reactive signal.
@@ -921,11 +954,20 @@ export function createRegistry(config) {
921
954
  */
922
955
  function signal(initial, opts) {
923
956
  const node = createNode(initial, FLAG_SIGNAL);
924
- node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
957
+ node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : OBJECT_IS;
925
958
  node.version = globalVersion;
926
959
  statSignals++;
927
960
 
961
+ // birthGen pinned at construction. The set/read closures check
962
+ // `node.gen === birthGen` to detect stale handles after dispose +
963
+ // pool-slot recycling. Without this, a retained set() from a disposed
964
+ // signal can overwrite the recycled slot's new resident; a retained
965
+ // read() inside an active observer can create a phantom subscription
966
+ // to the recycled slot. See probe-c1-stale-set.mjs / probe-c1-stale-read.mjs.
967
+ const birthGen = node.gen;
968
+
928
969
  const read = () => {
970
+ if (node.gen !== birthGen) return undefined;
929
971
  if (isTrackingDeps && currentObserver !== null) {
930
972
  let expected = activeObserverCurrentDep;
931
973
  if (expected !== null && expected.source === node) {
@@ -942,6 +984,7 @@ export function createRegistry(config) {
942
984
  // path, and a closure over `node` beats a shared method's this[NODE_PTR]
943
985
  // load. Keeping it a closure also restores detached `const {set}=signal()`.
944
986
  read.set = (value) => {
987
+ if (node.gen !== birthGen) return;
945
988
  const eq = node.equals;
946
989
  if (eq && eq(node.value, value)) return;
947
990
  if (batchDepth > 0 && node.revertEpoch !== batchEpoch) {
@@ -982,10 +1025,13 @@ export function createRegistry(config) {
982
1025
  function computed(fn, opts) {
983
1026
  const node = createNode(undefined, FLAG_COMPUTED);
984
1027
  node.computeFn = fn;
985
- node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
1028
+ node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : OBJECT_IS;
986
1029
  statComputeds++;
987
1030
 
1031
+ const birthGen = node.gen;
1032
+
988
1033
  const read = () => {
1034
+ if (node.gen !== birthGen) return undefined;
989
1035
  if (isTrackingDeps && currentObserver !== null) {
990
1036
  let expected = activeObserverCurrentDep;
991
1037
  if (expected !== null && expected.source === node) {
@@ -1063,6 +1109,16 @@ export function createRegistry(config) {
1063
1109
  }
1064
1110
  };
1065
1111
 
1112
+ // Effect handles are first-class introspection handles (1.2.1): stamp
1113
+ // the same NODE_PTR / NODE_GEN pair signal() and computed() stamp, so
1114
+ // describe / track / dependencies / graph / findPath / ownerTree work
1115
+ // when handed the dispose handle directly. NODE_GEN mirrors birthGen
1116
+ // -- introspection validity agrees exactly with the disposer's own
1117
+ // stale-guard. (Pre-existing gap on every prior version: the disposer
1118
+ // was a bare closure and liveNode() reported live effects as stale.)
1119
+ disposeFn[NODE_PTR] = node;
1120
+ disposeFn[NODE_GEN] = birthGen;
1121
+
1066
1122
  if (firstRunError !== null) {
1067
1123
  disposeFn();
1068
1124
  throw firstRunError;
@@ -1255,11 +1311,11 @@ export function createRegistry(config) {
1255
1311
  }
1256
1312
 
1257
1313
  function hasObservers(handle) {
1258
- const node = handle != null ? handle[NODE_PTR] : undefined;
1314
+ const node = liveNode(handle);
1259
1315
  return node !== undefined && node.headSub !== null;
1260
1316
  }
1261
1317
  function observeObservers(handle, opts) {
1262
- const node = handle != null ? handle[NODE_PTR] : undefined;
1318
+ const node = liveNode(handle);
1263
1319
  if (node === undefined) throw new TypeError("observeObservers: argument is not a reactive handle");
1264
1320
  let e = lifecycleMap.get(node);
1265
1321
  if (e === undefined) {
@@ -1281,32 +1337,66 @@ export function createRegistry(config) {
1281
1337
  function describeNode(node) {
1282
1338
  const fl = node.flags;
1283
1339
  const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
1340
+ // Plain property assignment, not Object.defineProperty.
1341
+ // Object.keys() never includes symbol-keyed properties regardless of
1342
+ // descriptor — enumerable: false was defending nothing. Confirmed
1343
+ // empirically: `o[Symbol()] = x; Object.keys(o)` returns only
1344
+ // string-keyed enumerable props.
1284
1345
  const d = {id: node.id, kind, value: node.value};
1285
- Object.defineProperty(d, NODE_PTR, {value: node, enumerable: false});
1346
+ d[NODE_PTR] = node;
1347
+ d[NODE_GEN] = node.gen; // descriptors are re-walkable handles; stamp gen so the ABA guard holds for them too
1286
1348
  return d;
1287
1349
  }
1350
+ // Gen-guarded handle resolution (1.2.1): with the v1.2 owner tree, the
1351
+ // ENGINE recycles slots autonomously (owner re-run cascade-disposes owned
1352
+ // children), so stale handles are a normal occurrence -- introspecting the
1353
+ // slot's NEW resident through an old handle reports the wrong node.
1354
+ // read()/set() already guard via closure-captured birthGen; the
1355
+ // introspection surface must apply the same ABA guard via NODE_GEN.
1356
+ function liveNode(handle) {
1357
+ if (handle == null) return undefined;
1358
+ const node = handle[NODE_PTR];
1359
+ if (node === undefined) return undefined;
1360
+ if (handle[NODE_GEN] !== node.gen) return undefined; // stale: slot recycled
1361
+ return node;
1362
+ }
1288
1363
  function nodeId(handle) {
1289
- const node = handle != null ? handle[NODE_PTR] : undefined;
1364
+ const node = liveNode(handle);
1290
1365
  return node !== undefined ? node.id : undefined;
1291
1366
  }
1292
1367
  function describe(handle) {
1293
- const node = handle != null ? handle[NODE_PTR] : undefined;
1368
+ const node = liveNode(handle);
1294
1369
  return node !== undefined ? describeNode(node) : undefined;
1295
1370
  }
1296
1371
  function forEachObserver(handle, fn) {
1297
- const node = handle != null ? handle[NODE_PTR] : undefined;
1372
+ const node = liveNode(handle);
1298
1373
  if (node === undefined) return;
1299
1374
  let l = node.headSub;
1300
1375
  while (l !== null) { const nx = l.nextSub; fn(describeNode(l.target)); l = nx; }
1301
1376
  }
1377
+ /** Iterate this node's OWNED children (v1.2 owner tree). Additive 1.3 API
1378
+ * prototype: lets devtools/studio walk + render the ownership hierarchy
1379
+ * (cascade-disposal domains), which is invisible through dep/sub edges. */
1380
+ function forEachOwned(handle, fn) {
1381
+ const node = liveNode(handle);
1382
+ if (node === undefined) return;
1383
+ let c = node.firstOwned;
1384
+ while (c !== null) { const nx = c.nextOwned; fn(describeNode(c)); c = nx; }
1385
+ }
1386
+ /** Descriptor of this node's owner, or undefined (top-level / stale handle). */
1387
+ function ownerOf(handle) {
1388
+ const node = liveNode(handle);
1389
+ if (node === undefined || node.owner === null) return undefined;
1390
+ return describeNode(node.owner);
1391
+ }
1302
1392
  function forEachSource(handle, fn) {
1303
- const node = handle != null ? handle[NODE_PTR] : undefined;
1393
+ const node = liveNode(handle);
1304
1394
  if (node === undefined) return;
1305
1395
  let l = node.headDep;
1306
1396
  while (l !== null) { const nx = l.nextDep; fn(describeNode(l.source)); l = nx; }
1307
1397
  }
1308
1398
 
1309
- return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking, hasObservers, observeObservers, forEachObserver, forEachSource, nodeId, describe};
1399
+ return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking, hasObservers, observeObservers, forEachObserver, forEachSource, forEachOwned, ownerOf, nodeId, describe, onGraphMutation};
1310
1400
  }
1311
1401
 
1312
1402
  // ─────────────────────────────────────────────────────────────────
@@ -1375,6 +1465,15 @@ export function forEachObserver(handle, fn) {
1375
1465
  export function forEachSource(handle, fn) {
1376
1466
  return defaultRegistry.forEachSource(handle, fn);
1377
1467
  }
1468
+ export function onGraphMutation(fn) {
1469
+ return defaultRegistry.onGraphMutation(fn);
1470
+ }
1471
+ export function forEachOwned(handle, fn) {
1472
+ return defaultRegistry.forEachOwned(handle, fn);
1473
+ }
1474
+ export function ownerOf(handle) {
1475
+ return defaultRegistry.ownerOf(handle);
1476
+ }
1378
1477
  export function nodeId(handle) {
1379
1478
  return defaultRegistry.nodeId(handle);
1380
1479
  }
package/llms.txt CHANGED
@@ -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 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.
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
 
@@ -50,7 +50,44 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
50
50
 
51
51
  ## Version notes
52
52
 
53
- - **1.2.0** (current): structural refactor (three named layers: graph topology /
53
+ - **1.2.1** (current): correctness-and-introspection patch. Drop-in over 1.2.0;
54
+ no public surface removed. **Stale-handle introspection (ABA fix):** the v1.2.0
55
+ owner tree made the engine recycle pool slots autonomously on owner re-run, so
56
+ holding a stale handle stopped being a user error and became a routine
57
+ occurrence. Pre-1.2.1, `nodeId`/`describe`/`forEachObserver`/`forEachSource`/
58
+ `hasObservers`/`observeObservers`/`peek()` would resolve `NODE_PTR` ungated
59
+ and happily report the recycled slot's NEW resident through an old handle
60
+ (wrong id, wrong value, wrong edges). All six entry points + `peek()` + `read()`
61
+ + `set()` now resolve through a generation check (the same ABA discipline
62
+ `dispose()` already had); stale handles read as `undefined` (or throw the
63
+ documented `TypeError`, for `observeObservers`). `describe()` descriptors are
64
+ gen-stamped alongside `NODE_PTR`, so the "descriptors are re-walkable handles"
65
+ contract survives the guard: a fresh descriptor walks, one held across a
66
+ recycle correctly goes stale. **Added — `onGraphMutation(fn)`:** registry-level
67
+ (and top-level) graph-mutation hook for push-based tooling. Single nullable
68
+ listener; every fire point is `if (mutationHook !== null) mutationHook(opcode, intA, intB)`
69
+ — zero-cost gate when absent, allocation-free dispatch when present. Opcodes:
70
+ `1` node-create `(id, flags)`, `2` node-dispose `(id, flags)` (cascades
71
+ included), `3` link-add `(source.id, target.id)`, `4` link-remove `(source.id, target.id)`,
72
+ `5` recompute `(id, 0)` before each effect re-run / computed re-eval.
73
+ Contract: observe only — never throw, never mutate the graph from inside.
74
+ 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-
76
+ tree introspection. `forEachOwned` iterates a node's owned children as
77
+ re-walkable descriptors; `ownerOf` returns the owner's descriptor or
78
+ `undefined` (top-level or stale). Same descriptor conventions as
79
+ `forEachObserver`/`forEachSource`; garbage input is a no-op/`undefined`.
80
+ Also: `Object.is` hoisted to a module-level const (one IC entry instead of
81
+ per-call lookup in `signal()`/`computed()`). 404 tests, **100% line / 98.07%
82
+ 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,
85
+ the 5 opcodes, payload shape, registry isolation), and
86
+ `test/23-owner-introspection.test.mjs` (14 tests — `forEachOwned`, `ownerOf`,
87
+ and gen-guarded behaviour across the introspection surface). Differential
88
+ fuzz vs the published 1.1.5: 0 disagreements over 30,000 writes.
89
+
90
+ - **1.2.0**: structural refactor (three named layers: graph topology /
54
91
  ownership / propagation) plus four additive features built on top. Drop-in over
55
92
  1.1.5; no public surface removed. **Owner tree:** an effect or computed that
56
93
  creates nested observers (effect/computed) now owns them — when the owner
@@ -151,8 +188,13 @@ function observeObservers(
151
188
  ): () => void; // returns idempotent unobserve
152
189
  function forEachObserver(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
153
190
  function forEachSource(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
191
+ function forEachOwned(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void; // 1.2.1
192
+ function ownerOf(handle: Signal<any> | Computed<any>): NodeDescriptor | undefined; // 1.2.1
154
193
  function nodeId(handle: Signal<any> | Computed<any>): number | undefined; // 1.1.5
155
194
  function describe(handle: Signal<any> | Computed<any>): NodeDescriptor | undefined; // 1.1.5
195
+ function onGraphMutation( // 1.2.1
196
+ fn: ((opcode: 1|2|3|4|5, intA: number, intB: number) => void) | null
197
+ ): () => void; // unsub restores prior listener
156
198
  function onCleanup(fn: () => void): void;
157
199
  function stats(): RegistryStats;
158
200
 
@@ -324,6 +366,31 @@ const sandbox = createRegistry({ maxNodes: 256, onCapacityExceeded: "throw" });
324
366
  plugin(sandbox.signal, sandbox.computed, sandbox.effect);
325
367
  // later:
326
368
  sandbox.destroy(); // entire reactive world reset
369
+
370
+ // 1.2.1: push-based devtools via onGraphMutation
371
+ // Single listener; opcodes 1=create, 2=dispose, 3=link-add, 4=link-remove, 5=recompute.
372
+ // Allocation-free dispatch with just three integers.
373
+ const unsubscribe = onGraphMutation((opcode, intA, intB) => {
374
+ switch (opcode) {
375
+ case 1: devtools.onNodeCreate(intA, intB); break; // intA=id, intB=flags
376
+ case 2: devtools.onNodeDispose(intA); break;
377
+ case 3: devtools.onLinkAdd(intA, intB); break; // intA=source.id, intB=target.id
378
+ case 4: devtools.onLinkRemove(intA, intB); break;
379
+ case 5: devtools.onRecompute(intA); break;
380
+ }
381
+ });
382
+ // Stop listening — restores prior listener (or null), engine returns to zero-cost state.
383
+ unsubscribe();
384
+
385
+ // 1.2.1: walk the owner tree (cascade-disposal domains)
386
+ // forEachOwned + ownerOf complement forEachObserver + forEachSource:
387
+ // the dep/sub edges show DATA FLOW; the owner edges show LIFETIME BINDING.
388
+ function dumpOwnerTree(handle, depth = 0) {
389
+ const d = describe(handle);
390
+ if (!d) return;
391
+ console.log(" ".repeat(depth * 2), d.kind, d.id);
392
+ forEachOwned(d, (child) => dumpOwnerTree(child, depth + 1));
393
+ }
327
394
  ```
328
395
 
329
396
  ## File layout
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.1",
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": {