@zakkster/lite-signal 1.1.3 → 1.1.5
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/README.md +168 -39
- package/Signal.d.ts +57 -0
- package/Signal.js +320 -57
- package/Watch.js +4 -1
- package/llms.txt +69 -16
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
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
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
6
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
6
7
|

|
|
7
|
-
[](https://bundlephobia.com/result?p=@zakkster/lite-signal)
|
|
8
9
|
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
9
10
|
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
10
11
|

|
|
@@ -46,6 +47,7 @@ Synchronous, glitch-free, push-pull. No microtask queue, no allocations after wa
|
|
|
46
47
|
- [Benchmarks](#benchmarks)
|
|
47
48
|
- [Testing strategy](#testing-strategy)
|
|
48
49
|
- [What this is not](#what-this-is-not)
|
|
50
|
+
- [Ecosystem](#ecosystem)
|
|
49
51
|
- [Browser and runtime support](#browser-and-runtime-support)
|
|
50
52
|
- [Integration recipes](#integration-recipes)
|
|
51
53
|
- [Conformance](#conformance)
|
|
@@ -102,6 +104,9 @@ Full type definitions ship in [`Signal.d.ts`](./Signal.d.ts) and are referenced
|
|
|
102
104
|
|
|
103
105
|
## The case for object pooling
|
|
104
106
|
|
|
107
|
+
<details>
|
|
108
|
+
<summary>Why pre-allocate: the GC math, and the per-op zero-allocation table.</summary>
|
|
109
|
+
|
|
105
110
|
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.
|
|
106
111
|
|
|
107
112
|
`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:
|
|
@@ -116,10 +121,15 @@ A naive reactive library allocates one object per dependency edge, one per subsc
|
|
|
116
121
|
|
|
117
122
|
The free lists are singly-linked through a `nextFree` field on each pool object — `O(1)` pop, `O(1)` push, no fragmentation.
|
|
118
123
|
|
|
124
|
+
</details>
|
|
125
|
+
|
|
119
126
|
---
|
|
120
127
|
|
|
121
128
|
## Architecture in one diagram
|
|
122
129
|
|
|
130
|
+
<details>
|
|
131
|
+
<summary>Pools, the reactive graph, hot-path state, and the doubly-linked edge model.</summary>
|
|
132
|
+
|
|
123
133
|
```mermaid
|
|
124
134
|
flowchart TB
|
|
125
135
|
subgraph Pools[Pre-allocated object pools]
|
|
@@ -161,10 +171,15 @@ Every reactive entity is a `ReactiveNode` with bit flags (`COMPUTED`, `EFFECT`,
|
|
|
161
171
|
|
|
162
172
|
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.
|
|
163
173
|
|
|
174
|
+
</details>
|
|
175
|
+
|
|
164
176
|
---
|
|
165
177
|
|
|
166
178
|
## How a write propagates
|
|
167
179
|
|
|
180
|
+
<details>
|
|
181
|
+
<summary>The set → mark → flush sequence, and why computeds stay pull-based.</summary>
|
|
182
|
+
|
|
168
183
|
```mermaid
|
|
169
184
|
sequenceDiagram
|
|
170
185
|
participant U as User code
|
|
@@ -195,6 +210,8 @@ The flush phase uses **two queue buffers** (`effectQueueA` / `effectQueueB`) alt
|
|
|
195
210
|
|
|
196
211
|
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.
|
|
197
212
|
|
|
213
|
+
</details>
|
|
214
|
+
|
|
198
215
|
---
|
|
199
216
|
|
|
200
217
|
## API reference
|
|
@@ -296,6 +313,33 @@ Returns `true` iff a read right now would record a dependency on the current reg
|
|
|
296
313
|
|
|
297
314
|
For wrapper libraries (lite-store, lite-query, lite-form) gating lazy allocation on the read path. Per-registry — call `registry.isTracking()` if your signals live in a non-default registry.
|
|
298
315
|
|
|
316
|
+
### Observer-lifecycle introspection
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
// Start a ticker only while something is actually watching a derived value.
|
|
320
|
+
const now = signal(performance.now());
|
|
321
|
+
const unobserve = observeObservers(now, {
|
|
322
|
+
onConnect: () => startRAF(), // 0 → 1 observers
|
|
323
|
+
onDisconnect: () => stopRAF(), // 1 → 0 observers
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
hasObservers(now); // O(1): is anyone subscribed right now? (a peek doesn't count)
|
|
327
|
+
|
|
328
|
+
// Walk the live graph in either direction (lite-devtools):
|
|
329
|
+
forEachObserver(sum, d => console.log(d.kind, d.value)); // subscribers of `sum`
|
|
330
|
+
forEachSource(sum, d => console.log(d.kind, d.value)); // dependencies of `sum`
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Six functions (top-level + per-registry) — four in 1.1.4, two more in 1.1.5 — for auto-pausing wrappers and graph inspection:
|
|
334
|
+
|
|
335
|
+
- **`hasObservers(handle)` → `boolean`** — O(1) (`node.headSub !== null`). The auto-pause predicate.
|
|
336
|
+
- **`observeObservers(handle, { onConnect?, onDisconnect? })` → `unobserve`** — fires on the 0→1 and 1→0 observer transitions *after* registration (transition-only — no immediate fire if already observed). Re-tracking a persistently-read source does **not** churn. This is the hook `lite-time` / `lite-raf` use to run a clock only while a derived value is watched. Throws `TypeError` on a non-handle.
|
|
337
|
+
- **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`** — walk subscribers / dependencies; `fn` gets a `{ id, kind, value }` descriptor (`kind` ∈ `"signal" | "computed" | "effect"`; `id` added in 1.1.5). No-op on a non-handle.
|
|
338
|
+
- **`nodeId(handle)` → `number | undefined`** *(1.1.5)* — the node's stable per-allocation id; the dedupe key for graph traversal. `undefined` on a non-handle.
|
|
339
|
+
- **`describe(handle)` → `{ id, kind, value } | undefined`** *(1.1.5)* — the handle's own descriptor. **Re-walkable**: pass it back into `forEachObserver`/`forEachSource` to recurse the graph. `undefined` on a non-handle.
|
|
340
|
+
|
|
341
|
+
The surface is gated by an internal lifecycle counter: when nothing is being observed, the hot path adds a single branch-predicted `count !== 0` check in link alloc/free and nothing else — **zero steady-state cost when unused**.
|
|
342
|
+
|
|
299
343
|
### onCleanup
|
|
300
344
|
|
|
301
345
|
```ts
|
|
@@ -346,6 +390,9 @@ r.destroy(); // reset all pools, invalidate generations
|
|
|
346
390
|
|
|
347
391
|
## Capacity, growth, and the link ceiling
|
|
348
392
|
|
|
393
|
+
<details>
|
|
394
|
+
<summary>Pool sizing, the grow policy, and why there is a 16× link ceiling.</summary>
|
|
395
|
+
|
|
349
396
|
The engine has two pool sizes: **nodes** and **links**. Both are fixed at registry creation but can be configured to grow.
|
|
350
397
|
|
|
351
398
|
```mermaid
|
|
@@ -370,6 +417,8 @@ Default sizing for a Twitch-extension-style budget:
|
|
|
370
417
|
|
|
371
418
|
`stats()` reports `signals`, `computeds`, `effects`, `activeLinks`, `pooledLinks`, `linkPoolCapacity`. Drop it on screen for live observability.
|
|
372
419
|
|
|
420
|
+
</details>
|
|
421
|
+
|
|
373
422
|
---
|
|
374
423
|
|
|
375
424
|
## Watchers
|
|
@@ -505,6 +554,9 @@ All three primitives live in a separate module (`Watch.js`) and are re-exported
|
|
|
505
554
|
|
|
506
555
|
## Edge cases pinned down
|
|
507
556
|
|
|
557
|
+
<details>
|
|
558
|
+
<summary>Diamonds, self-feedback, NaN/±0, throwing bodies, 32-bit version wrap, deep-chain limits.</summary>
|
|
559
|
+
|
|
508
560
|
These are the questions you'd ask in a code review, with the answers:
|
|
509
561
|
|
|
510
562
|
- **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.
|
|
@@ -518,26 +570,29 @@ These are the questions you'd ask in a code review, with the answers:
|
|
|
518
570
|
- **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.
|
|
519
571
|
- **`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.
|
|
520
572
|
|
|
573
|
+
</details>
|
|
574
|
+
|
|
521
575
|
---
|
|
522
576
|
|
|
523
577
|
## Benchmarks
|
|
524
578
|
|
|
525
|
-
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 inner runs ×
|
|
579
|
+
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 inner runs × 11 outer invocations (median reported). Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters. Numbers below are lite-signal **@1.1.5** vs alien-signals on the same loop; the full five-framework comparison (incl. preact, vue-reactivity, solid across 34 reactive-suite tests) is in [`resultsReactive.txt`](./resultsReactive.txt). *(Both halves are now @1.1.5 — this throughput table median-of-11, the cross-framework reactivity suite median-of-12 in [`resultsReactive.txt`](./resultsReactive.txt). 1.1.5 is additive over 1.1.4 (node-identity only), so the engine numbers are perf-neutral vs 1.1.4.)*
|
|
526
580
|
|
|
527
|
-
| Scenario | What it stresses | lite-signal | alien-signals |
|
|
528
|
-
| ---------- | -------------------------------- | ----------- | ------------- |
|
|
529
|
-
| **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **
|
|
530
|
-
| **BROADCAST** | 1 signal → 1000 effects (fan-out)
|
|
531
|
-
| **KAIROS** | 1 signal → 1000 computeds → 1 effect | **
|
|
532
|
-
| **DEEP CHAIN** | 256-deep computed chain → 1 effect
|
|
533
|
-
|
|
|
534
|
-
|
|
|
581
|
+
| Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
|
|
582
|
+
| ---------- | -------------------------------- | ----------- | ------------- | ------------- |
|
|
583
|
+
| **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **314K ops/s** | 198K | **+59%** |
|
|
584
|
+
| **BROADCAST** | 1 signal → 1000 effects (fan-out) | **27K** | 22K | **+20%** |
|
|
585
|
+
| **KAIROS** | 1 signal → 1000 computeds → 1 effect | **15K** | 13K | **+19%** |
|
|
586
|
+
| **DEEP CHAIN** | 256-deep computed chain → 1 effect | 53K | **60K** | −12% |
|
|
587
|
+
| **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | **7K** | 7K | **+6%** ⬆ |
|
|
588
|
+
| **Δheap MUX** | transient alloc pressure, 20K iters | **0.3 KB** | 6,120 KB | — |
|
|
589
|
+
| **Retained MUX** | state surviving forced GC | **−7 KB** (none) | −1 KB | — |
|
|
535
590
|
|
|
536
|
-
**Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+
|
|
591
|
+
**Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+59%**, **BROADCAST** (fan-out) by **+20%**, and **KAIROS** (one source feeding a wide layer of memos) by **+19%** — the patterns that dominate real UI workloads: dashboards, scoreboards, HUDs, leaderboards, and any view that aggregates many inputs into a single computed slice. The **WIDE DENSE** row (⬆) is the headline of 1.1.4: at 1.1.2 lite-signal *lost* this dense, high-fan-in shape by −16%; the 1.1.4 retracking rewrite flips it to **+6%**. `alien-signals` now retains only a **−12% lead on DEEP CHAIN** (256-deep computed pipelines), where a flatter internal representation pays off when the propagation path is long rather than wide.
|
|
537
592
|
|
|
538
|
-
On allocation pressure, `lite-signal` is alone in the zero-Δheap band: ~
|
|
593
|
+
On allocation pressure, `lite-signal` is alone in the zero-Δheap band: ~0.3 KB of transient garbage on stable shapes across 20,000 iterations. The contrast is starkest on SMALL SELECTIVE — lite-signal 0.3 KB vs alien-signals **~15 MB** in the same loop. preact ranges from ~220 KB to low-single-digit MB per loop, solid runs into single-digit megabytes, and alien-signals — which earlier shared the zero-GC band with lite-signal — allocates from near-zero (BROADCAST) up to ~6 MB (MUX) per stable scenario in this run. Negative "retained" numbers mean V8 reclaimed memory below the pre-bench baseline during the post-run forced GC — no leaks anywhere.
|
|
539
594
|
|
|
540
|
-
> Note on the +
|
|
595
|
+
> Note on the +70.8 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.
|
|
541
596
|
|
|
542
597
|
The benchmark harness is in [`bench/benchmark.mjs`](./bench/benchmark.mjs); a full methodology write-up — including the anti-DCE design, workload diagrams, variance discipline, reproducibility recipe, and a self-validation procedure for the harness itself — lives in [`bench/README.md`](./bench/README.md). It:
|
|
543
598
|
|
|
@@ -562,15 +617,23 @@ Three tiers, all reproducible.
|
|
|
562
617
|
|
|
563
618
|
`npm test` runs the suite in `test/`, covering:
|
|
564
619
|
|
|
565
|
-
- **`01-
|
|
566
|
-
- **`02-
|
|
567
|
-
- **`03-
|
|
568
|
-
- **`05-
|
|
569
|
-
- **`06-nested-
|
|
570
|
-
- **`07-
|
|
571
|
-
- **`08-
|
|
572
|
-
- **`09-
|
|
573
|
-
- **`10-is-
|
|
620
|
+
- **`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.
|
|
621
|
+
- **`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`).
|
|
622
|
+
- **`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.
|
|
623
|
+
- **`05-scheduler_test.mjs`** — scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
|
|
624
|
+
- **`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.
|
|
625
|
+
- **`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.
|
|
626
|
+
- **`08-watch_test.mjs`** — Validates the user-land observer utilities (watch, when, whenAsync). Covers lifecycle teardown, old/new value tracking, and Promise-based asynchronous state resolution.
|
|
627
|
+
- **`09-conformance_test.mjs`** — Industry-standard conformance tests. Validates the engine against extreme edge cases from the johnsoncodehk reactive test suite, ensuring strict zero-GC invariants, correct cleanup isolation, and re-entrant stability.
|
|
628
|
+
- **`10-is-tracking_test.mjs`** — The `isTracking()` observer-context predicate. 11 tests across 5 describe blocks: true inside effect/computed bodies; false inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and `watch` callbacks (the untracked-window cases that catch an observer-only misimplementation); false outside any observer including at the call site of an unobserved computed read; state-restoration after a thrown body; per-registry isolation; top-level binding.
|
|
629
|
+
- **`11-adopted-reactive_test.mjs`** — 24 engine-agnostic edge cases adopted from across the ecosystem: alien-signals' parent-child link-integrity regression (#226–228), equality-predicate corners (preact/solid/vue), `signal.update(fn)` functional setter (vue/solid), `peek()` non-subscription depth (preact/vue), and the `subscribe` behavioral contract (preact/mobx).
|
|
630
|
+
- **`12-coverage_test.mjs`** — 18 targeted exercises for public surface and hot-path branches the behavioral suites don't incidentally hit: top-level routing to the default registry, the computed clean-read short-circuit (`markEpoch` O(1) skip), dependency-set shrink severing the stale tail, error/structural edge paths, and scheduler ABA across a recycled pool slot. The owner-tree block is capability-gated (runs on 1.2.0+, skipped on 1.1.x).
|
|
631
|
+
- **`13-introspection_test.mjs`** — The observer-lifecycle surface (1.1.4). 10 tests across 3 describe blocks: `hasObservers` (live observation reflects; a peek doesn't count), `observeObservers` auto-pause lifecycle (start-on-first / stop-on-last, no extra connect for a 2nd observer, re-observe fires again, no churn on re-track, conditional reads toggle honestly, transition-only registration, works for computeds), and `forEachObserver`/`forEachSource` enumeration (both directions; descriptor carries kind + value).
|
|
632
|
+
- **`14-lifecycle-teardown_test.mjs`** — Effect-teardown guards against the alien-signals@3.2.1 regressions (4 tests). A stopped effect must not re-subscribe to a signal read later in the same run; self-dispose must leave no orphaned link (clean `activeLinks`); a throwing setup must leave no live subscription; normal and dynamic re-tracking stay unaffected by the `allocateLink` eligibility gate.
|
|
633
|
+
- **`15-owner-lazy-alloc_test.mjs`** — Owner-adoption contract for the 1.2.0 owner tree (4 tests, capability-gated — skipped on 1.1.x). A signal allocated lazily *inside* a computed/effect must **not** be owner-adopted (it survives the owner's re-run — the lite-store/lite-form lazy-field shape) and sibling lazy signals must not cross-wire, while observers *are* still auto-disposed. Runs on 1.2.0+.
|
|
634
|
+
- **`16-alien-parity_test.mjs`** — Differential regression guards (3 tests) reproducing the *properties* behind alien-signals@3.2.0 fixed bugs: reads inside a cleanup create no spurious dependencies (the dispose-cleanup fix); an inner-effect write does not block later propagation through a computed chain (#112); a dynamic dependency-set change stays correct under dirty-check (#109/#110).
|
|
635
|
+
- **`17-reactivity_test.mjs`** — Behavioral suite (≈30 tests across 11 groups) mirroring universal signal-system bug classes: subscription lifecycle, cleanup ordering, stale-dependency tracking, batching/timing (incl. set-then-revert), equality cutoff (NaN/±0/custom), nested invalidation + glitch-free diamond, memory/retained nodes, the synchronous async-boundary, scheduler & loops (self-write termination, self-reading computed), and differential-review additions (cached computed errors, mid-batch pull, self-disposing getter, pooled-slot return). SSR hydration is a documented N/A — lite has no DOM layer.
|
|
636
|
+
- **`18-identity_test.mjs`** — Node identity (1.1.5; 5 tests). Unique/stable ids; `nodeId`/`describe` return `undefined` for a non-handle; the descriptor's visible shape is `{ id, kind, value }`; `forEach*` descriptors carry `id` and are **re-walkable** (`nodeId`/`forEachSource` accept a descriptor); identity walks are non-perturbing (add no observers).
|
|
574
637
|
|
|
575
638
|
```bash
|
|
576
639
|
npm test
|
|
@@ -578,7 +641,7 @@ npm test
|
|
|
578
641
|
|
|
579
642
|
### Tier 2 — Memory (allocation-free verification)
|
|
580
643
|
|
|
581
|
-
`npm run test:gc` runs `test/04-zero-
|
|
644
|
+
`npm run test:gc` runs `test/04-zero-gc_test.mjs` with `--expose-gc`:
|
|
582
645
|
|
|
583
646
|
- 100,000 `set()` calls on a graph with effects retain **< 200 KB** of heap.
|
|
584
647
|
- 1,000 create/dispose cycles retain **< 50 KB**.
|
|
@@ -593,7 +656,7 @@ npm run test:gc
|
|
|
593
656
|
|
|
594
657
|
### Tier 3 — Performance (comparative benchmark)
|
|
595
658
|
|
|
596
|
-
`npm run bench` runs the
|
|
659
|
+
`npm run bench` runs the comparative benchmark (9 scenarios — stable fan-in/out + dynamic/layered DAGs) against alien-signals (results.txt). `npm run bench-reactive` runs the cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, and solid (resultsReactive.txt). Output is plain text — easy to copy into PRs and changelogs.
|
|
597
660
|
|
|
598
661
|
```bash
|
|
599
662
|
npm run bench
|
|
@@ -609,29 +672,47 @@ npm run verify # test + test:gc + a sanity bench
|
|
|
609
672
|
|
|
610
673
|
## Performance Trade-offs & Topology Scaling
|
|
611
674
|
|
|
675
|
+
<details>
|
|
676
|
+
<summary>Stable vs dynamic topologies; Andrii Volynets' matrix, the 1.1.4 result, and the roadmap.</summary>
|
|
677
|
+
|
|
612
678
|
`lite-signal` was built with a strict mandate: **absolute zero garbage collection**. By packing the dependency graph into a flat, pre-allocated memory arena, we eliminate the Scavenger GC pauses that plague 120fps Canvas/WebGL loops.
|
|
613
679
|
|
|
614
|
-
|
|
680
|
+
Through **v1.1.2**, that came with a mathematical trade-off: while memory allocation is $O(1)$, the cursor-based retracking degraded to $O(N)$ linear scans under chaotic, high-fan-in, batched read-after-write — the shape of large DOM-style apps with heavy branch switching. **v1.1.4 closed that gap.** A version-stamped $O(1)$ reconciliation plus a `markEpoch` clean-read short-circuit on the pull replaced the cursor degradation; stable read order is unchanged (still $O(1)$, still zero-alloc).
|
|
615
681
|
|
|
616
|
-
**Andrii Volynets** (author of the phenomenal [Alien Signals](https://github.com/stackblitz/alien-signals)) generously ran `lite-signal` through his advanced topology matrix.
|
|
682
|
+
**Andrii Volynets** (author of the phenomenal [Alien Signals](https://github.com/stackblitz/alien-signals)) generously ran `lite-signal` through his advanced topology matrix on the **v1.1.2** engine. Those numbers — the *pre-rewrite baseline* — are below, followed by the 1.1.4 result.
|
|
617
683
|
|
|
618
684
|
#### 1. Stable Topologies (Fan-in / Fan-out / Broadcast)
|
|
619
|
-
In stable environments (
|
|
620
|
-
|
|
621
|
-
#### 2. Dynamic Topologies (Web Apps / Layered DAGs)
|
|
622
|
-
In highly chaotic graphs with branch switching, selective reads, and wide dense churn (typical of large DOM-based web frameworks like Vue or React), the $O(N)$ edge traversal cost of flat arrays becomes the dominant bottleneck.
|
|
685
|
+
In stable environments (game engines, particle systems, visualizers), `lite-signal` is blisteringly fast and maintains a near-zero allocation profile, keeping frame times perfectly flat — unchanged through 1.1.4.
|
|
623
686
|
|
|
624
|
-
|
|
625
|
-
|
|
687
|
+
#### 2. Dynamic Topologies (Web Apps / Layered DAGs) — closed in 1.1.4
|
|
688
|
+
*Andrii's v1.1.2 baseline (his host) — where the cursor retracking lost:*
|
|
689
|
+
| Scenario | alien-signals | reflex | lite-signal (1.1.2) |
|
|
626
690
|
| :--- | :--- | :--- | :--- |
|
|
627
691
|
| **1000x12 (4 sources, dynamic)** | 184ms | 194ms | 2031ms |
|
|
628
692
|
| **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
|
|
629
693
|
| **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
|
|
630
694
|
|
|
631
|
-
|
|
695
|
+
*1.1.5 on the local harness (slow 2016 MacBook — compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
|
|
696
|
+
| Scenario | alien-signals | lite-signal (1.1.5) | result |
|
|
697
|
+
| :--- | :--- | :--- | :--- |
|
|
698
|
+
| **LARGE WEB APP** (≈ 1000x12) | 2846ms | 2686ms | **lite +6%** |
|
|
699
|
+
| **WIDE DENSE** (≈ 1000x5) | 2826ms | 2671ms | **lite +6%** |
|
|
700
|
+
| **SMALL SELECTIVE** (≈ 64x6) | 2000ms | 1857ms | **lite +8%** |
|
|
701
|
+
|
|
702
|
+
> **Honest note (1.1.5 run):** alien-signals is much faster on these three shapes than in the 1.1.4 run above (SMALL SELECTIVE 2985ms → 2000ms, LARGE WEB APP 3026ms → 2846ms) — a newer alien build and/or host conditions — so lite-signal's margins narrowed from +60%/+11%/+6% to **+8%/+6%/+6%**. Within this run lite still leads all three and remains the only zero-Δheap library (see [`results.txt`](./results.txt)).
|
|
703
|
+
|
|
704
|
+
The cross-framework reactivity suite agrees independently and was re-run on **1.1.5** (median-of-12): `dyn: large web app` **555ms** (+6% vs alien-signals' 590ms) and `dyn: wide dense` **870ms** (+7% vs 933ms) are wins there too — lite-signal is the fastest of five frameworks on both, with preact and vue ~7–30× slower (see [`resultsReactive.txt`](./resultsReactive.txt)). The retracking is verified correct by `retracking.difftest.mjs` — 20,000 direct + 10,000 batched writes, 0 disagreements. *(A fresh run of Andrii's exact matrix on 1.1.5 is the definitive external confirmation and is pending; the two local suites above are the current evidence.)*
|
|
705
|
+
|
|
706
|
+
**The Takeaway:** as of 1.1.4 you no longer have to choose. `lite-signal` keeps the zero-GC, flat-arena profile for 120fps Canvas/WebGL **and** holds parity-or-ahead of alien-signals on dynamic, high-fan-in web-app topologies. The one shape where alien's flatter representation still leads is the 256-deep computed pipeline (DEEP CHAIN, −12%).
|
|
707
|
+
|
|
708
|
+
### Roadmap
|
|
709
|
+
- **1.1.5** — additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering).
|
|
710
|
+
- **1.2.0** — the **ownership hybrid**: an owner tree so nested effects/computeds auto-dispose with their parent (closes conformance #209 / #210, matching Solid's `createRoot` ergonomics).
|
|
711
|
+
- **1.3** — next engine work after the owner-tree validation.
|
|
712
|
+
|
|
713
|
+
> Note: the retracking rewrite that closes the dynamic-topology gap shipped in **1.1.4**, not a future release. The earlier roadmap that listed it under "v1.2" is superseded.
|
|
632
714
|
|
|
633
|
-
|
|
634
|
-
v1.2 (in benchmark validation) — Andrii Volynets (alien-signals#117) extended the benchmark matrix to dynamic-topology workloads and identified retracking-cost asymptotics, not allocation pressure, as the dominant cost in those scenarios. v1.2 replaces the cursor-based retracking with per-source version-stamped reconciliation: O(1) per read regardless of read order or dep-set churn. v1.2 will be validated against the alien-signals topology matrix and the before/after numbers published on release.
|
|
715
|
+
</details>
|
|
635
716
|
|
|
636
717
|
---
|
|
637
718
|
|
|
@@ -639,13 +720,39 @@ v1.2 (in benchmark validation) — Andrii Volynets (alien-signals#117) extended
|
|
|
639
720
|
|
|
640
721
|
- **A virtual DOM, JSX runtime, or rendering library.** It's the substrate. Plug it under whatever rendering layer you like.
|
|
641
722
|
- **A general-purpose state container.** No time-travel, no devtools integration, no serialization. (Build those on top if you need them.)
|
|
642
|
-
- **A perfect fit for every workload.**
|
|
723
|
+
- **A perfect fit for every workload.** On *256-deep computed pipelines* (DEEP CHAIN) `alien-signals` is still a bit faster — its flatter representation pays off when the propagation path is long rather than wide. (Through 1.1.2 this caveat also covered chaotic, high-fan-in read order; 1.1.4's retracking rewrite closed that — those shapes are now parity-or-ahead.) `lite-signal` is at its best on the fan-in / fan-out / wide-memo and dynamic-churn patterns that dominate animation loops, HUDs, and dashboards.
|
|
643
724
|
- **A library for the server.** It works in Node, but there's no SSR story. Use it on the client.
|
|
644
725
|
|
|
645
726
|
---
|
|
646
727
|
|
|
728
|
+
## Ecosystem
|
|
729
|
+
|
|
730
|
+
A growing family of zero-GC, ESM-only, sub-2KB packages built on `lite-signal`. All MIT, all by [@zakkster](https://www.npmjs.com/~zakkster).
|
|
731
|
+
|
|
732
|
+
**State & data**
|
|
733
|
+
- [`@zakkster/lite-store`](https://www.npmjs.com/package/@zakkster/lite-store) — Fine-grained reactivity for objects & arrays via Proxy. Direct mutation; lazy per-key signals (allocated only on first tracked read); proxy identity preserved across reads; cycle-safe disposal walk.
|
|
734
|
+
- [`@zakkster/lite-resource`](https://www.npmjs.com/package/@zakkster/lite-resource) — Async state as a signal. `resource(source, fetcher)` exposes data/error/loading/state with race-safe commits (generation guard), AbortSignal, stale-while-revalidate, and optimistic mutate.
|
|
735
|
+
- [`@zakkster/lite-form`](https://www.npmjs.com/package/@zakkster/lite-form) — Headless reactive forms. One validator per keystroke, hoisted Zod/Yup schema, ~1.5M keystrokes/sec on a 100-field form (8× the hand-written pattern). No DOM, no VDOM, no compiler.
|
|
736
|
+
- [`@zakkster/lite-router`](https://www.npmjs.com/package/@zakkster/lite-router) — Zero-GC sub-2KB SPA router. URL pathname, query params, and route matches as fine-grained signals — components re-render only when their slice of the URL changes.
|
|
737
|
+
- [`@zakkster/lite-persist`](https://www.npmjs.com/package/@zakkster/lite-persist) — Zero-GC reactive persistence. Debounced, coalesced localStorage/sessionStorage sync with cross-tab mirroring — a burst of writes becomes one storage write.
|
|
738
|
+
- [`@zakkster/lite-channel`](https://www.npmjs.com/package/@zakkster/lite-channel) — Cross-tab synchronization over BroadcastChannel. Multiplexed per-key sync, last-writer-wins (Lamport clock + tab-id tiebreak), reactive presence (peers, status, leader election as signals).
|
|
739
|
+
|
|
740
|
+
**Rendering (DOM / Canvas)**
|
|
741
|
+
- [`@zakkster/lite-element`](https://www.npmjs.com/package/@zakkster/lite-element) — Zero-GC reactive Custom Elements, no virtual DOM or templating. Component state survives synchronous reparents (sort, drag-and-drop, `insertBefore`) — the moves that destroy React, Vue, and Lit components.
|
|
742
|
+
- [`@zakkster/lite-virtual`](https://www.npmjs.com/package/@zakkster/lite-virtual) — Thrash-free list/grid windowing. Integer-gated reactive indices + `Object.is` cutoff means scrolling within a row writes zero bytes to the DOM. ~3.6M sub-row scrolls/sec, bounded pool regardless of count, fixed and variable heights, 2-D grid.
|
|
743
|
+
- [`@zakkster/lite-scene`](https://www.npmjs.com/package/@zakkster/lite-scene) — Reactive retained-mode Canvas2D scene graph. Nodes (group/rect/circle/line/text/image/path) take signals as props; the renderer redraws only what changed. Hit testing, clip groups, pointerEvents, nested transforms.
|
|
744
|
+
|
|
745
|
+
**Time & scheduling**
|
|
746
|
+
- [`@zakkster/lite-raf`](https://www.npmjs.com/package/@zakkster/lite-raf) — Zero-GC frame-rate scheduling. One `requestAnimationFrame` loop; frameTime/frameDelta/frameCount as signals; `rafEffect()` — reactive effects that run at most once per frame. Built for canvas/WebGL render loops and games.
|
|
747
|
+
- [`@zakkster/lite-time`](https://www.npmjs.com/package/@zakkster/lite-time) — Reactive, drift-corrected wall-clock cadence. One 1s heartbeat; zero-GC relativeTime/countdown/every; deterministic for tests and SSR. Not a date library — `Intl` does formatting, you bring the dates.
|
|
748
|
+
|
|
749
|
+
---
|
|
750
|
+
|
|
647
751
|
## Browser and runtime support
|
|
648
752
|
|
|
753
|
+
<details>
|
|
754
|
+
<summary>Support matrix (Chrome / Firefox / Safari / Node / Bun / Deno / Workers).</summary>
|
|
755
|
+
|
|
649
756
|
Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
|
|
650
757
|
|
|
651
758
|
| Target | Supported |
|
|
@@ -661,10 +768,15 @@ Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScri
|
|
|
661
768
|
|
|
662
769
|
ESM-only. No CommonJS build — modern bundlers handle this; legacy consumers can use a wrapper.
|
|
663
770
|
|
|
771
|
+
</details>
|
|
772
|
+
|
|
664
773
|
---
|
|
665
774
|
|
|
666
775
|
## Integration recipes
|
|
667
776
|
|
|
777
|
+
<details>
|
|
778
|
+
<summary>Game HUD (rAF), Twitch config sync, per-tenant sandboxing.</summary>
|
|
779
|
+
|
|
668
780
|
### Reactive game HUD with requestAnimationFrame
|
|
669
781
|
|
|
670
782
|
```js
|
|
@@ -725,16 +837,21 @@ function spawnPlugin(pluginCode) {
|
|
|
725
837
|
}
|
|
726
838
|
```
|
|
727
839
|
|
|
840
|
+
</details>
|
|
841
|
+
|
|
728
842
|
---
|
|
729
843
|
|
|
730
844
|
## Conformance
|
|
731
845
|
|
|
846
|
+
<details>
|
|
847
|
+
<summary>174/177 on the reactive-framework-test-suite; what lite-signal does and doesn't, by intent.</summary>
|
|
848
|
+
|
|
732
849
|
lite-signal is evaluated against the
|
|
733
850
|
[reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
|
|
734
851
|
the most comprehensive behavioral test battery for JavaScript reactive
|
|
735
852
|
libraries.
|
|
736
853
|
|
|
737
|
-
As of **v1.1.2
|
|
854
|
+
As of **v1.1.2** — and unchanged through **1.1.4** (the 1.1.4 retracking rewrite is verified behavior-preserving by `retracking.difftest.mjs`: 20,000 direct + 10,000 batched writes, 0 disagreements) — the conformance items that were open at v1.1.0 — batch revert
|
|
738
855
|
detection (#123 / #132 / #147), throw isolation in flush (#121), and
|
|
739
856
|
inner-write propagation through computed chains (#180 / #213) — are **closed**
|
|
740
857
|
(landed in v1.1.1). The remaining gaps are one deliberate design choice (#179,
|
|
@@ -763,6 +880,10 @@ more useful to library users than a green checkmark.
|
|
|
763
880
|
and most others by one re-evaluation per write.
|
|
764
881
|
- **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
|
|
765
882
|
solid. Half the field leaks the subscription.
|
|
883
|
+
- **Observer-lifecycle introspection** (`hasObservers` / `observeObservers`,
|
|
884
|
+
1.1.4): the 0→1 and 1→0 observer transitions are first-class, zero-cost-when-
|
|
885
|
+
unused hooks — the basis for auto-pausing a clock or RAF loop only while a
|
|
886
|
+
derived value is watched. Few signal libraries expose this.
|
|
766
887
|
|
|
767
888
|
### What lite-signal does NOT do yet
|
|
768
889
|
|
|
@@ -790,10 +911,15 @@ The remaining open items, by intent.
|
|
|
790
911
|
Per-test results, the runner adapter, and reproductions live in
|
|
791
912
|
`/conformance/`.
|
|
792
913
|
|
|
914
|
+
</details>
|
|
915
|
+
|
|
793
916
|
---
|
|
794
917
|
|
|
795
918
|
## FAQ
|
|
796
919
|
|
|
920
|
+
<details>
|
|
921
|
+
<summary>Microtasks, dual capacities, Object.is, destroy(), framework integration, dep-order stability.</summary>
|
|
922
|
+
|
|
797
923
|
**Why no microtask scheduler?**
|
|
798
924
|
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.
|
|
799
925
|
|
|
@@ -818,6 +944,8 @@ The cleanup runs *before* the next computeFn body, so the set's notification arr
|
|
|
818
944
|
**Is the dep order stable across re-runs?**
|
|
819
945
|
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.
|
|
820
946
|
|
|
947
|
+
</details>
|
|
948
|
+
|
|
821
949
|
---
|
|
822
950
|
|
|
823
951
|
## npm scripts
|
|
@@ -825,8 +953,9 @@ Yes, if your computeFn reads its deps in the same order each invocation. The `cu
|
|
|
825
953
|
```bash
|
|
826
954
|
npm test # behavior suite, ~1.3s
|
|
827
955
|
npm run test:gc # zero-gc suite, requires --expose-gc, ~3s
|
|
828
|
-
npm run bench # comparative benchmark, ~5min
|
|
829
|
-
npm run
|
|
956
|
+
npm run bench # comparative benchmark vs alien-signals (results.txt), ~5min
|
|
957
|
+
npm run bench-reactive # 5-framework reactivity suite (resultsReactive.txt)
|
|
958
|
+
npm run verify # test + test:gc + sanity bench; gate for publish
|
|
830
959
|
```
|
|
831
960
|
|
|
832
961
|
---
|
package/Signal.d.ts
CHANGED
|
@@ -92,6 +92,35 @@ export interface RegistryStats {
|
|
|
92
92
|
activeNodes: number;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// ─── Observer-lifecycle introspection (1.1.4) ─────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** Whether a described node is a signal, a computed, or an effect. */
|
|
98
|
+
export type NodeKind = "signal" | "computed" | "effect";
|
|
99
|
+
|
|
100
|
+
/** Non-perturbing snapshot of a graph neighbour yielded by the `forEach*` walkers. */
|
|
101
|
+
export interface NodeDescriptor {
|
|
102
|
+
/** Stable per-allocation node id (1.1.5+). Dedupe key for graph traversal, and the
|
|
103
|
+
* re-walk handle: a descriptor may be passed back into forEachObserver/forEachSource. */
|
|
104
|
+
id: number;
|
|
105
|
+
kind: NodeKind;
|
|
106
|
+
/** The node's current value (last stored/computed value; an effect's is its body return). */
|
|
107
|
+
value: unknown;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Transition callbacks for {@link Registry.observeObservers}. */
|
|
111
|
+
export interface ObserveObserversHooks {
|
|
112
|
+
/** Fired on the 0→1 observer transition (after registration). */
|
|
113
|
+
onConnect?: () => void;
|
|
114
|
+
/** Fired on the 1→0 observer transition. */
|
|
115
|
+
onDisconnect?: () => void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Stops an {@link Registry.observeObservers} subscription. Idempotent. */
|
|
119
|
+
export type Unobserve = () => void;
|
|
120
|
+
|
|
121
|
+
/** Anything carrying a node identity that the introspection surface can read. */
|
|
122
|
+
export type ReactiveHandle = Signal<any> | Computed<any>;
|
|
123
|
+
|
|
95
124
|
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
96
125
|
|
|
97
126
|
/** Thrown when a pool ceiling is hit. */
|
|
@@ -137,6 +166,22 @@ export interface Registry {
|
|
|
137
166
|
* False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and
|
|
138
167
|
* outside any observer. Use for lazy-allocation wrappers like lite-store. */
|
|
139
168
|
isTracking(): boolean;
|
|
169
|
+
/** O(1): does this source have at least one live observer right now? A `peek` does not count. */
|
|
170
|
+
hasObservers(handle: ReactiveHandle): boolean;
|
|
171
|
+
/** Auto-pause hook: fires `onConnect` on the 0→1 observer transition and `onDisconnect`
|
|
172
|
+
* on 1→0, after registration (transition-only — no immediate fire if already observed).
|
|
173
|
+
* Re-tracking a persistently-read source does not churn. Returns an idempotent unobserve.
|
|
174
|
+
* @throws TypeError if `handle` is not a reactive handle. */
|
|
175
|
+
observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
|
|
176
|
+
/** Walk the observers (subscribers) of `handle`, newest-first. No-op on a non-handle. */
|
|
177
|
+
forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
178
|
+
/** Walk the sources (dependencies) of `handle`. No-op on a non-handle. */
|
|
179
|
+
forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
180
|
+
/** Stable node id of `handle` (1.1.5+), or undefined for a non-handle. */
|
|
181
|
+
nodeId(handle: ReactiveHandle): number | undefined;
|
|
182
|
+
/** The own descriptor of `handle` (1.1.5+), or undefined for a non-handle. Re-walkable:
|
|
183
|
+
* the returned descriptor may be passed back into forEachObserver/forEachSource. */
|
|
184
|
+
describe(handle: ReactiveHandle): NodeDescriptor | undefined;
|
|
140
185
|
onCleanup(fn: () => void): void;
|
|
141
186
|
stats(): RegistryStats;
|
|
142
187
|
/** Reset everything: nodes, links, queues, global clock. Outstanding dispose
|
|
@@ -169,6 +214,18 @@ export function batch<T>(fn: () => T): T;
|
|
|
169
214
|
export function untrack<T>(fn: () => T): T;
|
|
170
215
|
/** Top-level binding of {@link Registry.isTracking} against the default registry. */
|
|
171
216
|
export function isTracking(): boolean;
|
|
217
|
+
/** Top-level binding of {@link Registry.hasObservers}. */
|
|
218
|
+
export function hasObservers(handle: ReactiveHandle): boolean;
|
|
219
|
+
/** Top-level binding of {@link Registry.observeObservers}. */
|
|
220
|
+
export function observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
|
|
221
|
+
/** Top-level binding of {@link Registry.forEachObserver}. */
|
|
222
|
+
export function forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
223
|
+
/** Top-level binding of {@link Registry.forEachSource}. */
|
|
224
|
+
export function forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
225
|
+
/** Top-level binding of {@link Registry.nodeId}. */
|
|
226
|
+
export function nodeId(handle: ReactiveHandle): number | undefined;
|
|
227
|
+
/** Top-level binding of {@link Registry.describe}. */
|
|
228
|
+
export function describe(handle: ReactiveHandle): NodeDescriptor | undefined;
|
|
172
229
|
export function onCleanup(fn: () => void): void;
|
|
173
230
|
export function stats(): RegistryStats;
|
|
174
231
|
export declare function destroy(): void;
|