@zakkster/lite-signal 1.1.4 → 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 +90 -32
- package/Signal.d.ts +12 -0
- package/Signal.js +40 -11
- package/llms.txt +32 -17
- package/package.json +1 -1
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,6 +554,9 @@ 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, 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.
|
|
@@ -545,25 +570,27 @@ These are the questions you'd ask in a code review, with the answers:
|
|
|
545
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.
|
|
546
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.
|
|
547
572
|
|
|
573
|
+
</details>
|
|
574
|
+
|
|
548
575
|
---
|
|
549
576
|
|
|
550
577
|
## Benchmarks
|
|
551
578
|
|
|
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 ×
|
|
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.)*
|
|
553
580
|
|
|
554
581
|
| Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
|
|
555
582
|
| ---------- | -------------------------------- | ----------- | ------------- | ------------- |
|
|
556
|
-
| **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **314K ops/s** |
|
|
557
|
-
| **BROADCAST** | 1 signal → 1000 effects (fan-out) | **
|
|
558
|
-
| **KAIROS** | 1 signal → 1000 computeds → 1 effect | **
|
|
559
|
-
| **DEEP CHAIN** | 256-deep computed chain → 1 effect | 53K | **60K** | −
|
|
560
|
-
| **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | **7K** |
|
|
561
|
-
| **Δheap MUX** | transient alloc pressure, 20K iters | **0.3 KB** |
|
|
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 | — |
|
|
562
589
|
| **Retained MUX** | state surviving forced GC | **−7 KB** (none) | −1 KB | — |
|
|
563
590
|
|
|
564
|
-
**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.
|
|
565
592
|
|
|
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
|
|
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.
|
|
567
594
|
|
|
568
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.
|
|
569
596
|
|
|
@@ -590,19 +617,23 @@ Three tiers, all reproducible.
|
|
|
590
617
|
|
|
591
618
|
`npm test` runs the suite in `test/`, covering:
|
|
592
619
|
|
|
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-
|
|
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).
|
|
606
637
|
|
|
607
638
|
```bash
|
|
608
639
|
npm test
|
|
@@ -610,7 +641,7 @@ npm test
|
|
|
610
641
|
|
|
611
642
|
### Tier 2 — Memory (allocation-free verification)
|
|
612
643
|
|
|
613
|
-
`npm run test:gc` runs `test/04-zero-
|
|
644
|
+
`npm run test:gc` runs `test/04-zero-gc_test.mjs` with `--expose-gc`:
|
|
614
645
|
|
|
615
646
|
- 100,000 `set()` calls on a graph with effects retain **< 200 KB** of heap.
|
|
616
647
|
- 1,000 create/dispose cycles retain **< 50 KB**.
|
|
@@ -641,6 +672,9 @@ npm run verify # test + test:gc + a sanity bench
|
|
|
641
672
|
|
|
642
673
|
## Performance Trade-offs & Topology Scaling
|
|
643
674
|
|
|
675
|
+
<details>
|
|
676
|
+
<summary>Stable vs dynamic topologies; Andrii Volynets' matrix, the 1.1.4 result, and the roadmap.</summary>
|
|
677
|
+
|
|
644
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.
|
|
645
679
|
|
|
646
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).
|
|
@@ -658,16 +692,18 @@ In stable environments (game engines, particle systems, visualizers), `lite-sign
|
|
|
658
692
|
| **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
|
|
659
693
|
| **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
|
|
660
694
|
|
|
661
|
-
*1.1.
|
|
662
|
-
| Scenario | alien-signals | lite-signal (1.1.
|
|
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 |
|
|
663
697
|
| :--- | :--- | :--- | :--- |
|
|
664
|
-
| **LARGE WEB APP** (≈ 1000x12) |
|
|
665
|
-
| **WIDE DENSE** (≈ 1000x5) |
|
|
666
|
-
| **SMALL SELECTIVE** (≈ 64x6) |
|
|
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%** |
|
|
667
701
|
|
|
668
|
-
|
|
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)).
|
|
669
703
|
|
|
670
|
-
|
|
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%).
|
|
671
707
|
|
|
672
708
|
### Roadmap
|
|
673
709
|
- **1.1.5** — additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering).
|
|
@@ -676,6 +712,8 @@ The cross-framework reactivity suite agrees independently: `dyn: large web app`
|
|
|
676
712
|
|
|
677
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.
|
|
678
714
|
|
|
715
|
+
</details>
|
|
716
|
+
|
|
679
717
|
---
|
|
680
718
|
|
|
681
719
|
## What this is not
|
|
@@ -712,6 +750,9 @@ A growing family of zero-GC, ESM-only, sub-2KB packages built on `lite-signal`.
|
|
|
712
750
|
|
|
713
751
|
## Browser and runtime support
|
|
714
752
|
|
|
753
|
+
<details>
|
|
754
|
+
<summary>Support matrix (Chrome / Firefox / Safari / Node / Bun / Deno / Workers).</summary>
|
|
755
|
+
|
|
715
756
|
Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
|
|
716
757
|
|
|
717
758
|
| Target | Supported |
|
|
@@ -727,10 +768,15 @@ Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScri
|
|
|
727
768
|
|
|
728
769
|
ESM-only. No CommonJS build — modern bundlers handle this; legacy consumers can use a wrapper.
|
|
729
770
|
|
|
771
|
+
</details>
|
|
772
|
+
|
|
730
773
|
---
|
|
731
774
|
|
|
732
775
|
## Integration recipes
|
|
733
776
|
|
|
777
|
+
<details>
|
|
778
|
+
<summary>Game HUD (rAF), Twitch config sync, per-tenant sandboxing.</summary>
|
|
779
|
+
|
|
734
780
|
### Reactive game HUD with requestAnimationFrame
|
|
735
781
|
|
|
736
782
|
```js
|
|
@@ -791,10 +837,15 @@ function spawnPlugin(pluginCode) {
|
|
|
791
837
|
}
|
|
792
838
|
```
|
|
793
839
|
|
|
840
|
+
</details>
|
|
841
|
+
|
|
794
842
|
---
|
|
795
843
|
|
|
796
844
|
## Conformance
|
|
797
845
|
|
|
846
|
+
<details>
|
|
847
|
+
<summary>174/177 on the reactive-framework-test-suite; what lite-signal does and doesn't, by intent.</summary>
|
|
848
|
+
|
|
798
849
|
lite-signal is evaluated against the
|
|
799
850
|
[reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
|
|
800
851
|
the most comprehensive behavioral test battery for JavaScript reactive
|
|
@@ -860,10 +911,15 @@ The remaining open items, by intent.
|
|
|
860
911
|
Per-test results, the runner adapter, and reproductions live in
|
|
861
912
|
`/conformance/`.
|
|
862
913
|
|
|
914
|
+
</details>
|
|
915
|
+
|
|
863
916
|
---
|
|
864
917
|
|
|
865
918
|
## FAQ
|
|
866
919
|
|
|
920
|
+
<details>
|
|
921
|
+
<summary>Microtasks, dual capacities, Object.is, destroy(), framework integration, dep-order stability.</summary>
|
|
922
|
+
|
|
867
923
|
**Why no microtask scheduler?**
|
|
868
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.
|
|
869
925
|
|
|
@@ -888,6 +944,8 @@ The cleanup runs *before* the next computeFn body, so the set's notification arr
|
|
|
888
944
|
**Is the dep order stable across re-runs?**
|
|
889
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.
|
|
890
946
|
|
|
947
|
+
</details>
|
|
948
|
+
|
|
891
949
|
---
|
|
892
950
|
|
|
893
951
|
## 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;
|
package/Signal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal v1.1.
|
|
2
|
+
* @zakkster/lite-signal v1.1.5
|
|
3
3
|
* --------------------
|
|
4
4
|
* Zero-GC reactive graph.
|
|
5
5
|
*
|
|
@@ -85,6 +85,8 @@ class ReactiveNode {
|
|
|
85
85
|
this.markEpoch = 0;
|
|
86
86
|
/** Recycle generation: bumped on dispose, used to invalidate stale scheduler closures. */
|
|
87
87
|
this.gen = 0;
|
|
88
|
+
/** Stable per-allocation id for introspection/devtools (1.1.5). Reassigned on each allocate-from-pool. */
|
|
89
|
+
this.id = 0;
|
|
88
90
|
|
|
89
91
|
/** Captured value at first .set() inside the current batch (revert detection). */
|
|
90
92
|
this.preBatchValue = undefined;
|
|
@@ -194,6 +196,7 @@ export function createRegistry(config = {}) {
|
|
|
194
196
|
for (let i = 0; i < currentLinkCapacity - 1; i++) linkPool[i].nextFree = linkPool[i + 1];
|
|
195
197
|
|
|
196
198
|
let activeNodes = 0 | 0;
|
|
199
|
+
let nodeSeq = 1 | 0; // monotonic node-id source for introspection (1.1.5+); wraps modular | 0
|
|
197
200
|
let activeLinks = 0 | 0;
|
|
198
201
|
let statSignals = 0 | 0;
|
|
199
202
|
let statComputeds = 0 | 0;
|
|
@@ -239,7 +242,6 @@ export function createRegistry(config = {}) {
|
|
|
239
242
|
const po = currentObserver, pt = isTrackingDeps;
|
|
240
243
|
currentObserver = null;
|
|
241
244
|
isTrackingDeps = false;
|
|
242
|
-
|
|
243
245
|
try {
|
|
244
246
|
e.onConnect();
|
|
245
247
|
} finally {
|
|
@@ -256,7 +258,6 @@ export function createRegistry(config = {}) {
|
|
|
256
258
|
const po = currentObserver, pt = isTrackingDeps;
|
|
257
259
|
currentObserver = null;
|
|
258
260
|
isTrackingDeps = false;
|
|
259
|
-
|
|
260
261
|
try {
|
|
261
262
|
e.onDisconnect();
|
|
262
263
|
} finally {
|
|
@@ -281,12 +282,13 @@ export function createRegistry(config = {}) {
|
|
|
281
282
|
* @private
|
|
282
283
|
*/
|
|
283
284
|
function allocateLink(source, target) {
|
|
284
|
-
// Eligibility gate (lite's analogue of alien-signals' shouldTrack): if the observer
|
|
285
|
-
// disposed mid-run — self-dispose, or an outer observer torn down while suspended —
|
|
286
|
-
// flags are cleared to 0. Linking would splice a dead, pool-bound node back into
|
|
287
|
-
// subscriber list: a phantom edge that
|
|
288
|
-
//
|
|
289
|
-
//
|
|
285
|
+
// Eligibility gate (lite's analogue of alien-signals' shouldTrack): if the observer
|
|
286
|
+
// was disposed mid-run — self-dispose, or an outer observer torn down while suspended —
|
|
287
|
+
// its flags are cleared to 0. Linking would splice a dead, pool-bound node back into
|
|
288
|
+
// `source`'s subscriber list: a phantom edge that dangles into the pool and mis-targets
|
|
289
|
+
// the instant that slot is recycled (the next allocation). Skip it. This sits on the COLD
|
|
290
|
+
// path only — the inline cursor-hit fast path never reaches allocateLink — so steady-state
|
|
291
|
+
// tracked reads pay nothing. Legit tracking always passes a live observer (flags != 0).
|
|
290
292
|
if (target.flags === 0) return null;
|
|
291
293
|
let expected = activeObserverCurrentDep;
|
|
292
294
|
// Dead on this build: the inline cursor fast-path inside every read
|
|
@@ -513,6 +515,8 @@ export function createRegistry(config = {}) {
|
|
|
513
515
|
node.revertEpoch = 0;
|
|
514
516
|
node.preBatchValue = undefined;
|
|
515
517
|
node.preBatchVersion = 0;
|
|
518
|
+
node.id = nodeSeq;
|
|
519
|
+
nodeSeq = (nodeSeq + 1) | 0; // fresh identity per allocation (1.1.5)
|
|
516
520
|
return node;
|
|
517
521
|
}
|
|
518
522
|
|
|
@@ -1306,7 +1310,22 @@ export function createRegistry(config = {}) {
|
|
|
1306
1310
|
function describeNode(node) {
|
|
1307
1311
|
const fl = node.flags;
|
|
1308
1312
|
const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
|
|
1309
|
-
|
|
1313
|
+
const d = {id: node.id, kind, value: node.value};
|
|
1314
|
+
// Carry the node so the descriptor can be passed BACK into forEachObserver/forEachSource —
|
|
1315
|
+
// this is what lets lite-devtools BFS the full graph. Non-enumerable so {id,kind,value}
|
|
1316
|
+
// stays the visible shape (clean logs / JSON).
|
|
1317
|
+
Object.defineProperty(d, NODE_PTR, {value: node, enumerable: false});
|
|
1318
|
+
return d;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function nodeId(handle) {
|
|
1322
|
+
const node = handle != null ? handle[NODE_PTR] : undefined;
|
|
1323
|
+
return node !== undefined ? node.id : undefined;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function describe(handle) {
|
|
1327
|
+
const node = handle != null ? handle[NODE_PTR] : undefined;
|
|
1328
|
+
return node !== undefined ? describeNode(node) : undefined;
|
|
1310
1329
|
}
|
|
1311
1330
|
|
|
1312
1331
|
function forEachObserver(handle, fn) {
|
|
@@ -1345,7 +1364,9 @@ export function createRegistry(config = {}) {
|
|
|
1345
1364
|
hasObservers,
|
|
1346
1365
|
observeObservers,
|
|
1347
1366
|
forEachObserver,
|
|
1348
|
-
forEachSource
|
|
1367
|
+
forEachSource,
|
|
1368
|
+
nodeId,
|
|
1369
|
+
describe
|
|
1349
1370
|
};
|
|
1350
1371
|
}
|
|
1351
1372
|
|
|
@@ -1419,6 +1440,14 @@ export function forEachSource(handle, fn) {
|
|
|
1419
1440
|
return defaultRegistry.forEachSource(handle, fn);
|
|
1420
1441
|
}
|
|
1421
1442
|
|
|
1443
|
+
export function nodeId(handle) {
|
|
1444
|
+
return defaultRegistry.nodeId(handle);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
export function describe(handle) {
|
|
1448
|
+
return defaultRegistry.describe(handle);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1422
1451
|
/** @type {Registry["onCleanup"]} */
|
|
1423
1452
|
export function onCleanup(fn) {
|
|
1424
1453
|
return defaultRegistry.onCleanup(fn);
|
package/llms.txt
CHANGED
|
@@ -21,7 +21,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
21
21
|
- **Untrack**: `untrack(fn)` reads without subscribing.
|
|
22
22
|
- **isTracking**: `isTracking()` returns true iff a read right now would record a dependency on this registry (for wrappers that lazily allocate signals).
|
|
23
23
|
- **onCleanup**: registers teardown for the current computation; fires before each re-run and once on dispose. Works in effects and computeds.
|
|
24
|
-
- **Observer-lifecycle introspection** (1.1.4; top-level + per-registry): `hasObservers(handle)` returns true iff a signal/computed has ≥1 live observer right now (O(1); a `peek` does not count). `observeObservers(handle, { onConnect?, onDisconnect? })` fires on the 0→1 and 1→0 observer transitions (after registration; transition-only) and returns an idempotent unobserve — the auto-pause hook for tickers (lite-time/lite-raf start a source only while it's watched). `forEachObserver(handle, fn)` / `forEachSource(handle, fn)` walk the live graph in either direction, passing a `{ kind, value }` descriptor (`kind` is `"signal" | "computed" | "effect"`) — for inspection (lite-devtools). The surface is gated by an internal counter: zero steady-state cost when nothing is observed. `hasObservers`/`forEach*` no-op on a non-handle; `observeObservers` throws `TypeError`.
|
|
24
|
+
- **Observer-lifecycle introspection** (1.1.4; top-level + per-registry): `hasObservers(handle)` returns true iff a signal/computed has ≥1 live observer right now (O(1); a `peek` does not count). `observeObservers(handle, { onConnect?, onDisconnect? })` fires on the 0→1 and 1→0 observer transitions (after registration; transition-only) and returns an idempotent unobserve — the auto-pause hook for tickers (lite-time/lite-raf start a source only while it's watched). `forEachObserver(handle, fn)` / `forEachSource(handle, fn)` walk the live graph in either direction, passing a `{ kind, value }` descriptor (`kind` is `"signal" | "computed" | "effect"`) — for inspection (lite-devtools). The surface is gated by an internal counter: zero steady-state cost when nothing is observed. `hasObservers`/`forEach*` no-op on a non-handle; `observeObservers` throws `TypeError`. **Node identity (1.1.5):** `nodeId(handle)` returns the node's stable per-allocation id, `describe(handle)` returns the handle's own `{ id, kind, value }` descriptor, and `forEach*` descriptors now carry `id` too; a descriptor is **re-walkable** (pass it back into `forEachObserver`/`forEachSource`) — the recursion primitive lite-devtools uses to walk the full DAG.
|
|
25
25
|
- **Registry**: `createRegistry({ maxNodes, maxLinks, onCapacityExceeded, maxFlushPasses })` creates an isolated reactive world with its own pools. Useful for tests, plugins, multi-tenant sandboxes. Top-level helpers use a default registry created at module load.
|
|
26
26
|
- **CapacityError**: thrown when a fixed-size pool is exhausted under the `"throw"` policy, or when a `"grow"` policy hits the 16× starting-capacity ceiling on links.
|
|
27
27
|
|
|
@@ -50,7 +50,15 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
50
50
|
|
|
51
51
|
## Version notes
|
|
52
52
|
|
|
53
|
-
- **1.1.
|
|
53
|
+
- **1.1.5** (current): additive release in service of `@zakkster/lite-devtools` — stable
|
|
54
|
+
node identity on the introspection surface. `nodeId(handle)` -> the node's stable
|
|
55
|
+
per-allocation id (the dedupe key for graph walks); `describe(handle)` -> the handle's
|
|
56
|
+
own `{ id, kind, value }` descriptor, **re-walkable** (pass it back into
|
|
57
|
+
`forEachObserver`/`forEachSource` to walk the full DAG); `forEach*` descriptors now carry
|
|
58
|
+
`id`. One SMI write at allocation, node shape kept monomorphic — **zero steady-state
|
|
59
|
+
cost**. Drop-in over 1.1.4, no breaking changes. New `test/18-identity_test.mjs` (5 tests); plus `14-lifecycle-teardown`, `16-alien-parity`, and the `17-reactivity` behavioral suite.
|
|
60
|
+
|
|
61
|
+
- **1.1.4**: combined release — a retracking rewrite plus an observer-lifecycle
|
|
54
62
|
introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
|
|
55
63
|
reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
|
|
56
64
|
O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
|
|
@@ -111,6 +119,8 @@ function observeObservers(
|
|
|
111
119
|
): () => void; // returns idempotent unobserve
|
|
112
120
|
function forEachObserver(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
|
|
113
121
|
function forEachSource(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
|
|
122
|
+
function nodeId(handle: Signal<any> | Computed<any>): number | undefined; // 1.1.5
|
|
123
|
+
function describe(handle: Signal<any> | Computed<any>): NodeDescriptor | undefined; // 1.1.5
|
|
114
124
|
function onCleanup(fn: () => void): void;
|
|
115
125
|
function stats(): RegistryStats;
|
|
116
126
|
|
|
@@ -134,7 +144,8 @@ interface Computed<T> {
|
|
|
134
144
|
|
|
135
145
|
type Dispose = () => void;
|
|
136
146
|
|
|
137
|
-
interface NodeDescriptor { // yielded by forEachObserver / forEachSource
|
|
147
|
+
interface NodeDescriptor { // yielded by forEachObserver / forEachSource / describe
|
|
148
|
+
id: number; // stable per-allocation id (1.1.5); dedupe + re-walk key
|
|
138
149
|
kind: "signal" | "computed" | "effect";
|
|
139
150
|
value: unknown; // node's current value
|
|
140
151
|
}
|
|
@@ -153,8 +164,8 @@ interface RegistryStats {
|
|
|
153
164
|
activeLinks: number;
|
|
154
165
|
pooledLinks: number;
|
|
155
166
|
linkPoolCapacity: number;
|
|
156
|
-
nodePoolCapacity
|
|
157
|
-
activeNodes
|
|
167
|
+
nodePoolCapacity: number;
|
|
168
|
+
activeNodes: number;
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
class CapacityError extends Error {
|
|
@@ -235,9 +246,9 @@ path is long rather than wide.
|
|
|
235
246
|
These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
|
|
236
247
|
high-fan-in shapes that were lite-signal's documented weakness — `dyn: large web app`
|
|
237
248
|
and `dyn: wide dense` in the cross-framework reactivity suite — were closed by the
|
|
238
|
-
1.1.4 retracking rewrite and
|
|
239
|
-
vs alien-signals'
|
|
240
|
-
resultsReactive.txt for the full 34-test, 5-framework table.
|
|
249
|
+
1.1.4 retracking rewrite and remain the fastest of five frameworks (re-confirmed on
|
|
250
|
+
1.1.5, median-of-12): 555ms / 870ms vs alien-signals' 590ms / 933ms, with preact and
|
|
251
|
+
vue ~7–30× slower. See resultsReactive.txt for the full 34-test, 5-framework table.
|
|
241
252
|
|
|
242
253
|
On allocation pressure, lite-signal is alone in the zero-Δheap band: ~15 KB of
|
|
243
254
|
transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
|
|
@@ -287,20 +298,24 @@ sandbox.destroy(); // entire reactive world reset
|
|
|
287
298
|
|
|
288
299
|
- `Signal.js` — full implementation, single file.
|
|
289
300
|
- `Signal.d.ts` — TypeScript declarations for all public API.
|
|
290
|
-
- `test/01-
|
|
291
|
-
- `test/02-
|
|
292
|
-
- `test/03-
|
|
293
|
-
- `test/04-zero-
|
|
294
|
-
- `test/05-
|
|
295
|
-
- `test/06-nested-
|
|
296
|
-
- `test/07-
|
|
297
|
-
- `test/08-
|
|
298
|
-
- `test/09-
|
|
301
|
+
- `test/01-core_test.mjs` — signal/computed/effect basics, equality, untrack.
|
|
302
|
+
- `test/02-topology_test.mjs` — diamonds, chains, fan-out/in, cycle detection.
|
|
303
|
+
- `test/03-pool_test.mjs` — capacity errors, grow policy, pool reuse.
|
|
304
|
+
- `test/04-zero-gc_test.mjs` — heap retention (run with --expose-gc).
|
|
305
|
+
- `test/05-scheduler_test.mjs` — scheduler races, dispose, gen counter, version wrap.
|
|
306
|
+
- `test/06-nested-objects_test.mjs` — nested-object & reference-identity behaviours.
|
|
307
|
+
- `test/07-dispose_test.mjs` — universal disposal: registry.dispose(api).
|
|
308
|
+
- `test/08-watch_test.mjs` — new watch reactivity tests.
|
|
309
|
+
- `test/09-conformance_test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
|
|
299
310
|
- `test/10-is-tracking_test.mjs` — `isTracking()` across observer bodies, untracked windows, and outside any observer.
|
|
300
311
|
- `test/11-adopted-reactive_test.mjs` — engine-agnostic edge cases adopted from the wider ecosystem (alien-signals link-integrity #226–228, equality-predicate corners, `update()`/`peek()`/`subscribe` contracts).
|
|
301
312
|
- `test/12-coverage_test.mjs` — targeted public-surface + hot-path branch coverage (top-level routing, clean-read short-circuit, tail severance, scheduler ABA); owner-tree block capability-gated.
|
|
302
313
|
- `test/13-introspection_test.mjs` — observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
|
|
303
314
|
- `test/14-lifecycle-teardown_test.mjs` — effect-teardown guards: stopped effect doesn't re-subscribe to a signal read later in the same run, self-dispose leaves no orphan link, throwing setup leaves no live subscription (4 tests).
|
|
315
|
+
- `test/15-owner-lazy-alloc_test.mjs` — owner-adoption contract for the 1.2.0 owner tree (lazy signals never adopted; observers still auto-disposed); capability-gated, skipped on 1.1.x (4 tests).
|
|
316
|
+
- `test/16-alien-parity_test.mjs` — differential guards vs alien-signals@3.2.0 fixed-bug classes: cleanup reads create no deps, inner write doesn't block computed-chain propagation, dynamic dep-set correct under dirty-check (3 tests).
|
|
317
|
+
- `test/17-reactivity_test.mjs` — behavioral suite over universal signal-system bug classes (subscription lifecycle, cleanup ordering, stale-dep tracking, batching, equality, nested invalidation, memory, sync async-boundary, scheduler/loops, + differential-review additions); SSR is N/A (≈30 tests).
|
|
318
|
+
- `test/18-identity_test.mjs` — node identity (1.1.5): `nodeId`/`describe`, descriptor `id`, re-walkable descriptors, non-perturbing (5 tests).
|
|
304
319
|
- `bench/benchmark.mjs` — anti-DCE throughput harness (ops/s; results.txt).
|
|
305
320
|
- `bench/benchmarkReactive.mjs` — cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
|
|
306
321
|
- `demo/index.html` — interactive visualization of the reactive graph.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Zero-GC reactive graph. Monomorphic object pool, versioned push-pull propagation, 32-bit modular versioning. Built for hot paths and long-running processes.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|