@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 +119 -0
- package/README.md +62 -5
- package/Signal.d.ts +58 -4
- package/Signal.js +112 -13
- package/llms.txt +69 -2
- package/package.json +5 -2
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
/**
|
|
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.
|
|
183
|
-
* the returned descriptor may be passed back into forEachObserver/
|
|
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
|
|
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.
|
|
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() {
|
|
910
|
-
|
|
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 :
|
|
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 :
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1364
|
+
const node = liveNode(handle);
|
|
1290
1365
|
return node !== undefined ? node.id : undefined;
|
|
1291
1366
|
}
|
|
1292
1367
|
function describe(handle) {
|
|
1293
|
-
const node = handle
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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": {
|