@zakkster/lite-signal 1.0.5 → 1.1.0

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,79 @@ 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
+ **167 of 177 tests pass (94.4%)**, placing lite-signal **fifth of sixteen**
714
+ evaluated libraries — behind alien-signals (177), @preact/signals-core (174),
715
+ @reatom/core (173), and @vue/reactivity (170), and ahead of anod, solid-js,
716
+ tansu, @solidjs/signals, the TC39 signals polyfill, mobx, Angular signals,
717
+ Svelte, S.js, and reactively.
718
+
719
+ We publish both passing and failing tests, because honesty about behavior is
720
+ more useful to library users than a green checkmark.
721
+
722
+ ### What lite-signal does that no other library does
723
+
724
+ - **`batch()` returns the callback's value.** Every other library evaluated
725
+ returns `void`. `const total = batch(() => ...)` is a lite-signal idiom.
726
+ - **Cycle detection** in effects (matches preact, reatom, svelte, solid).
727
+ Many libraries silently iterate to a 200-step bail; lite-signal throws so
728
+ the bug surfaces at development time.
729
+ - **`Object.is` equality** throughout, including NaN — matches Vue,
730
+ Angular, Reatom, the TC39 polyfill, and tansu. The `===` camp returns
731
+ incorrect results on NaN flows.
732
+ - **Single-pass propagation** through computed chains on inner writes —
733
+ matches alien-signals and Vue; faster than preact, solid, reatom, mobx,
734
+ and most others by one re-evaluation per write.
735
+ - **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
736
+ solid. Half the field leaks the subscription.
737
+
738
+ ### What v1.1.0 doesn't do yet
739
+
740
+ 10 tests fail. We've categorized them by intent.
741
+
742
+ **Targeted for v1.2** (6 tests):
743
+
744
+ - **Revert detection inside batches** (#147, #132, #123): writes inside a
745
+ `batch()` that net to no change still mark dependents as dirty. Vue,
746
+ Solid, Mobx, and roughly half the field share this behavior. v1.2 will
747
+ capture pre-batch values per signal and skip propagation on revert.
748
+ - **Throw isolation in batch flush** (#121): if an effect throws during
749
+ flush, lite-signal currently halts the flush. v1.2 will collect errors,
750
+ finish the flush, then re-throw as `AggregateError`.
751
+ - **Inner-write propagation through computed chains** (#180, #213): two
752
+ specific propagation paths where lite-signal disagrees with the field.
753
+ Both are propagation-order bugs in the recursive computed resolver,
754
+ not zero-GC tradeoffs.
755
+
756
+ **Design choices we will not change** (2 tests):
757
+
758
+ - **Inner writes inside computeds** (#179): writing to a signal from inside
759
+ a computed is a side effect, not a derivation. Use an `effect` instead.
760
+ Most of the field also fails this test.
761
+ - **Nested batch coalescing inside an effect body** (#235): explicit
762
+ `batch()` calls *inside* an executing effect do not coalesce beyond the
763
+ effect's own implicit batching. Most libraries behave this way. Wrap the
764
+ batch outside the effect for the intended semantics.
765
+
766
+ **Opt-in feature, deferred** (2 tests):
767
+
768
+ - **Solid-style cascading disposal of nested effects** (#209, #210):
769
+ lite-signal does not maintain an owner tree of parent-child effects.
770
+ This matches preact, vue, mobx, the TC39 polyfill, Angular, Svelte,
771
+ tansu, and Solid 1.x. Solid 2 / @solidjs/signals, reatom, and anod
772
+ implement it. If you need it, please open an issue.
773
+
774
+ Per-test results, the runner adapter, and reproductions live in
775
+ `/conformance/`.
776
+
777
+ ---
778
+
571
779
  ## FAQ
572
780
 
573
781
  **Why no microtask scheduler?**
package/Signal.d.ts CHANGED
@@ -165,3 +165,78 @@ 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
+ export declare function destroy(): void;
169
+
170
+ /**
171
+ * Configuration options for the watch utility.
172
+ */
173
+ export interface WatchOptions {
174
+ /** * If true, fires the callback immediately upon registration
175
+ * with `oldValue` set to `undefined`.
176
+ */
177
+ immediate?: boolean;
178
+ }
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
+
218
+ /**
219
+ * Promise-returning variant of {@link when}. The returned promise resolves
220
+ * when `predicate` first returns a truthy value.
221
+ *
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.
226
+ *
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.
239
+ */
240
+ export function whenAsync(
241
+ predicate: () => unknown
242
+ ): Promise<void>;
package/Signal.js CHANGED
@@ -68,6 +68,7 @@ class ReactiveNode {
68
68
  this.currentDep = null;
69
69
  // Doubly-linked subscriber list (these targets depend on this node).
70
70
  this.headSub = null;
71
+ this.tailSub = null;
71
72
 
72
73
  // Pool free-list pointer.
73
74
  this.nextFree = null;
@@ -244,10 +245,13 @@ export function createRegistry(config = {}) {
244
245
  link.source = source;
245
246
  link.target = target;
246
247
 
247
- link.prevSub = null;
248
- link.nextSub = source.headSub;
249
- if (source.headSub !== null) source.headSub.prevSub = link;
250
- source.headSub = link;
248
+ link.nextSub = null;
249
+ link.prevSub = source.tailSub;
250
+
251
+ if (source.tailSub !== null) source.tailSub.nextSub = link;
252
+ else source.headSub = link;
253
+
254
+ source.tailSub = link;
251
255
  }
252
256
 
253
257
  link.nextDep = expected;
@@ -270,8 +274,9 @@ export function createRegistry(config = {}) {
270
274
  function freeLink(link, target, source) {
271
275
  const pSub = link.prevSub;
272
276
  const nSub = link.nextSub;
277
+
273
278
  if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
274
- if (nSub !== null) nSub.prevSub = pSub;
279
+ if (nSub !== null) nSub.prevSub = pSub; else source.tailSub = pSub;
275
280
 
276
281
  link.source = null;
277
282
  link.target = null;
@@ -335,6 +340,7 @@ export function createRegistry(config = {}) {
335
340
  node.tailDep = null;
336
341
  node.currentDep = null;
337
342
  node.headSub = null;
343
+ node.tailSub = null;
338
344
 
339
345
  node.gen = (node.gen + 1) | 0;
340
346
  node.nextFree = freeNodeHead;
@@ -382,6 +388,7 @@ export function createRegistry(config = {}) {
382
388
  node.tailDep = null;
383
389
  node.currentDep = null;
384
390
  node.headSub = null;
391
+ node.tailSub = null;
385
392
  node.version = 0;
386
393
  node.evalVersion = 0;
387
394
  node.markEpoch = 0;
@@ -391,10 +398,19 @@ export function createRegistry(config = {}) {
391
398
  /** Invoke registered cleanup function(s) on `node` and clear. @private */
392
399
  function runCleanup(node) {
393
400
  const cleanup = node.cleanupFn;
394
- if (cleanup) {
401
+ if (cleanup === undefined) return;
402
+
403
+ const prevObserver = currentObserver;
404
+ const prevTracking = isTrackingDeps;
405
+ currentObserver = null;
406
+ isTrackingDeps = false;
407
+ try {
395
408
  if (typeof cleanup === "function") cleanup();
396
409
  else for (let i = 0; i < cleanup.length; i++) cleanup[i]();
410
+ } finally {
397
411
  node.cleanupFn = undefined;
412
+ currentObserver = prevObserver;
413
+ isTrackingDeps = prevTracking;
398
414
  }
399
415
  }
400
416
 
@@ -541,6 +557,12 @@ export function createRegistry(config = {}) {
541
557
 
542
558
  runCleanup(node);
543
559
 
560
+ // Cleanup may have disposed us (e.g. via a synchronous dispose() call from
561
+ // within the user's cleanup body). disposeNode clears flags to 0 and nulls
562
+ // computeFn; bailing here prevents a TypeError on computeFn() below and
563
+ // avoids reinitialising observer state on a freed slot.
564
+ if ((node.flags & FLAG_EFFECT) === 0) return;
565
+
544
566
  const prevObserver = currentObserver;
545
567
  const prevActiveDep = activeObserverCurrentDep;
546
568
  const prevTracking = isTrackingDeps;
@@ -910,6 +932,7 @@ export function createRegistry(config = {}) {
910
932
  n.tailDep = null;
911
933
  n.currentDep = null;
912
934
  n.headSub = null;
935
+ n.tailSub = null;
913
936
  n.version = 0;
914
937
  n.evalVersion = 0;
915
938
  n.markEpoch = 0;
@@ -1004,4 +1027,17 @@ export function onCleanup(fn) {
1004
1027
  /** @type {Registry["stats"]} */
1005
1028
  export function stats() {
1006
1029
  return defaultRegistry.stats();
1007
- }
1030
+ }
1031
+
1032
+ /** * Wipe the default registry. strictly for test-suite isolation.
1033
+ * @private
1034
+ */
1035
+ export function destroy() {
1036
+ return defaultRegistry.destroy();
1037
+ }
1038
+
1039
+ /**
1040
+ * Re-export of the user-land watch utility.
1041
+ * @see {@link watch} in Watch.js for full implementation details.
1042
+ */
1043
+ export {watch, when, whenAsync} from "./Watch.js"
package/Watch.js ADDED
@@ -0,0 +1,192 @@
1
+ import { effect, untrack } from "./Signal.js";
2
+
3
+ /**
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.
7
+ * @private
8
+ */
9
+ const UNINITIALIZED = Symbol("watch.uninitialized");
10
+
11
+ /**
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).
16
+ *
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
+ *
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.
27
+ *
28
+ * @example
29
+ * const count = signal(0);
30
+ * const stop = watch(count, (next, prev) => console.log(prev, "→", next));
31
+ * count.set(1); // logs: 0 → 1
32
+ * stop();
33
+ *
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.
49
+ * @template T
50
+ */
51
+ export function watch(source, callback, options) {
52
+ const immediate = options !== undefined && options.immediate === true;
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;
116
+
117
+ const stop = () => {
118
+ if (stopFn !== null) stopFn();
119
+ else wantsStopEarly = true;
120
+ };
121
+
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
+ }
131
+ });
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.5",
3
+ "version": "1.1.0",
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",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "Signal.js",
20
20
  "Signal.d.ts",
21
+ "Watch.js",
21
22
  "README.md",
22
23
  "llms.txt",
23
24
  "LICENSE.txt"