@zakkster/lite-signal 1.1.4 → 1.2.0
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 +415 -0
- package/README.md +144 -54
- package/Signal.d.ts +12 -0
- package/Signal.js +536 -594
- package/llms.txt +65 -18
- package/package.json +3 -2
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`.
|
|
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.
|
|
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
|
|
|
@@ -32,7 +32,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
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
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
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.
|
|
@@ -50,7 +50,47 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
50
50
|
|
|
51
51
|
## Version notes
|
|
52
52
|
|
|
53
|
-
- **1.
|
|
53
|
+
- **1.2.0** (current): structural refactor (three named layers: graph topology /
|
|
54
|
+
ownership / propagation) plus four additive features built on top. Drop-in over
|
|
55
|
+
1.1.5; no public surface removed. **Owner tree:** an effect or computed that
|
|
56
|
+
creates nested observers (effect/computed) now owns them — when the owner
|
|
57
|
+
re-runs or is disposed, the engine cascade-disposes those observers before
|
|
58
|
+
the new run. Plain signals are deliberately NOT owner-adopted so lazy-
|
|
59
|
+
allocation wrappers (lite-store keys, lite-form fields) continue to survive
|
|
60
|
+
re-runs of the computed that allocated them. **Pre-batch revert:** inside a
|
|
61
|
+
`batch(...)`, set X then set X back (under the signal's `equals`) reverts the
|
|
62
|
+
version bump — downstream effects/computeds do not fire. **AggregateError on
|
|
63
|
+
multi-throw:** two or more effects throwing in the same flush pass aggregate to
|
|
64
|
+
`AggregateError` at the trigger; single-throw is unchanged. **Scheduler thunk
|
|
65
|
+
caching:** the scheduler closure is cached on the node and gen-bound, so async
|
|
66
|
+
schedules that fire post-dispose against a recycled slot are guaranteed no-ops
|
|
67
|
+
(ABA safe). Internal split: `currentObserver` and `currentOwner` are now
|
|
68
|
+
distinct pointers (today they move together, no behavioural change). **Perf:**
|
|
69
|
+
shared `peek` (one closure per registry instead of per primitive) shaves
|
|
70
|
+
10–14% off signal/computed creation on the `S:create*` micros, no hot-path or
|
|
71
|
+
behavioural change. 363 tests, 100% line / 98.62% branch coverage on
|
|
72
|
+
`Signal.js` + `Watch.js`. Differential fuzz vs the published 1.1.5: 0
|
|
73
|
+
disagreements over 30,000 writes. New `test/19-v12-additions.test.mjs`
|
|
74
|
+
(24 tests) and `test/20-axis-stress.test.mjs` (23 tests — eight orthogonal
|
|
75
|
+
engine-invariant axes plus permanent conformance pins). **Conformance fixes
|
|
76
|
+
in 1.2.0**: #141 (dispose during execution then continue: no re-run), and
|
|
77
|
+
#238 / #241 / #243 (cleanup ordering on cascade: inner-before-outer,
|
|
78
|
+
deepest-first, and the inner-only-re-run regression). #141 was a latent
|
|
79
|
+
crash present in 1.1.5 too; the v1.2 owner tree exercised it more
|
|
80
|
+
aggressively. Fix is two-fold — nullify the tracking cursor in `disposeNode`
|
|
81
|
+
when the disposed node is the active observer (plus a `gen`-snapshot guard
|
|
82
|
+
in `executeEffect`/`pullComputed`); and swap `runCleanup` to cascade
|
|
83
|
+
children first, then own.
|
|
84
|
+
|
|
85
|
+
- **1.1.5**: additive release in service of `@zakkster/lite-devtools` — stable
|
|
86
|
+
node identity on the introspection surface. `nodeId(handle)` -> the node's stable
|
|
87
|
+
per-allocation id (the dedupe key for graph walks); `describe(handle)` -> the handle's
|
|
88
|
+
own `{ id, kind, value }` descriptor, **re-walkable** (pass it back into
|
|
89
|
+
`forEachObserver`/`forEachSource` to walk the full DAG); `forEach*` descriptors now carry
|
|
90
|
+
`id`. One SMI write at allocation, node shape kept monomorphic — **zero steady-state
|
|
91
|
+
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
|
+
|
|
93
|
+
- **1.1.4**: combined release — a retracking rewrite plus an observer-lifecycle
|
|
54
94
|
introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
|
|
55
95
|
reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
|
|
56
96
|
O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
|
|
@@ -111,6 +151,8 @@ function observeObservers(
|
|
|
111
151
|
): () => void; // returns idempotent unobserve
|
|
112
152
|
function forEachObserver(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
|
|
113
153
|
function forEachSource(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
|
|
154
|
+
function nodeId(handle: Signal<any> | Computed<any>): number | undefined; // 1.1.5
|
|
155
|
+
function describe(handle: Signal<any> | Computed<any>): NodeDescriptor | undefined; // 1.1.5
|
|
114
156
|
function onCleanup(fn: () => void): void;
|
|
115
157
|
function stats(): RegistryStats;
|
|
116
158
|
|
|
@@ -134,7 +176,8 @@ interface Computed<T> {
|
|
|
134
176
|
|
|
135
177
|
type Dispose = () => void;
|
|
136
178
|
|
|
137
|
-
interface NodeDescriptor { // yielded by forEachObserver / forEachSource
|
|
179
|
+
interface NodeDescriptor { // yielded by forEachObserver / forEachSource / describe
|
|
180
|
+
id: number; // stable per-allocation id (1.1.5); dedupe + re-walk key
|
|
138
181
|
kind: "signal" | "computed" | "effect";
|
|
139
182
|
value: unknown; // node's current value
|
|
140
183
|
}
|
|
@@ -153,8 +196,8 @@ interface RegistryStats {
|
|
|
153
196
|
activeLinks: number;
|
|
154
197
|
pooledLinks: number;
|
|
155
198
|
linkPoolCapacity: number;
|
|
156
|
-
nodePoolCapacity
|
|
157
|
-
activeNodes
|
|
199
|
+
nodePoolCapacity: number;
|
|
200
|
+
activeNodes: number;
|
|
158
201
|
}
|
|
159
202
|
|
|
160
203
|
class CapacityError extends Error {
|
|
@@ -235,9 +278,9 @@ path is long rather than wide.
|
|
|
235
278
|
These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
|
|
236
279
|
high-fan-in shapes that were lite-signal's documented weakness — `dyn: large web app`
|
|
237
280
|
and `dyn: wide dense` in the cross-framework reactivity suite — were closed by the
|
|
238
|
-
1.1.4 retracking rewrite and
|
|
239
|
-
vs alien-signals'
|
|
240
|
-
resultsReactive.txt for the full 34-test, 5-framework table.
|
|
281
|
+
1.1.4 retracking rewrite and remain the fastest of five frameworks (re-confirmed on
|
|
282
|
+
1.1.5, median-of-12): 555ms / 870ms vs alien-signals' 590ms / 933ms, with preact and
|
|
283
|
+
vue ~7–30× slower. See resultsReactive.txt for the full 34-test, 5-framework table.
|
|
241
284
|
|
|
242
285
|
On allocation pressure, lite-signal is alone in the zero-Δheap band: ~15 KB of
|
|
243
286
|
transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
|
|
@@ -287,20 +330,24 @@ sandbox.destroy(); // entire reactive world reset
|
|
|
287
330
|
|
|
288
331
|
- `Signal.js` — full implementation, single file.
|
|
289
332
|
- `Signal.d.ts` — TypeScript declarations for all public API.
|
|
290
|
-
- `test/01-
|
|
291
|
-
- `test/02-
|
|
292
|
-
- `test/03-
|
|
293
|
-
- `test/04-zero-
|
|
294
|
-
- `test/05-
|
|
295
|
-
- `test/06-nested-
|
|
296
|
-
- `test/07-
|
|
297
|
-
- `test/08-
|
|
298
|
-
- `test/09-
|
|
333
|
+
- `test/01-core_test.mjs` — signal/computed/effect basics, equality, untrack.
|
|
334
|
+
- `test/02-topology_test.mjs` — diamonds, chains, fan-out/in, cycle detection.
|
|
335
|
+
- `test/03-pool_test.mjs` — capacity errors, grow policy, pool reuse.
|
|
336
|
+
- `test/04-zero-gc_test.mjs` — heap retention (run with --expose-gc).
|
|
337
|
+
- `test/05-scheduler_test.mjs` — scheduler races, dispose, gen counter, version wrap.
|
|
338
|
+
- `test/06-nested-objects_test.mjs` — nested-object & reference-identity behaviours.
|
|
339
|
+
- `test/07-dispose_test.mjs` — universal disposal: registry.dispose(api).
|
|
340
|
+
- `test/08-watch_test.mjs` — new watch reactivity tests.
|
|
341
|
+
- `test/09-conformance_test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
|
|
299
342
|
- `test/10-is-tracking_test.mjs` — `isTracking()` across observer bodies, untracked windows, and outside any observer.
|
|
300
343
|
- `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).
|
|
301
344
|
- `test/12-coverage_test.mjs` — targeted public-surface + hot-path branch coverage (top-level routing, clean-read short-circuit, tail severance, scheduler ABA); owner-tree block capability-gated.
|
|
302
345
|
- `test/13-introspection_test.mjs` — observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
|
|
303
346
|
- `test/14-lifecycle-teardown_test.mjs` — effect-teardown guards: stopped effect doesn't re-subscribe to a signal read later in the same run, self-dispose leaves no orphan link, throwing setup leaves no live subscription (4 tests).
|
|
347
|
+
- `test/15-owner-lazy-alloc_test.mjs` — owner-adoption contract for the 1.2.0 owner tree (lazy signals never adopted; observers still auto-disposed); capability-gated, skipped on 1.1.x (4 tests).
|
|
348
|
+
- `test/16-alien-parity_test.mjs` — differential guards vs alien-signals@3.2.0 fixed-bug classes: cleanup reads create no deps, inner write doesn't block computed-chain propagation, dynamic dep-set correct under dirty-check (3 tests).
|
|
349
|
+
- `test/17-reactivity_test.mjs` — behavioral suite over universal signal-system bug classes (subscription lifecycle, cleanup ordering, stale-dep tracking, batching, equality, nested invalidation, memory, sync async-boundary, scheduler/loops, + differential-review additions); SSR is N/A (≈30 tests).
|
|
350
|
+
- `test/18-identity_test.mjs` — node identity (1.1.5): `nodeId`/`describe`, descriptor `id`, re-walkable descriptors, non-perturbing (5 tests).
|
|
304
351
|
- `bench/benchmark.mjs` — anti-DCE throughput harness (ops/s; results.txt).
|
|
305
352
|
- `bench/benchmarkReactive.mjs` — cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
|
|
306
353
|
- `demo/index.html` — interactive visualization of the reactive graph.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"Watch.js",
|
|
23
23
|
"README.md",
|
|
24
24
|
"llms.txt",
|
|
25
|
-
"LICENSE.txt"
|
|
25
|
+
"LICENSE.txt",
|
|
26
|
+
"CHANGELOG.md"
|
|
26
27
|
],
|
|
27
28
|
"scripts": {
|
|
28
29
|
"test": "node --test --test-reporter=spec",
|