@zakkster/lite-signal 1.2.2 → 1.3.0-rc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -1
- package/README.md +37 -30
- package/Signal.d.ts +28 -9
- package/Signal.js +228 -84
- package/Watch.js +13 -13
- package/llms.txt +47 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,108 @@ All notable changes to `@zakkster/lite-signal` are documented here.
|
|
|
4
4
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
-
## [1.
|
|
7
|
+
## [1.3.0] -- 2026-06-XX
|
|
8
|
+
|
|
9
|
+
The pool minor: the node and link pools become **growable and incrementally
|
|
10
|
+
populated**, the propagation mark phase moves to an **intrusive linked-list
|
|
11
|
+
stack**, and a small **registry config surface** (`prealloc`,
|
|
12
|
+
`onCapacityExceeded`, `maxFlushPasses`) is exposed. Drop-in over 1.2.2 -- the
|
|
13
|
+
hot paths and public callable API are byte-identical; everything here is pool
|
|
14
|
+
mechanics, construction-time behavior, and new opt-in config. Steady-state
|
|
15
|
+
zero-GC is unchanged: after warm-up the pools recycle exactly as before.
|
|
16
|
+
|
|
17
|
+
**Default behavior: `prealloc: "eager"`.** The pools are preallocated up front
|
|
18
|
+
by default, preserving 1.2.x's deterministic-latency profile (no allocation
|
|
19
|
+
inside a hot loop or render frame -- the contract that matters for the 16ms /
|
|
20
|
+
120fps Twitch-overlay and canvas use cases this engine targets). Lazy population
|
|
21
|
+
is available as an opt-in (`prealloc: "lazy"`) for footprint-sensitive or
|
|
22
|
+
fast-cold-start consumers. See the tradeoff note under *Added -- registry config*.
|
|
23
|
+
|
|
24
|
+
### Added -- growable pools (`onCapacityExceeded: "grow"`)
|
|
25
|
+
|
|
26
|
+
The node and link pools can now grow past their initial capacity instead of
|
|
27
|
+
only throwing. Growth is **chunked and incremental**, not a single doubling
|
|
28
|
+
burst:
|
|
29
|
+
|
|
30
|
+
- **Link pool** refills in contiguous runs of up to **1024** links per
|
|
31
|
+
free-list miss; **node pool** in runs of up to **256**. This bounds any
|
|
32
|
+
single growth pause to roughly `chunk x ~0.5us` and keeps the freshly
|
|
33
|
+
constructed slots contiguous in memory (better locality than scattered
|
|
34
|
+
one-at-a-time `new`).
|
|
35
|
+
- `onCapacityExceeded` (default `"throw"`) selects the policy: `"throw"` fails
|
|
36
|
+
fast with a `CapacityError` when a pool is full (the 1.2.x behavior, now
|
|
37
|
+
named); `"grow"` extends the pool on demand. Link growth is bounded by a hard
|
|
38
|
+
ceiling of `maxLinks * 16`.
|
|
39
|
+
- The growth path **no longer length-extends the effect queues or mark stack**.
|
|
40
|
+
Previously `arr.length = newCap` permanently converted those arrays from
|
|
41
|
+
PACKED to HOLEY elements-kind -- a silent flush-path tax. They now grow by
|
|
42
|
+
sequential `arr[len++] = x` appends, which keep them packed.
|
|
43
|
+
|
|
44
|
+
### Added -- registry config surface
|
|
45
|
+
|
|
46
|
+
`createRegistry(config)` accepts three new options. All are additive and
|
|
47
|
+
non-breaking; omitting `config` reproduces 1.2.x behavior with the eager
|
|
48
|
+
default.
|
|
49
|
+
|
|
50
|
+
- **`prealloc`** (`"eager"` default | `"lazy"`). `"eager"` constructs the full
|
|
51
|
+
`maxNodes` / `maxLinks` pools up front -- deterministic latency, zero
|
|
52
|
+
allocation inside any subsequent hot path, at the cost of a larger resident
|
|
53
|
+
heap that every major GC must trace. `"lazy"` treats `maxNodes` / `maxLinks`
|
|
54
|
+
as capacity *ledgers*, constructs nodes/links on first demand, and recycles
|
|
55
|
+
through the free lists thereafter -- smaller heap, faster cold start, lighter
|
|
56
|
+
GC marking, identical zero-GC steady state after warm-up. **Choose eager for
|
|
57
|
+
hard-real-time (render loops, game ticks, extension frame budgets); choose
|
|
58
|
+
lazy for footprint-sensitive or short-lived registries.**
|
|
59
|
+
- **`onCapacityExceeded`** (`"throw"` default | `"grow"`) -- see above.
|
|
60
|
+
- **`maxFlushPasses`** (default `100`) -- cycle-protection ceiling: the maximum
|
|
61
|
+
number of effect-queue drain passes before the flush throws an `Error`
|
|
62
|
+
prefixed `"CycleError:"`. Exposes what was a fixed internal bound so
|
|
63
|
+
pathological-but-legitimate deep-cascade graphs can raise it.
|
|
64
|
+
|
|
65
|
+
### Changed -- intrusive mark stack in `markDownstream`
|
|
66
|
+
|
|
67
|
+
The propagation mark phase now walks an **iterative DFS backed by an intrusive
|
|
68
|
+
linked-list stack** (a `nextMark` field on each node) instead of a separate
|
|
69
|
+
`markStack` container array. Because `nextMark` sits adjacent to the
|
|
70
|
+
`markEpoch` field that the same sweep reads, the stack write lands in an
|
|
71
|
+
already-hot cache line. Behaviorally identical -- same nodes marked in the same
|
|
72
|
+
order, same glitch-free guarantee -- and it removes the container array's growth
|
|
73
|
+
and HOLEY-conversion concerns entirely. The mark stack never grows the JS call
|
|
74
|
+
stack regardless of graph depth (the iterative property from 1.2.4 is retained).
|
|
75
|
+
|
|
76
|
+
### Added -- `ReactiveNode.nextMark`
|
|
77
|
+
|
|
78
|
+
One field added to the node shape to back the intrusive mark stack. Initialized
|
|
79
|
+
to `null` on construction, cleared on pop during a sweep and defensively on
|
|
80
|
+
dispose, so the chain stays clean for reuse. This is the only node-shape change
|
|
81
|
+
in 1.3.0.
|
|
82
|
+
|
|
83
|
+
### Verified
|
|
84
|
+
|
|
85
|
+
- **Full suite green** against the 1.3.0 engine: 424 tests, 414 pass, 0 fail,
|
|
86
|
+
10 skip. The 10 skips are the 9 `{skip:true}` `signalBox` tests in
|
|
87
|
+
`24-signalbox` (those primitives land in 1.5.0) plus 1 architecturally-N/A
|
|
88
|
+
SSR case in `17-reactivity`. The eager-default flip changed no test outcome.
|
|
89
|
+
Four new tests in `03-pool` cover the 1.3.0 paths: lazy on-demand construction
|
|
90
|
+
reaching the same steady state as eager, a never-allocated lazy registry
|
|
91
|
+
surviving `destroy()`, `"grow"` extending both pool ledgers, and the
|
|
92
|
+
`maxLinks * 16` link ceiling.
|
|
93
|
+
- **Coverage** (c8@11, Node 22): `Signal.js` 100% statements / 98.26% branches /
|
|
94
|
+
100% functions / 100% lines; `Watch.js` 100% across all four. The lazy-pool
|
|
95
|
+
`destroy()` paths added in 1.3.0 are covered by the new `03-pool` tests.
|
|
96
|
+
- **Behavior-preservation difftest**: 20,000 direct + 10,000 batched writes
|
|
97
|
+
against the published 1.1.5 reference, 0 disagreements. Pool growth, chunked
|
|
98
|
+
refill, and the intrusive mark stack do not alter observable propagation.
|
|
99
|
+
- **Zero-GC steady state holds**: after warm-up, writing through a built graph
|
|
100
|
+
allocates nothing and the pool does not grow (eager) / does not grow further
|
|
101
|
+
(lazy, post warm-up). Confirmed across deep-chain, wide fan-out, and batched
|
|
102
|
+
scenarios.
|
|
103
|
+
- **`stats()` shape unchanged from 1.2.x** (8 keys). The cumulative allocation
|
|
104
|
+
counters (`totalAllocations` / `totalDisposals` / `poolGrowths`) remain
|
|
105
|
+
reserved for 1.4.0 and are still absent here -- the introspection-contract
|
|
106
|
+
test continues to pin their absence.
|
|
107
|
+
|
|
108
|
+
|
|
8
109
|
|
|
9
110
|
A code-deletion ship: a `createNode` audit removes ten redundant field-writes
|
|
10
111
|
that defended against a state the engine cannot produce on a clean free-list.
|
package/README.md
CHANGED
|
@@ -444,10 +444,11 @@ The effect dispose handle (`const dispose = effect(...)`) is still a plain funct
|
|
|
444
444
|
|
|
445
445
|
```ts
|
|
446
446
|
const r = createRegistry({
|
|
447
|
-
maxNodes:
|
|
448
|
-
maxLinks:
|
|
449
|
-
|
|
450
|
-
|
|
447
|
+
maxNodes: 1024, // default (ledger)
|
|
448
|
+
maxLinks: 4 * 1024, // default = maxNodes * 4 (ledger)
|
|
449
|
+
prealloc: "eager", // default. Other: "lazy"
|
|
450
|
+
maxFlushPasses: 100, // default
|
|
451
|
+
onCapacityExceeded: "throw" // default. Other: "grow"
|
|
451
452
|
});
|
|
452
453
|
|
|
453
454
|
const s = r.signal(0);
|
|
@@ -466,29 +467,34 @@ r.destroy(); // reset all pools, invalidate generations
|
|
|
466
467
|
<details>
|
|
467
468
|
<summary>Pool sizing, the grow policy, and why there is a 16× link ceiling.</summary>
|
|
468
469
|
|
|
469
|
-
The engine has two
|
|
470
|
+
The engine has two pools: **nodes** and **links**. Their capacities are set at registry creation. As of **1.3.0** you also choose *when* the pools are populated (`prealloc`) and *what happens* when a capacity is hit (`onCapacityExceeded`).
|
|
471
|
+
|
|
472
|
+
**`prealloc` (1.3.0)** -- `"eager"` (default) constructs the full `maxNodes` / `maxLinks` pools up front: deterministic latency, zero allocation inside any subsequent hot path (the contract that matters for 16ms render frames and 120fps canvas loops), at the cost of a larger resident heap. `"lazy"` treats the capacities as *ledgers*, constructs nodes/links on first demand, and recycles through the free lists thereafter: smaller heap, faster cold start, lighter GC marking, **identical zero-GC steady state after warm-up**. Choose eager for hard-real-time, lazy for footprint-sensitive or short-lived registries (per-viewer sandboxes, tests).
|
|
473
|
+
|
|
474
|
+
**`onCapacityExceeded`** -- `"throw"` (default) fails fast with a `CapacityError`. `"grow"` extends the pool on demand. Growth is **chunked and incremental** -- contiguous runs of up to **1024 links / 256 nodes** per free-list miss, not a single doubling burst -- so any one growth pause stays bounded (~chunk x ~0.5us) and freshly constructed slots stay contiguous in memory. The capacity *ledger* still doubles, so `stats()` semantics are unchanged.
|
|
470
475
|
|
|
471
476
|
```mermaid
|
|
472
477
|
flowchart LR
|
|
473
|
-
A[allocator hits empty
|
|
478
|
+
A[allocator hits empty free-list] --> B{policy?}
|
|
474
479
|
B -- "throw" --> C[CapacityError]
|
|
475
|
-
B -- "grow" --> D[
|
|
476
|
-
D --> E{
|
|
480
|
+
B -- "grow" --> D[construct a chunked run<br/>up to 1024 links / 256 nodes<br/>ledger doubles]
|
|
481
|
+
D --> E{ledger > 16x original links?}
|
|
477
482
|
E -- yes --> F[CapacityError<br/>link ceiling]
|
|
478
483
|
E -- no --> G[allocate, continue]
|
|
479
484
|
```
|
|
480
485
|
|
|
481
|
-
Why a ceiling? Unbounded growth hides leaks. If your app reaches
|
|
486
|
+
Why a ceiling? Unbounded growth hides leaks. If your app reaches 16x its starting link capacity, something is wrong and you want to know -- `CapacityError` is louder than a slow OOM crash four hours later.
|
|
482
487
|
|
|
483
488
|
Default sizing for a Twitch-extension-style budget:
|
|
484
489
|
|
|
485
|
-
| Workload | maxNodes | maxLinks | policy |
|
|
486
|
-
| ----------------------------------- | -------- | -------- | -------- |
|
|
487
|
-
| Tiny widget (<=50 reactive cells) | 256 | 1024 | `"throw"` |
|
|
488
|
-
| Standard overlay (~500 cells) | 1024 | 4096 | `"throw"` |
|
|
489
|
-
| Heavy dashboard (variable scale) | 2048 | 16384 | `"grow"` |
|
|
490
|
+
| Workload | maxNodes | maxLinks | prealloc | policy |
|
|
491
|
+
| ----------------------------------- | -------- | -------- | -------- | -------- |
|
|
492
|
+
| Tiny widget (<=50 reactive cells) | 256 | 1024 | `"eager"` | `"throw"` |
|
|
493
|
+
| Standard overlay (~500 cells) | 1024 | 4096 | `"eager"` | `"throw"` |
|
|
494
|
+
| Heavy dashboard (variable scale) | 2048 | 16384 | `"eager"` | `"grow"` |
|
|
495
|
+
| Per-viewer sandbox / short-lived | 512 | 2048 | `"lazy"` | `"throw"` |
|
|
490
496
|
|
|
491
|
-
`stats()` reports `signals`, `computeds`, `effects`, `activeLinks`, `pooledLinks`, `linkPoolCapacity
|
|
497
|
+
`stats()` reports `signals`, `computeds`, `effects`, `activeNodes`, `activeLinks`, `pooledLinks`, `nodePoolCapacity`, `linkPoolCapacity` (8 keys; the capacity keys are ledgers under `"lazy"`). Drop it on screen for live observability.
|
|
492
498
|
|
|
493
499
|
</details>
|
|
494
500
|
|
|
@@ -500,9 +506,9 @@ Default sizing for a Twitch-extension-style budget:
|
|
|
500
506
|
|
|
501
507
|
| API | Use case | Lifecycle | Hot-path safe? |
|
|
502
508
|
|---|---|---|---|
|
|
503
|
-
| `watch(source, cb)` | observe value changes over time | manual `stop()` |
|
|
504
|
-
| `watch(source, (v, p, stop) => ...)` | observe until a condition | self-dispose via callback arg |
|
|
505
|
-
| `when(predicate, cb)` | one-shot trigger when condition first true | auto-dispose |
|
|
509
|
+
| `watch(source, cb)` | observe value changes over time | manual `stop()` | yes -- zero-GC per fire |
|
|
510
|
+
| `watch(source, (v, p, stop) => ...)` | observe until a condition | self-dispose via callback arg | yes -- zero-GC per fire |
|
|
511
|
+
| `when(predicate, cb)` | one-shot trigger when condition first true | auto-dispose | yes -- zero-GC per check |
|
|
506
512
|
| `whenAsync(predicate)` | await a condition | auto-dispose | ! allocates Promise -- see below |
|
|
507
513
|
|
|
508
514
|
### `watch(source, callback, options?)`
|
|
@@ -699,7 +705,7 @@ Three tiers, all reproducible.
|
|
|
699
705
|
|
|
700
706
|
- **`01-core_test.mjs`** -- signal/computed/effect basics, equality semantics, NaN/+/-0, subscribe/peek/update, untrack, batch, cleanup ordering, first-run error recovery, nested object reference-identity gotchas.
|
|
701
707
|
- **`02-topology_test.mjs`** -- diamond glitch-freedom, 256-deep and 1024-deep computed chains, wide fan-out (1000 effects from one signal), dynamic dependency switching, conditional fan-out, nested effects, cycle detection (`CycleError`).
|
|
702
|
-
- **`03-pool_test.mjs`** -- `CapacityError` under both `"throw"` and `"grow"` policies, the 16× link ceiling, stable pool reuse across thousands of create/dispose cycles, registry isolation.
|
|
708
|
+
- **`03-pool_test.mjs`** -- `CapacityError` under both `"throw"` and `"grow"` policies, the 16× link ceiling, stable pool reuse across thousands of create/dispose cycles, registry isolation, and (1.3.0) the lazy-prealloc paths: on-demand construction reaching the same steady state as eager, a never-allocated lazy registry surviving `destroy()`, and `"grow"` extending both pool ledgers past their initial capacity.
|
|
703
709
|
- **`05-scheduler_test.mjs`** -- scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
|
|
704
710
|
- **`06-nested-objects_test.mjs`** -- array mutation patterns (push/splice/spread), deep nested paths, Map/Set/Date inside signals, custom structural equality, computed memoisation cutoffs over object slices, signal-of-signals composition, high-frequency object updates, batched immutable updates.
|
|
705
711
|
- **`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.
|
|
@@ -719,8 +725,8 @@ Three tiers, all reproducible.
|
|
|
719
725
|
- **`21-perf-pins_test.mjs`** -- v1.2.1 construction-shape pins (6 tests). Locks the canonical handle shapes (`signal` 6 own props: peek/set/update/subscribe + NODE_PTR/NODE_GEN; `computed` 4: peek/subscribe + NODE_PTR/NODE_GEN) so a future "let's unify them" change has to be explicit. Locks the 1.2.1 ABA guards: detached `const {set} = signal()` keeps working on a LIVE signal; `read()` returns `undefined` and skips dep-tracking on a stale handle (no phantom subscription to the recycled slot); `set()` on a stale handle is a no-op across three corruption tiers (disposed slot, recycled slot, downstream propagation); `peek()` returns `undefined` for stale signal and computed handles.
|
|
720
726
|
- **`22-mutation-hook_test.mjs`** -- 1.2.1 `onGraphMutation` semantics (12 tests across 2 suites). Registration: unsubscribe returns a function; `null` argument clears and the unsub restores the prior listener; non-function/non-null throws `TypeError`; multiple registrations stack LIFO; registries are isolated (no cross-talk). Opcode emission: `1` node-create fires with `(id, flags)` for signal (32) / computed (1) / effect (2); `2` node-dispose fires for cascade-disposed owned children; `3` link-add fires with `(source.id, target.id)` on dependency record; `4` link-remove fires when a dep-set flip severs the tail; `5` recompute fires on initial eval AND re-eval; the hook fires synchronously inside the mutation (listener sees its own event before the caller returns); payload is always three plain numbers -- no objects, no closures.
|
|
721
727
|
- **`23-owner-introspection_test.mjs`** -- 1.2.1 owner-tree introspection + effect-disposer regression (22 tests across 4 suites). `ownerOf`: undefined for top-level / garbage input / stale handle; returns the enclosing effect's descriptor for a child created inside an effect body. `forEachOwned`: no-op for handles with no owned children / garbage input / stale handle; iterates owned children as `{id, kind, value}` descriptors. Gen-guarded introspection (ABA fix): `nodeId` / `describe` / `hasObservers` return undefined / false for stale handles; `observeObservers` throws `TypeError`; `forEachObserver` / `forEachSource` are no-ops; descriptors returned by `describeNode` are themselves gen-stamped so a descriptor obtained pre-recycle correctly walks as a no-op post-recycle (the "descriptors are re-walkable handles" contract survives the guard). Plus the 1.2.1 effect-dispose-handle fix: passing the effect's disposer directly to `describe` / `nodeId` / `forEachSource` / `forEachOwned` / `ownerOf` / `hasObservers` works as a first-class introspection handle (pre-fix it was a bare closure and returned `undefined` for a *live* effect); after `fx()` dispose the same handle correctly goes stale on every entry point; the disposer's `NODE_GEN` mirrors the effect node's birthGen exactly.
|
|
722
|
-
- **`24-signalbox_test.mjs`** -- staged for v1.5.0; all 9 tests `{skip: true}` on 1.
|
|
723
|
-
- **`25-devtools-real-boot_test.mjs`** -- Devtools/Studio contract (10 tests). Boots the actual `Devtools.js` against the 1.
|
|
728
|
+
- **`24-signalbox_test.mjs`** -- staged for v1.5.0; all 9 tests `{skip: true}` on 1.3.x. The `signalBox` / `computedBox` allocation-light handle API lands in 1.5.0; the suite is committed early so the surface is pinned and the skips are visible in the test count (the 10 skips on 1.3.0 are these 9 plus 1 architecturally-N/A SSR case in `17-reactivity`).
|
|
729
|
+
- **`25-devtools-real-boot_test.mjs`** -- Devtools/Studio contract (10 tests). Boots the actual `Devtools.js` against the 1.3.0 engine and exercises all 19 Devtools exports plus the 10 symbols Studio imports from Devtools. Pins the ghost contract: heavy introspection (graph walk, owner-tree, observer descriptors) adds **zero** nodes to the live graph. Catches the real-rig failure mode where importing the package by its own name from a repo whose `package.json` declares `name: "@zakkster/lite-signal"` resolves to the published build instead of the local engine.
|
|
724
730
|
- **`26-free-list-invariant_test.mjs`** -- the 1.2.2 audit's cleanliness pins (3 invariant tests + 1 targeted coverage test). Asserts directly -- by inspecting freshly-allocated nodes through the documented `describe()` -> `NODE_PTR` introspection protocol -- that the `ReactiveNode` constructor and the fresh-pool-growth path initialize the ten fields the audit removed from `createNode` to identical values, so the deleted writes were defending against a state the engine cannot produce on a clean free list. The 4th test covers the swallow-on-self-dispose-then-throw branch in `pullComputed` (the path that lifted branch coverage from 98.07% to 98.43%).
|
|
725
731
|
|
|
726
732
|
```bash
|
|
@@ -824,7 +830,8 @@ The cross-framework reactivity suite agrees independently, re-run on **1.2.2** (
|
|
|
824
830
|
### Roadmap
|
|
825
831
|
- **1.1.5** -- additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering). *Shipped.*
|
|
826
832
|
- **1.2.0** -- the **ownership hybrid**: an owner tree so nested effects/computeds auto-dispose with their parent (closes conformance #209 / #210, matching Solid's `createRoot` ergonomics). Plus three additive features built on the same internal split: pre-batch revert (`batch(() => { a.set(99); a.set(10); })` doesn't re-fire), multi-throw `AggregateError`, and scheduler-thunk caching with an ABA gen guard. *Shipped.*
|
|
827
|
-
- **1.3** --
|
|
833
|
+
- **1.3.0** -- the **pool minor**: node and link pools become growable and incrementally populated. New `prealloc` config (`"eager"` default | `"lazy"`) chooses up-front vs on-demand construction; `onCapacityExceeded: "grow"` extends pools via chunked refill (runs of up to 1024 links / 256 nodes, ledger doubles) bounded by the 16x link ceiling; `maxFlushPasses` is now a public config. Internally the propagation mark phase moved to an intrusive linked-list stack (a `nextMark` field) -- the only node-shape change. The hot paths and public callable API are byte-identical to 1.2.2; steady-state zero-GC is unchanged. *Shipped.*
|
|
834
|
+
- **1.4** -- next engine work. The pull-mode recursion depth limit (~5,000 chained computeds) is the main outstanding architectural item; cumulative allocation counters (`totalAllocations` / `totalDisposals` / `poolGrowths`) are reserved for the `stats()` surface here.
|
|
828
835
|
|
|
829
836
|
> 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.
|
|
830
837
|
|
|
@@ -873,14 +880,14 @@ Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScri
|
|
|
873
880
|
|
|
874
881
|
| Target | Supported |
|
|
875
882
|
| --------------------------------- | --------- |
|
|
876
|
-
| Chrome / Edge (last 2 majors) |
|
|
877
|
-
| Firefox (last 2 majors) |
|
|
878
|
-
| Safari 14+ |
|
|
879
|
-
| Node.js 18+ |
|
|
880
|
-
| Bun |
|
|
881
|
-
| Twitch Extensions (1MB / 3s) |
|
|
882
|
-
| Cloudflare Workers |
|
|
883
|
-
| Deno |
|
|
883
|
+
| Chrome / Edge (last 2 majors) | yes |
|
|
884
|
+
| Firefox (last 2 majors) | yes |
|
|
885
|
+
| Safari 14+ | yes |
|
|
886
|
+
| Node.js 18+ | yes |
|
|
887
|
+
| Bun | yes |
|
|
888
|
+
| Twitch Extensions (1MB / 3s) | yes |
|
|
889
|
+
| Cloudflare Workers | yes |
|
|
890
|
+
| Deno | yes |
|
|
884
891
|
|
|
885
892
|
ESM-only. No CommonJS build -- modern bundlers handle this; legacy consumers can use a wrapper.
|
|
886
893
|
|
package/Signal.d.ts
CHANGED
|
@@ -82,11 +82,13 @@ export interface RegistryStats {
|
|
|
82
82
|
effects: number;
|
|
83
83
|
/** Number of dependency links currently in use. */
|
|
84
84
|
activeLinks: number;
|
|
85
|
-
/**
|
|
85
|
+
/** Dependency links available in the pool (ledger-based: `linkPoolCapacity - activeLinks`). */
|
|
86
86
|
pooledLinks: number;
|
|
87
|
-
/**
|
|
87
|
+
/** Link-pool capacity ledger. Doubles under the `"grow"` policy; under `"lazy"`
|
|
88
|
+
* prealloc it may exceed the count of physically constructed links. */
|
|
88
89
|
linkPoolCapacity: number;
|
|
89
|
-
/**
|
|
90
|
+
/** Node-pool capacity ledger. Doubles under the `"grow"` policy; under `"lazy"`
|
|
91
|
+
* prealloc it may exceed the count of physically constructed nodes. */
|
|
90
92
|
nodePoolCapacity: number;
|
|
91
93
|
/** Number of nodes currently allocated (signals + computeds + alive effects). */
|
|
92
94
|
activeNodes: number;
|
|
@@ -163,14 +165,31 @@ export class CapacityError extends Error {
|
|
|
163
165
|
// --- Registry -----------------------------------------------------------------
|
|
164
166
|
|
|
165
167
|
export interface RegistryConfig {
|
|
166
|
-
/** Initial node-pool capacity. Default: 1024. */
|
|
168
|
+
/** Initial node-pool capacity (a ledger under `prealloc: "lazy"`). Default: 1024. */
|
|
167
169
|
maxNodes?: number;
|
|
168
|
-
/** Initial link-pool capacity. Default: `maxNodes * 4`. */
|
|
170
|
+
/** Initial link-pool capacity (a ledger under `prealloc: "lazy"`). Default: `maxNodes * 4`. */
|
|
169
171
|
maxLinks?: number;
|
|
170
172
|
/**
|
|
171
|
-
*
|
|
172
|
-
* - `"
|
|
173
|
-
*
|
|
173
|
+
* Pool population strategy. Default: `"eager"`.
|
|
174
|
+
* - `"eager"`: construct the full `maxNodes` / `maxLinks` pools up front.
|
|
175
|
+
* Deterministic latency -- zero allocation inside any subsequent hot path
|
|
176
|
+
* (the contract for render loops, game ticks, and extension frame budgets),
|
|
177
|
+
* at the cost of a larger resident heap that every major GC traces.
|
|
178
|
+
* - `"lazy"`: treat `maxNodes` / `maxLinks` as capacity ledgers and construct
|
|
179
|
+
* nodes/links on first demand, recycling through the free lists thereafter.
|
|
180
|
+
* Smaller heap, faster cold start, lighter GC marking; identical zero-GC
|
|
181
|
+
* steady state after warm-up. Prefer for footprint-sensitive or short-lived
|
|
182
|
+
* registries.
|
|
183
|
+
*/
|
|
184
|
+
prealloc?: "eager" | "lazy";
|
|
185
|
+
/**
|
|
186
|
+
* Behaviour when a pool is exhausted. Default: `"throw"`.
|
|
187
|
+
* - `"throw"`: throw {@link CapacityError} immediately when the pool is full.
|
|
188
|
+
* - `"grow"`: extend the pool on demand. Growth is chunked and incremental
|
|
189
|
+
* (contiguous runs of up to 1024 links / 256 nodes per free-list miss),
|
|
190
|
+
* not a single doubling burst, so any one growth pause stays bounded. The
|
|
191
|
+
* capacity ledger still doubles, keeping {@link RegistryStats} semantics
|
|
192
|
+
* unchanged. Link growth is bounded by a hard ceiling of `maxLinks * 16`.
|
|
174
193
|
*/
|
|
175
194
|
onCapacityExceeded?: "throw" | "grow";
|
|
176
195
|
/** Max effect-queue drain passes before a flush-cycle `Error` (message prefixed `"CycleError:"`) is thrown. Default: 100. */
|
|
@@ -282,7 +301,7 @@ export function describe(handle: ReactiveHandle): NodeDescriptor | undefined;
|
|
|
282
301
|
export function onGraphMutation(fn: GraphMutationListener | null): GraphMutationUnsubscribe;
|
|
283
302
|
export function onCleanup(fn: () => void): void;
|
|
284
303
|
export function stats(): RegistryStats;
|
|
285
|
-
export
|
|
304
|
+
export function destroy(): void;
|
|
286
305
|
|
|
287
306
|
/**
|
|
288
307
|
* Configuration options for the watch utility.
|
package/Signal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal v1.
|
|
2
|
+
* @zakkster/lite-signal v1.3.0
|
|
3
3
|
* --------------------
|
|
4
4
|
* Hybrid Doubly-Linked-List Reactive Graph Engine -- decoupled (Signal1_3) base
|
|
5
5
|
* with the two 1.1.3 performance fixes ported in:
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* were never the regression. Same EDGE NOTE as 1.1.3 applies to fix (2): a nested
|
|
12
12
|
* re-read of the same source can retain one bounded, dispose-reclaimed link.
|
|
13
13
|
*
|
|
14
|
+
* Original header:
|
|
15
|
+
* Hybrid Doubly-Linked-List Reactive Graph Engine.
|
|
16
|
+
*
|
|
14
17
|
* Performance model:
|
|
15
18
|
* - ReactiveLink DLL object pool guarantees O(1) graph edge allocation.
|
|
16
19
|
* - Inlined O(1) cursor fast-path for stable steady-state reads.
|
|
@@ -124,6 +127,11 @@ class ReactiveNode {
|
|
|
124
127
|
|
|
125
128
|
// Pool free-list pointer.
|
|
126
129
|
this.nextFree = null;
|
|
130
|
+
// 1.3.0: Intrusive mark-stack pointer. markDownstream chains visited
|
|
131
|
+
// nodes through this field instead of an external array, eliminating the
|
|
132
|
+
// separate cache line + bounds-check on each push/pop. Always null
|
|
133
|
+
// outside an active markDownstream sweep; cleared on pop and on dispose.
|
|
134
|
+
this.nextMark = null;
|
|
127
135
|
}
|
|
128
136
|
}
|
|
129
137
|
|
|
@@ -174,11 +182,25 @@ export class CapacityError extends Error {
|
|
|
174
182
|
* default registry; call {@link setDefaultRegistry} to swap that for your own.
|
|
175
183
|
*
|
|
176
184
|
* @param {object} [config]
|
|
177
|
-
* @param {number} [config.maxNodes=1024] Initial node-pool capacity.
|
|
178
|
-
* @param {number} [config.maxLinks=maxNodes*4] Initial link-pool capacity.
|
|
185
|
+
* @param {number} [config.maxNodes=1024] Initial node-pool capacity (ledger).
|
|
186
|
+
* @param {number} [config.maxLinks=maxNodes*4] Initial link-pool capacity (ledger).
|
|
187
|
+
* @param {"eager"|"lazy"} [config.prealloc="eager"]
|
|
188
|
+
* Pool population strategy. `"eager"` constructs the full `maxNodes` /
|
|
189
|
+
* `maxLinks` pools up front -- deterministic latency, zero allocation
|
|
190
|
+
* inside any subsequent hot path (the contract for render loops, game
|
|
191
|
+
* ticks, and extension frame budgets), at the cost of a larger resident
|
|
192
|
+
* heap that every major GC traces. `"lazy"` treats the capacities as
|
|
193
|
+
* ledgers and constructs nodes/links on first demand, recycling through
|
|
194
|
+
* the free lists thereafter -- smaller heap, faster cold start, lighter
|
|
195
|
+
* GC marking, identical zero-GC steady state after warm-up. Choose eager
|
|
196
|
+
* for hard-real-time, lazy for footprint-sensitive or short-lived registries.
|
|
179
197
|
* @param {"throw"|"grow"} [config.onCapacityExceeded="throw"]
|
|
180
|
-
* `"throw"` fails fast when
|
|
181
|
-
* `"grow"`
|
|
198
|
+
* `"throw"` fails fast with a {@link CapacityError} when a pool is full.
|
|
199
|
+
* `"grow"` extends the pool on a free-list miss. Growth is chunked and
|
|
200
|
+
* incremental -- contiguous runs of up to 1024 links / 256 nodes per
|
|
201
|
+
* miss, not a single doubling burst -- so any one growth pause stays
|
|
202
|
+
* bounded; the capacity ledger still doubles (`stats()` semantics
|
|
203
|
+
* unchanged). Link growth is bounded by a hard ceiling of `maxLinks * 16`.
|
|
182
204
|
* @param {number} [config.maxFlushPasses=100] Cycle-protection: max effect-queue
|
|
183
205
|
* drain passes before throwing an
|
|
184
206
|
* Error prefixed `"CycleError:"`.
|
|
@@ -194,15 +216,24 @@ export function createRegistry(config) {
|
|
|
194
216
|
const maxFlushPasses = (config !== undefined && config.maxFlushPasses !== undefined) ? config.maxFlushPasses : 100;
|
|
195
217
|
const maxLinkLimit = currentLinkCapacity * 16;
|
|
196
218
|
|
|
219
|
+
// Lazy pool population (P1): maxNodes / maxLinks are capacity ledgers,
|
|
220
|
+
// not eager construction counts. Nodes/links are constructed on first
|
|
221
|
+
// demand and recycled through the free lists thereafter -- the zero-GC
|
|
222
|
+
// steady state is identical after warm-up, but the heap no longer carries
|
|
223
|
+
// never-used live objects for every major GC to mark.
|
|
224
|
+
const prealloc = (config !== undefined && config.prealloc !== undefined) ? config.prealloc : "eager";
|
|
197
225
|
const nodePool = [];
|
|
198
|
-
|
|
199
|
-
let freeNodeHead = nodePool[0];
|
|
200
|
-
for (let i = 0; i < currentNodesCapacity - 1; i++) nodePool[i].nextFree = nodePool[i + 1];
|
|
201
|
-
|
|
226
|
+
let freeNodeHead = null;
|
|
202
227
|
const linkPool = [];
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
228
|
+
let freeLinkHead = null;
|
|
229
|
+
if (prealloc === "eager") {
|
|
230
|
+
for (let i = 0; i < currentNodesCapacity; i++) nodePool[i] = new ReactiveNode();
|
|
231
|
+
freeNodeHead = nodePool[0];
|
|
232
|
+
for (let i = 0; i < currentNodesCapacity - 1; i++) nodePool[i].nextFree = nodePool[i + 1];
|
|
233
|
+
for (let i = 0; i < currentLinkCapacity; i++) linkPool[i] = new ReactiveLink();
|
|
234
|
+
freeLinkHead = linkPool[0];
|
|
235
|
+
for (let i = 0; i < currentLinkCapacity - 1; i++) linkPool[i].nextFree = linkPool[i + 1];
|
|
236
|
+
}
|
|
206
237
|
|
|
207
238
|
let activeNodes = 0;
|
|
208
239
|
let activeLinks = 0;
|
|
@@ -212,7 +243,6 @@ export function createRegistry(config) {
|
|
|
212
243
|
|
|
213
244
|
const effectQueueA = [];
|
|
214
245
|
const effectQueueB = [];
|
|
215
|
-
const markStack = [];
|
|
216
246
|
let activeQueue = effectQueueA;
|
|
217
247
|
let activeQueueLen = 0;
|
|
218
248
|
let isQueueA = true;
|
|
@@ -229,20 +259,35 @@ export function createRegistry(config) {
|
|
|
229
259
|
let nodeSeq = 1 | 0;
|
|
230
260
|
let lifecycleCount = 0 | 0;
|
|
231
261
|
const lifecycleMap = new WeakMap();
|
|
262
|
+
|
|
232
263
|
function fireConnect(node) {
|
|
233
264
|
const e = lifecycleMap.get(node);
|
|
234
265
|
if (e === undefined || e.onConnect === undefined) return;
|
|
235
266
|
const po = currentObserver, pt = isTrackingDeps;
|
|
236
|
-
currentObserver = null;
|
|
237
|
-
|
|
267
|
+
currentObserver = null;
|
|
268
|
+
isTrackingDeps = false;
|
|
269
|
+
try {
|
|
270
|
+
e.onConnect();
|
|
271
|
+
} finally {
|
|
272
|
+
currentObserver = po;
|
|
273
|
+
isTrackingDeps = pt;
|
|
274
|
+
}
|
|
238
275
|
}
|
|
276
|
+
|
|
239
277
|
function fireDisconnect(node) {
|
|
240
278
|
const e = lifecycleMap.get(node);
|
|
241
279
|
if (e === undefined || e.onDisconnect === undefined) return;
|
|
242
280
|
const po = currentObserver, pt = isTrackingDeps;
|
|
243
|
-
currentObserver = null;
|
|
244
|
-
|
|
281
|
+
currentObserver = null;
|
|
282
|
+
isTrackingDeps = false;
|
|
283
|
+
try {
|
|
284
|
+
e.onDisconnect();
|
|
285
|
+
} finally {
|
|
286
|
+
currentObserver = po;
|
|
287
|
+
isTrackingDeps = pt;
|
|
288
|
+
}
|
|
245
289
|
}
|
|
290
|
+
|
|
246
291
|
let isFlushing = false;
|
|
247
292
|
|
|
248
293
|
const flushErrorBuffer = [];
|
|
@@ -281,11 +326,14 @@ export function createRegistry(config) {
|
|
|
281
326
|
// recompute profiler. Opcodes: 1 node-create, 2 node-dispose, 3 link-add,
|
|
282
327
|
// 4 link-remove, 5 recompute.
|
|
283
328
|
let mutationHook = null;
|
|
329
|
+
|
|
284
330
|
function onGraphMutation(fn) {
|
|
285
331
|
if (fn !== null && typeof fn !== "function") throw new TypeError("onGraphMutation: listener must be a function or null");
|
|
286
332
|
const prev = mutationHook;
|
|
287
333
|
mutationHook = fn;
|
|
288
|
-
return () => {
|
|
334
|
+
return () => {
|
|
335
|
+
if (mutationHook === fn) mutationHook = prev;
|
|
336
|
+
};
|
|
289
337
|
}
|
|
290
338
|
|
|
291
339
|
function allocateLink(source, target) {
|
|
@@ -317,24 +365,38 @@ export function createRegistry(config) {
|
|
|
317
365
|
|
|
318
366
|
let link;
|
|
319
367
|
if (freeLinkHead === null) {
|
|
320
|
-
if (policy === "throw") throw new CapacityError("links", currentLinkCapacity);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
368
|
+
if (policy === "throw" && linkPool.length >= currentLinkCapacity) throw new CapacityError("links", currentLinkCapacity);
|
|
369
|
+
// Incremental growth (P0): construct links on a free-list miss instead
|
|
370
|
+
// of doubling with an eager construction burst. The capacity LEDGER
|
|
371
|
+
// still doubles (stats() / ceiling semantics are unchanged) -- only the
|
|
372
|
+
// physical allocation is amortized. Eliminates multi-ms pauses in hot loops.
|
|
373
|
+
if (linkPool.length >= maxLinkLimit) throw new CapacityError("links", maxLinkLimit);
|
|
374
|
+
// Chunked refill (P1 rev2): construct a CONTIGUOUS run of links on a
|
|
375
|
+
// miss. Restores eager-pool heap locality for traversal-heavy graphs
|
|
376
|
+
// (lazy one-at-a-time construction interleaves pool objects with
|
|
377
|
+
// user allocations and costs 10-25% on dynamic/large-graph shapes)
|
|
378
|
+
// while keeping pauses bounded (~chunk x ~0.5us) and startup lazy.
|
|
379
|
+
let limit = (policy === "throw") ? currentLinkCapacity : maxLinkLimit;
|
|
380
|
+
let chunk = limit - linkPool.length;
|
|
381
|
+
if (chunk > 1024) chunk = 1024;
|
|
382
|
+
link = new ReactiveLink();
|
|
383
|
+
linkPool.push(link);
|
|
384
|
+
for (let i = 1; i < chunk; i++) {
|
|
385
|
+
const l = new ReactiveLink();
|
|
386
|
+
linkPool.push(l);
|
|
387
|
+
l.nextFree = freeLinkHead;
|
|
388
|
+
freeLinkHead = l;
|
|
389
|
+
}
|
|
390
|
+
if (linkPool.length > currentLinkCapacity) {
|
|
391
|
+
let doubled = currentLinkCapacity;
|
|
392
|
+
while (doubled < linkPool.length) doubled *= 2;
|
|
393
|
+
currentLinkCapacity = doubled > maxLinkLimit ? maxLinkLimit : doubled;
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
link = freeLinkHead;
|
|
397
|
+
freeLinkHead = link.nextFree;
|
|
398
|
+
link.nextFree = null;
|
|
333
399
|
}
|
|
334
|
-
|
|
335
|
-
link = freeLinkHead;
|
|
336
|
-
freeLinkHead = link.nextFree;
|
|
337
|
-
link.nextFree = null;
|
|
338
400
|
activeLinks = (activeLinks + 1) | 0;
|
|
339
401
|
|
|
340
402
|
link.source = source;
|
|
@@ -497,6 +559,7 @@ export function createRegistry(config) {
|
|
|
497
559
|
node.revertEpoch = 0;
|
|
498
560
|
node.preBatchValue = undefined;
|
|
499
561
|
node.preBatchVersion = 0;
|
|
562
|
+
node.nextMark = null; // 1.3.0: defensive -- disposal during a sweep shouldn't happen, but ensures clean state
|
|
500
563
|
|
|
501
564
|
node.gen = (node.gen + 1) | 0;
|
|
502
565
|
node.nextFree = freeNodeHead;
|
|
@@ -513,36 +576,44 @@ export function createRegistry(config) {
|
|
|
513
576
|
* @private
|
|
514
577
|
*/
|
|
515
578
|
function createNode(value, flags) {
|
|
579
|
+
let node;
|
|
516
580
|
if (freeNodeHead === null) {
|
|
517
|
-
if (policy === "throw") throw new CapacityError("nodes", currentNodesCapacity);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
nodePool.length
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
581
|
+
if (policy === "throw" && nodePool.length >= currentNodesCapacity) throw new CapacityError("nodes", currentNodesCapacity);
|
|
582
|
+
// Incremental growth (P0): chunked construction on a free-list miss;
|
|
583
|
+
// ledger doubles for stats() continuity. The effect queues are no
|
|
584
|
+
// longer length-extended here: `arr.length = n` converts a PACKED
|
|
585
|
+
// array to HOLEY permanently (a hidden flush-path tax); sequential
|
|
586
|
+
// `arr[len++] = x` appends keep them packed and auto-grow. (markStack
|
|
587
|
+
// is gone entirely as of 1.3.0's intrusive mark stack.)
|
|
588
|
+
let chunk = (policy === "throw") ? (currentNodesCapacity - nodePool.length) : 256;
|
|
589
|
+
if (chunk > 256) chunk = 256;
|
|
590
|
+
node = new ReactiveNode();
|
|
591
|
+
nodePool.push(node);
|
|
592
|
+
for (let i = 1; i < chunk; i++) {
|
|
593
|
+
const n = new ReactiveNode();
|
|
594
|
+
nodePool.push(n);
|
|
595
|
+
n.nextFree = freeNodeHead;
|
|
596
|
+
freeNodeHead = n;
|
|
597
|
+
}
|
|
598
|
+
if (nodePool.length > currentNodesCapacity) {
|
|
599
|
+
let doubled = currentNodesCapacity;
|
|
600
|
+
while (doubled < nodePool.length) doubled *= 2;
|
|
601
|
+
currentNodesCapacity = doubled;
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
node = freeNodeHead;
|
|
605
|
+
freeNodeHead = node.nextFree;
|
|
606
|
+
node.nextFree = null;
|
|
532
607
|
}
|
|
533
|
-
|
|
534
|
-
const node = freeNodeHead;
|
|
535
|
-
freeNodeHead = node.nextFree;
|
|
536
|
-
node.nextFree = null;
|
|
537
608
|
activeNodes = (activeNodes + 1) | 0;
|
|
538
609
|
|
|
539
|
-
// 1.2.
|
|
610
|
+
// 1.2.3: Clean free-list invariant (Andrii's recommendation).
|
|
540
611
|
//
|
|
541
612
|
// Every node leaving the pool is guaranteed-clean for the seven fields
|
|
542
613
|
// {headDep, tailDep, headSub, tailSub, revertEpoch, preBatchValue,
|
|
543
614
|
// preBatchVersion}: dispose() clears them on the recycle path, and the
|
|
544
615
|
// ReactiveNode constructor initializes them to the same values on the
|
|
545
|
-
// fresh-allocation path (
|
|
616
|
+
// fresh-allocation path (chunked refill above). Re-writing them
|
|
546
617
|
// here was defense against a state that cannot exist.
|
|
547
618
|
//
|
|
548
619
|
// What stays: fields that define the new lifetime (value, flags, id,
|
|
@@ -554,7 +625,8 @@ export function createRegistry(config) {
|
|
|
554
625
|
node.version = 0;
|
|
555
626
|
node.evalVersion = 0;
|
|
556
627
|
node.markEpoch = 0;
|
|
557
|
-
node.id = nodeSeq;
|
|
628
|
+
node.id = nodeSeq;
|
|
629
|
+
nodeSeq = (nodeSeq + 1) | 0; // fresh identity per allocation (ported from 1.1.5)
|
|
558
630
|
|
|
559
631
|
// Wire into Owner Context (lifecycle, not tracking -- keyed off currentOwner).
|
|
560
632
|
// ONLY observers (computed/effect) are adopted: a re-running owner disposes
|
|
@@ -564,7 +636,7 @@ export function createRegistry(config) {
|
|
|
564
636
|
// reading computed -- adopting it meant that computed's next run wiped the
|
|
565
637
|
// store key). Signals are therefore never owner-adopted.
|
|
566
638
|
//
|
|
567
|
-
// 1.2.
|
|
639
|
+
// 1.2.3 clean free-list invariant (extended to the owner tree):
|
|
568
640
|
// owner / prevOwned / firstOwned are all guaranteed-null on every node
|
|
569
641
|
// leaving the pool. Both teardown paths null them -- disposeNode (lines
|
|
570
642
|
// ~451-453) on direct dispose, runCleanup (lines ~609-615) on parent
|
|
@@ -646,23 +718,30 @@ export function createRegistry(config) {
|
|
|
646
718
|
|
|
647
719
|
/**
|
|
648
720
|
* Mark all transitive subscribers of `startNode` dirty.
|
|
649
|
-
* Iterative DFS
|
|
721
|
+
* 1.3.0: Iterative DFS backed by an intrusive linked-list stack (`nextMark`)
|
|
722
|
+
* instead of an external array (the iterative property itself is retained
|
|
723
|
+
* from 1.2.4) -- eliminates array bounds checks and consolidates the touched
|
|
724
|
+
* memory to the node's own cache line (we already loaded `t` to check
|
|
725
|
+
* t.markEpoch, so writing t.nextMark is in-cache).
|
|
650
726
|
* Effects are enqueued for the flush phase; computeds are merely marked
|
|
651
727
|
* (their re-evaluation is lazy -- triggered by the next read).
|
|
652
728
|
* @private
|
|
653
729
|
*/
|
|
654
730
|
function markDownstream(startNode) {
|
|
655
|
-
|
|
656
|
-
|
|
731
|
+
const gv = globalVersion; // hoist invariant module-scope read into a local
|
|
732
|
+
let markHead = startNode;
|
|
733
|
+
startNode.nextMark = null;
|
|
657
734
|
|
|
658
|
-
while (
|
|
659
|
-
const n =
|
|
660
|
-
|
|
735
|
+
while (markHead !== null) {
|
|
736
|
+
const n = markHead;
|
|
737
|
+
markHead = n.nextMark;
|
|
738
|
+
n.nextMark = null; // clear on pop; chain stays clean for future sweeps
|
|
661
739
|
|
|
740
|
+
let link = n.headSub;
|
|
662
741
|
while (link !== null) {
|
|
663
742
|
const t = link.target;
|
|
664
|
-
if (t.markEpoch !==
|
|
665
|
-
t.markEpoch =
|
|
743
|
+
if (t.markEpoch !== gv) {
|
|
744
|
+
t.markEpoch = gv;
|
|
666
745
|
const flags = t.flags;
|
|
667
746
|
|
|
668
747
|
if ((flags & FLAG_EFFECT) !== 0) {
|
|
@@ -671,7 +750,9 @@ export function createRegistry(config) {
|
|
|
671
750
|
activeQueue[activeQueueLen++] = t;
|
|
672
751
|
}
|
|
673
752
|
} else {
|
|
674
|
-
|
|
753
|
+
// Intrusive push: t.nextMark holds the prior head
|
|
754
|
+
t.nextMark = markHead;
|
|
755
|
+
markHead = t;
|
|
675
756
|
}
|
|
676
757
|
}
|
|
677
758
|
link = link.nextSub;
|
|
@@ -919,7 +1000,10 @@ export function createRegistry(config) {
|
|
|
919
1000
|
// closures: set() is the hot write path (a closure over `node` beats the
|
|
920
1001
|
// this[NODE_PTR] load and keeps `const {set} = signal()` working), and peek()'s
|
|
921
1002
|
// body is too cheap to absorb the node recovery.
|
|
922
|
-
function sharedUpdate(fn) {
|
|
1003
|
+
function sharedUpdate(fn) {
|
|
1004
|
+
return this.set(fn(this[NODE_PTR].value));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
923
1007
|
function sharedSubscribe(fn) {
|
|
924
1008
|
const read = this;
|
|
925
1009
|
return effect(() => {
|
|
@@ -933,6 +1017,7 @@ export function createRegistry(config) {
|
|
|
933
1017
|
}
|
|
934
1018
|
});
|
|
935
1019
|
}
|
|
1020
|
+
|
|
936
1021
|
// Shared peeks (one per registry, not per primitive). Save one closure
|
|
937
1022
|
// allocation per signal/computed creation versus the previous per-instance
|
|
938
1023
|
// arrows. Method-invoked, so `this` is the read function and this[NODE_PTR]
|
|
@@ -943,6 +1028,7 @@ export function createRegistry(config) {
|
|
|
943
1028
|
if (this[NODE_GEN] !== node.gen) return undefined; // stale handle: slot recycled (ABA guard, matches read())
|
|
944
1029
|
return node.value;
|
|
945
1030
|
}
|
|
1031
|
+
|
|
946
1032
|
function sharedComputedPeek() {
|
|
947
1033
|
const node = this[NODE_PTR];
|
|
948
1034
|
if (this[NODE_GEN] !== node.gen) return undefined;
|
|
@@ -1249,7 +1335,8 @@ export function createRegistry(config) {
|
|
|
1249
1335
|
* no-ops (every node's `gen` bump invalidates any outstanding handle).
|
|
1250
1336
|
*/
|
|
1251
1337
|
function destroy() {
|
|
1252
|
-
|
|
1338
|
+
const nodeCount = nodePool.length;
|
|
1339
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
1253
1340
|
const n = nodePool[i];
|
|
1254
1341
|
n.value = undefined;
|
|
1255
1342
|
n.computeFn = undefined;
|
|
@@ -1272,19 +1359,23 @@ export function createRegistry(config) {
|
|
|
1272
1359
|
n.prevOwned = null;
|
|
1273
1360
|
n.nextOwned = null;
|
|
1274
1361
|
n.firstOwned = null;
|
|
1362
|
+
n.nextMark = null;
|
|
1275
1363
|
|
|
1276
1364
|
n.gen = (n.gen + 1) | 0;
|
|
1277
1365
|
|
|
1278
|
-
|
|
1279
|
-
effectQueueB[i] = null;
|
|
1280
|
-
markStack[i] = null;
|
|
1281
|
-
|
|
1282
|
-
if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
|
|
1366
|
+
if (i < nodeCount - 1) n.nextFree = nodePool[i + 1];
|
|
1283
1367
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1368
|
+
if (nodeCount > 0) {
|
|
1369
|
+
nodePool[nodeCount - 1].nextFree = null;
|
|
1370
|
+
freeNodeHead = nodePool[0];
|
|
1371
|
+
} else {
|
|
1372
|
+
freeNodeHead = null;
|
|
1373
|
+
}
|
|
1374
|
+
effectQueueA.length = 0;
|
|
1375
|
+
effectQueueB.length = 0;
|
|
1286
1376
|
|
|
1287
|
-
|
|
1377
|
+
const linkCount = linkPool.length;
|
|
1378
|
+
for (let i = 0; i < linkCount; i++) {
|
|
1288
1379
|
const l = linkPool[i];
|
|
1289
1380
|
l.source = null;
|
|
1290
1381
|
l.target = null;
|
|
@@ -1292,10 +1383,14 @@ export function createRegistry(config) {
|
|
|
1292
1383
|
l.nextDep = null;
|
|
1293
1384
|
l.prevSub = null;
|
|
1294
1385
|
l.nextSub = null;
|
|
1295
|
-
if (i <
|
|
1386
|
+
if (i < linkCount - 1) l.nextFree = linkPool[i + 1];
|
|
1387
|
+
}
|
|
1388
|
+
if (linkCount > 0) {
|
|
1389
|
+
linkPool[linkCount - 1].nextFree = null;
|
|
1390
|
+
freeLinkHead = linkPool[0];
|
|
1391
|
+
} else {
|
|
1392
|
+
freeLinkHead = null;
|
|
1296
1393
|
}
|
|
1297
|
-
linkPool[currentLinkCapacity - 1].nextFree = null;
|
|
1298
|
-
freeLinkHead = linkPool[0];
|
|
1299
1394
|
|
|
1300
1395
|
activeNodes = 0;
|
|
1301
1396
|
activeLinks = 0;
|
|
@@ -1321,6 +1416,7 @@ export function createRegistry(config) {
|
|
|
1321
1416
|
const node = liveNode(handle);
|
|
1322
1417
|
return node !== undefined && node.headSub !== null;
|
|
1323
1418
|
}
|
|
1419
|
+
|
|
1324
1420
|
function observeObservers(handle, opts) {
|
|
1325
1421
|
const node = liveNode(handle);
|
|
1326
1422
|
if (node === undefined) throw new TypeError("observeObservers: argument is not a reactive handle");
|
|
@@ -1341,6 +1437,7 @@ export function createRegistry(config) {
|
|
|
1341
1437
|
if (lifecycleMap.delete(node)) lifecycleCount = (lifecycleCount - 1) | 0;
|
|
1342
1438
|
};
|
|
1343
1439
|
}
|
|
1440
|
+
|
|
1344
1441
|
function describeNode(node) {
|
|
1345
1442
|
const fl = node.flags;
|
|
1346
1443
|
const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
|
|
@@ -1354,6 +1451,7 @@ export function createRegistry(config) {
|
|
|
1354
1451
|
d[NODE_GEN] = node.gen; // descriptors are re-walkable handles; stamp gen so the ABA guard holds for them too
|
|
1355
1452
|
return d;
|
|
1356
1453
|
}
|
|
1454
|
+
|
|
1357
1455
|
// Gen-guarded handle resolution (1.2.1): with the v1.2 owner tree, the
|
|
1358
1456
|
// ENGINE recycles slots autonomously (owner re-run cascade-disposes owned
|
|
1359
1457
|
// children), so stale handles are a normal occurrence -- introspecting the
|
|
@@ -1367,20 +1465,28 @@ export function createRegistry(config) {
|
|
|
1367
1465
|
if (handle[NODE_GEN] !== node.gen) return undefined; // stale: slot recycled
|
|
1368
1466
|
return node;
|
|
1369
1467
|
}
|
|
1468
|
+
|
|
1370
1469
|
function nodeId(handle) {
|
|
1371
1470
|
const node = liveNode(handle);
|
|
1372
1471
|
return node !== undefined ? node.id : undefined;
|
|
1373
1472
|
}
|
|
1473
|
+
|
|
1374
1474
|
function describe(handle) {
|
|
1375
1475
|
const node = liveNode(handle);
|
|
1376
1476
|
return node !== undefined ? describeNode(node) : undefined;
|
|
1377
1477
|
}
|
|
1478
|
+
|
|
1378
1479
|
function forEachObserver(handle, fn) {
|
|
1379
1480
|
const node = liveNode(handle);
|
|
1380
1481
|
if (node === undefined) return;
|
|
1381
1482
|
let l = node.headSub;
|
|
1382
|
-
while (l !== null) {
|
|
1483
|
+
while (l !== null) {
|
|
1484
|
+
const nx = l.nextSub;
|
|
1485
|
+
fn(describeNode(l.target));
|
|
1486
|
+
l = nx;
|
|
1487
|
+
}
|
|
1383
1488
|
}
|
|
1489
|
+
|
|
1384
1490
|
/** Iterate this node's OWNED children (v1.2 owner tree). Additive 1.3 API
|
|
1385
1491
|
* prototype: lets devtools/studio walk + render the ownership hierarchy
|
|
1386
1492
|
* (cascade-disposal domains), which is invisible through dep/sub edges. */
|
|
@@ -1388,22 +1494,52 @@ export function createRegistry(config) {
|
|
|
1388
1494
|
const node = liveNode(handle);
|
|
1389
1495
|
if (node === undefined) return;
|
|
1390
1496
|
let c = node.firstOwned;
|
|
1391
|
-
while (c !== null) {
|
|
1497
|
+
while (c !== null) {
|
|
1498
|
+
const nx = c.nextOwned;
|
|
1499
|
+
fn(describeNode(c));
|
|
1500
|
+
c = nx;
|
|
1501
|
+
}
|
|
1392
1502
|
}
|
|
1503
|
+
|
|
1393
1504
|
/** Descriptor of this node's owner, or undefined (top-level / stale handle). */
|
|
1394
1505
|
function ownerOf(handle) {
|
|
1395
1506
|
const node = liveNode(handle);
|
|
1396
1507
|
if (node === undefined || node.owner === null) return undefined;
|
|
1397
1508
|
return describeNode(node.owner);
|
|
1398
1509
|
}
|
|
1510
|
+
|
|
1399
1511
|
function forEachSource(handle, fn) {
|
|
1400
1512
|
const node = liveNode(handle);
|
|
1401
1513
|
if (node === undefined) return;
|
|
1402
1514
|
let l = node.headDep;
|
|
1403
|
-
while (l !== null) {
|
|
1515
|
+
while (l !== null) {
|
|
1516
|
+
const nx = l.nextDep;
|
|
1517
|
+
fn(describeNode(l.source));
|
|
1518
|
+
l = nx;
|
|
1519
|
+
}
|
|
1404
1520
|
}
|
|
1405
1521
|
|
|
1406
|
-
return {
|
|
1522
|
+
return {
|
|
1523
|
+
signal,
|
|
1524
|
+
computed,
|
|
1525
|
+
effect,
|
|
1526
|
+
dispose,
|
|
1527
|
+
batch,
|
|
1528
|
+
untrack,
|
|
1529
|
+
onCleanup,
|
|
1530
|
+
stats,
|
|
1531
|
+
destroy,
|
|
1532
|
+
isTracking,
|
|
1533
|
+
hasObservers,
|
|
1534
|
+
observeObservers,
|
|
1535
|
+
forEachObserver,
|
|
1536
|
+
forEachSource,
|
|
1537
|
+
forEachOwned,
|
|
1538
|
+
ownerOf,
|
|
1539
|
+
nodeId,
|
|
1540
|
+
describe,
|
|
1541
|
+
onGraphMutation
|
|
1542
|
+
};
|
|
1407
1543
|
}
|
|
1408
1544
|
|
|
1409
1545
|
// -----------------------------------------------------------------
|
|
@@ -1463,27 +1599,35 @@ export function destroy() {
|
|
|
1463
1599
|
export function hasObservers(handle) {
|
|
1464
1600
|
return defaultRegistry.hasObservers(handle);
|
|
1465
1601
|
}
|
|
1602
|
+
|
|
1466
1603
|
export function observeObservers(handle, opts) {
|
|
1467
1604
|
return defaultRegistry.observeObservers(handle, opts);
|
|
1468
1605
|
}
|
|
1606
|
+
|
|
1469
1607
|
export function forEachObserver(handle, fn) {
|
|
1470
1608
|
return defaultRegistry.forEachObserver(handle, fn);
|
|
1471
1609
|
}
|
|
1610
|
+
|
|
1472
1611
|
export function forEachSource(handle, fn) {
|
|
1473
1612
|
return defaultRegistry.forEachSource(handle, fn);
|
|
1474
1613
|
}
|
|
1614
|
+
|
|
1475
1615
|
export function onGraphMutation(fn) {
|
|
1476
1616
|
return defaultRegistry.onGraphMutation(fn);
|
|
1477
1617
|
}
|
|
1618
|
+
|
|
1478
1619
|
export function forEachOwned(handle, fn) {
|
|
1479
1620
|
return defaultRegistry.forEachOwned(handle, fn);
|
|
1480
1621
|
}
|
|
1622
|
+
|
|
1481
1623
|
export function ownerOf(handle) {
|
|
1482
1624
|
return defaultRegistry.ownerOf(handle);
|
|
1483
1625
|
}
|
|
1626
|
+
|
|
1484
1627
|
export function nodeId(handle) {
|
|
1485
1628
|
return defaultRegistry.nodeId(handle);
|
|
1486
1629
|
}
|
|
1630
|
+
|
|
1487
1631
|
export function describe(handle) {
|
|
1488
1632
|
return defaultRegistry.describe(handle);
|
|
1489
1633
|
}
|
package/Watch.js
CHANGED
|
@@ -2,7 +2,7 @@ import { effect, untrack } from "./Signal.js";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Sentinel for "first run" in `watch`. Distinguishes a legitimate `undefined`
|
|
5
|
-
* source value from the uninitialized state
|
|
5
|
+
* source value from the uninitialized state -- necessary because a naive
|
|
6
6
|
* `oldValue === undefined` check would conflate them.
|
|
7
7
|
* @private
|
|
8
8
|
*/
|
|
@@ -10,7 +10,7 @@ const UNINITIALIZED = Symbol("watch.uninitialized");
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Track a reactive source and run a callback whenever its projected value
|
|
13
|
-
* changes. The callback receives `(newValue, oldValue, stop)`
|
|
13
|
+
* changes. The callback receives `(newValue, oldValue, stop)` -- the third
|
|
14
14
|
* argument is a dispose function that can be called from inside the callback
|
|
15
15
|
* to terminate the watcher (matching MobX's `reaction` ergonomics).
|
|
16
16
|
*
|
|
@@ -22,13 +22,13 @@ const UNINITIALIZED = Symbol("watch.uninitialized");
|
|
|
22
22
|
* fires the effect but the projected value is unchanged (e.g.,
|
|
23
23
|
* `watch(() => health() <= 0, ...)` where many `health` changes produce the
|
|
24
24
|
* same boolean). Wrapping the source in a `computed` would achieve the same
|
|
25
|
-
* via the computed's own equality check
|
|
25
|
+
* via the computed's own equality check -- the guard makes that wrapping
|
|
26
26
|
* optional.
|
|
27
27
|
*
|
|
28
28
|
* @example
|
|
29
29
|
* const count = signal(0);
|
|
30
|
-
* const stop = watch(count, (next, prev) => console.log(prev, "
|
|
31
|
-
* count.set(1); // logs: 0
|
|
30
|
+
* const stop = watch(count, (next, prev) => console.log(prev, "->", next));
|
|
31
|
+
* count.set(1); // logs: 0 -> 1
|
|
32
32
|
* stop();
|
|
33
33
|
*
|
|
34
34
|
* @example // Self-disposing on a condition
|
|
@@ -65,7 +65,7 @@ export function watch(source, callback, options) {
|
|
|
65
65
|
// ZERO-GC HOT PATH: the untrack body is hoisted into a closure allocated
|
|
66
66
|
// ONCE at registration time. If this were declared inline as
|
|
67
67
|
// `untrack(() => { ... })` inside the effect body, V8 would allocate a
|
|
68
|
-
// fresh closure on every fire
|
|
68
|
+
// fresh closure on every fire -- at 120fps that's 7,200 allocations per
|
|
69
69
|
// minute per watcher. The shared `currentNewValue` variable is the price
|
|
70
70
|
// for keeping the per-fire cost at exactly zero allocations.
|
|
71
71
|
const untrackedFire = () => {
|
|
@@ -123,7 +123,7 @@ export function when(predicate, callback) {
|
|
|
123
123
|
// Defense-in-depth: even if dispose timing lets one more evaluation
|
|
124
124
|
// through (e.g., during sync propagation), don't fire twice. In practice
|
|
125
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
|
|
126
|
+
// unreachable under the engine's self-cycle no-re-run guard -- hence ignored.
|
|
127
127
|
/* c8 ignore next -- unreachable defensive guard; see comment above */
|
|
128
128
|
if (fired) return;
|
|
129
129
|
if (predicate()) {
|
|
@@ -142,10 +142,10 @@ export function when(predicate, callback) {
|
|
|
142
142
|
* when `predicate` first returns a truthy value. Composes with `await` for
|
|
143
143
|
* declarative async control flow against reactive state.
|
|
144
144
|
*
|
|
145
|
-
*
|
|
145
|
+
* ! **HOT-PATH WARNING -- DO NOT USE PER FRAME.** This function calls
|
|
146
146
|
* `new Promise(...)`, which is a heap allocation. Every call allocates a
|
|
147
147
|
* Promise object plus its executor closure plus internal Promise infrastructure
|
|
148
|
-
* (resolve function, microtask state). This is unavoidable
|
|
148
|
+
* (resolve function, microtask state). This is unavoidable -- Promises require
|
|
149
149
|
* heap allocation by the language spec.
|
|
150
150
|
*
|
|
151
151
|
* **Use `whenAsync` for:** high-level scene/UI orchestration, boot sequences,
|
|
@@ -164,21 +164,21 @@ export function when(predicate, callback) {
|
|
|
164
164
|
* Note: this promise never rejects. If the predicate never becomes truthy,
|
|
165
165
|
* the promise never settles. Wrap in `Promise.race` for timeout semantics.
|
|
166
166
|
*
|
|
167
|
-
* @example //
|
|
167
|
+
* @example // OK -- high-level orchestration
|
|
168
168
|
* await whenAsync(() => user.isAuthenticated);
|
|
169
169
|
* navigate("/dashboard");
|
|
170
170
|
*
|
|
171
|
-
* @example //
|
|
171
|
+
* @example // OK -- boot sequence
|
|
172
172
|
* await whenAsync(() => assets.loaded);
|
|
173
173
|
* startGame();
|
|
174
174
|
*
|
|
175
|
-
* @example //
|
|
175
|
+
* @example // NOT OK -- per-frame, allocates a Promise every frame
|
|
176
176
|
* function animate() {
|
|
177
177
|
* whenAsync(() => physics.settled).then(render); // GC pressure!
|
|
178
178
|
* requestAnimationFrame(animate);
|
|
179
179
|
* }
|
|
180
180
|
*
|
|
181
|
-
* @example //
|
|
181
|
+
* @example // Same use case, zero-GC
|
|
182
182
|
* when(() => physics.settled, render); // no Promise, no GC pressure
|
|
183
183
|
*
|
|
184
184
|
* @example // With timeout
|
package/llms.txt
CHANGED
|
@@ -22,7 +22,7 @@ triggering write -- no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
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
24
|
- **Observer-lifecycle introspection** (1.1.4; top-level + per-registry): `hasObservers(handle)` returns true iff a signal/computed has >=1 live observer right now (O(1); a `peek` does not count). `observeObservers(handle, { onConnect?, onDisconnect? })` fires on the 0->1 and 1->0 observer transitions (after registration; transition-only) and returns an idempotent unobserve -- the auto-pause hook for tickers (lite-time/lite-raf start a source only while it's watched). `forEachObserver(handle, fn)` / `forEachSource(handle, fn)` walk the live graph in either direction, passing a `{ kind, value }` descriptor (`kind` is `"signal" | "computed" | "effect"`) -- for inspection (lite-devtools). The surface is gated by an internal counter: zero steady-state cost when nothing is observed. `hasObservers`/`forEach*` no-op on a non-handle; `observeObservers` throws `TypeError`. **Node identity (1.1.5):** `nodeId(handle)` returns the node's stable per-allocation id, `describe(handle)` returns the handle's own `{ id, kind, value }` descriptor, and `forEach*` descriptors now carry `id` too; a descriptor is **re-walkable** (pass it back into `forEachObserver`/`forEachSource`) -- the recursion primitive lite-devtools uses to walk the full DAG. **Owner-tree introspection (1.2.1):** `forEachOwned(handle, fn)` walks a node's owned children (lifetime-binding edges from the 1.2 owner tree -- invisible through dep/sub); `ownerOf(handle)` returns the owner's descriptor or `undefined` for top-level / stale handles. **Stale-handle guard (1.2.1):** the entire introspection surface plus `peek()`/`read()`/`set()` is now generation-checked. A handle held across the 1.2 owner-cascade auto-dispose used to resolve `NODE_PTR` ungated and silently report the recycled slot's NEW resident; 1.2.1 surfaces stale handles as `undefined` (or `TypeError`, for `observeObservers`) -- same ABA discipline `dispose()` always had. Descriptors are gen-stamped so the re-walkable contract survives the guard. **Push-based mutation hook (1.2.1):** `onGraphMutation(fn)` registers a single nullable listener invoked synchronously at every mutation point with three integers `(opcode, intA, intB)` -- opcodes `1` node-create / `2` node-dispose / `3` link-add / `4` link-remove / `5` recompute. Zero-cost gate when no listener is registered (one branch-predicted null check); allocation-free dispatch when registered. The connection point for push-based devtools/studio. Contract: observe only -- never throw, never mutate.
|
|
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
|
+
- **Registry**: `createRegistry({ maxNodes, maxLinks, prealloc, 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. `prealloc` (1.3.0; `"eager"` default | `"lazy"`) chooses up-front vs on-demand pool construction; `onCapacityExceeded` (`"throw"` default | `"grow"`) chooses fail-fast vs chunked-incremental growth bounded by the 16x link ceiling.
|
|
26
26
|
- **CapacityError**: thrown when a fixed-size pool is exhausted under the `"throw"` policy, or when a `"grow"` policy hits the 16× starting-capacity ceiling on links.
|
|
27
27
|
|
|
28
28
|
## Architecture invariants
|
|
@@ -50,7 +50,48 @@ triggering write -- no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
50
50
|
|
|
51
51
|
## Version notes
|
|
52
52
|
|
|
53
|
-
- **1.
|
|
53
|
+
- **1.3.0** (current): the **pool minor**. Node and link pools become growable
|
|
54
|
+
and incrementally populated; the propagation mark phase moves to an intrusive
|
|
55
|
+
linked-list stack; a small registry config surface is exposed. **The hot paths
|
|
56
|
+
and public callable API are byte-identical to 1.2.2** -- everything here is
|
|
57
|
+
pool mechanics, construction-time behavior, and new opt-in config. Steady-state
|
|
58
|
+
zero-GC is unchanged: after warm-up the pools recycle exactly as before.
|
|
59
|
+
**`prealloc` (`"eager"` default | `"lazy"`):** `"eager"` constructs the full
|
|
60
|
+
`maxNodes` / `maxLinks` pools up front -- deterministic latency, zero allocation
|
|
61
|
+
inside any subsequent hot path (the 16ms / 120fps render-frame contract), at the
|
|
62
|
+
cost of a larger resident heap every major GC traces. `"lazy"` treats the
|
|
63
|
+
capacities as ledgers, constructs nodes/links on first demand, recycles through
|
|
64
|
+
the free lists thereafter -- smaller heap, faster cold start, lighter GC marking,
|
|
65
|
+
identical zero-GC steady state after warm-up. **Growable pools
|
|
66
|
+
(`onCapacityExceeded: "grow"`):** pools extend past their initial capacity via
|
|
67
|
+
chunked refill -- contiguous runs of up to 1024 links / 256 nodes per free-list
|
|
68
|
+
miss, not a single doubling burst, so any one growth pause stays bounded
|
|
69
|
+
(~chunk x ~0.5us). The growth path no longer length-extends the effect queues
|
|
70
|
+
or mark stack (which permanently converted them PACKED->HOLEY). Link growth is
|
|
71
|
+
bounded by the hard `maxLinks * 16` ceiling; `"throw"` (default, now backed by a
|
|
72
|
+
named `CapacityError`) preserves the 1.2.x fail-fast behavior.
|
|
73
|
+
**`maxFlushPasses`** (default 100) is now a public config -- the cycle-protection
|
|
74
|
+
ceiling that throws an `Error` prefixed `"CycleError:"`. **Intrusive mark stack:**
|
|
75
|
+
`markDownstream` walks an iterative DFS backed by a `nextMark` field on each node
|
|
76
|
+
instead of an external `markStack` array; behaviorally identical (same nodes, same
|
|
77
|
+
order, same glitch-free guarantee), and the write lands in the cache line already
|
|
78
|
+
hot from reading `markEpoch`. `nextMark` is the only node-shape change in 1.3.0.
|
|
79
|
+
**`stats()` shape unchanged** (8 keys: `signals`, `computeds`, `effects`,
|
|
80
|
+
`activeNodes`, `activeLinks`, `pooledLinks`, `nodePoolCapacity`,
|
|
81
|
+
`linkPoolCapacity`); the capacity keys are ledgers under `"lazy"`. Cumulative
|
|
82
|
+
allocation counters (`totalAllocations` / `totalDisposals` / `poolGrowths`) remain
|
|
83
|
+
reserved for 1.4.0 and are still absent. **Tests:** 424 total, 414 pass, 0 fail,
|
|
84
|
+
10 skip (the 10 skips are 9 signalBox-staged-for-1.5.0 in
|
|
85
|
+
`test/24-signalbox_test.mjs` plus 1 architecturally-N/A SSR case in
|
|
86
|
+
`17-reactivity`); 4 new pool tests in `test/03-pool_test.mjs` cover the lazy
|
|
87
|
+
on-demand / never-allocated-destroy / grow-extends-ledger / link-ceiling paths.
|
|
88
|
+
**Coverage** (c8@11, Node 22): `Signal.js` 100% statements / 98.26% branches /
|
|
89
|
+
100% functions / 100% lines; `Watch.js` 100% across all four. Behavior-
|
|
90
|
+
preservation difftest: 20,000 direct + 10,000 batched writes vs the published
|
|
91
|
+
1.1.5 reference, 0 disagreements -- pool growth, chunked refill, and the intrusive
|
|
92
|
+
mark stack do not alter observable propagation. Drop-in over 1.2.2.
|
|
93
|
+
|
|
94
|
+
- **1.2.2**: `createNode` clean-free-list invariant audit. Removes
|
|
54
95
|
ten redundant field-writes that defended against a state the engine cannot
|
|
55
96
|
produce on a clean free-list: seven graph/batch fields (`headDep`, `tailDep`,
|
|
56
97
|
`headSub`, `tailSub`, `revertEpoch`, `preBatchValue`, `preBatchVersion`) and
|
|
@@ -270,10 +311,11 @@ interface NodeDescriptor { // yielded by forEachObserver / forEachSour
|
|
|
270
311
|
}
|
|
271
312
|
|
|
272
313
|
interface RegistryConfig {
|
|
273
|
-
maxNodes?: number; // default 1024
|
|
274
|
-
maxLinks?: number; // default maxNodes * 4
|
|
314
|
+
maxNodes?: number; // default 1024 (ledger under "lazy")
|
|
315
|
+
maxLinks?: number; // default maxNodes * 4 (ledger under "lazy")
|
|
316
|
+
prealloc?: "eager" | "lazy"; // default "eager" (1.3.0): up-front vs on-demand pool construction
|
|
275
317
|
maxFlushPasses?: number; // default 100
|
|
276
|
-
onCapacityExceeded?: "throw" | "grow"; // default "throw"
|
|
318
|
+
onCapacityExceeded?: "throw" | "grow"; // default "throw"; "grow" = chunked refill, ledger doubles, 16x link ceiling
|
|
277
319
|
}
|
|
278
320
|
|
|
279
321
|
interface RegistryStats {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0-rc",
|
|
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",
|