@zakkster/lite-signal 1.1.3 → 1.1.4
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 +100 -29
- package/Signal.d.ts +45 -0
- package/Signal.js +291 -57
- package/Watch.js +4 -1
- package/llms.txt +43 -5
- 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)
|
|
@@ -296,6 +298,31 @@ Returns `true` iff a read right now would record a dependency on the current reg
|
|
|
296
298
|
|
|
297
299
|
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
300
|
|
|
301
|
+
### Observer-lifecycle introspection
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
// Start a ticker only while something is actually watching a derived value.
|
|
305
|
+
const now = signal(performance.now());
|
|
306
|
+
const unobserve = observeObservers(now, {
|
|
307
|
+
onConnect: () => startRAF(), // 0 → 1 observers
|
|
308
|
+
onDisconnect: () => stopRAF(), // 1 → 0 observers
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
hasObservers(now); // O(1): is anyone subscribed right now? (a peek doesn't count)
|
|
312
|
+
|
|
313
|
+
// Walk the live graph in either direction (lite-devtools):
|
|
314
|
+
forEachObserver(sum, d => console.log(d.kind, d.value)); // subscribers of `sum`
|
|
315
|
+
forEachSource(sum, d => console.log(d.kind, d.value)); // dependencies of `sum`
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Four functions (top-level + per-registry), added in 1.1.4, for auto-pausing wrappers and graph inspection:
|
|
319
|
+
|
|
320
|
+
- **`hasObservers(handle)` → `boolean`** — O(1) (`node.headSub !== null`). The auto-pause predicate.
|
|
321
|
+
- **`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.
|
|
323
|
+
|
|
324
|
+
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
|
+
|
|
299
326
|
### onCleanup
|
|
300
327
|
|
|
301
328
|
```ts
|
|
@@ -522,22 +549,23 @@ These are the questions you'd ask in a code review, with the answers:
|
|
|
522
549
|
|
|
523
550
|
## Benchmarks
|
|
524
551
|
|
|
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 ×
|
|
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.1.4** 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).
|
|
526
553
|
|
|
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 | **14K**
|
|
532
|
-
| **DEEP CHAIN** | 256-deep computed chain → 1 effect
|
|
533
|
-
|
|
|
534
|
-
|
|
|
554
|
+
| Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
|
|
555
|
+
| ---------- | -------------------------------- | ----------- | ------------- | ------------- |
|
|
556
|
+
| **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **314K ops/s** | 194K | **+62%** |
|
|
557
|
+
| **BROADCAST** | 1 signal → 1000 effects (fan-out) | **26K** | 22K | **+22%** |
|
|
558
|
+
| **KAIROS** | 1 signal → 1000 computeds → 1 effect | **14K** | 12K | **+12%** |
|
|
559
|
+
| **DEEP CHAIN** | 256-deep computed chain → 1 effect | 53K | **60K** | −11% |
|
|
560
|
+
| **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | **7K** | 6K | **+6%** ⬆ |
|
|
561
|
+
| **Δheap MUX** | transient alloc pressure, 20K iters | **0.3 KB** | 3,907 KB | — |
|
|
562
|
+
| **Retained MUX** | state surviving forced GC | **−7 KB** (none) | −1 KB | — |
|
|
535
563
|
|
|
536
|
-
**Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+
|
|
564
|
+
**Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+62%**, **BROADCAST** (fan-out) by **+22%**, and **KAIROS** (one source feeding a wide layer of memos) by **+12%** — 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 **−11% 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
565
|
|
|
538
|
-
On allocation pressure, `lite-signal` is alone in the zero-Δheap band: ~
|
|
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 **29 MB** in the same loop. preact runs ~230 KB per loop, solid runs into single-digit megabytes, and alien-signals — which earlier shared the zero-GC band with lite-signal — now allocates 0.9-3.9 MB per stable scenario in current published versions. Negative "retained" numbers mean V8 reclaimed memory below the pre-bench baseline during the post-run forced GC — no leaks anywhere.
|
|
539
567
|
|
|
540
|
-
> Note on the +
|
|
568
|
+
> 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
569
|
|
|
542
570
|
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
571
|
|
|
@@ -571,6 +599,10 @@ Three tiers, all reproducible.
|
|
|
571
599
|
- **`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.
|
|
572
600
|
- **`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.
|
|
573
601
|
- **`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.
|
|
602
|
+
- **`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).
|
|
603
|
+
- **`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).
|
|
604
|
+
- **`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).
|
|
605
|
+
- **`14-lifecycle-teardown.test.mjs`** — Effect-teardown guards (1.1.4 fix). A stopped effect must not re-subscribe to a signal read later in the same run; self-dispose must leave no orphaned link; a throwing effect setup must leave no live subscription; normal and dynamic re-tracking stay unaffected.
|
|
574
606
|
|
|
575
607
|
```bash
|
|
576
608
|
npm test
|
|
@@ -593,7 +625,7 @@ npm run test:gc
|
|
|
593
625
|
|
|
594
626
|
### Tier 3 — Performance (comparative benchmark)
|
|
595
627
|
|
|
596
|
-
`npm run bench` runs the
|
|
628
|
+
`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
629
|
|
|
598
630
|
```bash
|
|
599
631
|
npm run bench
|
|
@@ -611,27 +643,38 @@ npm run verify # test + test:gc + a sanity bench
|
|
|
611
643
|
|
|
612
644
|
`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
645
|
|
|
614
|
-
|
|
646
|
+
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
647
|
|
|
616
|
-
**Andrii Volynets** (author of the phenomenal [Alien Signals](https://github.com/stackblitz/alien-signals)) generously ran `lite-signal` through his advanced topology matrix.
|
|
648
|
+
**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
649
|
|
|
618
650
|
#### 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.
|
|
651
|
+
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
652
|
|
|
624
|
-
|
|
625
|
-
|
|
653
|
+
#### 2. Dynamic Topologies (Web Apps / Layered DAGs) — closed in 1.1.4
|
|
654
|
+
*Andrii's v1.1.2 baseline (his host) — where the cursor retracking lost:*
|
|
655
|
+
| Scenario | alien-signals | reflex | lite-signal (1.1.2) |
|
|
626
656
|
| :--- | :--- | :--- | :--- |
|
|
627
657
|
| **1000x12 (4 sources, dynamic)** | 184ms | 194ms | 2031ms |
|
|
628
658
|
| **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
|
|
629
659
|
| **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
|
|
630
660
|
|
|
631
|
-
|
|
661
|
+
*1.1.4 on the local harness (slow 2016 MacBook — compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
|
|
662
|
+
| Scenario | alien-signals | lite-signal (1.1.4) | result |
|
|
663
|
+
| :--- | :--- | :--- | :--- |
|
|
664
|
+
| **LARGE WEB APP** (≈ 1000x12) | 3026ms | 2736ms | **lite +11%** |
|
|
665
|
+
| **WIDE DENSE** (≈ 1000x5) | 2960ms | 2802ms | **lite +6%** |
|
|
666
|
+
| **SMALL SELECTIVE** (≈ 64x6) | 2985ms | 1868ms | **lite +60%** |
|
|
667
|
+
|
|
668
|
+
The cross-framework reactivity suite agrees independently: `dyn: large web app` and `dyn: wide dense` are now wins there too, and lite-signal is the fastest of five frameworks on both (see [`resultsReactive.txt`](./resultsReactive.txt)). The new 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.4 is the definitive external confirmation and is pending; the two local suites above are the current evidence.)*
|
|
669
|
+
|
|
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, −11%).
|
|
632
671
|
|
|
633
|
-
### Roadmap
|
|
634
|
-
|
|
672
|
+
### 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.
|
|
676
|
+
|
|
677
|
+
> 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.
|
|
635
678
|
|
|
636
679
|
---
|
|
637
680
|
|
|
@@ -639,11 +682,34 @@ v1.2 (in benchmark validation) — Andrii Volynets (alien-signals#117) extended
|
|
|
639
682
|
|
|
640
683
|
- **A virtual DOM, JSX runtime, or rendering library.** It's the substrate. Plug it under whatever rendering layer you like.
|
|
641
684
|
- **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.**
|
|
685
|
+
- **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
686
|
- **A library for the server.** It works in Node, but there's no SSR story. Use it on the client.
|
|
644
687
|
|
|
645
688
|
---
|
|
646
689
|
|
|
690
|
+
## Ecosystem
|
|
691
|
+
|
|
692
|
+
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).
|
|
693
|
+
|
|
694
|
+
**State & data**
|
|
695
|
+
- [`@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.
|
|
696
|
+
- [`@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.
|
|
697
|
+
- [`@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.
|
|
698
|
+
- [`@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.
|
|
699
|
+
- [`@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.
|
|
700
|
+
- [`@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).
|
|
701
|
+
|
|
702
|
+
**Rendering (DOM / Canvas)**
|
|
703
|
+
- [`@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.
|
|
704
|
+
- [`@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.
|
|
705
|
+
- [`@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.
|
|
706
|
+
|
|
707
|
+
**Time & scheduling**
|
|
708
|
+
- [`@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.
|
|
709
|
+
- [`@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.
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
647
713
|
## Browser and runtime support
|
|
648
714
|
|
|
649
715
|
Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
|
|
@@ -734,7 +800,7 @@ lite-signal is evaluated against the
|
|
|
734
800
|
the most comprehensive behavioral test battery for JavaScript reactive
|
|
735
801
|
libraries.
|
|
736
802
|
|
|
737
|
-
As of **v1.1.2
|
|
803
|
+
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
804
|
detection (#123 / #132 / #147), throw isolation in flush (#121), and
|
|
739
805
|
inner-write propagation through computed chains (#180 / #213) — are **closed**
|
|
740
806
|
(landed in v1.1.1). The remaining gaps are one deliberate design choice (#179,
|
|
@@ -763,6 +829,10 @@ more useful to library users than a green checkmark.
|
|
|
763
829
|
and most others by one re-evaluation per write.
|
|
764
830
|
- **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
|
|
765
831
|
solid. Half the field leaks the subscription.
|
|
832
|
+
- **Observer-lifecycle introspection** (`hasObservers` / `observeObservers`,
|
|
833
|
+
1.1.4): the 0→1 and 1→0 observer transitions are first-class, zero-cost-when-
|
|
834
|
+
unused hooks — the basis for auto-pausing a clock or RAF loop only while a
|
|
835
|
+
derived value is watched. Few signal libraries expose this.
|
|
766
836
|
|
|
767
837
|
### What lite-signal does NOT do yet
|
|
768
838
|
|
|
@@ -825,8 +895,9 @@ Yes, if your computeFn reads its deps in the same order each invocation. The `cu
|
|
|
825
895
|
```bash
|
|
826
896
|
npm test # behavior suite, ~1.3s
|
|
827
897
|
npm run test:gc # zero-gc suite, requires --expose-gc, ~3s
|
|
828
|
-
npm run bench # comparative benchmark, ~5min
|
|
829
|
-
npm run
|
|
898
|
+
npm run bench # comparative benchmark vs alien-signals (results.txt), ~5min
|
|
899
|
+
npm run bench-reactive # 5-framework reactivity suite (resultsReactive.txt)
|
|
900
|
+
npm run verify # test + test:gc + sanity bench; gate for publish
|
|
830
901
|
```
|
|
831
902
|
|
|
832
903
|
---
|
package/Signal.d.ts
CHANGED
|
@@ -92,6 +92,32 @@ 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
|
+
kind: NodeKind;
|
|
103
|
+
/** The node's current value (last stored/computed value; an effect's is its body return). */
|
|
104
|
+
value: unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Transition callbacks for {@link Registry.observeObservers}. */
|
|
108
|
+
export interface ObserveObserversHooks {
|
|
109
|
+
/** Fired on the 0→1 observer transition (after registration). */
|
|
110
|
+
onConnect?: () => void;
|
|
111
|
+
/** Fired on the 1→0 observer transition. */
|
|
112
|
+
onDisconnect?: () => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Stops an {@link Registry.observeObservers} subscription. Idempotent. */
|
|
116
|
+
export type Unobserve = () => void;
|
|
117
|
+
|
|
118
|
+
/** Anything carrying a node identity that the introspection surface can read. */
|
|
119
|
+
export type ReactiveHandle = Signal<any> | Computed<any>;
|
|
120
|
+
|
|
95
121
|
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
96
122
|
|
|
97
123
|
/** Thrown when a pool ceiling is hit. */
|
|
@@ -137,6 +163,17 @@ export interface Registry {
|
|
|
137
163
|
* False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and
|
|
138
164
|
* outside any observer. Use for lazy-allocation wrappers like lite-store. */
|
|
139
165
|
isTracking(): boolean;
|
|
166
|
+
/** O(1): does this source have at least one live observer right now? A `peek` does not count. */
|
|
167
|
+
hasObservers(handle: ReactiveHandle): boolean;
|
|
168
|
+
/** Auto-pause hook: fires `onConnect` on the 0→1 observer transition and `onDisconnect`
|
|
169
|
+
* on 1→0, after registration (transition-only — no immediate fire if already observed).
|
|
170
|
+
* Re-tracking a persistently-read source does not churn. Returns an idempotent unobserve.
|
|
171
|
+
* @throws TypeError if `handle` is not a reactive handle. */
|
|
172
|
+
observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
|
|
173
|
+
/** Walk the observers (subscribers) of `handle`, newest-first. No-op on a non-handle. */
|
|
174
|
+
forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
175
|
+
/** Walk the sources (dependencies) of `handle`. No-op on a non-handle. */
|
|
176
|
+
forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
140
177
|
onCleanup(fn: () => void): void;
|
|
141
178
|
stats(): RegistryStats;
|
|
142
179
|
/** Reset everything: nodes, links, queues, global clock. Outstanding dispose
|
|
@@ -169,6 +206,14 @@ export function batch<T>(fn: () => T): T;
|
|
|
169
206
|
export function untrack<T>(fn: () => T): T;
|
|
170
207
|
/** Top-level binding of {@link Registry.isTracking} against the default registry. */
|
|
171
208
|
export function isTracking(): boolean;
|
|
209
|
+
/** Top-level binding of {@link Registry.hasObservers}. */
|
|
210
|
+
export function hasObservers(handle: ReactiveHandle): boolean;
|
|
211
|
+
/** Top-level binding of {@link Registry.observeObservers}. */
|
|
212
|
+
export function observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
|
|
213
|
+
/** Top-level binding of {@link Registry.forEachObserver}. */
|
|
214
|
+
export function forEachObserver(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
215
|
+
/** Top-level binding of {@link Registry.forEachSource}. */
|
|
216
|
+
export function forEachSource(handle: ReactiveHandle, fn: (descriptor: NodeDescriptor) => void): void;
|
|
172
217
|
export function onCleanup(fn: () => void): void;
|
|
173
218
|
export function stats(): RegistryStats;
|
|
174
219
|
export declare function destroy(): void;
|
package/Signal.js
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal v1.1.
|
|
2
|
+
* @zakkster/lite-signal v1.1.4
|
|
3
3
|
* --------------------
|
|
4
4
|
* Zero-GC reactive graph.
|
|
5
5
|
*
|
|
6
|
+
* CHANGES vs 1.1.2 (both perf-only; public surface and semantics unchanged):
|
|
7
|
+
* 1. pullComputed clean short-circuit. markDownstream already stamps markEpoch
|
|
8
|
+
* on every node in a changed signal's transitive cone; pullComputed now uses
|
|
9
|
+
* it to return a cached value in O(1) when no mark landed since the last eval,
|
|
10
|
+
* instead of walking (and recursively pulling) the whole dependency subtree.
|
|
11
|
+
* Erases the dynamic-graph regression (batched read-after-write workloads):
|
|
12
|
+
* "large web app" 4824ms -> 650ms, "wide dense" 4378ms -> 908ms locally.
|
|
13
|
+
* 2. allocateLink: the O(N) linear headDep scan on a cursor miss is replaced by
|
|
14
|
+
* an O(1) source.tailSub dedup. Dependency re-tracking is now O(N) regardless
|
|
15
|
+
* of read order, not O(N^2). Wide reordering nodes: ~50x locally (600-dep
|
|
16
|
+
* flip: 2810ms -> 46ms). First-track of a wide node: ~4.4x (48ms -> 10ms).
|
|
17
|
+
* SEVER-FIRST: on a cursor-miss divergence the unmatched dep tail is freed
|
|
18
|
+
* BEFORE any new link is allocated, so peak link usage never exceeds steady
|
|
19
|
+
* state (ZERO pool debt). A divergent re-track therefore cannot trigger a
|
|
20
|
+
* mid-compute pool growth under tight maxLinks + "throw" — the zero-GC budget
|
|
21
|
+
* holds. (The alternative "splice" variant is ~equal speed but transiently
|
|
22
|
+
* holds up to one node's fan-in of extra links until end-of-frame severTail.)
|
|
23
|
+
*
|
|
24
|
+
* EDGE NOTE (2): a node that reads the SAME source twice within one body, with a
|
|
25
|
+
* nested computed that also reads that source evaluated in between, retains ONE
|
|
26
|
+
* redundant dependency link for the node's lifetime. It is value-correct, bounded
|
|
27
|
+
* (does not grow across re-tracks), and reclaimed on dispose/destroy. The previous
|
|
28
|
+
* O(N) scan collapsed this to a single link; the O(1) dedup cannot see past a
|
|
29
|
+
* nested re-link of the same source. This is a deliberate cost/correctness trade.
|
|
30
|
+
*
|
|
6
31
|
* Architecture: monomorphic object pool + versioned push-pull propagation
|
|
7
32
|
* + SMI modular arithmetic for 32-bit version-wrap safety.
|
|
8
33
|
*
|
|
@@ -79,6 +104,8 @@ class ReactiveNode {
|
|
|
79
104
|
|
|
80
105
|
// Pool free-list pointer.
|
|
81
106
|
this.nextFree = null;
|
|
107
|
+
|
|
108
|
+
this.schedulerThunk = undefined;
|
|
82
109
|
}
|
|
83
110
|
}
|
|
84
111
|
|
|
@@ -151,13 +178,19 @@ export function createRegistry(config = {}) {
|
|
|
151
178
|
|
|
152
179
|
// --- ZERO-GC OBJECT POOLS ---
|
|
153
180
|
const nodePool = [];
|
|
181
|
+
|
|
154
182
|
for (let i = 0; i < currentNodesCapacity; i++) nodePool[i] = new ReactiveNode();
|
|
183
|
+
|
|
155
184
|
let freeNodeHead = nodePool[0];
|
|
185
|
+
|
|
156
186
|
for (let i = 0; i < currentNodesCapacity - 1; i++) nodePool[i].nextFree = nodePool[i + 1];
|
|
157
187
|
|
|
158
188
|
const linkPool = [];
|
|
189
|
+
|
|
159
190
|
for (let i = 0; i < currentLinkCapacity; i++) linkPool[i] = new ReactiveLink();
|
|
191
|
+
|
|
160
192
|
let freeLinkHead = linkPool[0];
|
|
193
|
+
|
|
161
194
|
for (let i = 0; i < currentLinkCapacity - 1; i++) linkPool[i].nextFree = linkPool[i + 1];
|
|
162
195
|
|
|
163
196
|
let activeNodes = 0 | 0;
|
|
@@ -170,6 +203,7 @@ export function createRegistry(config = {}) {
|
|
|
170
203
|
const effectQueueA = [];
|
|
171
204
|
const effectQueueB = [];
|
|
172
205
|
const markStack = [];
|
|
206
|
+
|
|
173
207
|
for (let i = 0; i < currentNodesCapacity; i++) {
|
|
174
208
|
effectQueueA[i] = null;
|
|
175
209
|
effectQueueB[i] = null;
|
|
@@ -189,6 +223,48 @@ export function createRegistry(config = {}) {
|
|
|
189
223
|
let isTrackingDeps = false;
|
|
190
224
|
let isFlushing = false;
|
|
191
225
|
|
|
226
|
+
// ── Observer-lifecycle introspection (opt-in; zero steady-state cost) ──
|
|
227
|
+
// lifecycleCount gates the hot-path checks in allocateLink/freeLink: with no node
|
|
228
|
+
// registered it stays 0 and each check folds to one predicted-false compare.
|
|
229
|
+
// Callbacks live in a WeakMap, NOT on the node struct, so node creation/footprint
|
|
230
|
+
// are untouched.
|
|
231
|
+
let lifecycleCount = 0 | 0;
|
|
232
|
+
const lifecycleMap = new WeakMap();
|
|
233
|
+
|
|
234
|
+
function fireConnect(node) {
|
|
235
|
+
const e = lifecycleMap.get(node);
|
|
236
|
+
|
|
237
|
+
if (e === undefined || e.onConnect === undefined) return;
|
|
238
|
+
|
|
239
|
+
const po = currentObserver, pt = isTrackingDeps;
|
|
240
|
+
currentObserver = null;
|
|
241
|
+
isTrackingDeps = false;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
e.onConnect();
|
|
245
|
+
} finally {
|
|
246
|
+
currentObserver = po;
|
|
247
|
+
isTrackingDeps = pt;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function fireDisconnect(node) {
|
|
252
|
+
const e = lifecycleMap.get(node);
|
|
253
|
+
|
|
254
|
+
if (e === undefined || e.onDisconnect === undefined) return;
|
|
255
|
+
|
|
256
|
+
const po = currentObserver, pt = isTrackingDeps;
|
|
257
|
+
currentObserver = null;
|
|
258
|
+
isTrackingDeps = false;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
e.onDisconnect();
|
|
262
|
+
} finally {
|
|
263
|
+
currentObserver = po;
|
|
264
|
+
isTrackingDeps = pt;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
192
268
|
// Reused buffer for throw isolation during flush
|
|
193
269
|
const flushErrorBuffer = [];
|
|
194
270
|
let flushErrorCount = 0 | 0;
|
|
@@ -205,45 +281,68 @@ export function createRegistry(config = {}) {
|
|
|
205
281
|
* @private
|
|
206
282
|
*/
|
|
207
283
|
function allocateLink(source, target) {
|
|
284
|
+
// Eligibility gate (lite's analogue of alien-signals' shouldTrack): if the observer was
|
|
285
|
+
// disposed mid-run — self-dispose, or an outer observer torn down while suspended — its
|
|
286
|
+
// flags are cleared to 0. Linking would splice a dead, pool-bound node back into `source`'s
|
|
287
|
+
// subscriber list: a phantom edge that mis-targets the instant the slot is recycled. Skip it.
|
|
288
|
+
// Cold path only (the inline cursor-hit fast path never reaches here) -> steady-state reads
|
|
289
|
+
// pay nothing. Legit tracking always passes a live observer (flags != 0).
|
|
290
|
+
if (target.flags === 0) return null;
|
|
208
291
|
let expected = activeObserverCurrentDep;
|
|
292
|
+
// Dead on this build: the inline cursor fast-path inside every read
|
|
293
|
+
// consumes a cursor *hit* before allocateLink runs, so on entry the
|
|
294
|
+
// cursor never matches `source`. Kept for the direct-call contract.
|
|
295
|
+
/* c8 ignore start */
|
|
209
296
|
if (expected !== null && expected.source === source) {
|
|
210
297
|
activeObserverCurrentDep = expected.nextDep;
|
|
211
298
|
return expected;
|
|
212
299
|
}
|
|
300
|
+
/* c8 ignore stop */
|
|
301
|
+
|
|
302
|
+
// SEVER-FIRST (zero pool debt): on any divergence, free the entire
|
|
303
|
+
// unmatched tail BEFORE allocating, so peak link usage never exceeds
|
|
304
|
+
// steady-state. Trades a little extra free/alloc churn on small reorders
|
|
305
|
+
// for honoring the zero-GC budget under tight maxLinks + "throw".
|
|
306
|
+
if (expected !== null) {
|
|
307
|
+
let stale = expected;
|
|
308
|
+
let prev = stale.prevDep;
|
|
213
309
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
310
|
+
if (prev !== null) prev.nextDep = null; else target.headDep = null;
|
|
311
|
+
|
|
312
|
+
target.tailDep = prev;
|
|
313
|
+
|
|
314
|
+
while (stale !== null) {
|
|
315
|
+
let next = stale.nextDep;
|
|
316
|
+
freeLink(stale, target, stale.source);
|
|
317
|
+
stale = next;
|
|
220
318
|
}
|
|
221
|
-
|
|
319
|
+
|
|
320
|
+
activeObserverCurrentDep = null;
|
|
222
321
|
}
|
|
223
322
|
|
|
323
|
+
// O(1) same-pass dedup (covers double-read of a still-linked source).
|
|
324
|
+
const lastSub = source.tailSub;
|
|
325
|
+
|
|
326
|
+
if (lastSub !== null && lastSub.target === target) return lastSub;
|
|
327
|
+
|
|
224
328
|
let link;
|
|
225
|
-
|
|
226
|
-
link = found;
|
|
227
|
-
let p = link.prevDep;
|
|
228
|
-
let n = link.nextDep;
|
|
229
|
-
if (p !== null) p.nextDep = n; else target.headDep = n;
|
|
230
|
-
if (n !== null) n.prevDep = p; else target.tailDep = p;
|
|
231
|
-
} else {
|
|
329
|
+
{
|
|
232
330
|
if (freeLinkHead === null) {
|
|
233
331
|
if (policy === "throw") throw new CapacityError("links", currentLinkCapacity);
|
|
332
|
+
|
|
234
333
|
const newCap = currentLinkCapacity * 2;
|
|
334
|
+
|
|
235
335
|
if (newCap > maxLinkLimit) throw new CapacityError("links", maxLinkLimit);
|
|
236
336
|
|
|
237
337
|
const newLinks = new Array(newCap - currentLinkCapacity);
|
|
338
|
+
|
|
238
339
|
for (let i = 0; i < newLinks.length; i++) newLinks[i] = new ReactiveLink();
|
|
239
340
|
for (let i = 0; i < newLinks.length - 1; i++) newLinks[i].nextFree = newLinks[i + 1];
|
|
240
341
|
|
|
241
342
|
const startIdx = linkPool.length;
|
|
242
343
|
linkPool.length = newCap;
|
|
243
344
|
|
|
244
|
-
for (let i = 0; i < newLinks.length; i++)
|
|
245
|
-
linkPool[startIdx + i] = newLinks[i];
|
|
246
|
-
}
|
|
345
|
+
for (let i = 0; i < newLinks.length; i++) linkPool[startIdx + i] = newLinks[i];
|
|
247
346
|
|
|
248
347
|
freeLinkHead = newLinks[0];
|
|
249
348
|
currentLinkCapacity = newCap;
|
|
@@ -259,26 +358,24 @@ export function createRegistry(config = {}) {
|
|
|
259
358
|
|
|
260
359
|
link.nextSub = null;
|
|
261
360
|
link.prevSub = source.tailSub;
|
|
361
|
+
const _was0 = lifecycleCount !== 0 && source.headSub === null; // 0→1 detect (pre-link)
|
|
262
362
|
|
|
263
|
-
if (source.tailSub !== null) source.tailSub.nextSub = link;
|
|
264
|
-
else source.headSub = link;
|
|
363
|
+
if (source.tailSub !== null) source.tailSub.nextSub = link; else source.headSub = link;
|
|
265
364
|
|
|
266
365
|
source.tailSub = link;
|
|
267
|
-
}
|
|
268
366
|
|
|
269
|
-
|
|
270
|
-
if (expected !== null) {
|
|
271
|
-
let p = expected.prevDep;
|
|
272
|
-
link.prevDep = p;
|
|
273
|
-
expected.prevDep = link;
|
|
274
|
-
if (p !== null) p.nextDep = link; else target.headDep = link;
|
|
275
|
-
} else {
|
|
276
|
-
let tail = target.tailDep;
|
|
277
|
-
link.prevDep = tail;
|
|
278
|
-
if (tail !== null) tail.nextDep = link; else target.headDep = link;
|
|
279
|
-
target.tailDep = link;
|
|
367
|
+
if (_was0) fireConnect(source);
|
|
280
368
|
}
|
|
281
369
|
|
|
370
|
+
// Append at the (post-sever) tail.
|
|
371
|
+
let tail = target.tailDep;
|
|
372
|
+
link.prevDep = tail;
|
|
373
|
+
link.nextDep = null;
|
|
374
|
+
|
|
375
|
+
if (tail !== null) tail.nextDep = link; else target.headDep = link;
|
|
376
|
+
|
|
377
|
+
target.tailDep = link;
|
|
378
|
+
|
|
282
379
|
return link;
|
|
283
380
|
}
|
|
284
381
|
|
|
@@ -289,6 +386,7 @@ export function createRegistry(config = {}) {
|
|
|
289
386
|
|
|
290
387
|
if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
|
|
291
388
|
if (nSub !== null) nSub.prevSub = pSub; else source.tailSub = pSub;
|
|
389
|
+
if (lifecycleCount !== 0 && source.headSub === null) fireDisconnect(source); // 1→0
|
|
292
390
|
|
|
293
391
|
link.source = null;
|
|
294
392
|
link.target = null;
|
|
@@ -305,12 +403,14 @@ export function createRegistry(config = {}) {
|
|
|
305
403
|
// --- MANUAL MEMORY MANAGEMENT ---
|
|
306
404
|
|
|
307
405
|
function disposeNode(node) {
|
|
406
|
+
/* c8 ignore next -- redundant: both callers (effect handle, dispose(api)) pre-check flags!==0; destroy() resets slots inline */
|
|
308
407
|
if (node.flags === 0) return; // Already freed
|
|
309
408
|
|
|
310
409
|
runCleanup(node);
|
|
311
410
|
|
|
312
411
|
// 1. Unlink from dependencies
|
|
313
412
|
let dLink = node.headDep;
|
|
413
|
+
|
|
314
414
|
while (dLink !== null) {
|
|
315
415
|
const next = dLink.nextDep;
|
|
316
416
|
freeLink(dLink, node, dLink.source);
|
|
@@ -319,6 +419,7 @@ export function createRegistry(config = {}) {
|
|
|
319
419
|
|
|
320
420
|
// 2. Unlink from subscribers
|
|
321
421
|
let sLink = node.headSub;
|
|
422
|
+
|
|
322
423
|
while (sLink !== null) {
|
|
323
424
|
const target = sLink.target;
|
|
324
425
|
const next = sLink.nextSub;
|
|
@@ -345,6 +446,7 @@ export function createRegistry(config = {}) {
|
|
|
345
446
|
node.computeFn = undefined;
|
|
346
447
|
node.cleanupFn = undefined;
|
|
347
448
|
node.scheduler = undefined;
|
|
449
|
+
node.schedulerThunk = undefined;
|
|
348
450
|
node.value = undefined;
|
|
349
451
|
node.equals = undefined;
|
|
350
452
|
node.flags = 0;
|
|
@@ -417,12 +519,14 @@ export function createRegistry(config = {}) {
|
|
|
417
519
|
/** Invoke registered cleanup function(s) on `node` and clear. @private */
|
|
418
520
|
function runCleanup(node) {
|
|
419
521
|
const cleanup = node.cleanupFn;
|
|
522
|
+
|
|
420
523
|
if (cleanup === undefined) return;
|
|
421
524
|
|
|
422
525
|
const prevObserver = currentObserver;
|
|
423
526
|
const prevTracking = isTrackingDeps;
|
|
424
527
|
currentObserver = null;
|
|
425
528
|
isTrackingDeps = false;
|
|
529
|
+
|
|
426
530
|
try {
|
|
427
531
|
if (typeof cleanup === "function") cleanup();
|
|
428
532
|
else for (let i = 0; i < cleanup.length; i++) cleanup[i]();
|
|
@@ -449,6 +553,7 @@ export function createRegistry(config = {}) {
|
|
|
449
553
|
let link = n.headSub;
|
|
450
554
|
while (link !== null) {
|
|
451
555
|
const t = link.target;
|
|
556
|
+
|
|
452
557
|
if ((t.markEpoch | 0) !== (globalVersion | 0)) {
|
|
453
558
|
t.markEpoch = globalVersion | 0;
|
|
454
559
|
// flags read stays INSIDE the markEpoch guard on purpose: hoisting it
|
|
@@ -471,6 +576,7 @@ export function createRegistry(config = {}) {
|
|
|
471
576
|
markStack[stackLen++] = t;
|
|
472
577
|
}
|
|
473
578
|
}
|
|
579
|
+
|
|
474
580
|
link = link.nextSub;
|
|
475
581
|
}
|
|
476
582
|
}
|
|
@@ -483,7 +589,9 @@ export function createRegistry(config = {}) {
|
|
|
483
589
|
*/
|
|
484
590
|
function safeExecute(node, gen) {
|
|
485
591
|
if ((node.gen | 0) !== (gen | 0)) return;
|
|
592
|
+
/* c8 ignore next -- unreachable after the gen guard: every disposal bumps node.gen, so a non-effect slot fails the gen check above first */
|
|
486
593
|
if ((node.flags & FLAG_EFFECT) === 0) return;
|
|
594
|
+
|
|
487
595
|
executeEffect(node);
|
|
488
596
|
}
|
|
489
597
|
|
|
@@ -515,11 +623,12 @@ export function createRegistry(config = {}) {
|
|
|
515
623
|
|
|
516
624
|
for (let i = 0; i < toRun; i++) {
|
|
517
625
|
const node = currentQueue[i];
|
|
626
|
+
|
|
518
627
|
try {
|
|
519
628
|
const scheduler = node.scheduler;
|
|
629
|
+
|
|
520
630
|
if (scheduler) {
|
|
521
|
-
|
|
522
|
-
scheduler(() => safeExecute(node, gen));
|
|
631
|
+
scheduler(node.schedulerThunk);
|
|
523
632
|
} else {
|
|
524
633
|
executeEffect(node);
|
|
525
634
|
}
|
|
@@ -535,11 +644,13 @@ export function createRegistry(config = {}) {
|
|
|
535
644
|
normalExit = true;
|
|
536
645
|
} finally {
|
|
537
646
|
isFlushing = false;
|
|
647
|
+
|
|
538
648
|
if (!normalExit) {
|
|
539
649
|
// Escaping via CycleError or any non-effect-body throw. Discard
|
|
540
650
|
// buffered effect errors — the structural failure supersedes them
|
|
541
651
|
// and prevents leaking stale errors to the next flush call.
|
|
542
652
|
for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
|
|
653
|
+
|
|
543
654
|
flushErrorCount = 0 | 0;
|
|
544
655
|
}
|
|
545
656
|
}
|
|
@@ -553,8 +664,11 @@ export function createRegistry(config = {}) {
|
|
|
553
664
|
}
|
|
554
665
|
// 2+ errors: snapshot into a fresh array for AggregateError, then clear.
|
|
555
666
|
const errs = flushErrorBuffer.slice(0, flushErrorCount);
|
|
667
|
+
|
|
556
668
|
for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
|
|
669
|
+
|
|
557
670
|
flushErrorCount = 0 | 0;
|
|
671
|
+
|
|
558
672
|
throw new AggregateError(errs, "Effects threw during flush");
|
|
559
673
|
}
|
|
560
674
|
}
|
|
@@ -566,9 +680,12 @@ export function createRegistry(config = {}) {
|
|
|
566
680
|
*/
|
|
567
681
|
function severTail(node) {
|
|
568
682
|
let stale = activeObserverCurrentDep;
|
|
683
|
+
|
|
569
684
|
if (stale !== null) {
|
|
570
685
|
let prev = stale.prevDep;
|
|
686
|
+
|
|
571
687
|
if (prev !== null) prev.nextDep = null; else node.headDep = null;
|
|
688
|
+
|
|
572
689
|
node.tailDep = prev;
|
|
573
690
|
|
|
574
691
|
while (stale !== null) {
|
|
@@ -585,6 +702,7 @@ export function createRegistry(config = {}) {
|
|
|
585
702
|
* @private
|
|
586
703
|
*/
|
|
587
704
|
function executeEffect(node) {
|
|
705
|
+
/* c8 ignore next -- defense-in-depth: writes during a flush re-queue for the next pass, so an effect cannot synchronously re-enter executeEffect */
|
|
588
706
|
if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Infinite effect loop detected.");
|
|
589
707
|
|
|
590
708
|
const isFirst = node.evalVersion === 0;
|
|
@@ -593,8 +711,10 @@ export function createRegistry(config = {}) {
|
|
|
593
711
|
let link = node.headDep;
|
|
594
712
|
const evalVer = node.evalVersion | 0;
|
|
595
713
|
let needsRun = false;
|
|
714
|
+
|
|
596
715
|
while (link !== null) {
|
|
597
716
|
const dep = link.source;
|
|
717
|
+
|
|
598
718
|
if ((dep.flags & FLAG_COMPUTED) !== 0) pullComputed(dep);
|
|
599
719
|
|
|
600
720
|
// Overflow-safe modular arithmetic version check
|
|
@@ -659,24 +779,40 @@ export function createRegistry(config = {}) {
|
|
|
659
779
|
return node.value;
|
|
660
780
|
}
|
|
661
781
|
|
|
782
|
+
// CLEAN SHORT-CIRCUIT: markDownstream stamps markEpoch=globalVersion on
|
|
783
|
+
// every node in a changed signal's transitive cone. If no mark landed
|
|
784
|
+
// at-or-after our last eval, nothing we depend on changed -> cached value
|
|
785
|
+
// is still valid; skip the (recursive) dependency walk entirely. O(1).
|
|
786
|
+
if (node.evalVersion !== 0 && ((node.markEpoch - node.evalVersion) | 0) <= 0) {
|
|
787
|
+
node.evalVersion = globalVersion | 0;
|
|
788
|
+
|
|
789
|
+
if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
|
|
790
|
+
|
|
791
|
+
return node.value;
|
|
792
|
+
}
|
|
793
|
+
|
|
662
794
|
let shouldRun = node.evalVersion === 0;
|
|
663
795
|
if (!shouldRun) {
|
|
664
796
|
let link = node.headDep;
|
|
665
797
|
const evalVer = node.evalVersion | 0;
|
|
798
|
+
|
|
666
799
|
while (link !== null) {
|
|
667
800
|
const dep = link.source;
|
|
801
|
+
|
|
668
802
|
if ((dep.flags & FLAG_COMPUTED) !== 0) pullComputed(dep);
|
|
669
803
|
// Modular Arithmetic 32-bit Wrap Check
|
|
670
804
|
if (((dep.version - evalVer) | 0) > 0) {
|
|
671
805
|
shouldRun = true;
|
|
672
806
|
break;
|
|
673
807
|
}
|
|
808
|
+
|
|
674
809
|
link = link.nextDep;
|
|
675
810
|
}
|
|
676
811
|
}
|
|
677
812
|
|
|
678
813
|
if (shouldRun) {
|
|
679
814
|
if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Circular dependency detected.");
|
|
815
|
+
|
|
680
816
|
node.flags = node.flags | FLAG_COMPUTING;
|
|
681
817
|
|
|
682
818
|
// Run cleanups registered during the previous compute pass before re-tracking.
|
|
@@ -694,6 +830,7 @@ export function createRegistry(config = {}) {
|
|
|
694
830
|
try {
|
|
695
831
|
const newValue = node.computeFn();
|
|
696
832
|
const eq = node.equals;
|
|
833
|
+
|
|
697
834
|
if (node.evalVersion === 0 || !eq || !eq(node.value, newValue)) {
|
|
698
835
|
node.value = newValue;
|
|
699
836
|
node.version = globalVersion | 0;
|
|
@@ -716,7 +853,9 @@ export function createRegistry(config = {}) {
|
|
|
716
853
|
}
|
|
717
854
|
|
|
718
855
|
node.evalVersion = globalVersion | 0;
|
|
856
|
+
|
|
719
857
|
if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
|
|
858
|
+
|
|
720
859
|
return node.value;
|
|
721
860
|
}
|
|
722
861
|
|
|
@@ -748,6 +887,7 @@ export function createRegistry(config = {}) {
|
|
|
748
887
|
// frame entirely. Only a cursor miss falls through to the cold path,
|
|
749
888
|
// where allocateLink re-reads the cursor for its relink logic.
|
|
750
889
|
const expected = activeObserverCurrentDep;
|
|
890
|
+
|
|
751
891
|
if (expected !== null && expected.source === node) {
|
|
752
892
|
activeObserverCurrentDep = expected.nextDep;
|
|
753
893
|
} else {
|
|
@@ -756,9 +896,11 @@ export function createRegistry(config = {}) {
|
|
|
756
896
|
}
|
|
757
897
|
return node.value;
|
|
758
898
|
};
|
|
899
|
+
|
|
759
900
|
read.peek = () => node.value;
|
|
760
901
|
read.set = (value) => {
|
|
761
902
|
const eq = node.equals;
|
|
903
|
+
|
|
762
904
|
if (eq && eq(node.value, value)) return;
|
|
763
905
|
|
|
764
906
|
// Revert capture: first .set() of this signal inside the current batch.
|
|
@@ -798,6 +940,7 @@ export function createRegistry(config = {}) {
|
|
|
798
940
|
const val = read();
|
|
799
941
|
const prevTracking = isTrackingDeps;
|
|
800
942
|
isTrackingDeps = false;
|
|
943
|
+
|
|
801
944
|
try {
|
|
802
945
|
fn(val);
|
|
803
946
|
} finally {
|
|
@@ -809,6 +952,7 @@ export function createRegistry(config = {}) {
|
|
|
809
952
|
// Secret pointer for safe, isolated disposal without allocating closures
|
|
810
953
|
read[NODE_PTR] = node;
|
|
811
954
|
read[NODE_GEN] = node.gen | 0;
|
|
955
|
+
|
|
812
956
|
return read;
|
|
813
957
|
}
|
|
814
958
|
|
|
@@ -835,6 +979,7 @@ export function createRegistry(config = {}) {
|
|
|
835
979
|
if (isTrackingDeps && currentObserver !== null) {
|
|
836
980
|
// Inlined cursor fast-path — see signal() read for rationale.
|
|
837
981
|
const expected = activeObserverCurrentDep;
|
|
982
|
+
|
|
838
983
|
if (expected !== null && expected.source === node) {
|
|
839
984
|
activeObserverCurrentDep = expected.nextDep;
|
|
840
985
|
} else {
|
|
@@ -843,6 +988,7 @@ export function createRegistry(config = {}) {
|
|
|
843
988
|
}
|
|
844
989
|
return pullComputed(node);
|
|
845
990
|
};
|
|
991
|
+
|
|
846
992
|
read.peek = () => pullComputed(node);
|
|
847
993
|
|
|
848
994
|
read.subscribe = (fn) => {
|
|
@@ -852,6 +998,7 @@ export function createRegistry(config = {}) {
|
|
|
852
998
|
const val = read();
|
|
853
999
|
const prevTracking = isTrackingDeps;
|
|
854
1000
|
isTrackingDeps = false;
|
|
1001
|
+
|
|
855
1002
|
try {
|
|
856
1003
|
fn(val);
|
|
857
1004
|
} finally {
|
|
@@ -862,6 +1009,7 @@ export function createRegistry(config = {}) {
|
|
|
862
1009
|
|
|
863
1010
|
read[NODE_PTR] = node;
|
|
864
1011
|
read[NODE_GEN] = node.gen | 0;
|
|
1012
|
+
|
|
865
1013
|
return read;
|
|
866
1014
|
}
|
|
867
1015
|
|
|
@@ -891,9 +1039,11 @@ export function createRegistry(config = {}) {
|
|
|
891
1039
|
statEffects = (statEffects + 1) | 0;
|
|
892
1040
|
|
|
893
1041
|
let firstRunError = null;
|
|
1042
|
+
|
|
894
1043
|
if (scheduler) {
|
|
895
1044
|
const gen = node.gen | 0;
|
|
896
|
-
|
|
1045
|
+
node.schedulerThunk = () => safeExecute(node, gen);
|
|
1046
|
+
scheduler(node.schedulerThunk);
|
|
897
1047
|
} else {
|
|
898
1048
|
try {
|
|
899
1049
|
executeEffect(node);
|
|
@@ -906,6 +1056,7 @@ export function createRegistry(config = {}) {
|
|
|
906
1056
|
|
|
907
1057
|
let disposed = false;
|
|
908
1058
|
const birthGen = node.gen | 0;
|
|
1059
|
+
|
|
909
1060
|
const disposeFn = function dispose() {
|
|
910
1061
|
if (disposed) return;
|
|
911
1062
|
disposed = true;
|
|
@@ -926,6 +1077,7 @@ export function createRegistry(config = {}) {
|
|
|
926
1077
|
disposeFn();
|
|
927
1078
|
throw firstRunError;
|
|
928
1079
|
}
|
|
1080
|
+
|
|
929
1081
|
return disposeFn;
|
|
930
1082
|
}
|
|
931
1083
|
|
|
@@ -974,9 +1126,12 @@ export function createRegistry(config = {}) {
|
|
|
974
1126
|
function batch(fn) {
|
|
975
1127
|
if (batchDepth === 0) {
|
|
976
1128
|
batchEpoch = (batchEpoch + 1) | 0;
|
|
1129
|
+
/* c8 ignore next -- 2^32 wraparound sentinel; unreachable without ~4e9 batches */
|
|
977
1130
|
if (batchEpoch === 0) batchEpoch = 1 | 0; // preserve the 0 sentinel
|
|
978
1131
|
}
|
|
1132
|
+
|
|
979
1133
|
batchDepth = (batchDepth + 1) | 0;
|
|
1134
|
+
|
|
980
1135
|
try {
|
|
981
1136
|
return fn();
|
|
982
1137
|
} finally {
|
|
@@ -994,25 +1149,12 @@ export function createRegistry(config = {}) {
|
|
|
994
1149
|
* @returns {T}
|
|
995
1150
|
*/
|
|
996
1151
|
/**
|
|
997
|
-
* Returns true iff a read RIGHT NOW would record a dependency
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
* and outside any observer.
|
|
1004
|
-
*
|
|
1005
|
-
* Use case: wrapper libraries (lite-store, lite-query, lite-form) that
|
|
1006
|
-
* lazily allocate reactive primitives on property reads — gating on
|
|
1007
|
-
* isTracking() lets them skip allocation when reads happen outside a
|
|
1008
|
-
* reactive context, preserving the zero-GC contract.
|
|
1009
|
-
*
|
|
1010
|
-
* Cost: two closure-variable loads, one AND, one return. ~1–2 ns.
|
|
1011
|
-
*
|
|
1012
|
-
* Note: per-registry. Wrappers operating against a non-default registry
|
|
1013
|
-
* MUST call that registry's `isTracking()`, not the top-level one — each
|
|
1014
|
-
* registry has its own tracking state.
|
|
1015
|
-
*
|
|
1152
|
+
* Returns true iff a read RIGHT NOW would record a dependency on this
|
|
1153
|
+
* registry. Mirrors the engine's own read-trap predicate (both flags).
|
|
1154
|
+
* False inside untrack(), subscribe callbacks, onCleanup bodies,
|
|
1155
|
+
* watch/when callbacks, and outside any observer. For wrapper libraries
|
|
1156
|
+
* (lite-store, lite-query, lite-form) that lazily allocate signals on
|
|
1157
|
+
* property reads. Per-registry. ~1-2 ns.
|
|
1016
1158
|
* @returns {boolean}
|
|
1017
1159
|
*/
|
|
1018
1160
|
function isTracking() {
|
|
@@ -1022,6 +1164,7 @@ export function createRegistry(config = {}) {
|
|
|
1022
1164
|
function untrack(fn) {
|
|
1023
1165
|
const prev = isTrackingDeps;
|
|
1024
1166
|
isTrackingDeps = false;
|
|
1167
|
+
|
|
1025
1168
|
try {
|
|
1026
1169
|
return fn();
|
|
1027
1170
|
} finally {
|
|
@@ -1039,6 +1182,7 @@ export function createRegistry(config = {}) {
|
|
|
1039
1182
|
function onCleanup(fn) {
|
|
1040
1183
|
if (currentObserver !== null) {
|
|
1041
1184
|
const existing = currentObserver.cleanupFn;
|
|
1185
|
+
|
|
1042
1186
|
if (existing === undefined) currentObserver.cleanupFn = fn;
|
|
1043
1187
|
else if (typeof existing === "function") currentObserver.cleanupFn = [existing, fn];
|
|
1044
1188
|
else existing.push(fn);
|
|
@@ -1075,6 +1219,7 @@ export function createRegistry(config = {}) {
|
|
|
1075
1219
|
n.cleanupFn = undefined;
|
|
1076
1220
|
n.equals = undefined;
|
|
1077
1221
|
n.scheduler = undefined;
|
|
1222
|
+
n.schedulerThunk = undefined;
|
|
1078
1223
|
n.flags = 0;
|
|
1079
1224
|
n.headDep = null;
|
|
1080
1225
|
n.tailDep = null;
|
|
@@ -1089,11 +1234,15 @@ export function createRegistry(config = {}) {
|
|
|
1089
1234
|
n.preBatchVersion = 0;
|
|
1090
1235
|
// Bump gen so any scheduler trampolines holding a stale node ref bail.
|
|
1091
1236
|
n.gen = (n.gen + 1) | 0;
|
|
1237
|
+
|
|
1092
1238
|
if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
|
|
1093
1239
|
}
|
|
1240
|
+
|
|
1094
1241
|
nodePool[currentNodesCapacity - 1].nextFree = null;
|
|
1095
1242
|
freeNodeHead = nodePool[0];
|
|
1096
1243
|
|
|
1244
|
+
// ... remainder of destroy() stays exactly the same
|
|
1245
|
+
|
|
1097
1246
|
for (let i = 0; i < currentLinkCapacity; i++) {
|
|
1098
1247
|
const l = linkPool[i];
|
|
1099
1248
|
l.source = null;
|
|
@@ -1104,6 +1253,7 @@ export function createRegistry(config = {}) {
|
|
|
1104
1253
|
l.nextSub = null;
|
|
1105
1254
|
if (i < currentLinkCapacity - 1) l.nextFree = linkPool[i + 1];
|
|
1106
1255
|
}
|
|
1256
|
+
|
|
1107
1257
|
linkPool[currentLinkCapacity - 1].nextFree = null;
|
|
1108
1258
|
freeLinkHead = linkPool[0];
|
|
1109
1259
|
|
|
@@ -1127,7 +1277,76 @@ export function createRegistry(config = {}) {
|
|
|
1127
1277
|
flushErrorBuffer.length = 0; // release the backing array
|
|
1128
1278
|
}
|
|
1129
1279
|
|
|
1130
|
-
|
|
1280
|
+
function hasObservers(handle) {
|
|
1281
|
+
const node = handle != null ? handle[NODE_PTR] : undefined;
|
|
1282
|
+
return node !== undefined && node.headSub !== null;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function observeObservers(handle, opts) {
|
|
1286
|
+
const node = handle != null ? handle[NODE_PTR] : undefined;
|
|
1287
|
+
if (node === undefined) throw new TypeError("observeObservers: argument is not a reactive handle");
|
|
1288
|
+
let e = lifecycleMap.get(node);
|
|
1289
|
+
if (e === undefined) {
|
|
1290
|
+
e = {onConnect: undefined, onDisconnect: undefined};
|
|
1291
|
+
lifecycleMap.set(node, e);
|
|
1292
|
+
lifecycleCount = (lifecycleCount + 1) | 0;
|
|
1293
|
+
}
|
|
1294
|
+
if (opts !== undefined) {
|
|
1295
|
+
if (opts.onConnect !== undefined) e.onConnect = opts.onConnect;
|
|
1296
|
+
if (opts.onDisconnect !== undefined) e.onDisconnect = opts.onDisconnect;
|
|
1297
|
+
}
|
|
1298
|
+
let live = true;
|
|
1299
|
+
return () => {
|
|
1300
|
+
if (!live) return;
|
|
1301
|
+
live = false;
|
|
1302
|
+
if (lifecycleMap.delete(node)) lifecycleCount = (lifecycleCount - 1) | 0;
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function describeNode(node) {
|
|
1307
|
+
const fl = node.flags;
|
|
1308
|
+
const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
|
|
1309
|
+
return {kind, value: node.value};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function forEachObserver(handle, fn) {
|
|
1313
|
+
const node = handle != null ? handle[NODE_PTR] : undefined;
|
|
1314
|
+
if (node === undefined) return;
|
|
1315
|
+
let l = node.headSub;
|
|
1316
|
+
while (l !== null) {
|
|
1317
|
+
const nx = l.nextSub;
|
|
1318
|
+
fn(describeNode(l.target));
|
|
1319
|
+
l = nx;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function forEachSource(handle, fn) {
|
|
1324
|
+
const node = handle != null ? handle[NODE_PTR] : undefined;
|
|
1325
|
+
if (node === undefined) return;
|
|
1326
|
+
let l = node.headDep;
|
|
1327
|
+
while (l !== null) {
|
|
1328
|
+
const nx = l.nextDep;
|
|
1329
|
+
fn(describeNode(l.source));
|
|
1330
|
+
l = nx;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
return {
|
|
1335
|
+
signal,
|
|
1336
|
+
computed,
|
|
1337
|
+
effect,
|
|
1338
|
+
dispose,
|
|
1339
|
+
batch,
|
|
1340
|
+
untrack,
|
|
1341
|
+
onCleanup,
|
|
1342
|
+
stats,
|
|
1343
|
+
destroy,
|
|
1344
|
+
isTracking,
|
|
1345
|
+
hasObservers,
|
|
1346
|
+
observeObservers,
|
|
1347
|
+
forEachObserver,
|
|
1348
|
+
forEachSource
|
|
1349
|
+
};
|
|
1131
1350
|
}
|
|
1132
1351
|
|
|
1133
1352
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -1178,13 +1397,28 @@ export function untrack(fn) {
|
|
|
1178
1397
|
|
|
1179
1398
|
/**
|
|
1180
1399
|
* True iff a read RIGHT NOW would record a dependency on the default registry.
|
|
1181
|
-
* See {@link createRegistry} for the per-registry version.
|
|
1182
|
-
* against a non-default registry MUST call THAT registry's `isTracking()`.
|
|
1400
|
+
* See {@link createRegistry} for the per-registry version.
|
|
1183
1401
|
*/
|
|
1184
1402
|
export function isTracking() {
|
|
1185
1403
|
return defaultRegistry.isTracking();
|
|
1186
1404
|
}
|
|
1187
1405
|
|
|
1406
|
+
export function hasObservers(handle) {
|
|
1407
|
+
return defaultRegistry.hasObservers(handle);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
export function observeObservers(handle, opts) {
|
|
1411
|
+
return defaultRegistry.observeObservers(handle, opts);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
export function forEachObserver(handle, fn) {
|
|
1415
|
+
return defaultRegistry.forEachObserver(handle, fn);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
export function forEachSource(handle, fn) {
|
|
1419
|
+
return defaultRegistry.forEachSource(handle, fn);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1188
1422
|
/** @type {Registry["onCleanup"]} */
|
|
1189
1423
|
export function onCleanup(fn) {
|
|
1190
1424
|
return defaultRegistry.onCleanup(fn);
|
package/Watch.js
CHANGED
|
@@ -121,7 +121,10 @@ export function when(predicate, callback) {
|
|
|
121
121
|
|
|
122
122
|
stopFn = effect(() => {
|
|
123
123
|
// Defense-in-depth: even if dispose timing lets one more evaluation
|
|
124
|
-
// through (e.g., during sync propagation), don't fire twice.
|
|
124
|
+
// through (e.g., during sync propagation), don't fire twice. In practice
|
|
125
|
+
// stop() disposes this effect before any re-entry, so the early return is
|
|
126
|
+
// unreachable under the engine's self-cycle no-re-run guard — hence ignored.
|
|
127
|
+
/* c8 ignore next -- unreachable defensive guard; see comment above */
|
|
125
128
|
if (fired) return;
|
|
126
129
|
if (predicate()) {
|
|
127
130
|
fired = true;
|
package/llms.txt
CHANGED
|
@@ -21,6 +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
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.
|
|
25
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.
|
|
26
27
|
|
|
@@ -45,10 +46,22 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
45
46
|
- `computed()` cache miss — O(deps + body), zero alloc if dep structure is stable.
|
|
46
47
|
- Effect re-run with stable dep order — zero alloc.
|
|
47
48
|
- Stable read order: O(1) per dep via cursor reuse.
|
|
48
|
-
- Chaotic/randomized read order:
|
|
49
|
+
- Chaotic/randomized read order: 1.1.4 added version-stamped per-source reconciliation plus a `markEpoch` clean-read short-circuit on the pull, so the prior O(N)-per-dep degradation no longer dominates high-fan-in batched read-after-write — the `dyn: large web app` and `dyn: wide dense` shapes that were the v1.1.x weakness are now the fastest of the five benchmarked frameworks (see resultsReactive.txt). Correctness verified by `retracking.difftest.mjs` (20,000 direct + 10,000 batched writes, 0 disagreements).
|
|
49
50
|
|
|
50
51
|
## Version notes
|
|
51
52
|
|
|
53
|
+
- **1.1.4** (current): combined release — a retracking rewrite plus an observer-lifecycle
|
|
54
|
+
introspection surface. Drop-in over 1.1.3. **Retracking:** version-stamped O(1)
|
|
55
|
+
reconciliation + a `markEpoch` clean-read short-circuit replace the cursor strategy's
|
|
56
|
+
O(N)-per-dep degradation under chaotic high-fan-in batched read-after-write; stable read
|
|
57
|
+
order is unchanged (still O(1), still zero-alloc). The two documented v1.1.x losses flipped
|
|
58
|
+
to wins and are now fastest of five frameworks — `dyn: large web app` 6194ms→571ms, `dyn:
|
|
59
|
+
wide dense` 5115ms→912ms (verified by `retracking.difftest.mjs`: 20k direct + 10k batched, 0
|
|
60
|
+
disagreements; no regressions elsewhere). **Introspection:** `hasObservers`,
|
|
61
|
+
`observeObservers`, `forEachObserver`, `forEachSource` (top-level + per-registry), gated by
|
|
62
|
+
an internal counter so zero steady-state cost when unused. New `test/13-introspection_test.mjs`
|
|
63
|
+
(10 tests). Internally staged as 1.1.4 + 1.1.5; ships as a single 1.1.4.
|
|
64
|
+
|
|
52
65
|
- **1.1.3**: adds `isTracking()` (top-level + per-registry). Returns true iff a
|
|
53
66
|
read right now would record a dependency (`isTrackingDeps && currentObserver !== null`).
|
|
54
67
|
False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, `watch` /
|
|
@@ -59,7 +72,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
59
72
|
No behavior or engine changes.
|
|
60
73
|
|
|
61
74
|
|
|
62
|
-
- **1.1.2
|
|
75
|
+
- **1.1.2**: hot-path micro-optimizations, no behavior/API change.
|
|
63
76
|
Inlined cursor fast-path in `signal`/`computed` reads (stable-order reads skip
|
|
64
77
|
the `allocateLink` call); zero-allocation creation path (`opts` read defensively
|
|
65
78
|
instead of defaulting to `{}`); single-closure `subscribe`. Owner-tree
|
|
@@ -77,8 +90,8 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
77
90
|
## When NOT to use
|
|
78
91
|
|
|
79
92
|
- Server-side rendering — no SSR story.
|
|
80
|
-
-
|
|
81
|
-
- If you want time-travel
|
|
93
|
+
- Graph *construction* is allocation-heavy (per-node closures): on create-many micro-benchmarks `alien-signals` builds faster. Real apps build once and update forever — lite-signal leads the update + dynamic-topology rows. (The former "chaotic read order" caveat was closed in 1.1.4.)
|
|
94
|
+
- If you want time-travel or serialization — build those on top. (Graph-inspection devtools are now buildable on the 1.1.4 introspection surface; see lite-devtools.)
|
|
82
95
|
|
|
83
96
|
## API summary
|
|
84
97
|
|
|
@@ -91,6 +104,13 @@ function dispose(api: Signal<any> | Computed<any> | Dispose): void;
|
|
|
91
104
|
function batch<T>(fn: () => T): T;
|
|
92
105
|
function untrack<T>(fn: () => T): T;
|
|
93
106
|
function isTracking(): boolean;
|
|
107
|
+
function hasObservers(handle: Signal<any> | Computed<any>): boolean;
|
|
108
|
+
function observeObservers(
|
|
109
|
+
handle: Signal<any> | Computed<any>,
|
|
110
|
+
hooks?: { onConnect?: () => void; onDisconnect?: () => void }
|
|
111
|
+
): () => void; // returns idempotent unobserve
|
|
112
|
+
function forEachObserver(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
|
|
113
|
+
function forEachSource(handle: Signal<any> | Computed<any>, fn: (d: NodeDescriptor) => void): void;
|
|
94
114
|
function onCleanup(fn: () => void): void;
|
|
95
115
|
function stats(): RegistryStats;
|
|
96
116
|
|
|
@@ -114,6 +134,11 @@ interface Computed<T> {
|
|
|
114
134
|
|
|
115
135
|
type Dispose = () => void;
|
|
116
136
|
|
|
137
|
+
interface NodeDescriptor { // yielded by forEachObserver / forEachSource
|
|
138
|
+
kind: "signal" | "computed" | "effect";
|
|
139
|
+
value: unknown; // node's current value
|
|
140
|
+
}
|
|
141
|
+
|
|
117
142
|
interface RegistryConfig {
|
|
118
143
|
maxNodes?: number; // default 1024
|
|
119
144
|
maxLinks?: number; // default maxNodes * 4
|
|
@@ -207,6 +232,13 @@ that dominate UI workloads. alien-signals retains a 15% lead on 256-deep compute
|
|
|
207
232
|
pipelines, where its flatter internal representation pays off when the propagation
|
|
208
233
|
path is long rather than wide.
|
|
209
234
|
|
|
235
|
+
These four are the *stable* topologies (unchanged through 1.1.4). The chaotic,
|
|
236
|
+
high-fan-in shapes that were lite-signal's documented weakness — `dyn: large web app`
|
|
237
|
+
and `dyn: wide dense` in the cross-framework reactivity suite — were closed by the
|
|
238
|
+
1.1.4 retracking rewrite and are now the fastest of five frameworks: 571ms / 912ms
|
|
239
|
+
vs alien-signals' 623ms / 1001ms, with preact and vue 10–18× slower. See
|
|
240
|
+
resultsReactive.txt for the full 34-test, 5-framework table.
|
|
241
|
+
|
|
210
242
|
On allocation pressure, lite-signal is alone in the zero-Δheap band: ~15 KB of
|
|
211
243
|
transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
|
|
212
244
|
~230 KB per loop; solid runs into single-digit megabytes; alien-signals — which
|
|
@@ -264,7 +296,13 @@ sandbox.destroy(); // entire reactive world reset
|
|
|
264
296
|
- `test/07-dispose.test.mjs` — universal disposal: registry.dispose(api).
|
|
265
297
|
- `test/08-watch.test.mjs` — new watch reactivity tests.
|
|
266
298
|
- `test/09-conformance.test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
|
|
267
|
-
- `
|
|
299
|
+
- `test/10-is-tracking_test.mjs` — `isTracking()` across observer bodies, untracked windows, and outside any observer.
|
|
300
|
+
- `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
|
+
- `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
|
+
- `test/13-introspection_test.mjs` — observer-lifecycle surface: `hasObservers`, `observeObservers` auto-pause, `forEach*` enumeration (10 tests).
|
|
303
|
+
- `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).
|
|
304
|
+
- `bench/benchmark.mjs` — anti-DCE throughput harness (ops/s; results.txt).
|
|
305
|
+
- `bench/benchmarkReactive.mjs` — cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, solid (resultsReactive.txt).
|
|
268
306
|
- `demo/index.html` — interactive visualization of the reactive graph.
|
|
269
307
|
|
|
270
308
|
## Install
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"test": "node --test --test-reporter=spec",
|
|
29
29
|
"test:gc": "node --expose-gc --test --test-reporter=spec",
|
|
30
|
+
"test:coverage": "c8 node --test --test-reporter=spec",
|
|
30
31
|
"bench": "node --expose-gc bench/benchmark.mjs",
|
|
31
32
|
"bench-reactive": "node --expose-gc bench/benchmarkReactive.mjs",
|
|
32
33
|
"verify": "npm test && npm run bench"
|
|
@@ -57,5 +58,12 @@
|
|
|
57
58
|
"engines": {
|
|
58
59
|
"node": ">=18"
|
|
59
60
|
},
|
|
60
|
-
"
|
|
61
|
+
"funding": {
|
|
62
|
+
"type": "github",
|
|
63
|
+
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
64
|
+
},
|
|
65
|
+
"sideEffects": false,
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"c8": "^11.0.0"
|
|
68
|
+
}
|
|
61
69
|
}
|