@zakkster/lite-signal 1.1.2 → 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 +124 -29
- package/Signal.d.ts +51 -0
- package/Signal.js +305 -36
- package/Watch.js +4 -1
- package/llms.txt +55 -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)
|
|
@@ -90,6 +92,7 @@ No microtask between `B` and `I`. No promise, no `queueMicrotask`. Just call sta
|
|
|
90
92
|
- **`dispose(api)`** — universal disposal for signals, computeds, and effect handles. Cross-registry calls are silent no-ops.
|
|
91
93
|
- **`batch(fn)`** — defer effect flush until the outermost batch closes.
|
|
92
94
|
- **`untrack(fn)`** — read without subscribing.
|
|
95
|
+
- **`isTracking()`** — `true` iff a read right now would subscribe (for lazy-allocation wrappers).
|
|
93
96
|
- **`onCleanup(fn)`** — register teardown for the current computation. Works in effects *and* computeds.
|
|
94
97
|
- **`createRegistry(config)`** — isolated pool for tests, plugins, sandboxing.
|
|
95
98
|
- **`stats()`** — pool occupancy snapshot. Used by the demo and easy to wire into perf overlays.
|
|
@@ -273,6 +276,53 @@ const value = untrack(() => s()); // read without subscribing
|
|
|
273
276
|
|
|
274
277
|
Useful inside computeds/effects when you need a current value but don't want it as a dependency.
|
|
275
278
|
|
|
279
|
+
### isTracking
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
function makeLazyField(initial) {
|
|
283
|
+
let s = null, value = initial;
|
|
284
|
+
return {
|
|
285
|
+
get() {
|
|
286
|
+
if (isTracking()) {
|
|
287
|
+
if (s === null) s = signal(value); // allocate only when subscribed
|
|
288
|
+
return s();
|
|
289
|
+
}
|
|
290
|
+
return value;
|
|
291
|
+
},
|
|
292
|
+
set(v) { value = v; if (s !== null) s.set(v); }
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Returns `true` iff a read right now would record a dependency on the current registry — an observer body is on the stack AND tracking is enabled. Mirrors the engine's own read-trap check (both flags), so it correctly returns `false` inside `untrack`, inside `subscribe` callbacks, inside `onCleanup` bodies, inside `watch` / `when` callbacks, and outside any observer.
|
|
298
|
+
|
|
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.
|
|
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
|
+
|
|
276
326
|
### onCleanup
|
|
277
327
|
|
|
278
328
|
```ts
|
|
@@ -499,22 +549,23 @@ These are the questions you'd ask in a code review, with the answers:
|
|
|
499
549
|
|
|
500
550
|
## Benchmarks
|
|
501
551
|
|
|
502
|
-
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).
|
|
503
553
|
|
|
504
|
-
| Scenario | What it stresses | lite-signal | alien-signals |
|
|
505
|
-
| ---------- | -------------------------------- | ----------- | ------------- |
|
|
506
|
-
| **MUX** | 256 signals → 1 sum → 1 effect (fan-in) | **
|
|
507
|
-
| **BROADCAST** | 1 signal → 1000 effects (fan-out)
|
|
508
|
-
| **KAIROS** | 1 signal → 1000 computeds → 1 effect | **14K**
|
|
509
|
-
| **DEEP CHAIN** | 256-deep computed chain → 1 effect
|
|
510
|
-
|
|
|
511
|
-
|
|
|
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 | — |
|
|
512
563
|
|
|
513
|
-
**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.
|
|
514
565
|
|
|
515
|
-
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.
|
|
516
567
|
|
|
517
|
-
> 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.
|
|
518
569
|
|
|
519
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:
|
|
520
571
|
|
|
@@ -547,6 +598,11 @@ Three tiers, all reproducible.
|
|
|
547
598
|
- **`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.
|
|
548
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.
|
|
549
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.
|
|
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.
|
|
550
606
|
|
|
551
607
|
```bash
|
|
552
608
|
npm test
|
|
@@ -569,7 +625,7 @@ npm run test:gc
|
|
|
569
625
|
|
|
570
626
|
### Tier 3 — Performance (comparative benchmark)
|
|
571
627
|
|
|
572
|
-
`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.
|
|
573
629
|
|
|
574
630
|
```bash
|
|
575
631
|
npm run bench
|
|
@@ -587,27 +643,38 @@ npm run verify # test + test:gc + a sanity bench
|
|
|
587
643
|
|
|
588
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.
|
|
589
645
|
|
|
590
|
-
|
|
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).
|
|
591
647
|
|
|
592
|
-
**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.
|
|
593
649
|
|
|
594
650
|
#### 1. Stable Topologies (Fan-in / Fan-out / Broadcast)
|
|
595
|
-
In stable environments (
|
|
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.
|
|
596
652
|
|
|
597
|
-
#### 2. Dynamic Topologies (Web Apps / Layered DAGs)
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
*Andrii's benchmark results for dynamic topologies:*
|
|
601
|
-
| Scenario | alien-signals | reflex | lite-signal |
|
|
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) |
|
|
602
656
|
| :--- | :--- | :--- | :--- |
|
|
603
657
|
| **1000x12 (4 sources, dynamic)** | 184ms | 194ms | 2031ms |
|
|
604
658
|
| **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
|
|
605
659
|
| **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
|
|
606
660
|
|
|
607
|
-
|
|
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%).
|
|
671
|
+
|
|
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.
|
|
608
676
|
|
|
609
|
-
|
|
610
|
-
v1.2 (in benchmark validation) — Andrii Volynets (alien-signals#117) extended the benchmark matrix to dynamic-topology workloads and identified retracking-cost asymptotics, not allocation pressure, as the dominant cost in those scenarios. v1.2 replaces the cursor-based retracking with per-source version-stamped reconciliation: O(1) per read regardless of read order or dep-set churn. v1.2 will be validated against the alien-signals topology matrix and the before/after numbers published on release.
|
|
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.
|
|
611
678
|
|
|
612
679
|
---
|
|
613
680
|
|
|
@@ -615,11 +682,34 @@ v1.2 (in benchmark validation) — Andrii Volynets (alien-signals#117) extended
|
|
|
615
682
|
|
|
616
683
|
- **A virtual DOM, JSX runtime, or rendering library.** It's the substrate. Plug it under whatever rendering layer you like.
|
|
617
684
|
- **A general-purpose state container.** No time-travel, no devtools integration, no serialization. (Build those on top if you need them.)
|
|
618
|
-
- **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.
|
|
619
686
|
- **A library for the server.** It works in Node, but there's no SSR story. Use it on the client.
|
|
620
687
|
|
|
621
688
|
---
|
|
622
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
|
+
|
|
623
713
|
## Browser and runtime support
|
|
624
714
|
|
|
625
715
|
Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScript.
|
|
@@ -710,7 +800,7 @@ lite-signal is evaluated against the
|
|
|
710
800
|
the most comprehensive behavioral test battery for JavaScript reactive
|
|
711
801
|
libraries.
|
|
712
802
|
|
|
713
|
-
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
|
|
714
804
|
detection (#123 / #132 / #147), throw isolation in flush (#121), and
|
|
715
805
|
inner-write propagation through computed chains (#180 / #213) — are **closed**
|
|
716
806
|
(landed in v1.1.1). The remaining gaps are one deliberate design choice (#179,
|
|
@@ -739,6 +829,10 @@ more useful to library users than a green checkmark.
|
|
|
739
829
|
and most others by one re-evaluation per write.
|
|
740
830
|
- **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
|
|
741
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.
|
|
742
836
|
|
|
743
837
|
### What lite-signal does NOT do yet
|
|
744
838
|
|
|
@@ -801,8 +895,9 @@ Yes, if your computeFn reads its deps in the same order each invocation. The `cu
|
|
|
801
895
|
```bash
|
|
802
896
|
npm test # behavior suite, ~1.3s
|
|
803
897
|
npm run test:gc # zero-gc suite, requires --expose-gc, ~3s
|
|
804
|
-
npm run bench # comparative benchmark, ~5min
|
|
805
|
-
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
|
|
806
901
|
```
|
|
807
902
|
|
|
808
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. */
|
|
@@ -133,6 +159,21 @@ export interface Registry {
|
|
|
133
159
|
dispose(api: Disposable): void;
|
|
134
160
|
batch<T>(fn: () => T): T;
|
|
135
161
|
untrack<T>(fn: () => T): T;
|
|
162
|
+
/** True iff a read RIGHT NOW would record a dependency on this registry.
|
|
163
|
+
* False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and
|
|
164
|
+
* outside any observer. Use for lazy-allocation wrappers like lite-store. */
|
|
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;
|
|
136
177
|
onCleanup(fn: () => void): void;
|
|
137
178
|
stats(): RegistryStats;
|
|
138
179
|
/** Reset everything: nodes, links, queues, global clock. Outstanding dispose
|
|
@@ -163,6 +204,16 @@ export function effect(fn: () => void, opts?: EffectOptions): Dispose;
|
|
|
163
204
|
export function dispose(api: Disposable): void;
|
|
164
205
|
export function batch<T>(fn: () => T): T;
|
|
165
206
|
export function untrack<T>(fn: () => T): T;
|
|
207
|
+
/** Top-level binding of {@link Registry.isTracking} against the default registry. */
|
|
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;
|
|
166
217
|
export function onCleanup(fn: () => void): void;
|
|
167
218
|
export function stats(): RegistryStats;
|
|
168
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;
|
|
309
|
+
|
|
310
|
+
if (prev !== null) prev.nextDep = null; else target.headDep = null;
|
|
213
311
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 {
|
|
@@ -993,9 +1148,23 @@ export function createRegistry(config = {}) {
|
|
|
993
1148
|
* @param {() => T} fn
|
|
994
1149
|
* @returns {T}
|
|
995
1150
|
*/
|
|
1151
|
+
/**
|
|
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.
|
|
1158
|
+
* @returns {boolean}
|
|
1159
|
+
*/
|
|
1160
|
+
function isTracking() {
|
|
1161
|
+
return isTrackingDeps && currentObserver !== null;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
996
1164
|
function untrack(fn) {
|
|
997
1165
|
const prev = isTrackingDeps;
|
|
998
1166
|
isTrackingDeps = false;
|
|
1167
|
+
|
|
999
1168
|
try {
|
|
1000
1169
|
return fn();
|
|
1001
1170
|
} finally {
|
|
@@ -1013,6 +1182,7 @@ export function createRegistry(config = {}) {
|
|
|
1013
1182
|
function onCleanup(fn) {
|
|
1014
1183
|
if (currentObserver !== null) {
|
|
1015
1184
|
const existing = currentObserver.cleanupFn;
|
|
1185
|
+
|
|
1016
1186
|
if (existing === undefined) currentObserver.cleanupFn = fn;
|
|
1017
1187
|
else if (typeof existing === "function") currentObserver.cleanupFn = [existing, fn];
|
|
1018
1188
|
else existing.push(fn);
|
|
@@ -1049,6 +1219,7 @@ export function createRegistry(config = {}) {
|
|
|
1049
1219
|
n.cleanupFn = undefined;
|
|
1050
1220
|
n.equals = undefined;
|
|
1051
1221
|
n.scheduler = undefined;
|
|
1222
|
+
n.schedulerThunk = undefined;
|
|
1052
1223
|
n.flags = 0;
|
|
1053
1224
|
n.headDep = null;
|
|
1054
1225
|
n.tailDep = null;
|
|
@@ -1063,11 +1234,15 @@ export function createRegistry(config = {}) {
|
|
|
1063
1234
|
n.preBatchVersion = 0;
|
|
1064
1235
|
// Bump gen so any scheduler trampolines holding a stale node ref bail.
|
|
1065
1236
|
n.gen = (n.gen + 1) | 0;
|
|
1237
|
+
|
|
1066
1238
|
if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
|
|
1067
1239
|
}
|
|
1240
|
+
|
|
1068
1241
|
nodePool[currentNodesCapacity - 1].nextFree = null;
|
|
1069
1242
|
freeNodeHead = nodePool[0];
|
|
1070
1243
|
|
|
1244
|
+
// ... remainder of destroy() stays exactly the same
|
|
1245
|
+
|
|
1071
1246
|
for (let i = 0; i < currentLinkCapacity; i++) {
|
|
1072
1247
|
const l = linkPool[i];
|
|
1073
1248
|
l.source = null;
|
|
@@ -1078,6 +1253,7 @@ export function createRegistry(config = {}) {
|
|
|
1078
1253
|
l.nextSub = null;
|
|
1079
1254
|
if (i < currentLinkCapacity - 1) l.nextFree = linkPool[i + 1];
|
|
1080
1255
|
}
|
|
1256
|
+
|
|
1081
1257
|
linkPool[currentLinkCapacity - 1].nextFree = null;
|
|
1082
1258
|
freeLinkHead = linkPool[0];
|
|
1083
1259
|
|
|
@@ -1101,7 +1277,76 @@ export function createRegistry(config = {}) {
|
|
|
1101
1277
|
flushErrorBuffer.length = 0; // release the backing array
|
|
1102
1278
|
}
|
|
1103
1279
|
|
|
1104
|
-
|
|
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
|
+
};
|
|
1105
1350
|
}
|
|
1106
1351
|
|
|
1107
1352
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -1150,6 +1395,30 @@ export function untrack(fn) {
|
|
|
1150
1395
|
return defaultRegistry.untrack(fn);
|
|
1151
1396
|
}
|
|
1152
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* True iff a read RIGHT NOW would record a dependency on the default registry.
|
|
1400
|
+
* See {@link createRegistry} for the per-registry version.
|
|
1401
|
+
*/
|
|
1402
|
+
export function isTracking() {
|
|
1403
|
+
return defaultRegistry.isTracking();
|
|
1404
|
+
}
|
|
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
|
+
|
|
1153
1422
|
/** @type {Registry["onCleanup"]} */
|
|
1154
1423
|
export function onCleanup(fn) {
|
|
1155
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
|
@@ -19,7 +19,9 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
19
19
|
- **dispose(api)**: universal disposal. Accepts a signal, computed, or effect dispose handle; idempotent; cross-registry calls are silent no-ops (per-registry `Symbol("node_ptr")` keys the node-identity slot on the returned API function, foreign signals fail the lookup and fall through). Passing an unrelated value is also safe; passing an arbitrary function invokes it (effect-handle contract).
|
|
20
20
|
- **Batch**: `batch(fn)` defers effect flush until the outermost batch closes. Nestable.
|
|
21
21
|
- **Untrack**: `untrack(fn)` reads without subscribing.
|
|
22
|
+
- **isTracking**: `isTracking()` returns true iff a read right now would record a dependency on this registry (for wrappers that lazily allocate signals).
|
|
22
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`.
|
|
23
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.
|
|
24
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.
|
|
25
27
|
|
|
@@ -44,11 +46,33 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
44
46
|
- `computed()` cache miss — O(deps + body), zero alloc if dep structure is stable.
|
|
45
47
|
- Effect re-run with stable dep order — zero alloc.
|
|
46
48
|
- Stable read order: O(1) per dep via cursor reuse.
|
|
47
|
-
- 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).
|
|
48
50
|
|
|
49
51
|
## Version notes
|
|
50
52
|
|
|
51
|
-
- **1.1.
|
|
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
|
+
|
|
65
|
+
- **1.1.3**: adds `isTracking()` (top-level + per-registry). Returns true iff a
|
|
66
|
+
read right now would record a dependency (`isTrackingDeps && currentObserver !== null`).
|
|
67
|
+
False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, `watch` /
|
|
68
|
+
`when` callbacks, and outside any observer. ~1–2 ns. For wrapper libraries
|
|
69
|
+
(lite-store, lite-query, lite-form) that allocate reactive primitives lazily
|
|
70
|
+
on property reads. Per-registry: wrappers operating against a non-default
|
|
71
|
+
registry must call THAT registry's `isTracking()`, not the top-level one.
|
|
72
|
+
No behavior or engine changes.
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
- **1.1.2**: hot-path micro-optimizations, no behavior/API change.
|
|
52
76
|
Inlined cursor fast-path in `signal`/`computed` reads (stable-order reads skip
|
|
53
77
|
the `allocateLink` call); zero-allocation creation path (`opts` read defensively
|
|
54
78
|
instead of defaulting to `{}`); single-closure `subscribe`. Owner-tree
|
|
@@ -66,8 +90,8 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
66
90
|
## When NOT to use
|
|
67
91
|
|
|
68
92
|
- Server-side rendering — no SSR story.
|
|
69
|
-
-
|
|
70
|
-
- 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.)
|
|
71
95
|
|
|
72
96
|
## API summary
|
|
73
97
|
|
|
@@ -79,6 +103,14 @@ function effect(fn: () => void, opts?: { scheduler?: (run: () => void) => void }
|
|
|
79
103
|
function dispose(api: Signal<any> | Computed<any> | Dispose): void;
|
|
80
104
|
function batch<T>(fn: () => T): T;
|
|
81
105
|
function untrack<T>(fn: () => T): T;
|
|
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;
|
|
82
114
|
function onCleanup(fn: () => void): void;
|
|
83
115
|
function stats(): RegistryStats;
|
|
84
116
|
|
|
@@ -102,6 +134,11 @@ interface Computed<T> {
|
|
|
102
134
|
|
|
103
135
|
type Dispose = () => void;
|
|
104
136
|
|
|
137
|
+
interface NodeDescriptor { // yielded by forEachObserver / forEachSource
|
|
138
|
+
kind: "signal" | "computed" | "effect";
|
|
139
|
+
value: unknown; // node's current value
|
|
140
|
+
}
|
|
141
|
+
|
|
105
142
|
interface RegistryConfig {
|
|
106
143
|
maxNodes?: number; // default 1024
|
|
107
144
|
maxLinks?: number; // default maxNodes * 4
|
|
@@ -195,6 +232,13 @@ that dominate UI workloads. alien-signals retains a 15% lead on 256-deep compute
|
|
|
195
232
|
pipelines, where its flatter internal representation pays off when the propagation
|
|
196
233
|
path is long rather than wide.
|
|
197
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
|
+
|
|
198
242
|
On allocation pressure, lite-signal is alone in the zero-Δheap band: ~15 KB of
|
|
199
243
|
transient garbage per 20,000-iteration loop, regardless of scenario. preact runs
|
|
200
244
|
~230 KB per loop; solid runs into single-digit megabytes; alien-signals — which
|
|
@@ -252,7 +296,13 @@ sandbox.destroy(); // entire reactive world reset
|
|
|
252
296
|
- `test/07-dispose.test.mjs` — universal disposal: registry.dispose(api).
|
|
253
297
|
- `test/08-watch.test.mjs` — new watch reactivity tests.
|
|
254
298
|
- `test/09-conformance.test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
|
|
255
|
-
- `
|
|
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).
|
|
256
306
|
- `demo/index.html` — interactive visualization of the reactive graph.
|
|
257
307
|
|
|
258
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
|
}
|