@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 +208 -3
- package/Signal.d.ts +60 -19
- package/Signal.js +151 -31
- package/Watch.js +169 -49
- package/llms.txt +55 -0
- package/package.json +1 -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,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
|
-
*
|
|
219
|
+
* Promise-returning variant of {@link when}. The returned promise resolves
|
|
220
|
+
* when `predicate` first returns a truthy value.
|
|
182
221
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
248
|
-
link.
|
|
249
|
-
|
|
250
|
-
source.
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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"
|
|
5
|
-
* from the uninitialized state
|
|
6
|
-
*
|
|
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
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
36
|
-
* watch(
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* @
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* @param {
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
117
|
+
const stop = () => {
|
|
118
|
+
if (stopFn !== null) stopFn();
|
|
119
|
+
else wantsStopEarly = true;
|
|
120
|
+
};
|
|
56
121
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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",
|