@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 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.2.2] -- 2026-06-16
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: 1024, // default
448
- maxLinks: 4 * 1024, // default = maxNodes * 4
449
- maxFlushPasses: 100, // default
450
- onCapacityExceeded: "throw" // default. Other: "grow"
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 pool sizes: **nodes** and **links**. Both are fixed at registry creation but can be configured to grow.
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 pool] --> B{policy?}
478
+ A[allocator hits empty free-list] --> B{policy?}
474
479
  B -- "throw" --> C[CapacityError]
475
- B -- "grow" --> D[double pool size]
476
- D --> E{new size > 16× original?}
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 16× its starting link capacity, something is wrong and you want to know -- `CapacityError` is louder than a slow OOM crash four hours later.
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`. Drop it on screen for live observability.
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()` | zero-GC per fire |
504
- | `watch(source, (v, p, stop) => ...)` | observe until a condition | self-dispose via callback arg | zero-GC per fire |
505
- | `when(predicate, cb)` | one-shot trigger when condition first true | auto-dispose | zero-GC per check |
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.2.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.2.2 are these 9 plus 1 architecturally-N/A SSR case in `17-reactivity`).
723
- - **`25-devtools-real-boot_test.mjs`** -- Devtools/Studio contract (10 tests). Boots the actual `Devtools.js` against the 1.2.2 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.
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** -- next engine work after the owner-tree validation. The pull-mode recursion depth limit (~5,000 chained computeds) is the main outstanding architectural item.
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
- /** Number of dependency links available in the pool. */
85
+ /** Dependency links available in the pool (ledger-based: `linkPoolCapacity - activeLinks`). */
86
86
  pooledLinks: number;
87
- /** Total link-pool capacity (grows under `"grow"` policy). */
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
- /** Total node-pool capacity (grows under `"grow"` policy). */
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
- * Behaviour when a pool is exhausted:
172
- * - `"throw"` (default): throw {@link CapacityError} immediately.
173
- * - `"grow"`: double the pool. Links are bounded by `maxLinks * 16`.
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 declare function destroy(): void;
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.2
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 pools are full.
181
- * `"grow"` doubles the pool (bounded by `maxLinks * 16` for links).
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
- for (let i = 0; i < currentNodesCapacity; i++) nodePool[i] = new ReactiveNode();
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
- for (let i = 0; i < currentLinkCapacity; i++) linkPool[i] = new ReactiveLink();
204
- let freeLinkHead = linkPool[0];
205
- for (let i = 0; i < currentLinkCapacity - 1; i++) linkPool[i].nextFree = linkPool[i + 1];
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; isTrackingDeps = false;
237
- try { e.onConnect(); } finally { currentObserver = po; isTrackingDeps = pt; }
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; isTrackingDeps = false;
244
- try { e.onDisconnect(); } finally { currentObserver = po; isTrackingDeps = pt; }
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 () => { if (mutationHook === fn) mutationHook = prev; };
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
- const newCap = currentLinkCapacity * 2;
322
- if (newCap > maxLinkLimit) throw new CapacityError("links", maxLinkLimit);
323
-
324
- const newLinks = new Array(newCap - currentLinkCapacity);
325
- for (let i = 0; i < newLinks.length; i++) newLinks[i] = new ReactiveLink();
326
- for (let i = 0; i < newLinks.length - 1; i++) newLinks[i].nextFree = newLinks[i + 1];
327
-
328
- const startIdx = linkPool.length;
329
- linkPool.length = newCap;
330
- for (let i = 0; i < newLinks.length; i++) linkPool[startIdx + i] = newLinks[i];
331
- freeLinkHead = newLinks[0];
332
- currentLinkCapacity = newCap;
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
- const newCap = currentNodesCapacity * 2;
519
- const newNodes = new Array(newCap - currentNodesCapacity);
520
- for (let i = 0; i < newNodes.length; i++) newNodes[i] = new ReactiveNode();
521
- for (let i = 0; i < newNodes.length - 1; i++) newNodes[i].nextFree = newNodes[i + 1];
522
-
523
- const startIdx = nodePool.length;
524
- nodePool.length = newCap;
525
- for (let i = 0; i < newNodes.length; i++) nodePool[startIdx + i] = newNodes[i];
526
- freeNodeHead = newNodes[0];
527
-
528
- effectQueueA.length = newCap;
529
- effectQueueB.length = newCap;
530
- markStack.length = newCap;
531
- currentNodesCapacity = newCap;
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.2: Clean free-list invariant (Andrii's recommendation).
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 (pool growth at lines above). Re-writing them
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; nodeSeq = (nodeSeq + 1) | 0; // fresh identity per allocation (ported from 1.1.5)
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.2 clean free-list invariant (extended to the owner tree):
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 via the markStack to avoid call-stack growth.
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
- let stackLen = 0;
656
- markStack[stackLen++] = startNode;
731
+ const gv = globalVersion; // hoist invariant module-scope read into a local
732
+ let markHead = startNode;
733
+ startNode.nextMark = null;
657
734
 
658
- while (stackLen !== 0) {
659
- const n = markStack[--stackLen];
660
- let link = n.headSub;
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 !== globalVersion) {
665
- t.markEpoch = globalVersion;
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
- markStack[stackLen++] = t;
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) { return this.set(fn(this[NODE_PTR].value)); }
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
- for (let i = 0; i < currentNodesCapacity; i++) {
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
- effectQueueA[i] = null;
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
- nodePool[currentNodesCapacity - 1].nextFree = null;
1285
- freeNodeHead = nodePool[0];
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
- for (let i = 0; i < currentLinkCapacity; i++) {
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 < currentLinkCapacity - 1) l.nextFree = linkPool[i + 1];
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) { const nx = l.nextSub; fn(describeNode(l.target)); l = nx; }
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) { const nx = c.nextOwned; fn(describeNode(c)); c = nx; }
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) { const nx = l.nextDep; fn(describeNode(l.source)); l = nx; }
1515
+ while (l !== null) {
1516
+ const nx = l.nextDep;
1517
+ fn(describeNode(l.source));
1518
+ l = nx;
1519
+ }
1404
1520
  }
1405
1521
 
1406
- return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking, hasObservers, observeObservers, forEachObserver, forEachSource, forEachOwned, ownerOf, nodeId, describe, onGraphMutation};
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 necessary because a naive
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)` the third
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 the guard makes that wrapping
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, "", next));
31
- * count.set(1); // logs: 0 1
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 at 120fps that's 7,200 allocations per
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 hence ignored.
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
- * ⚠️ **HOT-PATH WARNING DO NOT USE PER FRAME.** This function calls
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 Promises require
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 // OK high-level orchestration
167
+ * @example // OK -- high-level orchestration
168
168
  * await whenAsync(() => user.isAuthenticated);
169
169
  * navigate("/dashboard");
170
170
  *
171
- * @example // OK boot sequence
171
+ * @example // OK -- boot sequence
172
172
  * await whenAsync(() => assets.loaded);
173
173
  * startGame();
174
174
  *
175
- * @example // NOT OK per-frame, allocates a Promise every frame
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 // Same use case, zero-GC
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.2.2** (current): `createNode` clean-free-list invariant audit. Removes
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.2.2",
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",