@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/LICENSE.txt +21 -0
- package/README.md +585 -0
- package/Signal.d.ts +167 -0
- package/Signal.js +1007 -0
- package/llms.txt +192 -0
- package/package.json +58 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zahary
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
# @zakkster/lite-signal
|
|
2
|
+
|
|
3
|
+
> Zero-GC reactive graph for hot paths. Object-pooled nodes, versioned push-pull propagation, 32-bit modular epochs. Built for 16ms render budgets and 1MB extension bundles.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
6
|
+
[](https://bundlephobia.com/package/@zakkster/lite-signal)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
8
|
+
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+
[](LICENSE.txt)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @zakkster/lite-signal
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { signal, computed, effect, batch } from "@zakkster/lite-signal";
|
|
19
|
+
|
|
20
|
+
const count = signal(0);
|
|
21
|
+
const double = computed(() => count() * 2);
|
|
22
|
+
|
|
23
|
+
effect(() => console.log("double is", double()));
|
|
24
|
+
// → double is 0
|
|
25
|
+
|
|
26
|
+
count.set(21);
|
|
27
|
+
// → double is 42
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Synchronous, glitch-free, push-pull. No microtask queue, no allocations after warm-up, no surprises.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Table of contents
|
|
35
|
+
|
|
36
|
+
- [Why this exists](#why-this-exists)
|
|
37
|
+
- [What you get](#what-you-get)
|
|
38
|
+
- [The case for object pooling](#the-case-for-object-pooling)
|
|
39
|
+
- [Architecture in one diagram](#architecture-in-one-diagram)
|
|
40
|
+
- [How a write propagates](#how-a-write-propagates)
|
|
41
|
+
- [API reference](#api-reference)
|
|
42
|
+
- [Capacity, growth, and the link ceiling](#capacity-growth-and-the-link-ceiling)
|
|
43
|
+
- [Edge cases pinned down](#edge-cases-pinned-down)
|
|
44
|
+
- [Benchmarks](#benchmarks)
|
|
45
|
+
- [Testing strategy](#testing-strategy)
|
|
46
|
+
- [What this is not](#what-this-is-not)
|
|
47
|
+
- [Browser and runtime support](#browser-and-runtime-support)
|
|
48
|
+
- [Integration recipes](#integration-recipes)
|
|
49
|
+
- [FAQ](#faq)
|
|
50
|
+
- [npm scripts](#npm-scripts)
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Why this exists
|
|
55
|
+
|
|
56
|
+
Reactive graph libraries are now table-stakes for UI work. They all do the same thing: track reads, mark dirty, re-run on change. The differences live in the hot path.
|
|
57
|
+
|
|
58
|
+
`lite-signal` was built under three constraints simultaneously:
|
|
59
|
+
|
|
60
|
+
1. **No allocation after warm-up.** A 60fps Twitch overlay can't tolerate GC pauses. `set`, `peek`, and re-runs touch no heap.
|
|
61
|
+
2. **Zero microtasks.** Effects flush synchronously in the same call stack as `set()`. There is no scheduler queue. Predictable cause-and-effect makes debugging tractable.
|
|
62
|
+
3. **Survive forever.** A multi-day extension session can issue billions of writes. Internal versions use 32-bit modular arithmetic — the engine never overflows.
|
|
63
|
+
|
|
64
|
+
Other libraries hit two of three. None of the ones I measured hit all three.
|
|
65
|
+
|
|
66
|
+
```mermaid
|
|
67
|
+
flowchart LR
|
|
68
|
+
A[set called] --> B[bump globalVersion<br/>via 32-bit add]
|
|
69
|
+
B --> C[markDownstream<br/>iterative DFS, pre-allocated stack]
|
|
70
|
+
C --> D[push observable effects<br/>to active queue buffer]
|
|
71
|
+
D --> E{batch depth zero?}
|
|
72
|
+
E -- yes --> F[flushEffects<br/>double-buffered swap]
|
|
73
|
+
E -- no --> G[return,<br/>queue drains on batch close]
|
|
74
|
+
F --> H[per-effect: pull deps,<br/>compare versions, re-run if dirty]
|
|
75
|
+
H --> I[user code]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
No microtask between `B` and `I`. No promise, no `queueMicrotask`. Just call stack.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## What you get
|
|
83
|
+
|
|
84
|
+
- **`signal(value, { equals? })`** — root reactive cell. `set`, `peek`, `update`, `subscribe`.
|
|
85
|
+
- **`computed(fn, { equals? })`** — memoized derivation. Lazy. Pulls deps on read.
|
|
86
|
+
- **`effect(fn, { scheduler? })`** — side-effect runner. Returns a dispose function.
|
|
87
|
+
- **`dispose(api)`** — universal disposal for signals, computeds, and effect handles. Cross-registry calls are silent no-ops.
|
|
88
|
+
- **`batch(fn)`** — defer effect flush until the outermost batch closes.
|
|
89
|
+
- **`untrack(fn)`** — read without subscribing.
|
|
90
|
+
- **`onCleanup(fn)`** — register teardown for the current computation. Works in effects *and* computeds.
|
|
91
|
+
- **`createRegistry(config)`** — isolated pool for tests, plugins, sandboxing.
|
|
92
|
+
- **`stats()`** — pool occupancy snapshot. Used by the demo and easy to wire into perf overlays.
|
|
93
|
+
- **`CapacityError`** — thrown when a fixed-size pool is exhausted under the `"throw"` policy.
|
|
94
|
+
|
|
95
|
+
Full type definitions ship in [`Signal.d.ts`](./Signal.d.ts) and are referenced from `package.json`. Every public symbol has JSDoc.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## The case for object pooling
|
|
100
|
+
|
|
101
|
+
A naive reactive library allocates one object per dependency edge, one per subscription, one per queued effect. With 1000 computeds × 1 update / frame × 60 fps, that's 60,000 short-lived objects per second. The major GC will catch up with you.
|
|
102
|
+
|
|
103
|
+
`lite-signal` solves this by pre-allocating two pools at startup — **nodes** (one per signal/computed/effect) and **links** (one per dependency edge) — and reusing them indefinitely. After the warm-up frames, the hot path performs zero allocations:
|
|
104
|
+
|
|
105
|
+
| Op | Allocations | Notes |
|
|
106
|
+
| ------------------- | ----------- | ------------------------------------------------------------------------------ |
|
|
107
|
+
| `signal.set(x)` | **0** | Bumps a 32-bit version counter, walks pre-pooled link list |
|
|
108
|
+
| `signal.peek()` | **0** | Direct value read |
|
|
109
|
+
| Effect re-run | **0** | Cursor reuses existing links via `currentDep` pointer |
|
|
110
|
+
| `computed()` read | **0** (steady-state) | Cache hit on `evalVersion === globalVersion` |
|
|
111
|
+
| Dispose | **0** | Returns nodes and links to the free lists |
|
|
112
|
+
|
|
113
|
+
The free lists are singly-linked through a `nextFree` field on each pool object — `O(1)` pop, `O(1)` push, no fragmentation.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Architecture in one diagram
|
|
118
|
+
|
|
119
|
+
```mermaid
|
|
120
|
+
flowchart TB
|
|
121
|
+
subgraph Pools[Pre-allocated object pools]
|
|
122
|
+
NP[ReactiveNode pool<br/>default 1024]
|
|
123
|
+
LP[ReactiveLink pool<br/>default 4096]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
subgraph Graph[Reactive graph]
|
|
127
|
+
S1((signal))
|
|
128
|
+
S2((signal))
|
|
129
|
+
C1[[computed]]
|
|
130
|
+
C2[[computed]]
|
|
131
|
+
E1{effect}
|
|
132
|
+
S1 -->|link| C1
|
|
133
|
+
S2 -->|link| C1
|
|
134
|
+
C1 -->|link| C2
|
|
135
|
+
C2 -->|link| E1
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
subgraph Hot[Hot-path state]
|
|
139
|
+
GV[globalVersion<br/>32-bit modular int]
|
|
140
|
+
MS[markStack<br/>iterative DFS buffer]
|
|
141
|
+
Q1[effectQueueA]
|
|
142
|
+
Q2[effectQueueB<br/>double-buffered]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
NP -.->|alloc / free| Graph
|
|
146
|
+
LP -.->|alloc / free| Graph
|
|
147
|
+
Graph --> GV
|
|
148
|
+
Graph --> MS
|
|
149
|
+
Graph --> Q1
|
|
150
|
+
Graph --> Q2
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Every reactive entity is a `ReactiveNode` with bit flags (`COMPUTED`, `EFFECT`, `QUEUED`, `COMPUTING`, `HAS_ERROR`). Every edge between two nodes is a `ReactiveLink`, doubly-linked along two axes:
|
|
154
|
+
|
|
155
|
+
- **`dep` axis:** `prevDep` / `nextDep` — the list of dependencies on the *target* node (so a computed/effect can iterate its inputs in stable order).
|
|
156
|
+
- **`sub` axis:** `prevSub` / `nextSub` — the list of subscribers on the *source* node (so a signal can iterate downstream observers during mark phase).
|
|
157
|
+
|
|
158
|
+
Doubly-linked on both axes means `O(1)` unlink during the cursor-based reconciliation that happens at the end of every computed/effect re-run.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## How a write propagates
|
|
163
|
+
|
|
164
|
+
```mermaid
|
|
165
|
+
sequenceDiagram
|
|
166
|
+
participant U as User code
|
|
167
|
+
participant S as signal
|
|
168
|
+
participant Mark as markDownstream
|
|
169
|
+
participant Q as effectQueue
|
|
170
|
+
participant Flush as flushEffects
|
|
171
|
+
participant Eff as effect body
|
|
172
|
+
|
|
173
|
+
U->>S: signal.set(value)
|
|
174
|
+
S->>S: equals(prev, next) ? return
|
|
175
|
+
S->>S: bump node.version + globalVersion
|
|
176
|
+
S->>Mark: walk sub list (iterative DFS)
|
|
177
|
+
Mark->>Q: push observable effects (FLAG_QUEUED)
|
|
178
|
+
Note over Mark,Q: stale computeds left dirty<br/>(pulled lazily on next read)
|
|
179
|
+
S->>Flush: batchDepth == 0 ? flush
|
|
180
|
+
loop until queue empty
|
|
181
|
+
Flush->>Eff: for each effect: re-pull deps,<br/>compare versions, run if dirty
|
|
182
|
+
Eff-->>Flush: maybe re-queue (handled by buffer B)
|
|
183
|
+
Flush->>Flush: swap buffers A↔B, repeat
|
|
184
|
+
end
|
|
185
|
+
Note over Flush: maxFlushPasses=100<br/>guards against runaway loops
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The mark phase is **iterative**, not recursive — it uses a pre-allocated `markStack` array so a 10,000-node fan-out can't blow the JS call stack.
|
|
189
|
+
|
|
190
|
+
The flush phase uses **two queue buffers** (`effectQueueA` / `effectQueueB`) alternating each pass. An effect that writes during its own re-run gets re-queued into the *other* buffer, which is then processed in the next pass. After `maxFlushPasses` (default 100), the loop throws `CycleError`.
|
|
191
|
+
|
|
192
|
+
Computeds are **pull-based** — they're not in the effect queue. Reading a computed walks its dep list, recursively pulls upstream computeds, and only re-runs if any dep's version is greater than its own `evalVersion`. The version comparison uses 32-bit modular arithmetic: `((dep.version - evalVer) | 0) > 0`. This is the trick that makes the engine immune to integer overflow during long-running sessions.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## API reference
|
|
197
|
+
|
|
198
|
+
### Top-level
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import {
|
|
202
|
+
signal, computed, effect,
|
|
203
|
+
batch, untrack, onCleanup,
|
|
204
|
+
createRegistry, setDefaultRegistry,
|
|
205
|
+
stats, CapacityError
|
|
206
|
+
} from "@zakkster/lite-signal";
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The top-level functions route to a default registry created on import. For isolated sandboxes (tests, plugins, multi-tenant SDKs), use `createRegistry` directly.
|
|
210
|
+
|
|
211
|
+
### Signal
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
const s = signal(initial, { equals?: (a, b) => boolean });
|
|
215
|
+
|
|
216
|
+
s(); // tracked read
|
|
217
|
+
s.peek(); // untracked read
|
|
218
|
+
s.set(value); // notify downstream
|
|
219
|
+
s.update(fn); // s.set(fn(s.peek()))
|
|
220
|
+
const off = s.subscribe(value => { ... });
|
|
221
|
+
off(); // unsubscribe
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`equals` defaults to `Object.is` (so `NaN` notifies once, `-0`/`+0` are distinct). Pass `() => false` to force every write to propagate, or your own deep-equal to skip redundant updates.
|
|
225
|
+
|
|
226
|
+
### Computed
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
const c = computed(() => s() * 2, { equals?: (a, b) => boolean });
|
|
230
|
+
|
|
231
|
+
c(); // tracked read, lazy evaluation
|
|
232
|
+
c.peek(); // untracked read, may still compute
|
|
233
|
+
const off = c.subscribe(value => { ... });
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Computeds **cache by version**, not by value. Reading a clean computed (one whose dependencies haven't changed since its `evalVersion`) is `O(deps)` — it still walks the dep list to check versions, then returns the cached value. The `equals` option short-circuits downstream propagation when the new computed value matches the old.
|
|
237
|
+
|
|
238
|
+
### Effect
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const dispose = effect(() => {
|
|
242
|
+
console.log(s());
|
|
243
|
+
onCleanup(() => { /* fires on next run + final dispose */ });
|
|
244
|
+
}, {
|
|
245
|
+
scheduler?: (runEffect) => void // optional, see below
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
dispose();
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Effects run **once eagerly** on creation, then again whenever any tracked dependency changes. Dispose returns the node to the pool. If a scheduler is provided, the runner is handed to the scheduler instead of executing inline — useful for batching reactive updates into requestAnimationFrame, microtasks, or your own frame loop.
|
|
252
|
+
|
|
253
|
+
### Batch
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
batch(() => {
|
|
257
|
+
s1.set(1);
|
|
258
|
+
s2.set(2);
|
|
259
|
+
s3.set(3);
|
|
260
|
+
}); // effects flush exactly once at the end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Nestable. Effects only flush on the outermost close.
|
|
264
|
+
|
|
265
|
+
### Untrack
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const value = untrack(() => s()); // read without subscribing
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Useful inside computeds/effects when you need a current value but don't want it as a dependency.
|
|
272
|
+
|
|
273
|
+
### onCleanup
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
effect(() => {
|
|
277
|
+
const id = setInterval(tick, 100);
|
|
278
|
+
onCleanup(() => clearInterval(id));
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Registers a teardown for the *current* computation. Fires before every re-run and once on dispose. Supports multiple cleanups per scope (they're stored as a flat list, run in registration order). Works inside computeds too — useful for canceling async work when memos become stale.
|
|
283
|
+
|
|
284
|
+
### dispose
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
const s = signal(0);
|
|
288
|
+
const c = computed(() => s() * 2);
|
|
289
|
+
const e = effect(() => { /* ... */ });
|
|
290
|
+
|
|
291
|
+
dispose(s); // signal → returns the node to the pool
|
|
292
|
+
dispose(c); // computed → same, also unlinks its upstreams
|
|
293
|
+
dispose(e); // effect handle → identical to calling e()
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
One function for all three primitives. Idempotent. Cross-registry calls are silent no-ops — each registry holds a private `Symbol("node_ptr")` keyed on its own nodes, so passing a signal from registry A to `registry B.dispose()` won't corrupt either pool. Passing an unrelated value (`null`, `42`, `{}`) is also a safe no-op. Passing an arbitrary function invokes it (the effect-handle contract).
|
|
297
|
+
|
|
298
|
+
The effect dispose handle (`const dispose = effect(...)`) is still a plain function — you can call it directly. `dispose()` exists to unify the call site when you're managing a heterogeneous bag of reactive resources, which is the common case for component teardown and tests.
|
|
299
|
+
|
|
300
|
+
### createRegistry
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
const r = createRegistry({
|
|
304
|
+
maxNodes: 1024, // default
|
|
305
|
+
maxLinks: 4 * 1024, // default = maxNodes * 4
|
|
306
|
+
maxFlushPasses: 100, // default
|
|
307
|
+
onCapacityExceeded: "throw" // default. Other: "grow"
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const s = r.signal(0);
|
|
311
|
+
const e = r.effect(() => s());
|
|
312
|
+
r.destroy(); // reset all pools, invalidate generations
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`createRegistry` is the unit of isolation. Two registries share no state — useful for multi-tenant code, plugin sandboxes, and tests that need a fresh world.
|
|
316
|
+
|
|
317
|
+
`setDefaultRegistry(r)` swaps the registry used by top-level helpers. Use sparingly; intended for test setup.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Capacity, growth, and the link ceiling
|
|
322
|
+
|
|
323
|
+
The engine has two pool sizes: **nodes** and **links**. Both are fixed at registry creation but can be configured to grow.
|
|
324
|
+
|
|
325
|
+
```mermaid
|
|
326
|
+
flowchart LR
|
|
327
|
+
A[allocator hits empty pool] --> B{policy?}
|
|
328
|
+
B -- "throw" --> C[CapacityError]
|
|
329
|
+
B -- "grow" --> D[double pool size]
|
|
330
|
+
D --> E{new size > 16× original?}
|
|
331
|
+
E -- yes --> F[CapacityError<br/>link ceiling]
|
|
332
|
+
E -- no --> G[allocate, continue]
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Why a ceiling? Unbounded growth hides leaks. If your app reaches 16× its starting link capacity, something is wrong and you want to know — `CapacityError` is louder than a slow OOM crash four hours later.
|
|
336
|
+
|
|
337
|
+
Default sizing for a Twitch-extension-style budget:
|
|
338
|
+
|
|
339
|
+
| Workload | maxNodes | maxLinks | policy |
|
|
340
|
+
| ----------------------------------- | -------- | -------- | -------- |
|
|
341
|
+
| Tiny widget (≤50 reactive cells) | 256 | 1024 | `"throw"` |
|
|
342
|
+
| Standard overlay (~500 cells) | 1024 | 4096 | `"throw"` |
|
|
343
|
+
| Heavy dashboard (variable scale) | 2048 | 16384 | `"grow"` |
|
|
344
|
+
|
|
345
|
+
`stats()` reports `signals`, `computeds`, `effects`, `activeLinks`, `pooledLinks`, `linkPoolCapacity`. Drop it on screen for live observability.
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Edge cases pinned down
|
|
350
|
+
|
|
351
|
+
These are the questions you'd ask in a code review, with the answers:
|
|
352
|
+
|
|
353
|
+
- **Diamond dependency.** Glitch-free. The mark phase walks the graph once; computeds are pulled lazily on read, so each one re-runs at most once per propagation regardless of how many paths reach it.
|
|
354
|
+
- **Writing to a signal during its own effect (self-feedback loop).** The new value re-queues the effect into the alternate buffer. After 100 flush passes (configurable), `CycleError` is thrown — you have a real loop, not just a deep update.
|
|
355
|
+
- **Writing to a signal *inside its computed*.** Throws `CycleError` immediately at the inner `set` — this is a structural cycle, not a deep update, and the engine refuses to attempt it.
|
|
356
|
+
- **NaN, -0, +0.** Default `equals` is `Object.is`. `NaN === NaN` is true for our purposes (so setting NaN twice doesn't re-fire). `-0` and `+0` are distinct.
|
|
357
|
+
- **First-run effect throws.** The half-initialised node is disposed cleanly, deps unlinked, then the error propagates to the caller. No leaked dangling subscriptions.
|
|
358
|
+
- **Computed throws.** The error is cached on the node (`FLAG_HAS_ERROR`) and re-thrown on every subsequent read until a dependency changes. This is symmetric with successful caching.
|
|
359
|
+
- **Dispose during flush.** Effects re-check their generation (`gen`) before running through a scheduler trampoline. If `dispose()` bumped the gen between schedule and execute, the trampoline becomes a no-op.
|
|
360
|
+
- **32-bit version wrap.** Versions are `(... + 1) | 0`, so after 2^31 writes they wrap to a negative number. The comparison `((dep.version - evalVer) | 0) > 0` is wrap-safe — it works on the *modular distance*, not raw integer ordering.
|
|
361
|
+
- **Deep chain depth.** Computed resolution is recursive in the JS call stack. Chains beyond ~10,000 deep risk `RangeError: Maximum call stack size exceeded`. Effects use an iterative mark phase, so signal → effect fan-out has no depth limit other than memory.
|
|
362
|
+
- **`destroy()` after dispose.** `destroy()` bumps every node's generation, so any in-flight scheduled trampolines from before destruction are silently dropped. Closures returned to user code from disposed effects guard with `if (node.flags === 0) return;` — calling `dispose()` again is a no-op.
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## Benchmarks
|
|
367
|
+
|
|
368
|
+
Honest numbers, against the same workload, with anti-DCE sinks and verified effect execution. All measurements: Node 22, **2016-era Intel MacBook Pro (4 cores, ~10 yr old hardware)**, 20K iterations × 5 runs (median reported). Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters.
|
|
369
|
+
|
|
370
|
+
| Scenario | What it stresses | lite-signal | alien-signals | preact | solid-js |
|
|
371
|
+
| ---------- | -------------------------------- | ----------- | ------------- | ---------- | --------- |
|
|
372
|
+
| **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **252K ops/s** | 191K | 153K | 76K |
|
|
373
|
+
| **KAIROS** | 1 signal → 1000 computeds → 1 effect | **15K** | 14K | 11K | 4K |
|
|
374
|
+
| **BROADCAST** | 1 signal → 1000 effects (fan-out) | 23K | **26K** | 16K | 7K |
|
|
375
|
+
| **DEEP CHAIN** | 256-deep computed chain → 1 effect | 52K | **87K** | 49K | 15K |
|
|
376
|
+
| **Δheap MUX** | transient alloc pressure | **15 KB** | 13 KB | 230 KB | 2,834 KB |
|
|
377
|
+
| **Retained MUX** | state surviving forced GC | **−20 KB** (none) | −2 KB | −13 KB | −14 KB |
|
|
378
|
+
|
|
379
|
+
**Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+32%** over alien-signals — the dominant pattern in dashboards, scoreboards, HUDs, and leaderboards. It also edges ahead on **KAIROS** (one source fanning into a wide layer of memos) by ~7%, though that gap is within run-to-run noise. On pure fan-out (BROADCAST) and long pipelines (DEEP CHAIN), `alien-signals`' flatter internal representation is faster — by 13% and 67% respectively. On allocation pressure, `lite-signal` and `alien-signals` are in a different league: Preact pays ~230 KB of churn per MUX run, Solid pays ~2.8 MB. Negative "retained" numbers mean V8 reclaimed more memory than the bench allocated during the post-run forced GC — no leaks anywhere.
|
|
380
|
+
|
|
381
|
+
> Note on the +71 KB retained that lite-signal shows on KAIROS specifically: that's the pre-allocated pool sitting in memory holding the live graph (1002 nodes + ~2000 links). The pool *is* the working memory — see the [Case for object pooling](#case-for-object-pooling) section. On the other benches the graph is small enough that the same pool floats below baseline after GC.
|
|
382
|
+
|
|
383
|
+
The benchmark harness is in [`bench/bench.mjs`](./bench/bench.mjs). It:
|
|
384
|
+
|
|
385
|
+
1. Writes every effect's output to a shared `Float64Array(4096)` exposed on `globalThis` — V8 cannot prove these writes are dead.
|
|
386
|
+
2. Uses the **client** Solid runtime (`solid-js/dist/solid.js`), not the SSR stub Node resolves to by default. The default Node resolution silently no-ops effects, which is how earlier benchmarks across the ecosystem have reported Solid at ~50 GHz throughput.
|
|
387
|
+
3. Validates each lib's sink slot is non-zero after the timed loop and prints `sink=✓` for each line. If you ever see `sink=✗`, the run is invalid.
|
|
388
|
+
|
|
389
|
+
Run it yourself:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
npm install --no-save alien-signals @preact/signals-core solid-js
|
|
393
|
+
npm run bench
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Testing strategy
|
|
399
|
+
|
|
400
|
+
Three tiers, all reproducible.
|
|
401
|
+
|
|
402
|
+
### Tier 1 — Behavior (unit tests, fast)
|
|
403
|
+
|
|
404
|
+
`npm test` runs the suite in `test/`. 131 tests across 43 suites covering:
|
|
405
|
+
|
|
406
|
+
- **`01-core.test.mjs`** — signal/computed/effect basics, equality semantics, NaN/±0, subscribe/peek/update, untrack, batch, cleanup ordering, first-run error recovery, nested object reference-identity gotchas.
|
|
407
|
+
- **`02-topology.test.mjs`** — diamond glitch-freedom, 256-deep and 1024-deep computed chains, wide fan-out (1000 effects from one signal), dynamic dependency switching, conditional fan-out, nested effects, cycle detection (`CycleError`).
|
|
408
|
+
- **`03-pool.test.mjs`** — `CapacityError` under both `"throw"` and `"grow"` policies, the 16× link ceiling, stable pool reuse across thousands of create/dispose cycles, registry isolation.
|
|
409
|
+
- **`05-scheduler.test.mjs`** — scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
|
|
410
|
+
- **`06-nested-objects.test.mjs`** — array mutation patterns (push/splice/spread), deep nested paths, Map/Set/Date inside signals, custom structural equality, computed memoisation cutoffs over object slices, signal-of-signals composition, high-frequency object updates, batched immutable updates.
|
|
411
|
+
- **`07-dispose.test.mjs`** — unified `dispose(api)` across signals, computeds and effect handles, idempotency, cross-registry isolation (per-registry Symbol prevents pool corruption), foreign-value safety, top-level helper routing, 500-cycle balanced churn leaving pool and stats stable.
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
npm test
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Tier 2 — Memory (allocation-free verification)
|
|
418
|
+
|
|
419
|
+
`npm run test:gc` runs `test/04-zero-gc.test.mjs` with `--expose-gc`:
|
|
420
|
+
|
|
421
|
+
- 100,000 `set()` calls on a graph with effects retain **< 200 KB** of heap.
|
|
422
|
+
- 1,000 create/dispose cycles retain **< 50 KB**.
|
|
423
|
+
- Batched writes do not increase retained heap monotonically.
|
|
424
|
+
- Deep-chain propagation through 256 nodes stays under a tight steady-state budget.
|
|
425
|
+
|
|
426
|
+
If these fail, something allocates in the hot path and we want to find it before publish.
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
npm run test:gc
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Tier 3 — Performance (comparative benchmark)
|
|
433
|
+
|
|
434
|
+
`npm run bench` runs the four-scenario comparative benchmark from the previous section. Output is plain text — easy to copy into PRs and changelogs.
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
npm run bench
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
A full pre-publish check is:
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
npm run verify # test + test:gc + a sanity bench
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## What this is not
|
|
449
|
+
|
|
450
|
+
- **A virtual DOM, JSX runtime, or rendering library.** It's the substrate. Plug it under whatever rendering layer you like.
|
|
451
|
+
- **A general-purpose state container.** No time-travel, no devtools integration, no serialization. (Build those on top if you need them.)
|
|
452
|
+
- **A perfect fit for every workload.** If your reactive graph is mostly long chains of memos with chaotic read order, `alien-signals` is genuinely faster on those shapes. `lite-signal` optimizes for *stable* read order — the same observer reading the same deps in the same order, frame after frame, which is the dominant pattern in animation loops and HUD overlays.
|
|
453
|
+
- **A library for the server.** It works in Node, but there's no SSR story. Use it on the client.
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Browser and runtime support
|
|
458
|
+
|
|
459
|
+
Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
|
|
460
|
+
|
|
461
|
+
| Target | Supported |
|
|
462
|
+
| --------------------------------- | --------- |
|
|
463
|
+
| Chrome / Edge (last 2 majors) | ✓ |
|
|
464
|
+
| Firefox (last 2 majors) | ✓ |
|
|
465
|
+
| Safari 14+ | ✓ |
|
|
466
|
+
| Node.js 18+ | ✓ |
|
|
467
|
+
| Bun | ✓ |
|
|
468
|
+
| Twitch Extensions (1MB / 3s) | ✓ |
|
|
469
|
+
| Cloudflare Workers | ✓ |
|
|
470
|
+
| Deno | ✓ |
|
|
471
|
+
|
|
472
|
+
ESM-only. No CommonJS build — modern bundlers handle this; legacy consumers can use a wrapper.
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
## Integration recipes
|
|
477
|
+
|
|
478
|
+
### Reactive game HUD with requestAnimationFrame
|
|
479
|
+
|
|
480
|
+
```js
|
|
481
|
+
import { signal, effect } from "@zakkster/lite-signal";
|
|
482
|
+
|
|
483
|
+
const score = signal(0);
|
|
484
|
+
const health = signal(100);
|
|
485
|
+
|
|
486
|
+
let frameRequested = false;
|
|
487
|
+
const rafScheduler = (run) => {
|
|
488
|
+
if (frameRequested) return;
|
|
489
|
+
frameRequested = true;
|
|
490
|
+
requestAnimationFrame(() => { frameRequested = false; run(); });
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
effect(() => {
|
|
494
|
+
hudCanvas.draw({ score: score(), health: health() });
|
|
495
|
+
}, { scheduler: rafScheduler });
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### Twitch Extension config sync
|
|
499
|
+
|
|
500
|
+
```js
|
|
501
|
+
import { signal, effect, batch } from "@zakkster/lite-signal";
|
|
502
|
+
|
|
503
|
+
const config = {
|
|
504
|
+
theme: signal("dark"),
|
|
505
|
+
rgbHue: signal(180),
|
|
506
|
+
showStats: signal(true)
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
Twitch.ext.configuration.onChanged(() => {
|
|
510
|
+
const cfg = JSON.parse(Twitch.ext.configuration.broadcaster?.content || "{}");
|
|
511
|
+
batch(() => {
|
|
512
|
+
if (cfg.theme) config.theme.set(cfg.theme);
|
|
513
|
+
if (cfg.rgbHue) config.rgbHue.set(cfg.rgbHue);
|
|
514
|
+
if (cfg.showStats !== undefined) config.showStats.set(cfg.showStats);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
effect(() => applyTheme(config.theme(), config.rgbHue()));
|
|
519
|
+
effect(() => statsPanel.toggle(config.showStats()));
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Per-tenant sandboxing
|
|
523
|
+
|
|
524
|
+
```js
|
|
525
|
+
import { createRegistry } from "@zakkster/lite-signal";
|
|
526
|
+
|
|
527
|
+
function spawnPlugin(pluginCode) {
|
|
528
|
+
const r = createRegistry({ maxNodes: 256, maxLinks: 1024 });
|
|
529
|
+
try {
|
|
530
|
+
pluginCode(r); // plugin uses r.signal, r.effect, etc.
|
|
531
|
+
} catch (err) {
|
|
532
|
+
console.error("Plugin failed:", err);
|
|
533
|
+
}
|
|
534
|
+
return () => r.destroy(); // unload kills the whole reactive world
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
540
|
+
## FAQ
|
|
541
|
+
|
|
542
|
+
**Why no microtask scheduler?**
|
|
543
|
+
Microtask schedulers solve a real problem (deduplicating multiple `set()`s into one effect run) but introduce a worse one: causal opacity. When `signal.set(x)` returns, you don't know whether your effect has run yet. `lite-signal` chooses synchronous flush + explicit `batch()` for the same deduplication outcome with predictable timing.
|
|
544
|
+
|
|
545
|
+
**Why both `nodes` and `links` capacities?**
|
|
546
|
+
A 1000-signal graph might have anywhere from 1000 to 1,000,000 edges depending on cross-dependencies. Tying them together would waste memory or under-provision. Separate caps let you size for your actual topology.
|
|
547
|
+
|
|
548
|
+
**Why `Object.is` and not `===`?**
|
|
549
|
+
Two reasons: `NaN !== NaN` would cause a `set(NaN)` followed by `set(NaN)` to re-fire effects (almost never what you want); and `-0 === +0` would silently merge signed zeros, which is a footgun in physics/animation code where the sign carries information.
|
|
550
|
+
|
|
551
|
+
**Will `destroy()` interrupt in-flight effects?**
|
|
552
|
+
Effects already on the call stack will finish their current invocation. Future scheduled runs (via `scheduler` option) become no-ops because their captured generation no longer matches the node's gen. Effects in the active queue but not yet executed are dropped.
|
|
553
|
+
|
|
554
|
+
**How do I integrate with React/Vue/Svelte?**
|
|
555
|
+
`signal.subscribe(callback)` is the integration surface. For React, wire it into `useSyncExternalStore`. For Vue, expose `signal()` as a getter. For Svelte, return `{ subscribe }` matching the store contract.
|
|
556
|
+
|
|
557
|
+
**Can I read a computed without subscribing?**
|
|
558
|
+
Yes — `computed.peek()` triggers re-evaluation if needed but doesn't add a dependency edge. `untrack(() => c())` is equivalent but slightly more expensive (it toggles a global flag).
|
|
559
|
+
|
|
560
|
+
**What happens if I `set()` from inside an effect's cleanup?**
|
|
561
|
+
The cleanup runs *before* the next computeFn body, so the set's notification arrives normally and propagates after the current flush pass. No special-case behavior — the queue handles it.
|
|
562
|
+
|
|
563
|
+
**Is the dep order stable across re-runs?**
|
|
564
|
+
Yes, if your computeFn reads its deps in the same order each invocation. The `currentDep` cursor walks the existing dep list and tries to match; matches reuse the existing link (zero alloc), mismatches insert/remove. Stable order = stable performance.
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
## npm scripts
|
|
569
|
+
|
|
570
|
+
```bash
|
|
571
|
+
npm test # behavior suite, ~1.3s
|
|
572
|
+
npm run test:gc # zero-gc suite, requires --expose-gc, ~3s
|
|
573
|
+
npm run bench # comparative benchmark, ~5min wall clock
|
|
574
|
+
npm run verify # all of the above; gate for publish
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
## License
|
|
580
|
+
|
|
581
|
+
MIT © Zahary Shinikchiev
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
> Part of the **@zakkster** zero-GC stack: [`lite-ecs`](https://www.npmjs.com/package/@zakkster/lite-ecs) · [`lite-ease`](https://www.npmjs.com/package/@zakkster/lite-ease) · [`lite-pointer-tracker`](https://www.npmjs.com/package/@zakkster/lite-pointer-tracker) · [`lite-bmfont`](https://www.npmjs.com/package/@zakkster/lite-bmfont) · [`lite-color`](https://www.npmjs.com/package/@zakkster/lite-color)
|