@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 +211 -3
- package/Signal.d.ts +75 -0
- package/Signal.js +43 -7
- package/Watch.js +192 -0
- package/llms.txt +55 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
6
6
|

|
|
7
|
-
[](https://bundlephobia.com/result?p=@zakkster/lite-signal)
|
|
8
8
|
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
9
9
|
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
10
10
|

|
|
@@ -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.
|
|
475
|
-
|
|
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.
|
|
248
|
-
link.
|
|
249
|
-
|
|
250
|
-
source.
|
|
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
|
|
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"
|