@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/README.md
CHANGED
|
@@ -104,6 +104,9 @@ Full type definitions ship in [`Signal.d.ts`](./Signal.d.ts) and are referenced
|
|
|
104
104
|
|
|
105
105
|
## The case for object pooling
|
|
106
106
|
|
|
107
|
+
<details>
|
|
108
|
+
<summary>Why pre-allocate: the GC math, and the per-op zero-allocation table.</summary>
|
|
109
|
+
|
|
107
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.
|
|
108
111
|
|
|
109
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:
|
|
@@ -118,10 +121,15 @@ A naive reactive library allocates one object per dependency edge, one per subsc
|
|
|
118
121
|
|
|
119
122
|
The free lists are singly-linked through a `nextFree` field on each pool object — `O(1)` pop, `O(1)` push, no fragmentation.
|
|
120
123
|
|
|
124
|
+
</details>
|
|
125
|
+
|
|
121
126
|
---
|
|
122
127
|
|
|
123
128
|
## Architecture in one diagram
|
|
124
129
|
|
|
130
|
+
<details>
|
|
131
|
+
<summary>Pools, the reactive graph, hot-path state, and the doubly-linked edge model.</summary>
|
|
132
|
+
|
|
125
133
|
```mermaid
|
|
126
134
|
flowchart TB
|
|
127
135
|
subgraph Pools[Pre-allocated object pools]
|
|
@@ -163,10 +171,15 @@ Every reactive entity is a `ReactiveNode` with bit flags (`COMPUTED`, `EFFECT`,
|
|
|
163
171
|
|
|
164
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.
|
|
165
173
|
|
|
174
|
+
</details>
|
|
175
|
+
|
|
166
176
|
---
|
|
167
177
|
|
|
168
178
|
## How a write propagates
|
|
169
179
|
|
|
180
|
+
<details>
|
|
181
|
+
<summary>The set → mark → flush sequence, and why computeds stay pull-based.</summary>
|
|
182
|
+
|
|
170
183
|
```mermaid
|
|
171
184
|
sequenceDiagram
|
|
172
185
|
participant U as User code
|
|
@@ -197,6 +210,8 @@ The flush phase uses **two queue buffers** (`effectQueueA` / `effectQueueB`) alt
|
|
|
197
210
|
|
|
198
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.
|
|
199
212
|
|
|
213
|
+
</details>
|
|
214
|
+
|
|
200
215
|
---
|
|
201
216
|
|
|
202
217
|
## API reference
|
|
@@ -315,11 +330,13 @@ forEachObserver(sum, d => console.log(d.kind, d.value)); // subscribers of `sum
|
|
|
315
330
|
forEachSource(sum, d => console.log(d.kind, d.value)); // dependencies of `sum`
|
|
316
331
|
```
|
|
317
332
|
|
|
318
|
-
|
|
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:
|
|
319
334
|
|
|
320
335
|
- **`hasObservers(handle)` → `boolean`** — O(1) (`node.headSub !== null`). The auto-pause predicate.
|
|
321
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.
|
|
322
|
-
- **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`** — walk subscribers / dependencies; `fn` gets a `{ kind, value }` descriptor (`kind` ∈ `"signal" | "computed" | "effect"`). No-op 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.
|
|
323
340
|
|
|
324
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**.
|
|
325
342
|
|
|
@@ -373,6 +390,9 @@ r.destroy(); // reset all pools, invalidate generations
|
|
|
373
390
|
|
|
374
391
|
## Capacity, growth, and the link ceiling
|
|
375
392
|
|
|
393
|
+
<details>
|
|
394
|
+
<summary>Pool sizing, the grow policy, and why there is a 16× link ceiling.</summary>
|
|
395
|
+
|
|
376
396
|
The engine has two pool sizes: **nodes** and **links**. Both are fixed at registry creation but can be configured to grow.
|
|
377
397
|
|
|
378
398
|
```mermaid
|
|
@@ -397,6 +417,8 @@ Default sizing for a Twitch-extension-style budget:
|
|
|
397
417
|
|
|
398
418
|
`stats()` reports `signals`, `computeds`, `effects`, `activeLinks`, `pooledLinks`, `linkPoolCapacity`. Drop it on screen for live observability.
|
|
399
419
|
|
|
420
|
+
</details>
|
|
421
|
+
|
|
400
422
|
---
|
|
401
423
|
|
|
402
424
|
## Watchers
|
|
@@ -532,38 +554,48 @@ All three primitives live in a separate module (`Watch.js`) and are re-exported
|
|
|
532
554
|
|
|
533
555
|
## Edge cases pinned down
|
|
534
556
|
|
|
557
|
+
<details>
|
|
558
|
+
<summary>Diamonds, self-feedback, nested-effect ownership (v1.2), pre-batch revert (v1.2), multi-throw AggregateError (v1.2), NaN/±0, throwing bodies, 32-bit version wrap, deep-chain limits.</summary>
|
|
559
|
+
|
|
535
560
|
These are the questions you'd ask in a code review, with the answers:
|
|
536
561
|
|
|
537
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.
|
|
538
563
|
- **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.
|
|
539
564
|
- **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.
|
|
565
|
+
- **Nested effects (v1.2 owner tree).** An effect or computed that creates nested observers (effect/computed) **owns** them. When the owner re-runs or is disposed, those owned children cascade-dispose before the new run — no leaked nested subscriptions, no manual bookkeeping. Plain signals are deliberately NOT owner-adopted so lazy-allocation wrappers (lite-store keys, lite-form fields) continue to survive their allocating computed's re-runs.
|
|
566
|
+
- **Pre-batch revert (v1.2).** Inside `batch(...)`, if a signal is set and then set back to its pre-batch value (under its own `equals`), the version bump is reverted and downstream effects/computeds do not fire. Eliminates a class of spurious re-runs from temporary state mutations.
|
|
567
|
+
- **Multi-throw in one flush (v1.2).** Two or more effects throwing in the same flush pass aggregate to `AggregateError` at the triggering `set()` / batch boundary; effects that don't throw still run. A single thrown error is rethrown unwrapped (no API change for the common case).
|
|
540
568
|
- **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.
|
|
541
569
|
- **First-run effect throws.** The half-initialised node is disposed cleanly, deps unlinked, then the error propagates to the caller. No leaked dangling subscriptions.
|
|
542
570
|
- **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.
|
|
543
|
-
- **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.
|
|
571
|
+
- **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. The trampoline closure is cached on the node (v1.2) so repeated re-schedules reuse the same function — ABA safe under async schedulers.
|
|
544
572
|
- **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.
|
|
545
|
-
- **Deep chain depth.** Computed resolution is recursive in the JS call stack. Chains beyond ~
|
|
573
|
+
- **Deep chain depth.** Computed resolution is recursive in the JS call stack. Chains beyond ~5,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.
|
|
546
574
|
- **`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.
|
|
547
575
|
|
|
576
|
+
</details>
|
|
577
|
+
|
|
548
578
|
---
|
|
549
579
|
|
|
550
580
|
## Benchmarks
|
|
551
581
|
|
|
552
|
-
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 × 10 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.
|
|
582
|
+
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 × 10 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.2.0** 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.2.0 — this throughput table median-of-10, the cross-framework reactivity suite median-of-10 in [`resultsReactive.txt`](./resultsReactive.txt). 1.2.0 is drop-in over 1.1.5; the hot paths are byte-identical, so steady-state numbers are within run-to-run noise.)*
|
|
553
583
|
|
|
554
584
|
| Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
|
|
555
585
|
| ---------- | -------------------------------- | ----------- | ------------- | ------------- |
|
|
556
|
-
| **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **
|
|
557
|
-
| **
|
|
558
|
-
| **
|
|
559
|
-
| **DEEP CHAIN** | 256-deep computed chain → 1 effect |
|
|
560
|
-
| **
|
|
561
|
-
|
|
|
562
|
-
| **
|
|
586
|
+
| **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **318K ops/s** | 203K | **+38%** |
|
|
587
|
+
| **KAIROS** | 1 signal → 1000 computeds → 1 effect | **15K** | 13K | **+18%** |
|
|
588
|
+
| **BROADCAST** | 1 signal → 1000 effects (fan-out) | **25K** | 24K | **+9%** |
|
|
589
|
+
| **DEEP CHAIN** | 256-deep computed chain → 1 effect | 52K | **66K** | −21% |
|
|
590
|
+
| **DYNAMIC DAG** | sqrt-layered, FAN=6, read flips each iter | **2K** | 2K | **+9%** |
|
|
591
|
+
| **LARGE WEB APP** | 12 layers × ~80 wide, conditional reads | **7K** | 7K | **+2%** |
|
|
592
|
+
| **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | **7K** | 7K | **+4%** |
|
|
593
|
+
| **Δheap MUX** | transient alloc pressure, 20K iters | **0.3 KB** | 7,780 KB | — |
|
|
594
|
+
| **Retained MUX** | state surviving forced GC | **−9 KB** (none) | −2 KB | — |
|
|
563
595
|
|
|
564
|
-
**Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+
|
|
596
|
+
**Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+38%**, **KAIROS** (one source feeding a wide layer of memos) by **+18%**, **BROADCAST** (fan-out) by **+9%**, and the three dynamic-topology shapes (**DYNAMIC DAG**, **LARGE WEB APP**, **WIDE DENSE**) — the patterns that dominate real UI workloads: dashboards, scoreboards, HUDs, leaderboards, and any view that aggregates many inputs into a single computed slice. `alien-signals` retains its **−21% lead on DEEP CHAIN** (256-deep computed pipelines), where a flatter internal representation pays off when the propagation path is long rather than wide. The two narrower-than-1.1.5 shapes — SELECTIVE DAG and SMALL SELECTIVE — are construction-bound; see the `S: createComputations*` rows in [`resultsReactive.txt`](./resultsReactive.txt) for the underlying cost.
|
|
565
597
|
|
|
566
|
-
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
|
|
598
|
+
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.
|
|
567
599
|
|
|
568
600
|
> 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.
|
|
569
601
|
|
|
@@ -590,19 +622,25 @@ Three tiers, all reproducible.
|
|
|
590
622
|
|
|
591
623
|
`npm test` runs the suite in `test/`, covering:
|
|
592
624
|
|
|
593
|
-
- **`01-
|
|
594
|
-
- **`02-
|
|
595
|
-
- **`03-
|
|
596
|
-
- **`05-
|
|
597
|
-
- **`06-nested-
|
|
598
|
-
- **`07-
|
|
599
|
-
- **`08-
|
|
600
|
-
- **`09-
|
|
601
|
-
- **`10-is-
|
|
602
|
-
- **`11-adopted-
|
|
603
|
-
- **`12-
|
|
604
|
-
- **`13-
|
|
605
|
-
- **`14-lifecycle-
|
|
625
|
+
- **`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.
|
|
626
|
+
- **`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`).
|
|
627
|
+
- **`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.
|
|
628
|
+
- **`05-scheduler_test.mjs`** — scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
|
|
629
|
+
- **`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.
|
|
630
|
+
- **`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.
|
|
631
|
+
- **`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.
|
|
632
|
+
- **`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.
|
|
633
|
+
- **`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.
|
|
634
|
+
- **`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).
|
|
635
|
+
- **`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, scheduler ABA across a recycled pool slot, and the v1.2 owner-tree paths (direct-child detach, cascade tolerates an already-freed child). Capability-gated via a runtime probe, so the same file runs unchanged across engines.
|
|
636
|
+
- **`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).
|
|
637
|
+
- **`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.
|
|
638
|
+
- **`15-owner-lazy-alloc_test.mjs`** — Owner-adoption contract for the 1.2.0 owner tree (5 tests). 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 (nested effect/computed) *are* still auto-disposed on the owner's re-run.
|
|
639
|
+
- **`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).
|
|
640
|
+
- **`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.
|
|
641
|
+
- **`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).
|
|
642
|
+
- **`19-v12-additions_test.mjs`** — v1.2.0 release-prep regressions (24 tests across 8 suites). Shared `peek` (one closure per registry, identical reference across primitives, no tracking, two registries hold independent peeks). Owner-adoption rule (signals not adopted, computeds/effects adopted, cascade drains correctly). Pre-batch revert (signal-level, propagates through computeds, respects custom `equals`, nested batches, final-different-value still fires). Multi-throw aggregation (`AggregateError` with both errors carried, single-throw unwrapped, engine survives). `CycleError` via `maxFlushPasses` (default + custom). `maxLinks` config branch under `throw` and `grow`. Documented disposed-signal semantics (read undefined, set silent no-op, dispose idempotent). Scheduler-thunk ABA guard across a recycled pool slot.
|
|
643
|
+
- **`20-axis-stress_test.mjs`** — engine-invariant regression guards along eight orthogonal "axes" (16 tests across 9 suites). Pins lite-signal's actual contract on: batch semantics under exception (writes commit; pre-batch revert holds; effects see the post-throw value), connect/disconnect lifecycle re-entrancy (`observeObservers` from inside an `onConnect`, transition-only registration), untrack does NOT suppress owner adoption (a nested effect created via `untrack` is still owner-cascaded), untrack inside a computed body (no hidden dep leaks; tracked source re-evaluates), queue safety under self-dispose mid-flush (no UAF), value-dependent cycle detection (computed graph closes a cycle, `CycleError` thrown), nested-effect creation order (effects run synchronously on creation; immediately-stopped one still ran), synchronous flush (no scheduler in the default path; batch coalesces). Plus a bonus suite: 1,000 effect-create-then-dispose cycles return pool to baseline; `dispose()` idempotent; `dispose()` on foreign values safe.
|
|
606
644
|
|
|
607
645
|
```bash
|
|
608
646
|
npm test
|
|
@@ -610,7 +648,7 @@ npm test
|
|
|
610
648
|
|
|
611
649
|
### Tier 2 — Memory (allocation-free verification)
|
|
612
650
|
|
|
613
|
-
`npm run test:gc` runs `test/04-zero-
|
|
651
|
+
`npm run test:gc` runs `test/04-zero-gc_test.mjs` with `--expose-gc`:
|
|
614
652
|
|
|
615
653
|
- 100,000 `set()` calls on a graph with effects retain **< 200 KB** of heap.
|
|
616
654
|
- 1,000 create/dispose cycles retain **< 50 KB**.
|
|
@@ -631,7 +669,26 @@ npm run test:gc
|
|
|
631
669
|
npm run bench
|
|
632
670
|
```
|
|
633
671
|
|
|
634
|
-
|
|
672
|
+
### Tier 4 — Torture soaks (crash detection under chaos)
|
|
673
|
+
|
|
674
|
+
`bench/torture/` contains three soak harnesses that build large randomised graphs (1,500 / 7,500 / 3,300 nodes) and run mixed fuzz workloads — leaf writes, batched writes, computed rewires, effect rewires, nested-batch + untrack reads, and microtask-scheduled async flushes — for 5–10 seconds. These are not perf benchmarks. The numbers they print (ops/sec) reflect random workload composition, not engine throughput; the existing `bench/benchmark.mjs` is the canonical perf harness. What the soaks DO assert, with a non-zero exit code on failure:
|
|
675
|
+
|
|
676
|
+
- zero thrown exceptions during the run, and
|
|
677
|
+
- after teardown, `activeNodes` / `activeLinks` return to the leaf-only baseline (the dispose path is sound under sustained churn).
|
|
678
|
+
|
|
679
|
+
```bash
|
|
680
|
+
node --expose-gc bench/torture/graph-fuzzer.mjs # 10s random-DAG fuzz, 1500 nodes
|
|
681
|
+
node --expose-gc bench/torture/torture-soak.mjs # 5s high-volume churn, 7500 nodes
|
|
682
|
+
node --expose-gc bench/torture/scheduler-bench.mjs # 10s microtask-scheduled, 3300 nodes
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
Run any of them with `TORTURE_SECONDS=N` for a longer soak. Indicative numbers from a development host (post-teardown pool returns to baseline in all three):
|
|
686
|
+
|
|
687
|
+
| | duration | ops | errors | post-teardown nodes / links |
|
|
688
|
+
| --------------------- | --------:| --------:| ------:| --------------------------- |
|
|
689
|
+
| graph-fuzzer | 10 s | 7.6 M | 0 | 500 / 0 |
|
|
690
|
+
| torture-soak | 5 s | 1.2 M | 0 | 2500 / 0 |
|
|
691
|
+
| scheduler-bench | 10 s | 28.8 M | 0 | 1000 / 0 |
|
|
635
692
|
|
|
636
693
|
```bash
|
|
637
694
|
npm run verify # test + test:gc + a sanity bench
|
|
@@ -641,6 +698,9 @@ npm run verify # test + test:gc + a sanity bench
|
|
|
641
698
|
|
|
642
699
|
## Performance Trade-offs & Topology Scaling
|
|
643
700
|
|
|
701
|
+
<details>
|
|
702
|
+
<summary>Stable vs dynamic topologies; Andrii Volynets' matrix, the 1.1.4 result, and the roadmap.</summary>
|
|
703
|
+
|
|
644
704
|
`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.
|
|
645
705
|
|
|
646
706
|
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).
|
|
@@ -658,24 +718,29 @@ In stable environments (game engines, particle systems, visualizers), `lite-sign
|
|
|
658
718
|
| **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
|
|
659
719
|
| **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
|
|
660
720
|
|
|
661
|
-
*1.
|
|
662
|
-
| Scenario | alien-signals | lite-signal (1.
|
|
721
|
+
*1.2.0 on the local harness (slow 2016 MacBook — compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
|
|
722
|
+
| Scenario | alien-signals | lite-signal (1.2.0) | result |
|
|
663
723
|
| :--- | :--- | :--- | :--- |
|
|
664
|
-
| **LARGE WEB APP** (≈ 1000x12) |
|
|
665
|
-
| **WIDE DENSE** (≈ 1000x5)
|
|
666
|
-
| **
|
|
724
|
+
| **LARGE WEB APP** (≈ 1000x12) | 2711ms | 2666ms | **lite +2%** |
|
|
725
|
+
| **WIDE DENSE** (≈ 1000x5) | 2781ms | 2678ms | **lite +4%** |
|
|
726
|
+
| **DYNAMIC DAG** (sqrt-layered, FAN=6) | 9987ms | 9113ms | **lite +9%** |
|
|
727
|
+
| **SMALL SELECTIVE** (≈ 64x6) | 1716ms | 1860ms | alien +8% |
|
|
728
|
+
|
|
729
|
+
> **Honest note (1.2.0 run):** the dynamic-DAG win held vs 1.1.5 (+9% vs alien at +10% before); LARGE WEB APP and WIDE DENSE narrowed slightly but lite-signal still leads. SMALL SELECTIVE went the other way (+8% in 1.1.5 → −8% in 1.2.0) on this host — that scenario is construction-bound, and the construction path's host noise is visible in the `S: createComputations*` rows of [`resultsReactive.txt`](./resultsReactive.txt). Within this run lite remains the only zero-Δheap library on every stable scenario (see [`results.txt`](./results.txt)).
|
|
667
730
|
|
|
668
|
-
The cross-framework reactivity suite agrees independently: `dyn: large web app` and `dyn: wide dense` are
|
|
731
|
+
The cross-framework reactivity suite agrees independently and was re-run on **1.2.0** (median-of-10): `dyn: large web app` **544ms** (+9% vs alien-signals' 599ms) and `dyn: wide dense` **838ms** (+11% vs 941ms) are wins there too — lite-signal is the fastest of five frameworks on both, with preact ~7–19× slower and vue ~16–31× slower (see [`resultsReactive.txt`](./resultsReactive.txt)). The retracking is verified correct by `retracking.difftest.mjs` — 20,000 direct + 10,000 batched writes, 0 disagreements against the **published 1.1.5** reference (re-pinned for v1.2).
|
|
669
732
|
|
|
670
|
-
**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, −
|
|
733
|
+
**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, −21% on the 1.2.0 run).
|
|
671
734
|
|
|
672
735
|
### Roadmap
|
|
673
|
-
- **1.1.5** — additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering).
|
|
674
|
-
- **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).
|
|
675
|
-
- **1.3** — next engine work after the owner-tree validation.
|
|
736
|
+
- **1.1.5** — additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering). *Shipped.*
|
|
737
|
+
- **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). Plus three additive features built on the same internal split: pre-batch revert (`batch(() => { a.set(99); a.set(10); })` doesn't re-fire), multi-throw `AggregateError`, and scheduler-thunk caching with an ABA gen guard. *Shipped.*
|
|
738
|
+
- **1.3** — next engine work after the owner-tree validation. The pull-mode recursion depth limit (~5,000 chained computeds) is the main outstanding architectural item.
|
|
676
739
|
|
|
677
740
|
> 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.
|
|
678
741
|
|
|
742
|
+
</details>
|
|
743
|
+
|
|
679
744
|
---
|
|
680
745
|
|
|
681
746
|
## What this is not
|
|
@@ -712,6 +777,9 @@ A growing family of zero-GC, ESM-only, sub-2KB packages built on `lite-signal`.
|
|
|
712
777
|
|
|
713
778
|
## Browser and runtime support
|
|
714
779
|
|
|
780
|
+
<details>
|
|
781
|
+
<summary>Support matrix (Chrome / Firefox / Safari / Node / Bun / Deno / Workers).</summary>
|
|
782
|
+
|
|
715
783
|
Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
|
|
716
784
|
|
|
717
785
|
| Target | Supported |
|
|
@@ -727,10 +795,15 @@ Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScri
|
|
|
727
795
|
|
|
728
796
|
ESM-only. No CommonJS build — modern bundlers handle this; legacy consumers can use a wrapper.
|
|
729
797
|
|
|
798
|
+
</details>
|
|
799
|
+
|
|
730
800
|
---
|
|
731
801
|
|
|
732
802
|
## Integration recipes
|
|
733
803
|
|
|
804
|
+
<details>
|
|
805
|
+
<summary>Game HUD (rAF), Twitch config sync, per-tenant sandboxing.</summary>
|
|
806
|
+
|
|
734
807
|
### Reactive game HUD with requestAnimationFrame
|
|
735
808
|
|
|
736
809
|
```js
|
|
@@ -791,24 +864,32 @@ function spawnPlugin(pluginCode) {
|
|
|
791
864
|
}
|
|
792
865
|
```
|
|
793
866
|
|
|
867
|
+
</details>
|
|
868
|
+
|
|
794
869
|
---
|
|
795
870
|
|
|
796
871
|
## Conformance
|
|
797
872
|
|
|
873
|
+
<details>
|
|
874
|
+
<summary>177/178 on the reactive-framework-test-suite; what lite-signal does and doesn't, by intent.</summary>
|
|
875
|
+
|
|
798
876
|
lite-signal is evaluated against the
|
|
799
877
|
[reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
|
|
800
878
|
the most comprehensive behavioral test battery for JavaScript reactive
|
|
801
879
|
libraries.
|
|
802
880
|
|
|
803
|
-
As of **v1.
|
|
804
|
-
detection (#123 / #132 / #147), throw isolation
|
|
805
|
-
inner-write propagation through computed chains
|
|
806
|
-
(landed in v1.1.1
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
881
|
+
As of **v1.2.0**, the conformance items that were open at v1.1.0 are
|
|
882
|
+
**all closed**: batch revert detection (#123 / #132 / #147), throw isolation
|
|
883
|
+
in flush (#121), inner-write propagation through computed chains
|
|
884
|
+
(#180 / #213) all landed in v1.1.1; the retracking rewrite (1.1.4) is
|
|
885
|
+
verified behavior-preserving by `retracking.difftest.mjs` (20,000 direct
|
|
886
|
+
+ 10,000 batched writes, 0 disagreements against the prior reference); and
|
|
887
|
+
the **owner-tree items #209 / #210** close with the v1.2 ownership hybrid.
|
|
888
|
+
The one remaining open item is a deliberate design choice (#179, below).
|
|
889
|
+
The exact post-1.2 pass count is being re-run against the upstream suite;
|
|
890
|
+
per-test results and the runner adapter live in `/conformance/`.
|
|
891
|
+
|
|
892
|
+
**177 of 178 tests pass **, placing lite-signal **in the second place of sixteen**
|
|
812
893
|
evaluated libraries — just behind alien-signals (177).
|
|
813
894
|
|
|
814
895
|
We publish both passing and failing tests, because honesty about behavior is
|
|
@@ -848,22 +929,29 @@ The remaining open items, by intent.
|
|
|
848
929
|
effect's own implicit batching. Most libraries behave this way. Wrap the
|
|
849
930
|
batch outside the effect for the intended semantics.
|
|
850
931
|
|
|
851
|
-
**
|
|
932
|
+
**Closed in v1.2** (2 tests, previously "Landing in v1.2"):
|
|
852
933
|
|
|
853
934
|
- **Solid-style cascading disposal of nested effects** (#209, #210):
|
|
854
|
-
|
|
855
|
-
effects
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
935
|
+
v1.2 introduces an internal owner tree. An effect or computed that creates
|
|
936
|
+
nested effects/computeds (observers) owns them; when the owner re-runs or
|
|
937
|
+
is disposed, those children cascade-dispose before the new run. Plain
|
|
938
|
+
signals are deliberately NOT owner-adopted (lazy-allocation wrappers like
|
|
939
|
+
`lite-store` allocate a key's signal inside its reading computed and need
|
|
940
|
+
it to survive re-runs). Closes #209 / #210 against the upstream suite;
|
|
941
|
+
conformance pass count under the v1.2 engine is being re-run.
|
|
859
942
|
|
|
860
943
|
Per-test results, the runner adapter, and reproductions live in
|
|
861
944
|
`/conformance/`.
|
|
862
945
|
|
|
946
|
+
</details>
|
|
947
|
+
|
|
863
948
|
---
|
|
864
949
|
|
|
865
950
|
## FAQ
|
|
866
951
|
|
|
952
|
+
<details>
|
|
953
|
+
<summary>Microtasks, dual capacities, Object.is, destroy(), framework integration, dep-order stability.</summary>
|
|
954
|
+
|
|
867
955
|
**Why no microtask scheduler?**
|
|
868
956
|
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.
|
|
869
957
|
|
|
@@ -888,6 +976,8 @@ The cleanup runs *before* the next computeFn body, so the set's notification arr
|
|
|
888
976
|
**Is the dep order stable across re-runs?**
|
|
889
977
|
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.
|
|
890
978
|
|
|
979
|
+
</details>
|
|
980
|
+
|
|
891
981
|
---
|
|
892
982
|
|
|
893
983
|
## npm scripts
|
package/Signal.d.ts
CHANGED
|
@@ -99,6 +99,9 @@ export type NodeKind = "signal" | "computed" | "effect";
|
|
|
99
99
|
|
|
100
100
|
/** Non-perturbing snapshot of a graph neighbour yielded by the `forEach*` walkers. */
|
|
101
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;
|
|
102
105
|
kind: NodeKind;
|
|
103
106
|
/** The node's current value (last stored/computed value; an effect's is its body return). */
|
|
104
107
|
value: unknown;
|
|
@@ -174,6 +177,11 @@ export interface Registry {
|
|
|
174
177
|
forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
175
178
|
/** Walk the sources (dependencies) of `handle`. No-op on a non-handle. */
|
|
176
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;
|
|
177
185
|
onCleanup(fn: () => void): void;
|
|
178
186
|
stats(): RegistryStats;
|
|
179
187
|
/** Reset everything: nodes, links, queues, global clock. Outstanding dispose
|
|
@@ -214,6 +222,10 @@ export function observeObservers(handle: ReactiveHandle, hooks?: ObserveObserver
|
|
|
214
222
|
export function forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
215
223
|
/** Top-level binding of {@link Registry.forEachSource}. */
|
|
216
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;
|
|
217
229
|
export function onCleanup(fn: () => void): void;
|
|
218
230
|
export function stats(): RegistryStats;
|
|
219
231
|
export declare function destroy(): void;
|