@zakkster/lite-signal 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +243 -99
- package/README.md +197 -165
- package/Signal.d.ts +21 -21
- package/Signal.js +87 -80
- package/llms.txt +130 -74
- package/package.json +3 -2
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,27 +30,72 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
30
30
|
- **Two object pools**: nodes (default 1024) and links (default 4×nodes). Singly-linked via `nextFree`, O(1) allocate/free.
|
|
31
31
|
- **Doubly-linked edge lists**: each link is in both a dep-list (on the target/observer) and a sub-list (on the source/dependency). O(1) unlink during cursor reconciliation.
|
|
32
32
|
- **Cursor reconciliation**: at the start of each computed/effect run, `activeObserverCurrentDep` points at the head of the existing dep list. As the body reads deps, matching links advance the cursor (zero alloc); non-matching links insert before the cursor and the displaced tail is severed at the end of the run.
|
|
33
|
-
- **Iterative mark phase**: `markDownstream` uses a pre-allocated `markStack` array for DFS
|
|
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;
|
|
54
99
|
no public surface removed. **Stale-handle introspection (ABA fix):** the v1.2.0
|
|
55
100
|
owner tree made the engine recycle pool slots autonomously on owner re-run, so
|
|
56
101
|
holding a stale handle stopped being a user error and became a routine
|
|
@@ -63,16 +108,16 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
63
108
|
documented `TypeError`, for `observeObservers`). `describe()` descriptors are
|
|
64
109
|
gen-stamped alongside `NODE_PTR`, so the "descriptors are re-walkable handles"
|
|
65
110
|
contract survives the guard: a fresh descriptor walks, one held across a
|
|
66
|
-
recycle correctly goes stale. **Added
|
|
111
|
+
recycle correctly goes stale. **Added -- `onGraphMutation(fn)`:** registry-level
|
|
67
112
|
(and top-level) graph-mutation hook for push-based tooling. Single nullable
|
|
68
113
|
listener; every fire point is `if (mutationHook !== null) mutationHook(opcode, intA, intB)`
|
|
69
|
-
|
|
114
|
+
-- zero-cost gate when absent, allocation-free dispatch when present. Opcodes:
|
|
70
115
|
`1` node-create `(id, flags)`, `2` node-dispose `(id, flags)` (cascades
|
|
71
116
|
included), `3` link-add `(source.id, target.id)`, `4` link-remove `(source.id, target.id)`,
|
|
72
117
|
`5` recompute `(id, 0)` before each effect re-run / computed re-eval.
|
|
73
|
-
Contract: observe only
|
|
118
|
+
Contract: observe only -- never throw, never mutate the graph from inside.
|
|
74
119
|
This is the connection point lite-devtools 1.1 / lite-studio 1.1 use to go
|
|
75
|
-
push-based. **Added
|
|
120
|
+
push-based. **Added -- `forEachOwned(handle, fn)` / `ownerOf(handle)`:** owner-
|
|
76
121
|
tree introspection. `forEachOwned` iterates a node's owned children as
|
|
77
122
|
re-walkable descriptors; `ownerOf` returns the owner's descriptor or
|
|
78
123
|
`undefined` (top-level or stale). Same descriptor conventions as
|
|
@@ -80,23 +125,23 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
80
125
|
Also: `Object.is` hoisted to a module-level const (one IC entry instead of
|
|
81
126
|
per-call lookup in `signal()`/`computed()`). 404 tests, **100% line / 98.07%
|
|
82
127
|
branch coverage** on `Signal.js` + `Watch.js`. New
|
|
83
|
-
`test/21-perf-pins.test.mjs` (6 tests
|
|
84
|
-
`test/22-mutation-hook.test.mjs` (12 tests
|
|
128
|
+
`test/21-perf-pins.test.mjs` (6 tests -- construction-shape + ABA-guard pins),
|
|
129
|
+
`test/22-mutation-hook.test.mjs` (12 tests -- `onGraphMutation` registration,
|
|
85
130
|
the 5 opcodes, payload shape, registry isolation), and
|
|
86
|
-
`test/23-owner-introspection.test.mjs` (14 tests
|
|
131
|
+
`test/23-owner-introspection.test.mjs` (14 tests -- `forEachOwned`, `ownerOf`,
|
|
87
132
|
and gen-guarded behaviour across the introspection surface). Differential
|
|
88
133
|
fuzz vs the published 1.1.5: 0 disagreements over 30,000 writes.
|
|
89
134
|
|
|
90
135
|
- **1.2.0**: structural refactor (three named layers: graph topology /
|
|
91
136
|
ownership / propagation) plus four additive features built on top. Drop-in over
|
|
92
137
|
1.1.5; no public surface removed. **Owner tree:** an effect or computed that
|
|
93
|
-
creates nested observers (effect/computed) now owns them
|
|
138
|
+
creates nested observers (effect/computed) now owns them -- when the owner
|
|
94
139
|
re-runs or is disposed, the engine cascade-disposes those observers before
|
|
95
140
|
the new run. Plain signals are deliberately NOT owner-adopted so lazy-
|
|
96
141
|
allocation wrappers (lite-store keys, lite-form fields) continue to survive
|
|
97
142
|
re-runs of the computed that allocated them. **Pre-batch revert:** inside a
|
|
98
143
|
`batch(...)`, set X then set X back (under the signal's `equals`) reverts the
|
|
99
|
-
version bump
|
|
144
|
+
version bump -- downstream effects/computeds do not fire. **AggregateError on
|
|
100
145
|
multi-throw:** two or more effects throwing in the same flush pass aggregate to
|
|
101
146
|
`AggregateError` at the trigger; single-throw is unchanged. **Scheduler thunk
|
|
102
147
|
caching:** the scheduler closure is cached on the node and gen-bound, so async
|
|
@@ -104,36 +149,36 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
104
149
|
(ABA safe). Internal split: `currentObserver` and `currentOwner` are now
|
|
105
150
|
distinct pointers (today they move together, no behavioural change). **Perf:**
|
|
106
151
|
shared `peek` (one closure per registry instead of per primitive) shaves
|
|
107
|
-
10
|
|
152
|
+
10-14% off signal/computed creation on the `S:create*` micros, no hot-path or
|
|
108
153
|
behavioural change. 363 tests, 100% line / 98.62% branch coverage on
|
|
109
154
|
`Signal.js` + `Watch.js`. Differential fuzz vs the published 1.1.5: 0
|
|
110
155
|
disagreements over 30,000 writes. New `test/19-v12-additions.test.mjs`
|
|
111
|
-
(24 tests) and `test/20-axis-stress.test.mjs` (23 tests
|
|
156
|
+
(24 tests) and `test/20-axis-stress.test.mjs` (23 tests -- eight orthogonal
|
|
112
157
|
engine-invariant axes plus permanent conformance pins). **Conformance fixes
|
|
113
158
|
in 1.2.0**: #141 (dispose during execution then continue: no re-run), and
|
|
114
159
|
#238 / #241 / #243 (cleanup ordering on cascade: inner-before-outer,
|
|
115
160
|
deepest-first, and the inner-only-re-run regression). #141 was a latent
|
|
116
161
|
crash present in 1.1.5 too; the v1.2 owner tree exercised it more
|
|
117
|
-
aggressively. Fix is two-fold
|
|
162
|
+
aggressively. Fix is two-fold -- nullify the tracking cursor in `disposeNode`
|
|
118
163
|
when the disposed node is the active observer (plus a `gen`-snapshot guard
|
|
119
164
|
in `executeEffect`/`pullComputed`); and swap `runCleanup` to cascade
|
|
120
165
|
children first, then own.
|
|
121
166
|
|
|
122
|
-
- **1.1.5**: additive release in service of `@zakkster/lite-devtools`
|
|
167
|
+
- **1.1.5**: additive release in service of `@zakkster/lite-devtools` -- stable
|
|
123
168
|
node identity on the introspection surface. `nodeId(handle)` -> the node's stable
|
|
124
169
|
per-allocation id (the dedupe key for graph walks); `describe(handle)` -> the handle's
|
|
125
170
|
own `{ id, kind, value }` descriptor, **re-walkable** (pass it back into
|
|
126
171
|
`forEachObserver`/`forEachSource` to walk the full DAG); `forEach*` descriptors now carry
|
|
127
|
-
`id`. One SMI write at allocation, node shape kept monomorphic
|
|
172
|
+
`id`. One SMI write at allocation, node shape kept monomorphic -- **zero steady-state
|
|
128
173
|
cost**. Drop-in over 1.1.4, no breaking changes. New `test/18-identity_test.mjs` (5 tests); plus `14-lifecycle-teardown`, `16-alien-parity`, and the `17-reactivity` behavioral suite.
|
|
129
174
|
|
|
130
|
-
- **1.1.4**: combined release
|
|
175
|
+
- **1.1.4**: combined release -- a retracking rewrite plus an observer-lifecycle
|
|
131
176
|
introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
|
|
132
177
|
reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
|
|
133
178
|
O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
|
|
134
179
|
order is unchanged (still O(1), still zero-alloc). The two documented v1.1.x losses flipped
|
|
135
|
-
to wins and are now fastest of five frameworks
|
|
136
|
-
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
|
|
137
182
|
disagreements; no regressions elsewhere). **Introspection:** `hasObservers`,
|
|
138
183
|
`observeObservers`, `forEachObserver`, `forEachSource` (top-level + per-registry), gated by
|
|
139
184
|
an internal counter so zero steady-state cost when unused. New `test/13-introspection_test.mjs`
|
|
@@ -142,7 +187,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
142
187
|
- **1.1.3**: adds `isTracking()` (top-level + per-registry). Returns true iff a
|
|
143
188
|
read right now would record a dependency (`isTrackingDeps && currentObserver !== null`).
|
|
144
189
|
False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, `watch` /
|
|
145
|
-
`when` callbacks, and outside any observer. ~1
|
|
190
|
+
`when` callbacks, and outside any observer. ~1-2 ns. For wrapper libraries
|
|
146
191
|
(lite-store, lite-query, lite-form) that allocate reactive primitives lazily
|
|
147
192
|
on property reads. Per-registry: wrappers operating against a non-default
|
|
148
193
|
registry must call THAT registry's `isTracking()`, not the top-level one.
|
|
@@ -166,9 +211,9 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
166
211
|
|
|
167
212
|
## When NOT to use
|
|
168
213
|
|
|
169
|
-
- Server-side rendering
|
|
170
|
-
- Graph *construction* is allocation-heavy (per-node closures): on create-many micro-benchmarks `alien-signals` builds faster. Real apps build once and update forever
|
|
171
|
-
- 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.)
|
|
172
217
|
|
|
173
218
|
## API summary
|
|
174
219
|
|
|
@@ -259,11 +304,11 @@ Fires callback on every change of the projected source value.
|
|
|
259
304
|
- **Signature**: `watch<T>(source: () => T, callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void, options?: { immediate?: boolean }): () => void`
|
|
260
305
|
- **Returns**: dispose function. Idempotent. Safe to call synchronously inside the callback.
|
|
261
306
|
- **options.immediate**: when `true`, callback fires once on registration with `oldValue = undefined`. Default `false`.
|
|
262
|
-
- **Equality guard**: uses `Object.is(newValue, oldValue)` internally to avoid firing when the projected value is unchanged across dep mutations. Critical for raw getter sources like `() => health() <= 0`
|
|
307
|
+
- **Equality guard**: uses `Object.is(newValue, oldValue)` internally to avoid firing when the projected value is unchanged across dep mutations. Critical for raw getter sources like `() => health() <= 0` -- many dep changes can produce the same boolean. Wrapping the source in `computed()` would achieve the same via the computed's own equality check.
|
|
263
308
|
- **UNINITIALIZED sentinel**: uses `Symbol("watch.uninitialized")` instead of `undefined` to detect first-run. This means `signal(undefined)` is correctly distinguished from "watcher hasn't fired yet".
|
|
264
309
|
- **Callback reads are untracked**: the callback can read other signals without registering them as dependencies.
|
|
265
310
|
- **Stop in callback**: third callback argument is a stop handle. Safe to call at any point including synchronously during the immediate fire (the `wantsStopEarly` flag defers dispose until after the effect is registered).
|
|
266
|
-
- **Allocation profile**: 3 closures at registration (stop, effect body, hoisted untrack body). **Zero allocations per fire**
|
|
311
|
+
- **Allocation profile**: 3 closures at registration (stop, effect body, hoisted untrack body). **Zero allocations per fire** -- the untrack body is hoisted with `currentNewValue` as shared mutable state. Hot-path safe at 120fps.
|
|
267
312
|
|
|
268
313
|
### when(predicate, callback)
|
|
269
314
|
|
|
@@ -275,7 +320,7 @@ Fires callback exactly once when predicate first returns truthy, then auto-dispo
|
|
|
275
320
|
- **One-shot guarantee**: internal `fired` flag protects against double-fire even if dispose timing lets one more evaluation through.
|
|
276
321
|
- **Callback reads are untracked.**
|
|
277
322
|
- **Truthy/falsy semantics**: standard JS truthy check. `0`, `""`, `null`, `undefined`, `false`, `NaN` do not trigger; everything else does.
|
|
278
|
-
- **Allocation profile**: 2 closures at registration (stop, effect body). **Zero allocations per check**
|
|
323
|
+
- **Allocation profile**: 2 closures at registration (stop, effect body). **Zero allocations per check** -- `untrack(callback)` passes the user's callback directly without wrapping. Hot-path safe at 120fps.
|
|
279
324
|
|
|
280
325
|
### whenAsync(predicate)
|
|
281
326
|
|
|
@@ -285,11 +330,11 @@ Promise variant of `when`.
|
|
|
285
330
|
- **Returns**: Promise that resolves when predicate first becomes truthy.
|
|
286
331
|
- **Implementation**: `return new Promise((resolve) => when(predicate, resolve))`.
|
|
287
332
|
- **Foot-gun**: promise never rejects. If predicate never becomes truthy, promise never settles. Use `Promise.race` for timeout: `Promise.race([whenAsync(p), timeoutPromise])`.
|
|
288
|
-
-
|
|
333
|
+
- **! HOT-PATH WARNING**: `new Promise(...)` is a heap allocation. Each call allocates 1 Promise + 1 executor closure + Promise infrastructure (resolve fn, microtask state) + 2 closures from internal `when` call. This is unavoidable -- Promises require heap allocation by spec. **Do NOT call per frame.** Use for high-level orchestration (boot sequences, scene transitions, awaiting user input). For 60/120fps logic, use `when(predicate, callback)` directly -- it's zero-GC.
|
|
289
334
|
|
|
290
335
|
### Architecture note
|
|
291
336
|
|
|
292
|
-
None of these touch the reactive engine
|
|
337
|
+
None of these touch the reactive engine -- no `FLAG_WATCHER` on `ReactiveNode`, no extension to the object pool, no new internal primitive. They compose entirely from public API. This is the test that the core engine is structurally complete: when a higher-level pattern can be built without extending the engine, the engine has enough.
|
|
293
338
|
|
|
294
339
|
### Allocation profile summary
|
|
295
340
|
|
|
@@ -299,35 +344,35 @@ None of these touch the reactive engine — no `FLAG_WATCHER` on `ReactiveNode`,
|
|
|
299
344
|
| `when` | 2 closures | 0 |
|
|
300
345
|
| `whenAsync` | 1 Promise + executor + Promise internals + 2 closures (from `when`) | 0 |
|
|
301
346
|
|
|
302
|
-
The deliberate engineering for `watch`'s "0 per fire" is the hoisted `untrackedFire` closure with `currentNewValue` shared mutable state
|
|
347
|
+
The deliberate engineering for `watch`'s "0 per fire" is the hoisted `untrackedFire` closure with `currentNewValue` shared mutable state -- see source comment in `watch.js`. Inline arrow function inside the effect body would allocate per fire and break the zero-GC contract.
|
|
303
348
|
|
|
304
349
|
## Benchmark snapshot (Node 22, 2016-era Intel MacBook Pro, 20K iter × 5 runs × 50+ invocations)
|
|
305
350
|
|
|
306
351
|
| Scenario | lite-signal | alien-signals | preact | solid |
|
|
307
352
|
| --------------------------------------- | ----------- | ------------- | -------- | -------- |
|
|
308
|
-
| MUX (256 sigs
|
|
309
|
-
| BROADCAST (1 sig
|
|
310
|
-
| KAIROS (1 sig
|
|
311
|
-
| 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 |
|
|
312
357
|
|
|
313
358
|
lite-signal wins three of four scenarios against current published versions of the
|
|
314
|
-
alternatives: MUX +20%, BROADCAST +9%, KAIROS +8%
|
|
359
|
+
alternatives: MUX +20%, BROADCAST +9%, KAIROS +8% -- fan-in aggregation, fan-out
|
|
315
360
|
broadcast, and one-source-to-wide-memo-layer respectively. These are the patterns
|
|
316
361
|
that dominate UI workloads. alien-signals retains a 15% lead on 256-deep computed
|
|
317
362
|
pipelines, where its flatter internal representation pays off when the propagation
|
|
318
363
|
path is long rather than wide.
|
|
319
364
|
|
|
320
365
|
These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
|
|
321
|
-
high-fan-in shapes that were lite-signal's documented weakness
|
|
322
|
-
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
|
|
323
368
|
1.1.4 retracking rewrite and remain the fastest of five frameworks (re-confirmed on
|
|
324
369
|
1.1.5, median-of-12): 555ms / 870ms vs alien-signals' 590ms / 933ms, with preact and
|
|
325
|
-
vue ~7
|
|
370
|
+
vue ~7-30× slower. See resultsReactive.txt for the full 34-test, 5-framework table.
|
|
326
371
|
|
|
327
|
-
On allocation pressure, lite-signal is alone in the zero
|
|
372
|
+
On allocation pressure, lite-signal is alone in the zero-alloc band: ~15 KB of
|
|
328
373
|
transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
|
|
329
|
-
~230 KB per loop; solid runs into single-digit megabytes; alien-signals
|
|
330
|
-
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
|
|
331
376
|
current published version (not a leak; retained heap is near zero everywhere,
|
|
332
377
|
but it is real GC pressure on the hot path).
|
|
333
378
|
|
|
@@ -379,7 +424,7 @@ const unsubscribe = onGraphMutation((opcode, intA, intB) => {
|
|
|
379
424
|
case 5: devtools.onRecompute(intA); break;
|
|
380
425
|
}
|
|
381
426
|
});
|
|
382
|
-
// Stop listening
|
|
427
|
+
// Stop listening -- restores prior listener (or null), engine returns to zero-cost state.
|
|
383
428
|
unsubscribe();
|
|
384
429
|
|
|
385
430
|
// 1.2.1: walk the owner tree (cascade-disposal domains)
|
|
@@ -395,29 +440,40 @@ function dumpOwnerTree(handle, depth = 0) {
|
|
|
395
440
|
|
|
396
441
|
## File layout
|
|
397
442
|
|
|
398
|
-
- `Signal.js`
|
|
399
|
-
- `Signal.d.ts`
|
|
400
|
-
- `test/01-core_test.mjs`
|
|
401
|
-
- `test/02-topology_test.mjs`
|
|
402
|
-
- `test/03-pool_test.mjs`
|
|
403
|
-
- `test/04-zero-gc_test.mjs`
|
|
404
|
-
- `test/05-scheduler_test.mjs`
|
|
405
|
-
- `test/06-nested-objects_test.mjs`
|
|
406
|
-
- `test/07-dispose_test.mjs`
|
|
407
|
-
- `test/08-watch_test.mjs`
|
|
408
|
-
- `test/09-conformance_test.mjs`
|
|
409
|
-
- `test/10-is-tracking_test.mjs`
|
|
410
|
-
- `test/11-adopted-reactive_test.mjs`
|
|
411
|
-
- `test/12-coverage_test.mjs`
|
|
412
|
-
- `test/13-introspection_test.mjs`
|
|
413
|
-
- `test/14-lifecycle-teardown_test.mjs`
|
|
414
|
-
- `test/15-owner-lazy-alloc_test.mjs`
|
|
415
|
-
- `test/16-alien-parity_test.mjs`
|
|
416
|
-
- `test/17-reactivity_test.mjs`
|
|
417
|
-
- `test/18-identity_test.mjs`
|
|
418
|
-
- `
|
|
419
|
-
- `
|
|
420
|
-
- `
|
|
443
|
+
- `Signal.js` -- full implementation, single file.
|
|
444
|
+
- `Signal.d.ts` -- TypeScript declarations for all public API.
|
|
445
|
+
- `test/01-core_test.mjs` -- signal/computed/effect basics, equality, untrack.
|
|
446
|
+
- `test/02-topology_test.mjs` -- diamonds, chains, fan-out/in, cycle detection.
|
|
447
|
+
- `test/03-pool_test.mjs` -- capacity errors, grow policy, pool reuse.
|
|
448
|
+
- `test/04-zero-gc_test.mjs` -- heap retention (run with --expose-gc).
|
|
449
|
+
- `test/05-scheduler_test.mjs` -- scheduler races, dispose, gen counter, version wrap.
|
|
450
|
+
- `test/06-nested-objects_test.mjs` -- nested-object & reference-identity behaviours.
|
|
451
|
+
- `test/07-dispose_test.mjs` -- universal disposal: registry.dispose(api).
|
|
452
|
+
- `test/08-watch_test.mjs` -- new watch reactivity tests.
|
|
453
|
+
- `test/09-conformance_test.mjs` -- johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
|
|
454
|
+
- `test/10-is-tracking_test.mjs` -- `isTracking()` across observer bodies, untracked windows, and outside any observer.
|
|
455
|
+
- `test/11-adopted-reactive_test.mjs` -- engine-agnostic edge cases adopted from the wider ecosystem (alien-signals link-integrity #226-228, equality-predicate corners, `update()`/`peek()`/`subscribe` contracts).
|
|
456
|
+
- `test/12-coverage_test.mjs` -- targeted public-surface + hot-path branch coverage (top-level routing, clean-read short-circuit, tail severance, scheduler ABA); owner-tree block capability-gated.
|
|
457
|
+
- `test/13-introspection_test.mjs` -- observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
|
|
458
|
+
- `test/14-lifecycle-teardown_test.mjs` -- effect-teardown guards: stopped effect doesn't re-subscribe to a signal read later in the same run, self-dispose leaves no orphan link, throwing setup leaves no live subscription (4 tests).
|
|
459
|
+
- `test/15-owner-lazy-alloc_test.mjs` -- owner-adoption contract for the 1.2.0 owner tree (lazy signals never adopted; observers still auto-disposed); capability-gated, skipped on 1.1.x (4 tests).
|
|
460
|
+
- `test/16-alien-parity_test.mjs` -- differential guards vs alien-signals@3.2.0 fixed-bug classes: cleanup reads create no deps, inner write doesn't block computed-chain propagation, dynamic dep-set correct under dirty-check (3 tests).
|
|
461
|
+
- `test/17-reactivity_test.mjs` -- behavioral suite over universal signal-system bug classes (subscription lifecycle, cleanup ordering, stale-dep tracking, batching, equality, nested invalidation, memory, sync async-boundary, scheduler/loops, + differential-review additions); SSR is N/A (~30 tests).
|
|
462
|
+
- `test/18-identity_test.mjs` -- node identity (1.1.5): `nodeId`/`describe`, descriptor `id`, re-walkable descriptors, non-perturbing (5 tests).
|
|
463
|
+
- `test/19-v12-additions_test.mjs` -- 1.2.0 surface coverage: shared-peek, owner-adoption rule, pre-batch revert (24 tests).
|
|
464
|
+
- `test/20-axis-stress_test.mjs` -- eight orthogonal stress axes (batch-under-exception, connect/disconnect re-entrancy, untrack vs owner cascade, etc.) (23 tests).
|
|
465
|
+
- `test/21-perf-pins_test.mjs` -- 1.2.1 construction-shape + ABA-guard pins (6 tests).
|
|
466
|
+
- `test/22-mutation-hook_test.mjs` -- 1.2.1 `onGraphMutation` registration + opcode emission (12 tests).
|
|
467
|
+
- `test/23-owner-introspection_test.mjs` -- `ownerOf`, `forEachOwned`, gen-guarded introspection (14 tests).
|
|
468
|
+
- `test/24-signalbox_test.mjs` -- staged for 1.5.0; all tests `{skip: true}` on 1.2.x.
|
|
469
|
+
- `test/25-devtools-real-boot_test.mjs` -- 1.2.2: real `Devtools.js` boot against the engine, ghost-contract pin, `stats()` shape pin (10 tests).
|
|
470
|
+
- `test/26-free-list-invariant_test.mjs` -- 1.2.2: the audit's clean-free-list invariant, asserted by inspecting freshly-allocated nodes (4 tests).
|
|
471
|
+
- `bench/benchmark.mjs` -- anti-DCE throughput harness (ops/s; results.txt).
|
|
472
|
+
- `bench/benchmarkReactive.mjs` -- cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
|
|
473
|
+
- `bench/dispose-recycle.mjs` -- 1.2.2 targeted microbench: create/dispose/recreate cycle (the free-list invariant audit target).
|
|
474
|
+
- `bench/torture/` -- crash-detection soaks: `graph-fuzzer`, `scheduler-bench`, `torture-soak`. Exit 0 iff zero errors AND post-teardown pool clean.
|
|
475
|
+
- `bench/run-all.sh` + `bench/aggregate.mjs` -- cold-process-per-engine protocol + per-scenario aggregation.
|
|
476
|
+
- `demo/index.html` -- interactive visualization of the reactive graph.
|
|
421
477
|
|
|
422
478
|
## Install
|
|
423
479
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Zero-GC reactive graph. Monomorphic object pool, versioned push-pull propagation, 32-bit modular versioning. Built for hot paths and long-running processes.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"sideEffects": false,
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"c8": "^11.0.0"
|
|
71
|
+
"c8": "^11.0.0",
|
|
72
|
+
"@zakkster/lite-devtools": "^1.1.0"
|
|
72
73
|
}
|
|
73
74
|
}
|