@zakkster/lite-signal 1.1.1 → 1.1.3
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 +43 -24
- package/Signal.d.ts +26 -20
- package/Signal.js +110 -34
- package/llms.txt +23 -2
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -90,6 +90,7 @@ No microtask between `B` and `I`. No promise, no `queueMicrotask`. Just call sta
|
|
|
90
90
|
- **`dispose(api)`** — universal disposal for signals, computeds, and effect handles. Cross-registry calls are silent no-ops.
|
|
91
91
|
- **`batch(fn)`** — defer effect flush until the outermost batch closes.
|
|
92
92
|
- **`untrack(fn)`** — read without subscribing.
|
|
93
|
+
- **`isTracking()`** — `true` iff a read right now would subscribe (for lazy-allocation wrappers).
|
|
93
94
|
- **`onCleanup(fn)`** — register teardown for the current computation. Works in effects *and* computeds.
|
|
94
95
|
- **`createRegistry(config)`** — isolated pool for tests, plugins, sandboxing.
|
|
95
96
|
- **`stats()`** — pool occupancy snapshot. Used by the demo and easy to wire into perf overlays.
|
|
@@ -273,6 +274,28 @@ const value = untrack(() => s()); // read without subscribing
|
|
|
273
274
|
|
|
274
275
|
Useful inside computeds/effects when you need a current value but don't want it as a dependency.
|
|
275
276
|
|
|
277
|
+
### isTracking
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
function makeLazyField(initial) {
|
|
281
|
+
let s = null, value = initial;
|
|
282
|
+
return {
|
|
283
|
+
get() {
|
|
284
|
+
if (isTracking()) {
|
|
285
|
+
if (s === null) s = signal(value); // allocate only when subscribed
|
|
286
|
+
return s();
|
|
287
|
+
}
|
|
288
|
+
return value;
|
|
289
|
+
},
|
|
290
|
+
set(v) { value = v; if (s !== null) s.set(v); }
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Returns `true` iff a read right now would record a dependency on the current registry — an observer body is on the stack AND tracking is enabled. Mirrors the engine's own read-trap check (both flags), so it correctly returns `false` inside `untrack`, inside `subscribe` callbacks, inside `onCleanup` bodies, inside `watch` / `when` callbacks, and outside any observer.
|
|
296
|
+
|
|
297
|
+
For wrapper libraries (lite-store, lite-query, lite-form) gating lazy allocation on the read path. Per-registry — call `registry.isTracking()` if your signals live in a non-default registry.
|
|
298
|
+
|
|
276
299
|
### onCleanup
|
|
277
300
|
|
|
278
301
|
```ts
|
|
@@ -476,7 +499,7 @@ The "0 per fire" property for `watch` is deliberate engineering — the inner `u
|
|
|
476
499
|
|
|
477
500
|
### Tree-shaking
|
|
478
501
|
|
|
479
|
-
All three primitives live in a separate module (`
|
|
502
|
+
All three primitives live in a separate module (`Watch.js`) and are re-exported from the main entry (which binds them to its own `effect`/`untrack`, so there is exactly one engine instance). 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
503
|
|
|
481
504
|
---
|
|
482
505
|
|
|
@@ -537,7 +560,7 @@ Three tiers, all reproducible.
|
|
|
537
560
|
|
|
538
561
|
### Tier 1 — Behavior (unit tests, fast)
|
|
539
562
|
|
|
540
|
-
`npm test` runs the suite in `test
|
|
563
|
+
`npm test` runs the suite in `test/`, covering:
|
|
541
564
|
|
|
542
565
|
- **`01-core.test.mjs`** — signal/computed/effect basics, equality semantics, NaN/±0, subscribe/peek/update, untrack, batch, cleanup ordering, first-run error recovery, nested object reference-identity gotchas.
|
|
543
566
|
- **`02-topology.test.mjs`** — diamond glitch-freedom, 256-deep and 1024-deep computed chains, wide fan-out (1000 effects from one signal), dynamic dependency switching, conditional fan-out, nested effects, cycle detection (`CycleError`).
|
|
@@ -547,6 +570,7 @@ Three tiers, all reproducible.
|
|
|
547
570
|
- **`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
571
|
- **`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
572
|
- **`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.
|
|
573
|
+
- **`10-is-tracking.test.mjs`** — The `isTracking()` observer-context predicate. 11 tests across 5 describe blocks: true inside effect/computed bodies; false inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and `watch` callbacks (the untracked-window cases that catch an observer-only misimplementation); false outside any observer including at the call site of an unobserved computed read; state-restoration after a thrown body; per-registry isolation; top-level binding.
|
|
550
574
|
|
|
551
575
|
```bash
|
|
552
576
|
npm test
|
|
@@ -705,11 +729,19 @@ function spawnPlugin(pluginCode) {
|
|
|
705
729
|
|
|
706
730
|
## Conformance
|
|
707
731
|
|
|
708
|
-
lite-signal
|
|
732
|
+
lite-signal is evaluated against the
|
|
709
733
|
[reactive-framework-test-suite](https://github.com/johnsoncodehk/reactive-framework-test-suite),
|
|
710
734
|
the most comprehensive behavioral test battery for JavaScript reactive
|
|
711
735
|
libraries.
|
|
712
736
|
|
|
737
|
+
As of **v1.1.2**, the conformance items that were open at v1.1.0 — batch revert
|
|
738
|
+
detection (#123 / #132 / #147), throw isolation in flush (#121), and
|
|
739
|
+
inner-write propagation through computed chains (#180 / #213) — are **closed**
|
|
740
|
+
(landed in v1.1.1). The remaining gaps are one deliberate design choice (#179,
|
|
741
|
+
below) and the owner-tree items (#209 / #210), which land with the v1.2
|
|
742
|
+
ownership hybrid. The exact post-1.1.1 pass count is being re-run against the
|
|
743
|
+
upstream suite; per-test results and the runner adapter live in `/conformance/`.
|
|
744
|
+
|
|
713
745
|
**174 of 177 tests pass (98.3%)**, placing lite-signal **in a tie for second place of sixteen**
|
|
714
746
|
evaluated libraries — just behind alien-signals (177).
|
|
715
747
|
|
|
@@ -732,23 +764,9 @@ more useful to library users than a green checkmark.
|
|
|
732
764
|
- **Auto-unsubscribe** on first-run effect throws — matches preact, reatom,
|
|
733
765
|
solid. Half the field leaks the subscription.
|
|
734
766
|
|
|
735
|
-
### What
|
|
736
|
-
|
|
737
|
-
10 tests fail. We've categorized them by intent.
|
|
738
|
-
|
|
739
|
-
**Targeted for v1.2** (6 tests):
|
|
767
|
+
### What lite-signal does NOT do yet
|
|
740
768
|
|
|
741
|
-
|
|
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.
|
|
769
|
+
The remaining open items, by intent.
|
|
752
770
|
|
|
753
771
|
**Design choices we will not change** (2 tests):
|
|
754
772
|
|
|
@@ -760,13 +778,14 @@ more useful to library users than a green checkmark.
|
|
|
760
778
|
effect's own implicit batching. Most libraries behave this way. Wrap the
|
|
761
779
|
batch outside the effect for the intended semantics.
|
|
762
780
|
|
|
763
|
-
**
|
|
781
|
+
**Landing in v1.2** (2 tests):
|
|
764
782
|
|
|
765
783
|
- **Solid-style cascading disposal of nested effects** (#209, #210):
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
tansu, and Solid 1.x.
|
|
769
|
-
|
|
784
|
+
the baseline 1.1.x engine maintains no owner tree of parent-child
|
|
785
|
+
effects — matching preact, vue, mobx, the TC39 polyfill, Angular,
|
|
786
|
+
Svelte, tansu, and Solid 1.x. The v1.2 ownership hybrid adds an
|
|
787
|
+
owner tree so nested effects/computeds auto-dispose with their parent;
|
|
788
|
+
the #209 / #210 conformance tests are wired and skipped until then.
|
|
770
789
|
|
|
771
790
|
Per-test results, the runner adapter, and reproductions live in
|
|
772
791
|
`/conformance/`.
|
package/Signal.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zakkster/lite-signal — zero-GC reactive graph.
|
|
3
3
|
*
|
|
4
|
-
* Public type surface for the JavaScript implementation in `
|
|
4
|
+
* Public type surface for the JavaScript implementation in `Signal.js`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
// ─── Options ──────────────────────────────────────────────────────────────────
|
|
@@ -115,7 +115,7 @@ export interface RegistryConfig {
|
|
|
115
115
|
* - `"grow"`: double the pool. Links are bounded by `maxLinks * 16`.
|
|
116
116
|
*/
|
|
117
117
|
onCapacityExceeded?: "throw" | "grow";
|
|
118
|
-
/** Max effect-queue drain passes before a
|
|
118
|
+
/** Max effect-queue drain passes before a flush-cycle `Error` (message prefixed `"CycleError:"`) is thrown. Default: 100. */
|
|
119
119
|
maxFlushPasses?: number;
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -133,6 +133,10 @@ export interface Registry {
|
|
|
133
133
|
dispose(api: Disposable): void;
|
|
134
134
|
batch<T>(fn: () => T): T;
|
|
135
135
|
untrack<T>(fn: () => T): T;
|
|
136
|
+
/** True iff a read RIGHT NOW would record a dependency on this registry.
|
|
137
|
+
* False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and
|
|
138
|
+
* outside any observer. Use for lazy-allocation wrappers like lite-store. */
|
|
139
|
+
isTracking(): boolean;
|
|
136
140
|
onCleanup(fn: () => void): void;
|
|
137
141
|
stats(): RegistryStats;
|
|
138
142
|
/** Reset everything: nodes, links, queues, global clock. Outstanding dispose
|
|
@@ -163,6 +167,8 @@ export function effect(fn: () => void, opts?: EffectOptions): Dispose;
|
|
|
163
167
|
export function dispose(api: Disposable): void;
|
|
164
168
|
export function batch<T>(fn: () => T): T;
|
|
165
169
|
export function untrack<T>(fn: () => T): T;
|
|
170
|
+
/** Top-level binding of {@link Registry.isTracking} against the default registry. */
|
|
171
|
+
export function isTracking(): boolean;
|
|
166
172
|
export function onCleanup(fn: () => void): void;
|
|
167
173
|
export function stats(): RegistryStats;
|
|
168
174
|
export declare function destroy(): void;
|
|
@@ -177,24 +183,24 @@ export interface WatchOptions {
|
|
|
177
183
|
immediate?: boolean;
|
|
178
184
|
}
|
|
179
185
|
|
|
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
|
-
*/
|
|
186
|
+
/**
|
|
187
|
+
* Track a reactive source and run a callback whenever its projected value
|
|
188
|
+
* changes. The callback receives `(newValue, oldValue, stop)` — the third
|
|
189
|
+
* argument is a dispose function that can be called from inside the callback
|
|
190
|
+
* to terminate the watcher.
|
|
191
|
+
*
|
|
192
|
+
* Internal reads inside the callback are untracked.
|
|
193
|
+
*
|
|
194
|
+
* Uses `Object.is` to guard against the raw-getter case where a dep mutation
|
|
195
|
+
* fires the effect but the projected value is unchanged.
|
|
196
|
+
*
|
|
197
|
+
* @param source Reactive read function.
|
|
198
|
+
* @param callback Called with the new and previous values plus a stop handle.
|
|
199
|
+
* @param options `immediate: true` runs the callback once on registration
|
|
200
|
+
* with `oldValue = undefined`.
|
|
201
|
+
* @returns Dispose function. Idempotent and safe to call at any time, including
|
|
202
|
+
* synchronously during the immediate callback.
|
|
203
|
+
*/
|
|
198
204
|
export function watch<T>(
|
|
199
205
|
source: () => T,
|
|
200
206
|
callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void,
|
package/Signal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal
|
|
2
|
+
* @zakkster/lite-signal v1.1.2
|
|
3
3
|
* --------------------
|
|
4
4
|
* Zero-GC reactive graph.
|
|
5
5
|
*
|
|
@@ -143,10 +143,10 @@ export function createRegistry(config = {}) {
|
|
|
143
143
|
const NODE_PTR = Symbol("node_ptr");
|
|
144
144
|
const NODE_GEN = Symbol("node_gen");
|
|
145
145
|
|
|
146
|
-
let currentNodesCapacity = config.maxNodes
|
|
147
|
-
let currentLinkCapacity = config.maxLinks
|
|
148
|
-
const policy = config.onCapacityExceeded
|
|
149
|
-
const maxFlushPasses = config.maxFlushPasses
|
|
146
|
+
let currentNodesCapacity = config.maxNodes ?? 1024;
|
|
147
|
+
let currentLinkCapacity = config.maxLinks ?? currentNodesCapacity * 4;
|
|
148
|
+
const policy = config.onCapacityExceeded ?? "throw";
|
|
149
|
+
const maxFlushPasses = config.maxFlushPasses ?? 100;
|
|
150
150
|
const maxLinkLimit = currentLinkCapacity * 16;
|
|
151
151
|
|
|
152
152
|
// --- ZERO-GC OBJECT POOLS ---
|
|
@@ -440,19 +440,21 @@ export function createRegistry(config = {}) {
|
|
|
440
440
|
* @private
|
|
441
441
|
*/
|
|
442
442
|
function markDownstream(startNode) {
|
|
443
|
-
let stackLen = 0
|
|
444
|
-
markStack[stackLen] = startNode;
|
|
445
|
-
stackLen = (stackLen + 1) | 0;
|
|
443
|
+
let stackLen = 0;
|
|
444
|
+
markStack[stackLen++] = startNode;
|
|
446
445
|
|
|
447
|
-
while (stackLen
|
|
448
|
-
|
|
449
|
-
const n = markStack[stackLen];
|
|
446
|
+
while (stackLen !== 0) {
|
|
447
|
+
const n = markStack[--stackLen];
|
|
450
448
|
|
|
451
449
|
let link = n.headSub;
|
|
452
450
|
while (link !== null) {
|
|
453
451
|
const t = link.target;
|
|
454
452
|
if ((t.markEpoch | 0) !== (globalVersion | 0)) {
|
|
455
453
|
t.markEpoch = globalVersion | 0;
|
|
454
|
+
// flags read stays INSIDE the markEpoch guard on purpose: hoisting it
|
|
455
|
+
// above the guard would load t.flags for every already-marked revisit
|
|
456
|
+
// too, adding work on exactly the dedup-skip path that markEpoch exists
|
|
457
|
+
// to make cheap (diamond / shared-computed fan-out).
|
|
456
458
|
const flags = t.flags | 0;
|
|
457
459
|
|
|
458
460
|
if ((flags & FLAG_EFFECT) !== 0) {
|
|
@@ -461,14 +463,12 @@ export function createRegistry(config = {}) {
|
|
|
461
463
|
// writes (directly or through computed chains). The effect's evalVersion
|
|
462
464
|
// is bumped to the post-write globalVersion in its executeEffect finally,
|
|
463
465
|
// so subsequent unrelated writes still propagate normally.
|
|
464
|
-
if ((flags & FLAG_QUEUED
|
|
466
|
+
if ((flags & (FLAG_QUEUED | FLAG_COMPUTING)) === 0) {
|
|
465
467
|
t.flags = flags | FLAG_QUEUED;
|
|
466
|
-
activeQueue[activeQueueLen] = t;
|
|
467
|
-
activeQueueLen = (activeQueueLen + 1) | 0;
|
|
468
|
+
activeQueue[activeQueueLen++] = t;
|
|
468
469
|
}
|
|
469
470
|
} else {
|
|
470
|
-
markStack[stackLen] = t;
|
|
471
|
-
stackLen = (stackLen + 1) | 0;
|
|
471
|
+
markStack[stackLen++] = t;
|
|
472
472
|
}
|
|
473
473
|
}
|
|
474
474
|
link = link.nextSub;
|
|
@@ -732,14 +732,28 @@ export function createRegistry(config = {}) {
|
|
|
732
732
|
* Equality predicate. Returning true short-circuits notification.
|
|
733
733
|
* @returns {Signal<T>}
|
|
734
734
|
*/
|
|
735
|
-
function signal(initial, opts
|
|
735
|
+
function signal(initial, opts) {
|
|
736
736
|
const node = createNode(initial, FLAG_SIGNAL);
|
|
737
|
-
|
|
737
|
+
// Read opts defensively instead of defaulting to `= {}`: the default allocates
|
|
738
|
+
// a throwaway object on every no-opts call (the common path) — pure nursery
|
|
739
|
+
// garbage when mounting many signals.
|
|
740
|
+
node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
|
|
738
741
|
node.version = globalVersion | 0;
|
|
739
742
|
statSignals = (statSignals + 1) | 0;
|
|
740
743
|
|
|
741
744
|
const read = () => {
|
|
742
|
-
if (isTrackingDeps && currentObserver !== null)
|
|
745
|
+
if (isTrackingDeps && currentObserver !== null) {
|
|
746
|
+
// Inlined cursor fast-path: on stable read order this matches every
|
|
747
|
+
// time, so we skip a call into the (large, non-inlinable) allocateLink
|
|
748
|
+
// frame entirely. Only a cursor miss falls through to the cold path,
|
|
749
|
+
// where allocateLink re-reads the cursor for its relink logic.
|
|
750
|
+
const expected = activeObserverCurrentDep;
|
|
751
|
+
if (expected !== null && expected.source === node) {
|
|
752
|
+
activeObserverCurrentDep = expected.nextDep;
|
|
753
|
+
} else {
|
|
754
|
+
allocateLink(node, currentObserver);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
743
757
|
return node.value;
|
|
744
758
|
};
|
|
745
759
|
read.peek = () => node.value;
|
|
@@ -774,11 +788,21 @@ export function createRegistry(config = {}) {
|
|
|
774
788
|
read.update = (fn) => read.set(fn(node.value));
|
|
775
789
|
|
|
776
790
|
read.subscribe = (fn) => {
|
|
777
|
-
|
|
778
|
-
|
|
791
|
+
// Single-closure subscription: the read is tracked (it establishes the
|
|
792
|
+
// dependency), then fn runs untracked. untrack() is inlined here to (a) drop
|
|
793
|
+
// the second per-subscription closure and (b) save an untrack()+wrapper call
|
|
794
|
+
// on every fire, not just at setup.
|
|
795
|
+
// COUPLING: this duplicates untrack()'s isTrackingDeps save/restore. If
|
|
796
|
+
// untrack ever manages additional state (e.g. currentObserver), mirror it here.
|
|
779
797
|
return effect(() => {
|
|
780
|
-
|
|
781
|
-
|
|
798
|
+
const val = read();
|
|
799
|
+
const prevTracking = isTrackingDeps;
|
|
800
|
+
isTrackingDeps = false;
|
|
801
|
+
try {
|
|
802
|
+
fn(val);
|
|
803
|
+
} finally {
|
|
804
|
+
isTrackingDeps = prevTracking;
|
|
805
|
+
}
|
|
782
806
|
});
|
|
783
807
|
};
|
|
784
808
|
|
|
@@ -800,24 +824,39 @@ export function createRegistry(config = {}) {
|
|
|
800
824
|
* Equality predicate. Returning true blocks propagation downstream.
|
|
801
825
|
* @returns {Computed<T>}
|
|
802
826
|
*/
|
|
803
|
-
function computed(fn, opts
|
|
827
|
+
function computed(fn, opts) {
|
|
804
828
|
const node = createNode(undefined, FLAG_COMPUTED);
|
|
805
829
|
node.computeFn = fn;
|
|
806
|
-
|
|
830
|
+
// Defensive opts read; avoids the `= {}` per-call allocation. See signal().
|
|
831
|
+
node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
|
|
807
832
|
statComputeds = (statComputeds + 1) | 0;
|
|
808
833
|
|
|
809
834
|
const read = () => {
|
|
810
|
-
if (isTrackingDeps && currentObserver !== null)
|
|
835
|
+
if (isTrackingDeps && currentObserver !== null) {
|
|
836
|
+
// Inlined cursor fast-path — see signal() read for rationale.
|
|
837
|
+
const expected = activeObserverCurrentDep;
|
|
838
|
+
if (expected !== null && expected.source === node) {
|
|
839
|
+
activeObserverCurrentDep = expected.nextDep;
|
|
840
|
+
} else {
|
|
841
|
+
allocateLink(node, currentObserver);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
811
844
|
return pullComputed(node);
|
|
812
845
|
};
|
|
813
846
|
read.peek = () => pullComputed(node);
|
|
814
847
|
|
|
815
848
|
read.subscribe = (fn) => {
|
|
816
|
-
|
|
817
|
-
|
|
849
|
+
// Single-closure subscription — see signal() subscribe for rationale and
|
|
850
|
+
// the untrack-inlining coupling note.
|
|
818
851
|
return effect(() => {
|
|
819
|
-
|
|
820
|
-
|
|
852
|
+
const val = read();
|
|
853
|
+
const prevTracking = isTrackingDeps;
|
|
854
|
+
isTrackingDeps = false;
|
|
855
|
+
try {
|
|
856
|
+
fn(val);
|
|
857
|
+
} finally {
|
|
858
|
+
isTrackingDeps = prevTracking;
|
|
859
|
+
}
|
|
821
860
|
});
|
|
822
861
|
};
|
|
823
862
|
|
|
@@ -842,13 +881,15 @@ export function createRegistry(config = {}) {
|
|
|
842
881
|
* @returns {() => void} Dispose function. Idempotent. Safe to call
|
|
843
882
|
* after registry.destroy().
|
|
844
883
|
*/
|
|
845
|
-
function effect(fn, opts
|
|
884
|
+
function effect(fn, opts) {
|
|
846
885
|
const node = createNode(undefined, FLAG_EFFECT);
|
|
847
886
|
node.computeFn = fn;
|
|
848
|
-
|
|
887
|
+
// Defensive opts read; avoids the `= {}` per-call allocation. Read scheduler once
|
|
888
|
+
// and reuse the local for the trampoline check below.
|
|
889
|
+
const scheduler = opts !== undefined ? opts.scheduler : undefined;
|
|
890
|
+
node.scheduler = scheduler;
|
|
849
891
|
statEffects = (statEffects + 1) | 0;
|
|
850
892
|
|
|
851
|
-
const scheduler = opts.scheduler;
|
|
852
893
|
let firstRunError = null;
|
|
853
894
|
if (scheduler) {
|
|
854
895
|
const gen = node.gen | 0;
|
|
@@ -952,6 +993,32 @@ export function createRegistry(config = {}) {
|
|
|
952
993
|
* @param {() => T} fn
|
|
953
994
|
* @returns {T}
|
|
954
995
|
*/
|
|
996
|
+
/**
|
|
997
|
+
* Returns true iff a read RIGHT NOW would record a dependency in the current
|
|
998
|
+
* call stack: an observer body is on the stack AND tracking is enabled.
|
|
999
|
+
*
|
|
1000
|
+
* Mirrors the engine's own read-trap predicate (both flags checked). Returns
|
|
1001
|
+
* false inside `untrack()`, inside `signal.subscribe`'s callback (which
|
|
1002
|
+
* inlines the same untracked-notify pattern), inside `runCleanup` bodies,
|
|
1003
|
+
* and outside any observer.
|
|
1004
|
+
*
|
|
1005
|
+
* Use case: wrapper libraries (lite-store, lite-query, lite-form) that
|
|
1006
|
+
* lazily allocate reactive primitives on property reads — gating on
|
|
1007
|
+
* isTracking() lets them skip allocation when reads happen outside a
|
|
1008
|
+
* reactive context, preserving the zero-GC contract.
|
|
1009
|
+
*
|
|
1010
|
+
* Cost: two closure-variable loads, one AND, one return. ~1–2 ns.
|
|
1011
|
+
*
|
|
1012
|
+
* Note: per-registry. Wrappers operating against a non-default registry
|
|
1013
|
+
* MUST call that registry's `isTracking()`, not the top-level one — each
|
|
1014
|
+
* registry has its own tracking state.
|
|
1015
|
+
*
|
|
1016
|
+
* @returns {boolean}
|
|
1017
|
+
*/
|
|
1018
|
+
function isTracking() {
|
|
1019
|
+
return isTrackingDeps && currentObserver !== null;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
955
1022
|
function untrack(fn) {
|
|
956
1023
|
const prev = isTrackingDeps;
|
|
957
1024
|
isTrackingDeps = false;
|
|
@@ -1060,7 +1127,7 @@ export function createRegistry(config = {}) {
|
|
|
1060
1127
|
flushErrorBuffer.length = 0; // release the backing array
|
|
1061
1128
|
}
|
|
1062
1129
|
|
|
1063
|
-
return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy};
|
|
1130
|
+
return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking};
|
|
1064
1131
|
}
|
|
1065
1132
|
|
|
1066
1133
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -1109,6 +1176,15 @@ export function untrack(fn) {
|
|
|
1109
1176
|
return defaultRegistry.untrack(fn);
|
|
1110
1177
|
}
|
|
1111
1178
|
|
|
1179
|
+
/**
|
|
1180
|
+
* True iff a read RIGHT NOW would record a dependency on the default registry.
|
|
1181
|
+
* See {@link createRegistry} for the per-registry version. Wrappers operating
|
|
1182
|
+
* against a non-default registry MUST call THAT registry's `isTracking()`.
|
|
1183
|
+
*/
|
|
1184
|
+
export function isTracking() {
|
|
1185
|
+
return defaultRegistry.isTracking();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1112
1188
|
/** @type {Registry["onCleanup"]} */
|
|
1113
1189
|
export function onCleanup(fn) {
|
|
1114
1190
|
return defaultRegistry.onCleanup(fn);
|
package/llms.txt
CHANGED
|
@@ -19,6 +19,7 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
19
19
|
- **dispose(api)**: universal disposal. Accepts a signal, computed, or effect dispose handle; idempotent; cross-registry calls are silent no-ops (per-registry `Symbol("node_ptr")` keys the node-identity slot on the returned API function, foreign signals fail the lookup and fall through). Passing an unrelated value is also safe; passing an arbitrary function invokes it (effect-handle contract).
|
|
20
20
|
- **Batch**: `batch(fn)` defers effect flush until the outermost batch closes. Nestable.
|
|
21
21
|
- **Untrack**: `untrack(fn)` reads without subscribing.
|
|
22
|
+
- **isTracking**: `isTracking()` returns true iff a read right now would record a dependency on this registry (for wrappers that lazily allocate signals).
|
|
22
23
|
- **onCleanup**: registers teardown for the current computation; fires before each re-run and once on dispose. Works in effects and computeds.
|
|
23
24
|
- **Registry**: `createRegistry({ maxNodes, maxLinks, onCapacityExceeded, maxFlushPasses })` creates an isolated reactive world with its own pools. Useful for tests, plugins, multi-tenant sandboxes. Top-level helpers use a default registry created at module load.
|
|
24
25
|
- **CapacityError**: thrown when a fixed-size pool is exhausted under the `"throw"` policy, or when a `"grow"` policy hits the 16× starting-capacity ceiling on links.
|
|
@@ -46,6 +47,25 @@ triggering write — no `queueMicrotask`, no promises, no scheduler ticks.
|
|
|
46
47
|
- Stable read order: O(1) per dep via cursor reuse.
|
|
47
48
|
- Chaotic/randomized read order: degrades to O(N) per dep due to list re-insertion.
|
|
48
49
|
|
|
50
|
+
## Version notes
|
|
51
|
+
|
|
52
|
+
- **1.1.3**: adds `isTracking()` (top-level + per-registry). Returns true iff a
|
|
53
|
+
read right now would record a dependency (`isTrackingDeps && currentObserver !== null`).
|
|
54
|
+
False inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, `watch` /
|
|
55
|
+
`when` callbacks, and outside any observer. ~1–2 ns. For wrapper libraries
|
|
56
|
+
(lite-store, lite-query, lite-form) that allocate reactive primitives lazily
|
|
57
|
+
on property reads. Per-registry: wrappers operating against a non-default
|
|
58
|
+
registry must call THAT registry's `isTracking()`, not the top-level one.
|
|
59
|
+
No behavior or engine changes.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
- **1.1.2** (current): hot-path micro-optimizations, no behavior/API change.
|
|
63
|
+
Inlined cursor fast-path in `signal`/`computed` reads (stable-order reads skip
|
|
64
|
+
the `allocateLink` call); zero-allocation creation path (`opts` read defensively
|
|
65
|
+
instead of defaulting to `{}`); single-closure `subscribe`. Owner-tree
|
|
66
|
+
conformance items #209/#210 are wired but skipped pending the v1.2 ownership
|
|
67
|
+
hybrid.
|
|
68
|
+
|
|
49
69
|
## When to use
|
|
50
70
|
|
|
51
71
|
- Animation loops, game HUDs, scoreboards, telemetry overlays.
|
|
@@ -70,6 +90,7 @@ function effect(fn: () => void, opts?: { scheduler?: (run: () => void) => void }
|
|
|
70
90
|
function dispose(api: Signal<any> | Computed<any> | Dispose): void;
|
|
71
91
|
function batch<T>(fn: () => T): T;
|
|
72
92
|
function untrack<T>(fn: () => T): T;
|
|
93
|
+
function isTracking(): boolean;
|
|
73
94
|
function onCleanup(fn: () => void): void;
|
|
74
95
|
function stats(): RegistryStats;
|
|
75
96
|
|
|
@@ -232,7 +253,7 @@ sandbox.destroy(); // entire reactive world reset
|
|
|
232
253
|
|
|
233
254
|
## File layout
|
|
234
255
|
|
|
235
|
-
- `Signal.js` — full implementation,
|
|
256
|
+
- `Signal.js` — full implementation, single file.
|
|
236
257
|
- `Signal.d.ts` — TypeScript declarations for all public API.
|
|
237
258
|
- `test/01-core.test.mjs` — signal/computed/effect basics, equality, untrack.
|
|
238
259
|
- `test/02-topology.test.mjs` — diamonds, chains, fan-out/in, cycle detection.
|
|
@@ -243,7 +264,7 @@ sandbox.destroy(); // entire reactive world reset
|
|
|
243
264
|
- `test/07-dispose.test.mjs` — universal disposal: registry.dispose(api).
|
|
244
265
|
- `test/08-watch.test.mjs` — new watch reactivity tests.
|
|
245
266
|
- `test/09-conformance.test.mjs` — johnsoncodehk/reactive-framework-test-suite conformance fixes tests.
|
|
246
|
-
- `bench/
|
|
267
|
+
- `bench/benchmark.mjs` — comparative benchmark vs alien-signals, preact, solid.
|
|
247
268
|
- `demo/index.html` — interactive visualization of the reactive graph.
|
|
248
269
|
|
|
249
270
|
## Install
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
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",
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
"types": "./Signal.d.ts",
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
-
"
|
|
13
|
+
"node": "./Signal.js",
|
|
14
14
|
"import": "./Signal.js",
|
|
15
|
+
"types": "./Signal.d.ts",
|
|
15
16
|
"default": "./Signal.js"
|
|
16
17
|
}
|
|
17
18
|
},
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
"test": "node --test --test-reporter=spec",
|
|
28
29
|
"test:gc": "node --expose-gc --test --test-reporter=spec",
|
|
29
30
|
"bench": "node --expose-gc bench/benchmark.mjs",
|
|
31
|
+
"bench-reactive": "node --expose-gc bench/benchmarkReactive.mjs",
|
|
30
32
|
"verify": "npm test && npm run bench"
|
|
31
33
|
},
|
|
32
34
|
"keywords": [
|