@zakkster/lite-signal 1.0.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/llms.txt ADDED
@@ -0,0 +1,192 @@
1
+ # @zakkster/lite-signal
2
+
3
+ > Zero-GC reactive graph library. Object-pooled nodes and links, versioned
4
+ > push-pull propagation, 32-bit modular epoch counters. Synchronous flush, no
5
+ > microtask scheduler, no allocations in hot path after warm-up. ESM-only.
6
+ > ~3.2 KB min+gz, zero dependencies, MIT licensed.
7
+
8
+ The library exposes a small reactive primitives API (signal, computed, effect,
9
+ batch, untrack, onCleanup) backed by pre-allocated pools. Built for animation
10
+ loops, Twitch Extensions, game HUDs, and other contexts where GC pauses break
11
+ the frame budget. Effects flush synchronously in the same call stack as the
12
+ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
13
+
14
+ ## Core concepts
15
+
16
+ - **Signal**: root reactive cell. `signal(initial, { equals? })` returns a function. Call it to read (tracked), use `.peek()` to read without tracking, `.set(v)` to notify downstream, `.update(fn)` for read-modify-write, `.subscribe(fn)` for an effect-backed listener.
17
+ - **Computed**: lazy memoized derivation. `computed(fn, { equals? })`. Cache hits return in O(deps) (version comparison only). Cache misses re-run the body and refresh `evalVersion`. Errors are cached via `FLAG_HAS_ERROR` and re-thrown on every read until a dep changes.
18
+ - **Effect**: side-effect runner. `effect(fn, { scheduler? })`. Runs immediately on creation, then on any tracked dep change. Returns a dispose function. Optional scheduler defers execution (e.g., to rAF, microtask, custom frame loop).
19
+ - **dispose(api)**: universal disposal. Accepts a signal, computed, or effect dispose handle; idempotent; cross-registry calls are silent no-ops (per-registry `Symbol("node_ptr")` keys the node-identity slot on the returned API function, foreign signals fail the lookup and fall through). Passing an unrelated value is also safe; passing an arbitrary function invokes it (effect-handle contract).
20
+ - **Batch**: `batch(fn)` defers effect flush until the outermost batch closes. Nestable.
21
+ - **Untrack**: `untrack(fn)` reads without subscribing.
22
+ - **onCleanup**: registers teardown for the current computation; fires before each re-run and once on dispose. Works in effects and computeds.
23
+ - **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.
24
+ - **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.
25
+
26
+ ## Architecture invariants
27
+
28
+ - **Two object pools**: nodes (default 1024) and links (default 4×nodes). Singly-linked via `nextFree`, O(1) allocate/free.
29
+ - **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.
30
+ - **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.
31
+ - **Iterative mark phase**: `markDownstream` uses a pre-allocated `markStack` array for DFS — no recursion, no call stack growth on wide fan-out.
32
+ - **Recursive computed pull**: `pullComputed` is call-stack recursive; deep chains beyond ~10,000 nodes will throw `RangeError`. Effects don't have this limit.
33
+ - **Double-buffered effect queue**: `effectQueueA` / `effectQueueB` alternate each flush pass. An effect that writes during its own re-run gets re-queued into the alternate buffer.
34
+ - **maxFlushPasses ceiling**: default 100. Exceeding this throws `CycleError`. Prevents runaway effect loops.
35
+ - **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.
36
+ - **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.
37
+ - **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).
38
+
39
+ ## Performance characteristics
40
+
41
+ - `signal.set(v)` — O(downstream observers), zero alloc after warm-up.
42
+ - `signal.peek()` — O(1), zero alloc.
43
+ - `computed()` cache hit — O(deps), zero alloc.
44
+ - `computed()` cache miss — O(deps + body), zero alloc if dep structure is stable.
45
+ - Effect re-run with stable dep order — zero alloc.
46
+ - Stable read order: O(1) per dep via cursor reuse.
47
+ - Chaotic/randomized read order: degrades to O(N) per dep due to list re-insertion.
48
+
49
+ ## When to use
50
+
51
+ - Animation loops, game HUDs, scoreboards, telemetry overlays.
52
+ - Twitch Extensions (1 MB bundle / 3 s cold start budget).
53
+ - Long-running browser sessions (millions of writes per session).
54
+ - Multi-tenant SDKs where each tenant needs an isolated reactive world.
55
+ - Any context where a GC pause breaks the frame budget.
56
+
57
+ ## When NOT to use
58
+
59
+ - Server-side rendering — no SSR story.
60
+ - Workloads with mostly chaotic read order — `alien-signals` performs better here.
61
+ - If you want time-travel, devtools, or serialization — build those on top.
62
+
63
+ ## API summary
64
+
65
+ ```ts
66
+ // Default registry helpers
67
+ function signal<T>(initial: T, opts?: { equals?: (a: T, b: T) => boolean }): Signal<T>;
68
+ function computed<T>(fn: () => T, opts?: { equals?: (a: T, b: T) => boolean }): Computed<T>;
69
+ function effect(fn: () => void, opts?: { scheduler?: (run: () => void) => void }): Dispose;
70
+ function dispose(api: Signal<any> | Computed<any> | Dispose): void;
71
+ function batch<T>(fn: () => T): T;
72
+ function untrack<T>(fn: () => T): T;
73
+ function onCleanup(fn: () => void): void;
74
+ function stats(): RegistryStats;
75
+
76
+ // Registry construction
77
+ function createRegistry(config?: RegistryConfig): Registry;
78
+ function setDefaultRegistry(r: Registry): void;
79
+
80
+ interface Signal<T> {
81
+ (): T; // tracked read
82
+ peek(): T; // untracked read
83
+ set(value: T): void;
84
+ update(fn: (prev: T) => T): void;
85
+ subscribe(fn: (value: T) => void): Dispose;
86
+ }
87
+
88
+ interface Computed<T> {
89
+ (): T; // tracked read
90
+ peek(): T; // untracked read
91
+ subscribe(fn: (value: T) => void): Dispose;
92
+ }
93
+
94
+ type Dispose = () => void;
95
+
96
+ interface RegistryConfig {
97
+ maxNodes?: number; // default 1024
98
+ maxLinks?: number; // default maxNodes * 4
99
+ maxFlushPasses?: number; // default 100
100
+ onCapacityExceeded?: "throw" | "grow"; // default "throw"
101
+ }
102
+
103
+ interface RegistryStats {
104
+ signals: number;
105
+ computeds: number;
106
+ effects: number;
107
+ activeLinks: number;
108
+ pooledLinks: number;
109
+ linkPoolCapacity: number;
110
+ nodePoolCapacity?: number;
111
+ activeNodes?: number;
112
+ }
113
+
114
+ class CapacityError extends Error {
115
+ kind: "nodes" | "links";
116
+ capacity: number;
117
+ }
118
+ ```
119
+
120
+ ## Benchmark snapshot (Node 22, 2016-era Intel MacBook Pro, 20K iter × 5 runs)
121
+
122
+ | Scenario | lite-signal | alien-signals | preact | solid |
123
+ | --------------------------------------- | ----------- | ------------- | -------- | -------- |
124
+ | MUX (256 sigs → sum → effect) | **252K ops/s** | 191K | 153K | 76K |
125
+ | KAIROS (1 sig → 1000 computeds → eff) | **15K** | 14K | 11K | 4K |
126
+ | BROADCAST (1 sig → 1000 effects) | 23K | **26K** | 16K | 7K |
127
+ | DEEP CHAIN (256-deep memos → eff) | 52K | **87K** | 49K | 15K |
128
+
129
+ lite-signal wins MUX (+32% vs alien) and is effectively tied with alien on KAIROS
130
+ (+7%, within noise). alien wins BROADCAST (+13%) and DEEP CHAIN (+67%); alien's
131
+ flatter internal representation has lower per-edge cost on long pipelines and
132
+ pure broadcast. Both libs allocate orders of magnitude less than preact/solid
133
+ during the timed loop (15-20 KB Δheap vs 230 KB-2.8 MB).
134
+
135
+ Retained heap after forced GC: typically negative for all libs (V8 compacts).
136
+ The one exception: lite-signal shows ~+71 KB retained on KAIROS, which is the
137
+ pre-allocated pool holding the live 1002-node graph in steady state. This is
138
+ the design: the pool IS the working memory. Not a leak.
139
+
140
+ ## Common idioms
141
+
142
+ ```js
143
+ // Glitch-free diamond
144
+ const a = signal(1);
145
+ const b = computed(() => a() + 1);
146
+ const c = computed(() => a() * 2);
147
+ const d = computed(() => b() + c()); // re-evaluates exactly once per a.set
148
+
149
+ // Conditional subscription (b only tracked when flag is true)
150
+ const flag = signal(true);
151
+ const a = signal(1);
152
+ const b = signal(2);
153
+ const sum = computed(() => flag() ? a() + b() : a());
154
+
155
+ // Scheduler integration (rAF batching)
156
+ let queued = false;
157
+ effect(() => render(state()), {
158
+ scheduler: run => {
159
+ if (queued) return;
160
+ queued = true;
161
+ requestAnimationFrame(() => { queued = false; run(); });
162
+ }
163
+ });
164
+
165
+ // Sandboxed plugin
166
+ const sandbox = createRegistry({ maxNodes: 256, onCapacityExceeded: "throw" });
167
+ plugin(sandbox.signal, sandbox.computed, sandbox.effect);
168
+ // later:
169
+ sandbox.destroy(); // entire reactive world reset
170
+ ```
171
+
172
+ ## File layout
173
+
174
+ - `src/index.js` — full implementation, ~500 lines, single file.
175
+ - `types/index.d.ts` — TypeScript declarations for all public API.
176
+ - `test/01-core.test.mjs` — signal/computed/effect basics, equality, untrack.
177
+ - `test/02-topology.test.mjs` — diamonds, chains, fan-out/in, cycle detection.
178
+ - `test/03-pool.test.mjs` — capacity errors, grow policy, pool reuse.
179
+ - `test/04-zero-gc.test.mjs` — heap retention (run with --expose-gc).
180
+ - `test/05-scheduler.test.mjs` — scheduler races, dispose, gen counter, version wrap.
181
+ - `bench/bench.mjs` — comparative benchmark vs alien-signals, preact, solid.
182
+ - `demo/index.html` — interactive visualization of the reactive graph.
183
+
184
+ ## Install
185
+
186
+ ```bash
187
+ npm install @zakkster/lite-signal
188
+ ```
189
+
190
+ ## License
191
+
192
+ MIT
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@zakkster/lite-signal",
3
+ "version": "1.0.0",
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
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "Signal.js",
9
+ "module": "Signal.js",
10
+ "types": "Signal.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "Signal.d.ts",
14
+ "import": "Signal.js",
15
+ "default": "Signal.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "Signal.js",
20
+ "Signal.d.ts",
21
+ "README.md",
22
+ "llms.txt",
23
+ "LICENSE.txt"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test --test-reporter=spec",
27
+ "test:gc": "node --expose-gc --test --test-reporter=spec",
28
+ "bench": "node --expose-gc bench/benchmark.mjs",
29
+ "verify": "npm test && npm run bench"
30
+ },
31
+ "keywords": [
32
+ "signal",
33
+ "signals",
34
+ "reactive",
35
+ "reactivity",
36
+ "computed",
37
+ "effect",
38
+ "zero-gc",
39
+ "zero-allocation",
40
+ "object-pool",
41
+ "fine-grained",
42
+ "twitch-extension",
43
+ "performance"
44
+ ],
45
+ "homepage": "https://github.com/PeshoVurtoleta/lite-signal#readme",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/PeshoVurtoleta/lite-signal.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/PeshoVurtoleta/lite-signal/issues",
52
+ "email": "shinikchiev@yahoo.com"
53
+ },
54
+ "engines": {
55
+ "node": ">=18"
56
+ },
57
+ "sideEffects": false
58
+ }