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