@zakkster/lite-signal 1.0.6 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-signal.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-signal)
6
6
  ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
7
- [![bundle size](https://img.shields.io/badge/min%2Bgz-~3.2KB-blue?style=flat-square)](https://bundlephobia.com/package/@zakkster/lite-signal)
7
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-signa?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-signal)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal)
9
9
  [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal)
10
10
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
@@ -40,6 +40,7 @@ Synchronous, glitch-free, push-pull. No microtask queue, no allocations after wa
40
40
  - [Architecture in one diagram](#architecture-in-one-diagram)
41
41
  - [How a write propagates](#how-a-write-propagates)
42
42
  - [API reference](#api-reference)
43
+ - [Watchers](#watchers)
43
44
  - [Capacity, growth, and the link ceiling](#capacity-growth-and-the-link-ceiling)
44
45
  - [Edge cases pinned down](#edge-cases-pinned-down)
45
46
  - [Benchmarks](#benchmarks)
@@ -47,6 +48,7 @@ Synchronous, glitch-free, push-pull. No microtask queue, no allocations after wa
47
48
  - [What this is not](#what-this-is-not)
48
49
  - [Browser and runtime support](#browser-and-runtime-support)
49
50
  - [Integration recipes](#integration-recipes)
51
+ - [Conformance](#conformance)
50
52
  - [FAQ](#faq)
51
53
  - [npm scripts](#npm-scripts)
52
54
 
@@ -347,6 +349,137 @@ Default sizing for a Twitch-extension-style budget:
347
349
 
348
350
  ---
349
351
 
352
+ ## Watchers
353
+
354
+ `@zakkster/lite-signal` ships three composable watcher primitives, all built from `effect` + `untrack` — no engine extensions, no per-watcher flag in `ReactiveNode`. The core stays small; the surface stays useful.
355
+
356
+ | API | Use case | Lifecycle | Hot-path safe? |
357
+ |---|---|---|---|
358
+ | `watch(source, cb)` | observe value changes over time | manual `stop()` | ✅ zero-GC per fire |
359
+ | `watch(source, (v, p, stop) => …)` | observe until a condition | self-dispose via callback arg | ✅ zero-GC per fire |
360
+ | `when(predicate, cb)` | one-shot trigger when condition first true | auto-dispose | ✅ zero-GC per check |
361
+ | `whenAsync(predicate)` | await a condition | auto-dispose | ⚠️ allocates Promise — see below |
362
+
363
+ ### `watch(source, callback, options?)`
364
+
365
+ Fires the callback whenever the source's projected value changes. The callback receives `(newValue, oldValue, stop)` — calling `stop()` from inside the callback disposes the watcher.
366
+
367
+ ```js
368
+ import { signal, watch } from "@zakkster/lite-signal";
369
+
370
+ const count = signal(0);
371
+
372
+ // Basic — observe forever
373
+ const stop = watch(count, (next, prev) => {
374
+ console.log(`${prev} → ${next}`);
375
+ });
376
+
377
+ count.set(1); // logs: 0 → 1
378
+ stop(); // manual dispose
379
+ ```
380
+
381
+ **Self-disposing watcher** — declarative termination from inside the callback:
382
+
383
+ ```js
384
+ watch(status, (next, prev, stop) => {
385
+ if (next === "ready") {
386
+ initialize();
387
+ stop(); // detach after first "ready"
388
+ }
389
+ });
390
+ ```
391
+
392
+ **Immediate option** — fires once on registration with `oldValue = undefined`:
393
+
394
+ ```js
395
+ watch(theme, (v) => applyTheme(v), { immediate: true });
396
+ ```
397
+
398
+ **Raw getter equality** — `watch` uses `Object.is` internally to avoid spurious fires when a dep mutation produces the same projected value:
399
+
400
+ ```js
401
+ const health = signal(10);
402
+ let deathLog = 0;
403
+ watch(() => health() <= 0, (isDead) => { deathLog++; });
404
+
405
+ health.set(9); // isDead is still false — no fire
406
+ health.set(8); // same — no fire
407
+ health.set(0); // crossed — fires once with (true, false)
408
+ ```
409
+
410
+ Without this guard, the callback would fire on every `health` mutation regardless of whether `isDead` changed. Wrapping the source in `computed()` would achieve the same via the computed's own equality check — the guard makes that wrapping optional.
411
+
412
+ ### `when(predicate, callback)`
413
+
414
+ Fires `callback` exactly once when `predicate` first returns a truthy value, then auto-disposes. If the predicate is already truthy at registration, fires synchronously.
415
+
416
+ ```js
417
+ import { when } from "@zakkster/lite-signal";
418
+
419
+ when(() => user.isAuthenticated, () => {
420
+ navigate("/dashboard");
421
+ });
422
+ ```
423
+
424
+ The returned dispose function can cancel before the predicate fires:
425
+
426
+ ```js
427
+ const cancel = when(() => slowApi.ready, () => start());
428
+ if (userBacked) cancel();
429
+ ```
430
+
431
+ ### `whenAsync(predicate)`
432
+
433
+ > ### ⚠️ Hot-path warning
434
+ >
435
+ > `whenAsync` calls `new Promise(...)` internally — **this is a heap allocation**. Every call allocates a Promise object, an executor closure, and Promise infrastructure (resolve function, microtask state). Promises require heap allocation by the language spec; this cost is unavoidable.
436
+ >
437
+ > **Use for:** high-level scene/UI orchestration, boot sequences, awaiting user input, level transitions. Anything that runs once or rarely.
438
+ >
439
+ > **NEVER use for:** per-frame entity updates, render-loop logic, animation tick handlers, anywhere that runs at 60/120 fps. The Promise allocations will be visible in GC traces and will cause frame-time spikes under sustained load.
440
+ >
441
+ > **For zero-GC hot-path logic, use `when` with a callback.**
442
+
443
+ Promise-returning variant of `when`. Composes with `async/await` for declarative async control flow against reactive state:
444
+
445
+ ```js
446
+ import { whenAsync } from "@zakkster/lite-signal";
447
+
448
+ async function bootSequence() {
449
+ await whenAsync(() => config.loaded);
450
+ await whenAsync(() => auth.ready);
451
+ await whenAsync(() => db.connected);
452
+ render();
453
+ }
454
+ ```
455
+
456
+ The promise never rejects on its own — if the predicate never becomes truthy, the promise never settles. For timeout semantics use `Promise.race`:
457
+
458
+ ```js
459
+ await Promise.race([
460
+ whenAsync(() => api.ready),
461
+ new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 5000))
462
+ ]);
463
+ ```
464
+
465
+ ### Allocation profile
466
+
467
+ Honest accounting of where memory is spent in each primitive:
468
+
469
+ | Primitive | Allocations at registration | Allocations per fire / check |
470
+ |---|---|---|
471
+ | `watch(source, cb)` | 3 closures (stop, effect body, hoisted untrack body) | **0** |
472
+ | `when(predicate, cb)` | 2 closures (stop, effect body) | **0** |
473
+ | `whenAsync(predicate)` | 1 Promise + 1 executor closure + Promise internals + 2 closures from `when` | **0** (after registration) |
474
+
475
+ The "0 per fire" property for `watch` is deliberate engineering — the inner `untrack` callback is hoisted to a single closure allocated once at registration, with `currentNewValue` as shared mutable state. If you read the source and wonder why we don't use a clean inline arrow function inside the effect body, this is the answer: doing it inline would allocate a fresh closure on every dep change, at 7,200 allocs per minute per watcher at 120 fps.
476
+
477
+ ### Tree-shaking
478
+
479
+ All three primitives live in a separate module (`src/watch.js`) and are re-exported from the main entry. If your bundle doesn't import them, they won't appear in the output — modern ESM tree-shaking (Vite, Rollup, esbuild) handles this reliably.
480
+
481
+ ---
482
+
350
483
  ## Edge cases pinned down
351
484
 
352
485
  These are the questions you'd ask in a code review, with the answers:
@@ -412,6 +545,8 @@ Three tiers, all reproducible.
412
545
  - **`05-scheduler.test.mjs`** — scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
413
546
  - **`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.
414
547
  - **`07-dispose.test.mjs`** — unified `dispose(api)` across signals, computeds and effect handles, idempotency, cross-registry isolation (per-registry Symbol prevents pool corruption), foreign-value safety, top-level helper routing, 500-cycle balanced churn leaving pool and stats stable.
548
+ - **`08-watch.test.mjs`** — Validates the user-land observer utilities (watch, when, whenAsync). Covers lifecycle teardown, old/new value tracking, and Promise-based asynchronous state resolution.
549
+ - **`09-conformance.test.mjs`** — Industry-standard conformance tests. Validates the engine against extreme edge cases from the johnsoncodehk reactive test suite, ensuring strict zero-GC invariants, correct cleanup isolation, and re-entrant stability.
415
550
 
416
551
  ```bash
417
552
  npm test
@@ -471,8 +606,8 @@ In highly chaotic graphs with branch switching, selective reads, and wide dense
471
606
 
472
607
  **The Takeaway:** "Zero-GC" and "topology scalability" are orthogonal dimensions. If you are building a DOM framework with massive dynamic `v-if` churn, use Alien Signals. If you are building a 120fps Canvas game with a stable scene graph where any GC pause is a dropped frame, use `lite-signal`.
473
608
 
474
- ### Roadmap: v1.1
475
- We are actively working on a v1.1 architectural update to address this topology degradation while maintaining the zero-GC contract. By moving to a version-stamped dependency reconciliation pass (`lastSeenInEval`) with a pre-allocated scratch buffer, we expect to drop dynamic read costs to $O(1)$ unconditionally.
609
+ ### Roadmap: v1.2
610
+ v1.2 (in benchmark validation) Andrii Volynets (alien-signals#117) extended the benchmark matrix to dynamic-topology workloads and identified retracking-cost asymptotics, not allocation pressure, as the dominant cost in those scenarios. v1.2 replaces the cursor-based retracking with per-source version-stamped reconciliation: O(1) per read regardless of read order or dep-set churn. v1.2 will be validated against the alien-signals topology matrix and the before/after numbers published on release.
476
611
 
477
612
  ---
478
613
 
@@ -568,6 +703,76 @@ function spawnPlugin(pluginCode) {
568
703
 
569
704
  ---
570
705
 
706
+ ## Conformance
707
+
708
+ lite-signal v1.1.0 was evaluated against the
709
+ [reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
710
+ the most comprehensive behavioral test battery for JavaScript reactive
711
+ libraries.
712
+
713
+ **174 of 177 tests pass (98.3%)**, placing lite-signal **in a tie for second place of sixteen**
714
+ evaluated libraries — just behind alien-signals (177).
715
+
716
+ We publish both passing and failing tests, because honesty about behavior is
717
+ more useful to library users than a green checkmark.
718
+
719
+ ### What lite-signal does that no other library does
720
+
721
+ - **`batch()` returns the callback's value.** Every other library evaluated
722
+ returns `void`. `const total = batch(() => ...)` is a lite-signal idiom.
723
+ - **Cycle detection** in effects (matches preact, reatom, svelte, solid).
724
+ Many libraries silently iterate to a 200-step bail; lite-signal throws so
725
+ the bug surfaces at development time.
726
+ - **`Object.is` equality** throughout, including NaN — matches Vue,
727
+ Angular, Reatom, the TC39 polyfill, and tansu. The `===` camp returns
728
+ incorrect results on NaN flows.
729
+ - **Single-pass propagation** through computed chains on inner writes —
730
+ matches alien-signals and Vue; faster than preact, solid, reatom, mobx,
731
+ and most others by one re-evaluation per write.
732
+ - **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
733
+ solid. Half the field leaks the subscription.
734
+
735
+ ### What v1.1.0 doesn't do yet
736
+
737
+ 10 tests fail. We've categorized them by intent.
738
+
739
+ **Targeted for v1.2** (6 tests):
740
+
741
+ - **Revert detection inside batches** (#147, #132, #123): writes inside a
742
+ `batch()` that net to no change still mark dependents as dirty. Vue,
743
+ Solid, Mobx, and roughly half the field share this behavior. v1.2 will
744
+ capture pre-batch values per signal and skip propagation on revert.
745
+ - **Throw isolation in batch flush** (#121): if an effect throws during
746
+ flush, lite-signal currently halts the flush. v1.2 will collect errors,
747
+ finish the flush, then re-throw as `AggregateError`.
748
+ - **Inner-write propagation through computed chains** (#180, #213): two
749
+ specific propagation paths where lite-signal disagrees with the field.
750
+ Both are propagation-order bugs in the recursive computed resolver,
751
+ not zero-GC tradeoffs.
752
+
753
+ **Design choices we will not change** (2 tests):
754
+
755
+ - **Inner writes inside computeds** (#179): writing to a signal from inside
756
+ a computed is a side effect, not a derivation. Use an `effect` instead.
757
+ Most of the field also fails this test.
758
+ - **Nested batch coalescing inside an effect body** (#235): explicit
759
+ `batch()` calls *inside* an executing effect do not coalesce beyond the
760
+ effect's own implicit batching. Most libraries behave this way. Wrap the
761
+ batch outside the effect for the intended semantics.
762
+
763
+ **Opt-in feature, deferred** (2 tests):
764
+
765
+ - **Solid-style cascading disposal of nested effects** (#209, #210):
766
+ lite-signal does not maintain an owner tree of parent-child effects.
767
+ This matches preact, vue, mobx, the TC39 polyfill, Angular, Svelte,
768
+ tansu, and Solid 1.x. Solid 2 / @solidjs/signals, reatom, and anod
769
+ implement it. If you need it, please open an issue.
770
+
771
+ Per-test results, the runner adapter, and reproductions live in
772
+ `/conformance/`.
773
+
774
+ ---
775
+
571
776
  ## FAQ
572
777
 
573
778
  **Why no microtask scheduler?**
package/Signal.d.ts CHANGED
@@ -165,7 +165,7 @@ export function batch<T>(fn: () => T): T;
165
165
  export function untrack<T>(fn: () => T): T;
166
166
  export function onCleanup(fn: () => void): void;
167
167
  export function stats(): RegistryStats;
168
-
168
+ export declare function destroy(): void;
169
169
 
170
170
  /**
171
171
  * Configuration options for the watch utility.
@@ -177,25 +177,66 @@ export interface WatchOptions {
177
177
  immediate?: boolean;
178
178
  }
179
179
 
180
+ //**
181
+ * Track a reactive source and run a callback whenever its projected value
182
+ * changes. The callback receives `(newValue, oldValue, stop)` — the third
183
+ * argument is a dispose function that can be called from inside the callback
184
+ * to terminate the watcher.
185
+ *
186
+ * Internal reads inside the callback are untracked.
187
+ *
188
+ * Uses `Object.is` to guard against the raw-getter case where a dep mutation
189
+ * fires the effect but the projected value is unchanged.
190
+ *
191
+ * @param source Reactive read function.
192
+ * @param callback Called with the new and previous values plus a stop handle.
193
+ * @param options `immediate: true` runs the callback once on registration
194
+ * with `oldValue = undefined`.
195
+ * @returns Dispose function. Idempotent and safe to call at any time, including
196
+ * synchronously during the immediate callback.
197
+ */
198
+ export function watch<T>(
199
+ source: () => T,
200
+ callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void,
201
+ options?: { immediate?: boolean }
202
+ ): () => void;
203
+
204
+ /**
205
+ * Fire `callback` exactly once when `predicate` first returns a truthy value,
206
+ * then auto-dispose. If `predicate` is already truthy at registration, fires
207
+ * synchronously.
208
+ *
209
+ * @param predicate Reactive read function; callback fires when truthy.
210
+ * @param callback Called once when predicate first truthy. Reads inside are untracked.
211
+ * @returns Dispose function. Call before predicate fires to cancel; idempotent.
212
+ */
213
+ export function when(
214
+ predicate: () => unknown,
215
+ callback: () => void
216
+ ): () => void;
217
+
180
218
  /**
181
- * Track a reactive source and run a callback whenever its evaluated value changes.
219
+ * Promise-returning variant of {@link when}. The returned promise resolves
220
+ * when `predicate` first returns a truthy value.
182
221
  *
183
- * Models Vue's `watch(source, callback)` and MobX's `reaction(predicate, effect)`.
184
- * Internal reads inside the callback are untracked they do not create reactive
185
- * dependencies.
222
+ * ⚠️ **HOT-PATH WARNING DO NOT USE PER FRAME.** This function calls
223
+ * `new Promise(...)`, which is a heap allocation (one Promise object plus
224
+ * executor closure plus internal infrastructure per call). Promises require
225
+ * heap allocation by the language spec — this cost is unavoidable.
186
226
  *
187
- * @example
188
- * const count = signal(0);
189
- * const stop = watch(() => count() * 2, (next, prev) => {
190
- * console.log(`Doubled count changed: ${prev} -> ${next}`);
191
- * });
192
- * * @param source A function that reads reactive values (e.g., a signal/computed getter).
193
- * @param callback Fired when the source's value changes. Receives the new and previous values.
194
- * @param options Optional configuration (e.g., `{ immediate: true }`).
195
- * @returns Dispose function call to stop watching and release the effect.
227
+ * **Use for:** high-level scene/UI orchestration, boot sequences, awaiting
228
+ * user input or network state, level transitions. Anything that runs once
229
+ * or rarely.
230
+ *
231
+ * **NEVER use for:** per-frame entity updates, render-loop logic, animation
232
+ * tick handlers. For zero-GC hot-path logic use {@link when} with a callback.
233
+ *
234
+ * Note: this promise never rejects. If the predicate never becomes truthy,
235
+ * the promise never settles. Wrap in `Promise.race` for timeout semantics.
236
+ *
237
+ * @param predicate Reactive read function; resolves promise when truthy.
238
+ * @returns Promise that resolves when predicate first truthy.
196
239
  */
197
- export function watch<T>(
198
- source: () => T,
199
- callback: (newValue: T, oldValue: T | undefined) => void,
200
- options?: WatchOptions
201
- ): () => void;
240
+ export function whenAsync(
241
+ predicate: () => unknown
242
+ ): Promise<void>;
package/Signal.js CHANGED
@@ -61,6 +61,13 @@ class ReactiveNode {
61
61
  /** Recycle generation: bumped on dispose, used to invalidate stale scheduler closures. */
62
62
  this.gen = 0;
63
63
 
64
+ /** Captured value at first .set() inside the current batch (revert detection). */
65
+ this.preBatchValue = undefined;
66
+ /** Captured version at first .set() inside the current batch. */
67
+ this.preBatchVersion = 0;
68
+ /** batchEpoch that owns the capture; 0 = no capture. */
69
+ this.revertEpoch = 0;
70
+
64
71
  // Doubly-linked dependency list (this node depends on these sources).
65
72
  this.headDep = null;
66
73
  this.tailDep = null;
@@ -68,6 +75,7 @@ class ReactiveNode {
68
75
  this.currentDep = null;
69
76
  // Doubly-linked subscriber list (these targets depend on this node).
70
77
  this.headSub = null;
78
+ this.tailSub = null;
71
79
 
72
80
  // Pool free-list pointer.
73
81
  this.nextFree = null;
@@ -174,12 +182,17 @@ export function createRegistry(config = {}) {
174
182
 
175
183
  // --- GLOBAL STATE ---
176
184
  let globalVersion = 1 | 0; // Forced 32-bit SMI
185
+ let batchEpoch = 1 | 0; // skip-zero sentinel; revertEpoch=0 means "no capture"
177
186
  let currentObserver = null;
178
187
  let activeObserverCurrentDep = null;
179
188
  let batchDepth = 0 | 0;
180
189
  let isTrackingDeps = false;
181
190
  let isFlushing = false;
182
191
 
192
+ // Reused buffer for throw isolation during flush
193
+ const flushErrorBuffer = [];
194
+ let flushErrorCount = 0 | 0;
195
+
183
196
  // --- ALLOCATORS ---
184
197
 
185
198
  /**
@@ -244,10 +257,13 @@ export function createRegistry(config = {}) {
244
257
  link.source = source;
245
258
  link.target = target;
246
259
 
247
- link.prevSub = null;
248
- link.nextSub = source.headSub;
249
- if (source.headSub !== null) source.headSub.prevSub = link;
250
- source.headSub = link;
260
+ link.nextSub = null;
261
+ link.prevSub = source.tailSub;
262
+
263
+ if (source.tailSub !== null) source.tailSub.nextSub = link;
264
+ else source.headSub = link;
265
+
266
+ source.tailSub = link;
251
267
  }
252
268
 
253
269
  link.nextDep = expected;
@@ -270,8 +286,9 @@ export function createRegistry(config = {}) {
270
286
  function freeLink(link, target, source) {
271
287
  const pSub = link.prevSub;
272
288
  const nSub = link.nextSub;
289
+
273
290
  if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
274
- if (nSub !== null) nSub.prevSub = pSub;
291
+ if (nSub !== null) nSub.prevSub = pSub; else source.tailSub = pSub;
275
292
 
276
293
  link.source = null;
277
294
  link.target = null;
@@ -335,6 +352,11 @@ export function createRegistry(config = {}) {
335
352
  node.tailDep = null;
336
353
  node.currentDep = null;
337
354
  node.headSub = null;
355
+ node.tailSub = null;
356
+
357
+ node.revertEpoch = 0;
358
+ node.preBatchValue = undefined;
359
+ node.preBatchVersion = 0;
338
360
 
339
361
  node.gen = (node.gen + 1) | 0;
340
362
  node.nextFree = freeNodeHead;
@@ -382,19 +404,32 @@ export function createRegistry(config = {}) {
382
404
  node.tailDep = null;
383
405
  node.currentDep = null;
384
406
  node.headSub = null;
407
+ node.tailSub = null;
385
408
  node.version = 0;
386
409
  node.evalVersion = 0;
387
410
  node.markEpoch = 0;
411
+ node.revertEpoch = 0;
412
+ node.preBatchValue = undefined;
413
+ node.preBatchVersion = 0;
388
414
  return node;
389
415
  }
390
416
 
391
417
  /** Invoke registered cleanup function(s) on `node` and clear. @private */
392
418
  function runCleanup(node) {
393
419
  const cleanup = node.cleanupFn;
394
- if (cleanup) {
420
+ if (cleanup === undefined) return;
421
+
422
+ const prevObserver = currentObserver;
423
+ const prevTracking = isTrackingDeps;
424
+ currentObserver = null;
425
+ isTrackingDeps = false;
426
+ try {
395
427
  if (typeof cleanup === "function") cleanup();
396
428
  else for (let i = 0; i < cleanup.length; i++) cleanup[i]();
429
+ } finally {
397
430
  node.cleanupFn = undefined;
431
+ currentObserver = prevObserver;
432
+ isTrackingDeps = prevTracking;
398
433
  }
399
434
  }
400
435
 
@@ -421,7 +456,12 @@ export function createRegistry(config = {}) {
421
456
  const flags = t.flags | 0;
422
457
 
423
458
  if ((flags & FLAG_EFFECT) !== 0) {
424
- if ((flags & FLAG_QUEUED) === 0) {
459
+ // "No re-run" semantics for self-cycles: an effect that is currently
460
+ // executing on the call stack must not be re-queued by its own body's
461
+ // writes (directly or through computed chains). The effect's evalVersion
462
+ // is bumped to the post-write globalVersion in its executeEffect finally,
463
+ // so subsequent unrelated writes still propagate normally.
464
+ if ((flags & FLAG_QUEUED) === 0 && (flags & FLAG_COMPUTING) === 0) {
425
465
  t.flags = flags | FLAG_QUEUED;
426
466
  activeQueue[activeQueueLen] = t;
427
467
  activeQueueLen = (activeQueueLen + 1) | 0;
@@ -449,40 +489,74 @@ export function createRegistry(config = {}) {
449
489
 
450
490
  /**
451
491
  * Drain the effect queue. Double-buffered so new effects scheduled mid-flush
452
- * end up in the next pass.
492
+ * end up in the next pass. Individual effect throws are caught, buffered, and
493
+ * re-thrown at the end of the flush (or wrapped in AggregateError if multiple).
453
494
  * @private
454
495
  */
455
496
  function flushEffects() {
456
497
  if (isFlushing) return;
457
498
  isFlushing = true;
458
499
  let passes = 0 | 0;
500
+ let normalExit = false;
459
501
 
460
- while (activeQueueLen > 0) {
461
- passes = (passes + 1) | 0;
462
- if (passes > maxFlushPasses) {
463
- isFlushing = false;
464
- throw new Error("CycleError: flush passes exceeded");
465
- }
502
+ try {
503
+ while (activeQueueLen > 0) {
504
+ passes = (passes + 1) | 0;
505
+ if (passes > maxFlushPasses) {
506
+ throw new Error("CycleError: flush passes exceeded");
507
+ }
466
508
 
467
- const toRun = activeQueueLen | 0;
468
- const currentQueue = activeQueue;
469
-
470
- isQueueA = !isQueueA;
471
- activeQueue = isQueueA ? effectQueueA : effectQueueB;
472
- activeQueueLen = 0 | 0;
473
-
474
- for (let i = 0; i < toRun; i++) {
475
- const node = currentQueue[i];
476
- const scheduler = node.scheduler;
477
- if (scheduler) {
478
- const gen = node.gen | 0;
479
- scheduler(() => safeExecute(node, gen));
480
- } else {
481
- executeEffect(node);
509
+ const toRun = activeQueueLen | 0;
510
+ const currentQueue = activeQueue;
511
+
512
+ isQueueA = !isQueueA;
513
+ activeQueue = isQueueA ? effectQueueA : effectQueueB;
514
+ activeQueueLen = 0 | 0;
515
+
516
+ for (let i = 0; i < toRun; i++) {
517
+ const node = currentQueue[i];
518
+ try {
519
+ const scheduler = node.scheduler;
520
+ if (scheduler) {
521
+ const gen = node.gen | 0;
522
+ scheduler(() => safeExecute(node, gen));
523
+ } else {
524
+ executeEffect(node);
525
+ }
526
+ } catch (err) {
527
+ // Buffer and continue. Effect's own try/finally inside
528
+ // executeEffect already restored observer state and
529
+ // severed tail deps before the throw landed here.
530
+ flushErrorBuffer[flushErrorCount] = err;
531
+ flushErrorCount = (flushErrorCount + 1) | 0;
532
+ }
482
533
  }
483
534
  }
535
+ normalExit = true;
536
+ } finally {
537
+ isFlushing = false;
538
+ if (!normalExit) {
539
+ // Escaping via CycleError or any non-effect-body throw. Discard
540
+ // buffered effect errors — the structural failure supersedes them
541
+ // and prevents leaking stale errors to the next flush call.
542
+ for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
543
+ flushErrorCount = 0 | 0;
544
+ }
545
+ }
546
+
547
+ if (flushErrorCount > 0) {
548
+ if (flushErrorCount === 1) {
549
+ const err = flushErrorBuffer[0];
550
+ flushErrorBuffer[0] = null; // drop reference, retain backing store
551
+ flushErrorCount = 0 | 0;
552
+ throw err;
553
+ }
554
+ // 2+ errors: snapshot into a fresh array for AggregateError, then clear.
555
+ const errs = flushErrorBuffer.slice(0, flushErrorCount);
556
+ for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
557
+ flushErrorCount = 0 | 0;
558
+ throw new AggregateError(errs, "Effects threw during flush");
484
559
  }
485
- isFlushing = false;
486
560
  }
487
561
 
488
562
  /**
@@ -541,6 +615,12 @@ export function createRegistry(config = {}) {
541
615
 
542
616
  runCleanup(node);
543
617
 
618
+ // Cleanup may have disposed us (e.g. via a synchronous dispose() call from
619
+ // within the user's cleanup body). disposeNode clears flags to 0 and nulls
620
+ // computeFn; bailing here prevents a TypeError on computeFn() below and
621
+ // avoids reinitialising observer state on a freed slot.
622
+ if ((node.flags & FLAG_EFFECT) === 0) return;
623
+
544
624
  const prevObserver = currentObserver;
545
625
  const prevActiveDep = activeObserverCurrentDep;
546
626
  const prevTracking = isTrackingDeps;
@@ -666,7 +746,26 @@ export function createRegistry(config = {}) {
666
746
  read.set = (value) => {
667
747
  const eq = node.equals;
668
748
  if (eq && eq(node.value, value)) return;
749
+
750
+ // Revert capture: first .set() of this signal inside the current batch.
751
+ // Guarded by batchDepth so out-of-batch writes pay zero added cost.
752
+ if (batchDepth > 0 && node.revertEpoch !== batchEpoch) {
753
+ node.preBatchValue = node.value;
754
+ node.preBatchVersion = node.version | 0;
755
+ node.revertEpoch = batchEpoch | 0;
756
+ }
757
+
669
758
  node.value = value;
759
+
760
+ // Revert detection: in-batch write whose value matches the pre-batch
761
+ // capture. Restore version (without bumping globalVersion) and skip
762
+ // propagation. Subscribers already queued by an earlier mid-batch set
763
+ // will dirty-check against this restored version at flush and bail.
764
+ if (batchDepth > 0 && node.revertEpoch === batchEpoch && eq && eq(node.preBatchValue, value)) {
765
+ node.version = node.preBatchVersion | 0;
766
+ return;
767
+ }
768
+
670
769
  globalVersion = (globalVersion + 1) | 0;
671
770
  node.version = globalVersion | 0;
672
771
  markDownstream(node);
@@ -832,6 +931,10 @@ export function createRegistry(config = {}) {
832
931
  * @returns {T}
833
932
  */
834
933
  function batch(fn) {
934
+ if (batchDepth === 0) {
935
+ batchEpoch = (batchEpoch + 1) | 0;
936
+ if (batchEpoch === 0) batchEpoch = 1 | 0; // preserve the 0 sentinel
937
+ }
835
938
  batchDepth = (batchDepth + 1) | 0;
836
939
  try {
837
940
  return fn();
@@ -910,9 +1013,13 @@ export function createRegistry(config = {}) {
910
1013
  n.tailDep = null;
911
1014
  n.currentDep = null;
912
1015
  n.headSub = null;
1016
+ n.tailSub = null;
913
1017
  n.version = 0;
914
1018
  n.evalVersion = 0;
915
1019
  n.markEpoch = 0;
1020
+ n.revertEpoch = 0;
1021
+ n.preBatchValue = undefined;
1022
+ n.preBatchVersion = 0;
916
1023
  // Bump gen so any scheduler trampolines holding a stale node ref bail.
917
1024
  n.gen = (n.gen + 1) | 0;
918
1025
  if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
@@ -942,9 +1049,15 @@ export function createRegistry(config = {}) {
942
1049
  activeObserverCurrentDep = null;
943
1050
  isTrackingDeps = false;
944
1051
  globalVersion = 1 | 0;
1052
+ batchEpoch = 1 | 0;
945
1053
  statSignals = 0 | 0;
946
1054
  statComputeds = 0 | 0;
947
1055
  statEffects = 0 | 0;
1056
+
1057
+ for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
1058
+
1059
+ flushErrorCount = 0 | 0;
1060
+ flushErrorBuffer.length = 0; // release the backing array
948
1061
  }
949
1062
 
950
1063
  return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy};
@@ -1006,8 +1119,15 @@ export function stats() {
1006
1119
  return defaultRegistry.stats();
1007
1120
  }
1008
1121
 
1122
+ /** * Wipe the default registry. strictly for test-suite isolation.
1123
+ * @private
1124
+ */
1125
+ export function destroy() {
1126
+ return defaultRegistry.destroy();
1127
+ }
1128
+
1009
1129
  /**
1010
1130
  * Re-export of the user-land watch utility.
1011
1131
  * @see {@link watch} in Watch.js for full implementation details.
1012
1132
  */
1013
- export {watch} from "./Watch.js"
1133
+ export {watch, when, whenAsync} from "./Watch.js"
package/Watch.js CHANGED
@@ -1,72 +1,192 @@
1
1
  import { effect, untrack } from "./Signal.js";
2
2
 
3
3
  /**
4
- * Sentinel for "first run" distinguishes a legitimate `undefined` source value
5
- * from the uninitialized state. Using `Symbol` instead of `undefined` ensures a
6
- * source like `signal(undefined)` correctly fires `callback(undefined, undefined)`
7
- * on first change rather than being treated as never-changed.
4
+ * Sentinel for "first run" in `watch`. Distinguishes a legitimate `undefined`
5
+ * source value from the uninitialized state necessary because a naive
6
+ * `oldValue === undefined` check would conflate them.
8
7
  * @private
9
8
  */
10
9
  const UNINITIALIZED = Symbol("watch.uninitialized");
11
10
 
12
11
  /**
13
- * Track a reactive source and run a callback whenever its value changes.
12
+ * Track a reactive source and run a callback whenever its projected value
13
+ * changes. The callback receives `(newValue, oldValue, stop)` — the third
14
+ * argument is a dispose function that can be called from inside the callback
15
+ * to terminate the watcher (matching MobX's `reaction` ergonomics).
14
16
  *
15
- * Models Vue's `watch(source, callback)` and MobX's `reaction(predicate, effect)`.
16
- * The callback is invoked with `(newValue, oldValue)`. Internal reads inside the
17
- * callback are untracked — they don't create reactive dependencies — so a callback
18
- * that reads other signals to perform a side-effect won't re-fire when those
19
- * unrelated signals change.
17
+ * Internal reads inside the callback are untracked: a callback that reads
18
+ * other signals to perform side-effects won't re-fire when those unrelated
19
+ * signals change.
20
20
  *
21
- * Disposing the returned function detaches the underlying effect and stops the
22
- * watcher.
21
+ * Uses `Object.is` to guard against the raw-getter case where a dep mutation
22
+ * fires the effect but the projected value is unchanged (e.g.,
23
+ * `watch(() => health() <= 0, ...)` where many `health` changes produce the
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
26
+ * optional.
23
27
  *
24
28
  * @example
25
29
  * const count = signal(0);
26
- * const stop = watch(count, (next, prev) => {
27
- * console.log(`count changed: ${prev} -> ${next}`);
28
- * });
29
- * count.set(1); // logs: "count changed: 0 -> 1"
30
- * count.set(2); // logs: "count changed: 1 -> 2"
30
+ * const stop = watch(count, (next, prev) => console.log(prev, "→", next));
31
+ * count.set(1); // logs: 0 1
31
32
  * stop();
32
- * count.set(3); // no log
33
33
  *
34
- * @example
35
- * // Immediate fires the callback once on registration with `oldValue = undefined`
36
- * watch(count, (next, prev) => console.log(next), { immediate: true });
37
- *
38
- * @param {() => T} source A function that reads reactive values (typically a
39
- * signal/computed getter, or a closure combining several).
40
- * @param {(newValue: T, oldValue: T | undefined) => void} callback
41
- * Called when the source's value changes. Receives the
42
- * new and previous values. Internal reads are untracked.
43
- * @param {{ immediate?: boolean }} [options]
44
- * `immediate: true` runs the callback once on registration
45
- * with `oldValue = undefined`. Defaults to false.
46
- * @returns {() => void} Dispose function — call to stop watching.
34
+ * @example // Self-disposing on a condition
35
+ * const status = signal("loading");
36
+ * watch(status, (next, prev, stop) => {
37
+ * if (next === "ready") { initialize(); stop(); }
38
+ * });
39
+ *
40
+ * @example // Immediate fire
41
+ * watch(count, (n, p) => render(n), { immediate: true });
42
+ *
43
+ * @param {() => T} source Reactive read function.
44
+ * @param {(newValue: T, oldValue: T | undefined, stop: () => void) => void} callback
45
+ * @param {{ immediate?: boolean }} [options] `immediate: true` runs callback
46
+ * once on registration with
47
+ * `oldValue = undefined`.
48
+ * @returns {() => void} Dispose function. Idempotent.
47
49
  * @template T
48
50
  */
49
51
  export function watch(source, callback, options) {
50
52
  const immediate = options !== undefined && options.immediate === true;
51
53
  let oldValue = UNINITIALIZED;
54
+ let currentNewValue; // shared mutable state, read by untrackedFire
55
+ let stopFn = null;
56
+ let wantsStopEarly = false;
57
+
58
+ // Late-binding stop handle: safe to call before `stopFn` is assigned (e.g.,
59
+ // synchronously inside the immediate fire), and safe to call multiple times.
60
+ const stop = () => {
61
+ if (stopFn !== null) stopFn();
62
+ else wantsStopEarly = true;
63
+ };
64
+
65
+ // ZERO-GC HOT PATH: the untrack body is hoisted into a closure allocated
66
+ // ONCE at registration time. If this were declared inline as
67
+ // `untrack(() => { ... })` inside the effect body, V8 would allocate a
68
+ // fresh closure on every fire — at 120fps that's 7,200 allocations per
69
+ // minute per watcher. The shared `currentNewValue` variable is the price
70
+ // for keeping the per-fire cost at exactly zero allocations.
71
+ const untrackedFire = () => {
72
+ if (oldValue === UNINITIALIZED) {
73
+ if (immediate) callback(currentNewValue, undefined, stop);
74
+ } else if (!Object.is(currentNewValue, oldValue)) {
75
+ callback(currentNewValue, oldValue, stop);
76
+ }
77
+ oldValue = currentNewValue;
78
+ };
79
+
80
+ stopFn = effect(() => {
81
+ currentNewValue = source();
82
+ untrack(untrackedFire);
83
+ });
84
+
85
+ // If the immediate callback called stop() before stopFn was assigned,
86
+ // honor it now.
87
+ if (wantsStopEarly) stopFn();
88
+ return stop;
89
+ }
90
+
91
+ /**
92
+ * Fire `callback` exactly once when `predicate` first returns a truthy value,
93
+ * then auto-dispose. If `predicate` is already truthy at registration, fires
94
+ * synchronously and disposes immediately.
95
+ *
96
+ * Models MobX's `when(predicate, effect)` semantics. The returned dispose
97
+ * function can be called to cancel the watcher before it fires (useful for
98
+ * conditional registration that should be revocable).
99
+ *
100
+ * @example // Trigger on state transition
101
+ * when(() => user.isAuthenticated, () => redirect("/dashboard"));
102
+ *
103
+ * @example // Cancellable
104
+ * const cancel = when(() => slow.ready, () => start());
105
+ * if (userBacked) cancel();
106
+ *
107
+ * @param {() => unknown} predicate Reactive read function; fired when truthy.
108
+ * @param {() => void} callback Called once when predicate first truthy.
109
+ * Internal reads are untracked.
110
+ * @returns {() => void} Dispose function. Idempotent.
111
+ */
112
+ export function when(predicate, callback) {
113
+ let stopFn = null;
114
+ let wantsStopEarly = false;
115
+ let fired = false;
52
116
 
53
- return effect(() => {
54
- // Track the source — this read registers the dependency.
55
- const newValue = source();
117
+ const stop = () => {
118
+ if (stopFn !== null) stopFn();
119
+ else wantsStopEarly = true;
120
+ };
56
121
 
57
- // Invoke the callback without registering further dependencies.
58
- untrack(() => {
59
- if (oldValue === UNINITIALIZED) {
60
- if (immediate) callback(newValue, undefined);
61
- } else if (!Object.is(newValue, oldValue)) {
62
- // Guard for raw inline getters: the effect re-runs whenever any read
63
- // dep changes, but the projected source value may be unchanged. Vue's
64
- // `watch` and MobX's `reaction` both short-circuit here. Wrapping the
65
- // source in a `computed` would also suppress this via the equality
66
- // check inside computed itself; the guard makes that wrapping optional.
67
- callback(newValue, oldValue);
68
- }
69
- oldValue = newValue;
70
- });
122
+ stopFn = effect(() => {
123
+ // Defense-in-depth: even if dispose timing lets one more evaluation
124
+ // through (e.g., during sync propagation), don't fire twice.
125
+ if (fired) return;
126
+ if (predicate()) {
127
+ fired = true;
128
+ untrack(callback);
129
+ stop();
130
+ }
71
131
  });
72
- }
132
+
133
+ if (wantsStopEarly) stopFn();
134
+ return stop;
135
+ }
136
+
137
+ /**
138
+ * Promise-returning variant of {@link when}. The returned promise resolves
139
+ * when `predicate` first returns a truthy value. Composes with `await` for
140
+ * declarative async control flow against reactive state.
141
+ *
142
+ * ⚠️ **HOT-PATH WARNING — DO NOT USE PER FRAME.** This function calls
143
+ * `new Promise(...)`, which is a heap allocation. Every call allocates a
144
+ * Promise object plus its executor closure plus internal Promise infrastructure
145
+ * (resolve function, microtask state). This is unavoidable — Promises require
146
+ * heap allocation by the language spec.
147
+ *
148
+ * **Use `whenAsync` for:** high-level scene/UI orchestration, boot sequences,
149
+ * waiting for user input, awaiting network state, level transitions. Anything
150
+ * that runs once or rarely.
151
+ *
152
+ * **NEVER use `whenAsync` for:** per-frame entity updates, render-loop logic,
153
+ * animation tick handlers, anywhere that runs at 60/120 fps. The Promise
154
+ * allocations will be visible in GC traces and will cause frame-time spikes
155
+ * under sustained load.
156
+ *
157
+ * **Zero-GC alternative:** use {@link when} with a callback. `when` is
158
+ * allocation-free per evaluation in its hot path (two closures total at
159
+ * registration, zero per predicate check).
160
+ *
161
+ * Note: this promise never rejects. If the predicate never becomes truthy,
162
+ * the promise never settles. Wrap in `Promise.race` for timeout semantics.
163
+ *
164
+ * @example // ✅ OK — high-level orchestration
165
+ * await whenAsync(() => user.isAuthenticated);
166
+ * navigate("/dashboard");
167
+ *
168
+ * @example // ✅ OK — boot sequence
169
+ * await whenAsync(() => assets.loaded);
170
+ * startGame();
171
+ *
172
+ * @example // ❌ NOT OK — per-frame, allocates a Promise every frame
173
+ * function animate() {
174
+ * whenAsync(() => physics.settled).then(render); // GC pressure!
175
+ * requestAnimationFrame(animate);
176
+ * }
177
+ *
178
+ * @example // ✅ Same use case, zero-GC
179
+ * when(() => physics.settled, render); // no Promise, no GC pressure
180
+ *
181
+ * @example // With timeout
182
+ * await Promise.race([
183
+ * whenAsync(() => api.ready),
184
+ * new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 5000))
185
+ * ]);
186
+ *
187
+ * @param {() => unknown} predicate Reactive read function; resolves promise when truthy.
188
+ * @returns {Promise<void>} Resolves when predicate first truthy.
189
+ */
190
+ export function whenAsync(predicate) {
191
+ return new Promise((resolve) => when(predicate, resolve));
192
+ }
package/llms.txt CHANGED
@@ -117,6 +117,59 @@ class CapacityError extends Error {
117
117
  }
118
118
  ```
119
119
 
120
+ ## Watchers (added in v1.1)
121
+
122
+ Three composable watcher primitives, all built from `effect` + `untrack`. Tree-shakeable.
123
+
124
+ ### watch(source, callback, options?)
125
+
126
+ Fires callback on every change of the projected source value.
127
+
128
+ - **Signature**: `watch<T>(source: () => T, callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void, options?: { immediate?: boolean }): () => void`
129
+ - **Returns**: dispose function. Idempotent. Safe to call synchronously inside the callback.
130
+ - **options.immediate**: when `true`, callback fires once on registration with `oldValue = undefined`. Default `false`.
131
+ - **Equality guard**: uses `Object.is(newValue, oldValue)` internally to avoid firing when the projected value is unchanged across dep mutations. Critical for raw getter sources like `() => health() <= 0` — many dep changes can produce the same boolean. Wrapping the source in `computed()` would achieve the same via the computed's own equality check.
132
+ - **UNINITIALIZED sentinel**: uses `Symbol("watch.uninitialized")` instead of `undefined` to detect first-run. This means `signal(undefined)` is correctly distinguished from "watcher hasn't fired yet".
133
+ - **Callback reads are untracked**: the callback can read other signals without registering them as dependencies.
134
+ - **Stop in callback**: third callback argument is a stop handle. Safe to call at any point including synchronously during the immediate fire (the `wantsStopEarly` flag defers dispose until after the effect is registered).
135
+ - **Allocation profile**: 3 closures at registration (stop, effect body, hoisted untrack body). **Zero allocations per fire** — the untrack body is hoisted with `currentNewValue` as shared mutable state. Hot-path safe at 120fps.
136
+
137
+ ### when(predicate, callback)
138
+
139
+ Fires callback exactly once when predicate first returns truthy, then auto-disposes.
140
+
141
+ - **Signature**: `when(predicate: () => unknown, callback: () => void): () => void`
142
+ - **Returns**: dispose function. Useful for cancelling before predicate fires; idempotent; no-op after callback has fired.
143
+ - **Synchronous fire**: if predicate is already truthy at registration, callback fires synchronously and watcher disposes immediately (same `wantsStopEarly` pattern as `watch`).
144
+ - **One-shot guarantee**: internal `fired` flag protects against double-fire even if dispose timing lets one more evaluation through.
145
+ - **Callback reads are untracked.**
146
+ - **Truthy/falsy semantics**: standard JS truthy check. `0`, `""`, `null`, `undefined`, `false`, `NaN` do not trigger; everything else does.
147
+ - **Allocation profile**: 2 closures at registration (stop, effect body). **Zero allocations per check** — `untrack(callback)` passes the user's callback directly without wrapping. Hot-path safe at 120fps.
148
+
149
+ ### whenAsync(predicate)
150
+
151
+ Promise variant of `when`.
152
+
153
+ - **Signature**: `whenAsync(predicate: () => unknown): Promise<void>`
154
+ - **Returns**: Promise that resolves when predicate first becomes truthy.
155
+ - **Implementation**: `return new Promise((resolve) => when(predicate, resolve))`.
156
+ - **Foot-gun**: promise never rejects. If predicate never becomes truthy, promise never settles. Use `Promise.race` for timeout: `Promise.race([whenAsync(p), timeoutPromise])`.
157
+ - **⚠️ HOT-PATH WARNING**: `new Promise(...)` is a heap allocation. Each call allocates 1 Promise + 1 executor closure + Promise infrastructure (resolve fn, microtask state) + 2 closures from internal `when` call. This is unavoidable — Promises require heap allocation by spec. **Do NOT call per frame.** Use for high-level orchestration (boot sequences, scene transitions, awaiting user input). For 60/120fps logic, use `when(predicate, callback)` directly — it's zero-GC.
158
+
159
+ ### Architecture note
160
+
161
+ None of these touch the reactive engine — no `FLAG_WATCHER` on `ReactiveNode`, no extension to the object pool, no new internal primitive. They compose entirely from public API. This is the test that the core engine is structurally complete: when a higher-level pattern can be built without extending the engine, the engine has enough.
162
+
163
+ ### Allocation profile summary
164
+
165
+ | Primitive | At registration | Per fire / check |
166
+ |---|---|---|
167
+ | `watch` | 3 closures | 0 |
168
+ | `when` | 2 closures | 0 |
169
+ | `whenAsync` | 1 Promise + executor + Promise internals + 2 closures (from `when`) | 0 |
170
+
171
+ The deliberate engineering for `watch`'s "0 per fire" is the hoisted `untrackedFire` closure with `currentNewValue` shared mutable state — see source comment in `watch.js`. Inline arrow function inside the effect body would allocate per fire and break the zero-GC contract.
172
+
120
173
  ## Benchmark snapshot (Node 22, 2016-era Intel MacBook Pro, 20K iter × 5 runs × 50+ invocations)
121
174
 
122
175
  | Scenario | lite-signal | alien-signals | preact | solid |
@@ -188,6 +241,8 @@ sandbox.destroy(); // entire reactive world reset
188
241
  - `test/05-scheduler.test.mjs` — scheduler races, dispose, gen counter, version wrap.
189
242
  - `test/06-nested-objects.test.mjs` — nested-object & reference-identity behaviours.
190
243
  - `test/07-dispose.test.mjs` — universal disposal: registry.dispose(api).
244
+ - `test/08-watch.test.mjs` — new watch reactivity tests.
245
+ - `test/09-conformance.test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
191
246
  - `bench/bench.mjs` — comparative benchmark vs alien-signals, preact, solid.
192
247
  - `demo/index.html` — interactive visualization of the reactive graph.
193
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal",
3
- "version": "1.0.6",
3
+ "version": "1.1.1",
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",