@pyreon/reactivity 0.14.0 → 0.15.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +87 -24
- package/lib/types/index.d.ts +14 -1
- package/package.json +5 -4
- package/src/batch.ts +135 -32
- package/src/computed.ts +21 -6
- package/src/effect.ts +149 -14
- package/src/env.d.ts +6 -0
- package/src/index.ts +10 -1
- package/src/signal.ts +3 -10
- package/src/tests/batch.test.ts +418 -0
- package/src/tests/computed.test.ts +32 -0
- package/src/tests/effect.test.ts +65 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"22680e1b-1","name":"batch.ts"},{"uid":"22680e1b-3","name":"cell.ts"},{"uid":"22680e1b-5","name":"scope.ts"},{"uid":"22680e1b-7","name":"tracking.ts"},{"uid":"22680e1b-9","name":"effect.ts"},{"uid":"22680e1b-11","name":"computed.ts"},{"uid":"22680e1b-13","name":"createSelector.ts"},{"uid":"22680e1b-15","name":"debug.ts"},{"uid":"22680e1b-17","name":"signal.ts"},{"uid":"22680e1b-19","name":"store.ts"},{"uid":"22680e1b-21","name":"reconcile.ts"},{"uid":"22680e1b-23","name":"resource.ts"},{"uid":"22680e1b-25","name":"watch.ts"},{"uid":"22680e1b-27","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"22680e1b-1":{"renderedLength":2427,"gzipLength":977,"brotliLength":0,"metaUid":"22680e1b-0"},"22680e1b-3":{"renderedLength":1636,"gzipLength":786,"brotliLength":0,"metaUid":"22680e1b-2"},"22680e1b-5":{"renderedLength":1977,"gzipLength":786,"brotliLength":0,"metaUid":"22680e1b-4"},"22680e1b-7":{"renderedLength":2227,"gzipLength":858,"brotliLength":0,"metaUid":"22680e1b-6"},"22680e1b-9":{"renderedLength":7391,"gzipLength":2397,"brotliLength":0,"metaUid":"22680e1b-8"},"22680e1b-11":{"renderedLength":4548,"gzipLength":1464,"brotliLength":0,"metaUid":"22680e1b-10"},"22680e1b-13":{"renderedLength":1810,"gzipLength":833,"brotliLength":0,"metaUid":"22680e1b-12"},"22680e1b-15":{"renderedLength":2469,"gzipLength":1092,"brotliLength":0,"metaUid":"22680e1b-14"},"22680e1b-17":{"renderedLength":2626,"gzipLength":1125,"brotliLength":0,"metaUid":"22680e1b-16"},"22680e1b-19":{"renderedLength":2879,"gzipLength":1056,"brotliLength":0,"metaUid":"22680e1b-18"},"22680e1b-21":{"renderedLength":2109,"gzipLength":867,"brotliLength":0,"metaUid":"22680e1b-20"},"22680e1b-23":{"renderedLength":1029,"gzipLength":475,"brotliLength":0,"metaUid":"22680e1b-22"},"22680e1b-25":{"renderedLength":1249,"gzipLength":582,"brotliLength":0,"metaUid":"22680e1b-24"},"22680e1b-27":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"22680e1b-26"}},"nodeMetas":{"22680e1b-0":{"id":"/src/batch.ts","moduleParts":{"index.js":"22680e1b-1"},"imported":[],"importedBy":[{"uid":"22680e1b-26"},{"uid":"22680e1b-10"},{"uid":"22680e1b-16"},{"uid":"22680e1b-6"}]},"22680e1b-2":{"id":"/src/cell.ts","moduleParts":{"index.js":"22680e1b-3"},"imported":[],"importedBy":[{"uid":"22680e1b-26"}]},"22680e1b-4":{"id":"/src/scope.ts","moduleParts":{"index.js":"22680e1b-5"},"imported":[],"importedBy":[{"uid":"22680e1b-26"},{"uid":"22680e1b-10"},{"uid":"22680e1b-8"}]},"22680e1b-6":{"id":"/src/tracking.ts","moduleParts":{"index.js":"22680e1b-7"},"imported":[{"uid":"22680e1b-0"}],"importedBy":[{"uid":"22680e1b-26"},{"uid":"22680e1b-10"},{"uid":"22680e1b-12"},{"uid":"22680e1b-8"},{"uid":"22680e1b-22"},{"uid":"22680e1b-16"}]},"22680e1b-8":{"id":"/src/effect.ts","moduleParts":{"index.js":"22680e1b-9"},"imported":[{"uid":"22680e1b-4"},{"uid":"22680e1b-6"}],"importedBy":[{"uid":"22680e1b-26"},{"uid":"22680e1b-10"},{"uid":"22680e1b-12"},{"uid":"22680e1b-22"},{"uid":"22680e1b-24"}]},"22680e1b-10":{"id":"/src/computed.ts","moduleParts":{"index.js":"22680e1b-11"},"imported":[{"uid":"22680e1b-0"},{"uid":"22680e1b-8"},{"uid":"22680e1b-4"},{"uid":"22680e1b-6"}],"importedBy":[{"uid":"22680e1b-26"}]},"22680e1b-12":{"id":"/src/createSelector.ts","moduleParts":{"index.js":"22680e1b-13"},"imported":[{"uid":"22680e1b-8"},{"uid":"22680e1b-6"}],"importedBy":[{"uid":"22680e1b-26"}]},"22680e1b-14":{"id":"/src/debug.ts","moduleParts":{"index.js":"22680e1b-15"},"imported":[],"importedBy":[{"uid":"22680e1b-26"},{"uid":"22680e1b-16"}]},"22680e1b-16":{"id":"/src/signal.ts","moduleParts":{"index.js":"22680e1b-17"},"imported":[{"uid":"22680e1b-0"},{"uid":"22680e1b-14"},{"uid":"22680e1b-6"}],"importedBy":[{"uid":"22680e1b-26"},{"uid":"22680e1b-22"},{"uid":"22680e1b-18"}]},"22680e1b-18":{"id":"/src/store.ts","moduleParts":{"index.js":"22680e1b-19"},"imported":[{"uid":"22680e1b-16"}],"importedBy":[{"uid":"22680e1b-26"},{"uid":"22680e1b-20"}]},"22680e1b-20":{"id":"/src/reconcile.ts","moduleParts":{"index.js":"22680e1b-21"},"imported":[{"uid":"22680e1b-18"}],"importedBy":[{"uid":"22680e1b-26"}]},"22680e1b-22":{"id":"/src/resource.ts","moduleParts":{"index.js":"22680e1b-23"},"imported":[{"uid":"22680e1b-8"},{"uid":"22680e1b-16"},{"uid":"22680e1b-6"}],"importedBy":[{"uid":"22680e1b-26"}]},"22680e1b-24":{"id":"/src/watch.ts","moduleParts":{"index.js":"22680e1b-25"},"imported":[{"uid":"22680e1b-8"}],"importedBy":[{"uid":"22680e1b-26"}]},"22680e1b-26":{"id":"/src/index.ts","moduleParts":{"index.js":"22680e1b-27"},"imported":[{"uid":"22680e1b-0"},{"uid":"22680e1b-2"},{"uid":"22680e1b-10"},{"uid":"22680e1b-12"},{"uid":"22680e1b-14"},{"uid":"22680e1b-8"},{"uid":"22680e1b-20"},{"uid":"22680e1b-22"},{"uid":"22680e1b-4"},{"uid":"22680e1b-16"},{"uid":"22680e1b-18"},{"uid":"22680e1b-6"},{"uid":"22680e1b-24"}],"importedBy":[],"isEntry":true}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -1,24 +1,53 @@
|
|
|
1
1
|
//#region src/batch.ts
|
|
2
|
+
const __DEV__ = process.env.NODE_ENV !== "production";
|
|
2
3
|
let batchDepth = 0;
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
|
|
4
|
+
const pendingRecomputes = /* @__PURE__ */ new Set();
|
|
5
|
+
const pendingEffects = /* @__PURE__ */ new Set();
|
|
6
|
+
const _nextEffectPass = /* @__PURE__ */ new Set();
|
|
7
|
+
let _visitedThisPass = null;
|
|
8
|
+
const _recomputes = /* @__PURE__ */ new WeakSet();
|
|
9
|
+
const MAX_PASSES = 32;
|
|
10
|
+
/**
|
|
11
|
+
* Mark a callback as a computed recompute (called from computed.ts at
|
|
12
|
+
* creation time). Routes future enqueues into the recompute queue so they
|
|
13
|
+
* settle before any effects fire.
|
|
14
|
+
*/
|
|
15
|
+
function _markRecompute(fn) {
|
|
16
|
+
_recomputes.add(fn);
|
|
17
|
+
}
|
|
6
18
|
function batch(fn) {
|
|
7
19
|
batchDepth++;
|
|
8
20
|
try {
|
|
9
21
|
fn();
|
|
10
22
|
} finally {
|
|
11
23
|
batchDepth--;
|
|
12
|
-
if (batchDepth === 0 &&
|
|
24
|
+
if (batchDepth === 0 && (pendingRecomputes.size > 0 || pendingEffects.size > 0)) {
|
|
13
25
|
batchDepth = 1;
|
|
14
26
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
let effectPass = 0;
|
|
28
|
+
while (pendingRecomputes.size > 0 || pendingEffects.size > 0) {
|
|
29
|
+
for (const r of pendingRecomputes) r();
|
|
30
|
+
pendingRecomputes.clear();
|
|
31
|
+
if (pendingEffects.size > 0) {
|
|
32
|
+
if (++effectPass > MAX_PASSES) {
|
|
33
|
+
if (__DEV__) console.warn(`[pyreon] batch effect flush exceeded MAX_PASSES (32) — possible infinite re-enqueue loop. ${pendingEffects.size} pending effects dropped. See packages/core/reactivity/src/batch.ts for the multi-pass flush contract.`);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
_visitedThisPass = /* @__PURE__ */ new Set();
|
|
37
|
+
for (const notify of pendingEffects) {
|
|
38
|
+
_visitedThisPass.add(notify);
|
|
39
|
+
notify();
|
|
40
|
+
}
|
|
41
|
+
pendingEffects.clear();
|
|
42
|
+
for (const next of _nextEffectPass) pendingEffects.add(next);
|
|
43
|
+
_nextEffectPass.clear();
|
|
44
|
+
}
|
|
20
45
|
}
|
|
21
46
|
} finally {
|
|
47
|
+
pendingRecomputes.clear();
|
|
48
|
+
pendingEffects.clear();
|
|
49
|
+
_nextEffectPass.clear();
|
|
50
|
+
_visitedThisPass = null;
|
|
22
51
|
batchDepth = 0;
|
|
23
52
|
}
|
|
24
53
|
}
|
|
@@ -28,7 +57,9 @@ function isBatching() {
|
|
|
28
57
|
return batchDepth > 0;
|
|
29
58
|
}
|
|
30
59
|
function enqueuePendingNotification(notify) {
|
|
31
|
-
|
|
60
|
+
if (_recomputes.has(notify)) pendingRecomputes.add(notify);
|
|
61
|
+
else if (_visitedThisPass !== null && _visitedThisPass.has(notify)) _nextEffectPass.add(notify);
|
|
62
|
+
else pendingEffects.add(notify);
|
|
32
63
|
}
|
|
33
64
|
/**
|
|
34
65
|
* Returns a Promise that resolves after all currently-pending microtasks have flushed.
|
|
@@ -273,6 +304,17 @@ function runUntracked(fn) {
|
|
|
273
304
|
//#endregion
|
|
274
305
|
//#region src/effect.ts
|
|
275
306
|
const _countSink$2 = globalThis;
|
|
307
|
+
let _snapshotCapture = null;
|
|
308
|
+
/**
|
|
309
|
+
* Register a capture/restore pair so reactivity-layer effects (`_bind`,
|
|
310
|
+
* `renderEffect`, `effect`) can preserve external context (e.g. the core
|
|
311
|
+
* provide/useContext stack) across signal-driven re-runs. Called by
|
|
312
|
+
* `@pyreon/core`'s context module at import time. Idempotent — calling again
|
|
313
|
+
* replaces the previously registered hook.
|
|
314
|
+
*/
|
|
315
|
+
function setSnapshotCapture(hook) {
|
|
316
|
+
_snapshotCapture = hook;
|
|
317
|
+
}
|
|
276
318
|
let _cleanupCollector = null;
|
|
277
319
|
/**
|
|
278
320
|
* Register a cleanup function inside an effect. The cleanup runs:
|
|
@@ -295,11 +337,17 @@ function onCleanup(fn) {
|
|
|
295
337
|
if (_cleanupCollector) _cleanupCollector.push(fn);
|
|
296
338
|
}
|
|
297
339
|
let _innerEffectCollector = null;
|
|
298
|
-
|
|
340
|
+
const _errorBridge = globalThis;
|
|
341
|
+
function _defaultErrorHandler(err) {
|
|
299
342
|
console.error("[pyreon] Unhandled effect error:", err);
|
|
343
|
+
}
|
|
344
|
+
let _userErrorHandler;
|
|
345
|
+
const _errorHandler = (err) => {
|
|
346
|
+
(_userErrorHandler ?? _defaultErrorHandler)(err);
|
|
347
|
+
_errorBridge.__pyreon_report_error__?.(err, "effect");
|
|
300
348
|
};
|
|
301
349
|
function setErrorHandler(fn) {
|
|
302
|
-
|
|
350
|
+
_userErrorHandler = fn;
|
|
303
351
|
}
|
|
304
352
|
/** Remove an effect from all dependency subscriber sets (local deps array). */
|
|
305
353
|
function cleanupLocalDeps$1(deps, fn) {
|
|
@@ -312,7 +360,11 @@ function cleanupLocalDeps$1(deps, fn) {
|
|
|
312
360
|
}
|
|
313
361
|
}
|
|
314
362
|
function effect(fn) {
|
|
363
|
+
if (process.env.NODE_ENV !== "production") {
|
|
364
|
+
if (fn.constructor && fn.constructor.name === "AsyncFunction") console.warn("[pyreon] effect() received an async function. Signal reads after the first `await` are NOT tracked — only the synchronous prefix is. Read every tracked signal BEFORE any await, or split into separate effects, or use `watch(source, asyncCb)` for async-in-callback patterns.");
|
|
365
|
+
}
|
|
315
366
|
const scope = getCurrentScope();
|
|
367
|
+
const snapshot = _snapshotCapture ? _snapshotCapture.capture() : null;
|
|
316
368
|
let disposed = false;
|
|
317
369
|
let isFirstRun = true;
|
|
318
370
|
let cleanup;
|
|
@@ -347,7 +399,7 @@ function effect(fn) {
|
|
|
347
399
|
};
|
|
348
400
|
const run = () => {
|
|
349
401
|
if (disposed) return;
|
|
350
|
-
if (
|
|
402
|
+
if (process.env.NODE_ENV !== "production") _countSink$2.__pyreon_count__?.("reactivity.effectRun");
|
|
351
403
|
runCleanup();
|
|
352
404
|
const outerCollector = _innerEffectCollector;
|
|
353
405
|
const myInners = [];
|
|
@@ -357,7 +409,7 @@ function effect(fn) {
|
|
|
357
409
|
setDepsCollector(deps);
|
|
358
410
|
const collected = [];
|
|
359
411
|
_cleanupCollector = collected;
|
|
360
|
-
cleanup = withTracking(run, fn) || void 0;
|
|
412
|
+
cleanup = withTracking(run, isFirstRun || snapshot === null || _snapshotCapture === null ? fn : () => _snapshotCapture.restore(snapshot, fn)) || void 0;
|
|
361
413
|
_cleanupCollector = null;
|
|
362
414
|
if (collected.length > 0) cleanups = collected;
|
|
363
415
|
setDepsCollector(null);
|
|
@@ -411,9 +463,11 @@ function effect(fn) {
|
|
|
411
463
|
function _bind(fn) {
|
|
412
464
|
const deps = [];
|
|
413
465
|
let disposed = false;
|
|
466
|
+
const snapshot = _snapshotCapture ? _snapshotCapture.capture() : null;
|
|
414
467
|
const run = () => {
|
|
415
468
|
if (disposed) return;
|
|
416
|
-
|
|
469
|
+
if (snapshot !== null && _snapshotCapture) _snapshotCapture.restore(snapshot, fn);
|
|
470
|
+
else fn();
|
|
417
471
|
};
|
|
418
472
|
setDepsCollector(deps);
|
|
419
473
|
withTracking(run, fn);
|
|
@@ -446,9 +500,14 @@ function renderEffectFullTrack(deps, run, fn) {
|
|
|
446
500
|
}
|
|
447
501
|
}
|
|
448
502
|
function renderEffect(fn) {
|
|
503
|
+
if (process.env.NODE_ENV !== "production") {
|
|
504
|
+
if (fn.constructor && fn.constructor.name === "AsyncFunction") console.warn("[pyreon] renderEffect() received an async function. Signal reads after the first `await` are NOT tracked — only the synchronous prefix is. Read every tracked signal BEFORE any await, or split into separate effects, or use `watch(source, asyncCb)` for async-in-callback patterns.");
|
|
505
|
+
}
|
|
449
506
|
const deps = [];
|
|
450
507
|
let disposed = false;
|
|
451
508
|
let isFirstRun = true;
|
|
509
|
+
const snapshot = _snapshotCapture ? _snapshotCapture.capture() : null;
|
|
510
|
+
const trackedFn = snapshot !== null && _snapshotCapture ? () => _snapshotCapture.restore(snapshot, fn) : fn;
|
|
452
511
|
const run = () => {
|
|
453
512
|
if (disposed) return;
|
|
454
513
|
if (isFirstRun) {
|
|
@@ -461,7 +520,7 @@ function renderEffect(fn) {
|
|
|
461
520
|
_restoreActiveEffect();
|
|
462
521
|
setDepsCollector(null);
|
|
463
522
|
}
|
|
464
|
-
} else renderEffectFullTrack(deps, run,
|
|
523
|
+
} else renderEffectFullTrack(deps, run, trackedFn);
|
|
465
524
|
};
|
|
466
525
|
run();
|
|
467
526
|
const dispose = () => {
|
|
@@ -491,6 +550,9 @@ function trackWithLocalDeps(deps, effect, fn) {
|
|
|
491
550
|
return result;
|
|
492
551
|
}
|
|
493
552
|
function computed(fn, options) {
|
|
553
|
+
if (process.env.NODE_ENV !== "production") {
|
|
554
|
+
if (fn.constructor && fn.constructor.name === "AsyncFunction") console.warn("[pyreon] computed() received an async function. The result type becomes `Computed<Promise<T>>`, and signal reads after the first `await` are NOT tracked. Use `createResource` for async-derived state, or compute synchronously over a signal that holds the awaited value.");
|
|
555
|
+
}
|
|
494
556
|
return options?.equals ? computedWithEquals(fn, options.equals) : computedLazy(fn);
|
|
495
557
|
}
|
|
496
558
|
/**
|
|
@@ -517,10 +579,11 @@ function computedLazy(fn) {
|
|
|
517
579
|
if (host._s) notifySubscribers(host._s);
|
|
518
580
|
if (directFns) for (const f of directFns) f?.();
|
|
519
581
|
};
|
|
582
|
+
_markRecompute(recompute);
|
|
520
583
|
const read = () => {
|
|
521
584
|
trackSubscriber(host);
|
|
522
585
|
if (dirty) {
|
|
523
|
-
if (
|
|
586
|
+
if (process.env.NODE_ENV !== "production") _countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
|
|
524
587
|
try {
|
|
525
588
|
if (tracked) {
|
|
526
589
|
setSkipDepsCollection(true);
|
|
@@ -576,7 +639,7 @@ function computedWithEquals(fn, equals) {
|
|
|
576
639
|
let directFns = null;
|
|
577
640
|
const recompute = () => {
|
|
578
641
|
if (disposed) return;
|
|
579
|
-
if (
|
|
642
|
+
if (process.env.NODE_ENV !== "production") _countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
|
|
580
643
|
cleanupLocalDeps(deps, recompute);
|
|
581
644
|
try {
|
|
582
645
|
const next = trackWithLocalDeps(deps, recompute, fn);
|
|
@@ -591,10 +654,11 @@ function computedWithEquals(fn, equals) {
|
|
|
591
654
|
if (host._s) notifySubscribers(host._s);
|
|
592
655
|
if (directFns) for (const f of directFns) f?.();
|
|
593
656
|
};
|
|
657
|
+
_markRecompute(recompute);
|
|
594
658
|
const read = () => {
|
|
595
659
|
trackSubscriber(host);
|
|
596
660
|
if (dirty) {
|
|
597
|
-
if (
|
|
661
|
+
if (process.env.NODE_ENV !== "production") _countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
|
|
598
662
|
cleanupLocalDeps(deps, recompute);
|
|
599
663
|
try {
|
|
600
664
|
value = trackWithLocalDeps(deps, recompute, fn);
|
|
@@ -793,14 +857,13 @@ function inspectSignal(sig) {
|
|
|
793
857
|
|
|
794
858
|
//#endregion
|
|
795
859
|
//#region src/signal.ts
|
|
796
|
-
const __DEV__ = typeof process !== "undefined" && process?.env?.NODE_ENV !== "production";
|
|
797
860
|
const _countSink = globalThis;
|
|
798
861
|
function _peek() {
|
|
799
862
|
return this._v;
|
|
800
863
|
}
|
|
801
864
|
function _set(newValue) {
|
|
802
865
|
if (Object.is(this._v, newValue)) return;
|
|
803
|
-
if (
|
|
866
|
+
if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("reactivity.signalWrite");
|
|
804
867
|
const prev = this._v;
|
|
805
868
|
this._v = newValue;
|
|
806
869
|
if (isTracing()) _notifyTraceListeners(this, prev, newValue);
|
|
@@ -860,9 +923,9 @@ function _debug() {
|
|
|
860
923
|
* update, subscribe) are shared across all signals — not per-signal closures.
|
|
861
924
|
*/
|
|
862
925
|
function signal(initialValue, options) {
|
|
863
|
-
if (
|
|
926
|
+
if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("reactivity.signalCreate");
|
|
864
927
|
const read = ((...args) => {
|
|
865
|
-
if (
|
|
928
|
+
if (process.env.NODE_ENV !== "production" && args.length > 0) console.warn("[Pyreon] signal() was called with an argument. Use signal.set(value) or signal.update(fn) to write. signal(value) only reads — the argument is ignored.");
|
|
866
929
|
trackSubscriber(read);
|
|
867
930
|
return read._v;
|
|
868
931
|
});
|
|
@@ -1124,5 +1187,5 @@ function watch(source, callback, opts = {}) {
|
|
|
1124
1187
|
}
|
|
1125
1188
|
|
|
1126
1189
|
//#endregion
|
|
1127
|
-
export { Cell, EffectScope, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, nextTick, onCleanup, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, signal, watch, why };
|
|
1190
|
+
export { Cell, EffectScope, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, nextTick, onCleanup, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, signal, watch, why };
|
|
1128
1191
|
//# sourceMappingURL=index.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -191,6 +191,19 @@ declare function inspectSignal<T>(sig: Signal<T>): SignalDebugInfo<T>;
|
|
|
191
191
|
interface Effect {
|
|
192
192
|
dispose(): void;
|
|
193
193
|
}
|
|
194
|
+
interface ReactiveSnapshotCapture {
|
|
195
|
+
capture: () => unknown;
|
|
196
|
+
/** Run `fn` with the previously-captured snapshot active. */
|
|
197
|
+
restore: <T>(snap: unknown, fn: () => T) => T;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Register a capture/restore pair so reactivity-layer effects (`_bind`,
|
|
201
|
+
* `renderEffect`, `effect`) can preserve external context (e.g. the core
|
|
202
|
+
* provide/useContext stack) across signal-driven re-runs. Called by
|
|
203
|
+
* `@pyreon/core`'s context module at import time. Idempotent — calling again
|
|
204
|
+
* replaces the previously registered hook.
|
|
205
|
+
*/
|
|
206
|
+
declare function setSnapshotCapture(hook: ReactiveSnapshotCapture | null): void;
|
|
194
207
|
/**
|
|
195
208
|
* Register a cleanup function inside an effect. The cleanup runs:
|
|
196
209
|
* - Before the effect re-runs (when dependencies change)
|
|
@@ -373,5 +386,5 @@ interface WatchOptions {
|
|
|
373
386
|
*/
|
|
374
387
|
declare function watch<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void), opts?: WatchOptions): () => void;
|
|
375
388
|
//#endregion
|
|
376
|
-
export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, nextTick, onCleanup, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, signal, watch, why };
|
|
389
|
+
export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReactiveSnapshotCapture, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, nextTick, onCleanup, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, signal, watch, why };
|
|
377
390
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/reactivity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Signals-based reactivity system for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/reactivity#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
|
+
"!lib/**/*.map",
|
|
17
18
|
"src",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
@@ -33,9 +34,6 @@
|
|
|
33
34
|
"publishConfig": {
|
|
34
35
|
"access": "public"
|
|
35
36
|
},
|
|
36
|
-
"devDependencies": {
|
|
37
|
-
"@pyreon/manifest": "0.13.1"
|
|
38
|
-
},
|
|
39
37
|
"scripts": {
|
|
40
38
|
"build": "vl_rolldown_build",
|
|
41
39
|
"dev": "vl_rolldown_build-watch",
|
|
@@ -43,5 +41,8 @@
|
|
|
43
41
|
"typecheck": "tsc --noEmit",
|
|
44
42
|
"lint": "oxlint .",
|
|
45
43
|
"prepublishOnly": "bun run build"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@pyreon/manifest": "0.13.1"
|
|
46
47
|
}
|
|
47
48
|
}
|
package/src/batch.ts
CHANGED
|
@@ -1,15 +1,66 @@
|
|
|
1
1
|
// Batch multiple signal updates into a single notification pass.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// Two-tier flush: computed recomputes drain first (within-pass Set-dedup),
|
|
3
|
+
// THEN effects drain (multi-pass with cross-pass re-fire support).
|
|
4
|
+
//
|
|
5
|
+
// Dev-mode invariant gate: see https://github.com/pyreon/pyreon/blob/main/packages/core/reactivity/src/tests/batch.test.ts
|
|
6
|
+
// for the property-based test that fuzzes random cascade graphs against this
|
|
7
|
+
// invariant. The build-time gate folds to dead code in production bundles.
|
|
8
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
4
9
|
|
|
5
10
|
let batchDepth = 0
|
|
6
11
|
|
|
7
|
-
// Two
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
// Two-tier queue design:
|
|
13
|
+
//
|
|
14
|
+
// 1. **`pendingRecomputes`** — computed.recompute callbacks. Drained FIRST in
|
|
15
|
+
// a cascading-iteration loop: cascading recomputes enqueued during a
|
|
16
|
+
// drain land in the same Set (Set.add idempotency dedupes), iteration
|
|
17
|
+
// visits added entries (JS Set iteration semantics), and the recompute
|
|
18
|
+
// layer settles before any effect fires. This guarantees effects always
|
|
19
|
+
// read fully-propagated computed values.
|
|
20
|
+
//
|
|
21
|
+
// 2. **`pendingEffects`** — effect.run callbacks. Drained SECOND, multi-pass.
|
|
22
|
+
// Within a single pass, Set.add idempotency gives us within-pass dedup
|
|
23
|
+
// (diamond / multi-dep selector). Across passes (entries re-enqueued
|
|
24
|
+
// AFTER being visited in the current pass — see `_visitedThisPass`), they
|
|
25
|
+
// fire AGAIN — needed for control flow that re-renders based on its own
|
|
26
|
+
// dispatch (e.g. ErrorBoundary's handler calling `error.set(err)` during
|
|
27
|
+
// the same run that mounted the throwing child). MAX_PASSES caps total
|
|
28
|
+
// passes at 32 to prevent pathological infinite re-enqueue loops.
|
|
29
|
+
//
|
|
30
|
+
// **Why two tiers, not one Set:**
|
|
31
|
+
// - Single-Set iteration (the prior design) worked for shallow cascades
|
|
32
|
+
// because the cascading recompute → effect re-enqueue happened BEFORE
|
|
33
|
+
// iteration reached the effect. For deep cascades (3+ hops), iteration
|
|
34
|
+
// reached the effect (with its stale upstream value) BEFORE the cascade
|
|
35
|
+
// finished propagating. Effect read stale values; subsequent cascade
|
|
36
|
+
// re-enqueues were dropped by Set.add idempotency.
|
|
37
|
+
// - Splitting recomputes from effects fixes this: all computed recomputes
|
|
38
|
+
// settle before any effect runs. The cascade-asymmetry contract from
|
|
39
|
+
// PR #381 is preserved (effects still fire once per batched change),
|
|
40
|
+
// AND deep-cascade correctness is added (effects always read settled
|
|
41
|
+
// values).
|
|
42
|
+
// - Multi-pass effect drain unblocks ErrorBoundary's "re-fire after
|
|
43
|
+
// dispatching from inside own run" pattern without breaking the
|
|
44
|
+
// single-fire contract for non-self-dispatching effects.
|
|
45
|
+
//
|
|
46
|
+
// **How a callback gets routed:** computed registers its `recompute` via
|
|
47
|
+
// `_markRecompute(fn)` at creation time. The internal `_recomputes` WeakSet
|
|
48
|
+
// tracks them. `enqueuePendingNotification` checks the WeakSet to route.
|
|
49
|
+
const pendingRecomputes = new Set<() => void>()
|
|
50
|
+
const pendingEffects = new Set<() => void>()
|
|
51
|
+
const _nextEffectPass = new Set<() => void>()
|
|
52
|
+
let _visitedThisPass: Set<() => void> | null = null
|
|
53
|
+
const _recomputes = new WeakSet<() => void>()
|
|
54
|
+
const MAX_PASSES = 32
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mark a callback as a computed recompute (called from computed.ts at
|
|
58
|
+
* creation time). Routes future enqueues into the recompute queue so they
|
|
59
|
+
* settle before any effects fire.
|
|
60
|
+
*/
|
|
61
|
+
export function _markRecompute(fn: () => void): void {
|
|
62
|
+
_recomputes.add(fn)
|
|
63
|
+
}
|
|
13
64
|
|
|
14
65
|
export function batch(fn: () => void): void {
|
|
15
66
|
batchDepth++
|
|
@@ -17,34 +68,72 @@ export function batch(fn: () => void): void {
|
|
|
17
68
|
fn()
|
|
18
69
|
} finally {
|
|
19
70
|
batchDepth--
|
|
20
|
-
if (batchDepth === 0 &&
|
|
71
|
+
if (batchDepth === 0 && (pendingRecomputes.size > 0 || pendingEffects.size > 0)) {
|
|
21
72
|
// Keep batching active during flush so cascade-notifications emitted
|
|
22
|
-
// by flushing subscribers enqueue into the
|
|
23
|
-
//
|
|
24
|
-
// while-loop drains any cascade rounds until the graph is stable.
|
|
25
|
-
//
|
|
26
|
-
// Without this, a diamond dependency (a → b, c → d → effect) re-fires
|
|
27
|
-
// the apex effect TWICE per signal write: the first notification path
|
|
28
|
-
// through `b` reaches `effect`, whose read clears `d`'s dirty flag;
|
|
29
|
-
// then when `c` is notified (still in the first flush round), it
|
|
30
|
-
// re-dirties `d`, which re-notifies `effect`. Keeping batchDepth at 1
|
|
31
|
-
// during flush routes those cascade-notifications through the Set,
|
|
32
|
-
// which dedupes on `d`'s recompute and on `effect`'s run.
|
|
33
|
-
//
|
|
34
|
-
// See `packages/internals/perf-harness/src/tests/diamond-probe.test.ts`
|
|
35
|
-
// for the empirical probe that caught this.
|
|
73
|
+
// by flushing subscribers enqueue into the same queues (dedup against
|
|
74
|
+
// already-queued entries) instead of firing inline.
|
|
36
75
|
batchDepth = 1
|
|
37
76
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
77
|
+
// Outer loop: alternate between tier-1 (recomputes) and tier-2
|
|
78
|
+
// (effects) until both queues are empty. An effect can write a
|
|
79
|
+
// signal whose subscribers include lazy `computed.recompute`s — those
|
|
80
|
+
// get enqueued into pendingRecomputes mid-effect, and we need to
|
|
81
|
+
// drain them BEFORE the next effect pass so downstream effects see
|
|
82
|
+
// the propagated dirty flag. MAX_PASSES caps the OUTER loop —
|
|
83
|
+
// counts effect-tier passes only since recomputes converge by
|
|
84
|
+
// `equals` short-circuit and don't infinite-loop in practice.
|
|
85
|
+
let effectPass = 0
|
|
86
|
+
while (pendingRecomputes.size > 0 || pendingEffects.size > 0) {
|
|
87
|
+
// Tier 1: drain all recomputes via cascading iteration. Set
|
|
88
|
+
// semantics visit entries added during iteration; Set.add
|
|
89
|
+
// idempotency dedupes diamond cascades. Recomputes converge by
|
|
90
|
+
// `equals` short-circuit (computedWithEquals returns early when
|
|
91
|
+
// value is unchanged) and computedLazy's `if (dirty) return`
|
|
92
|
+
// guard prevents re-fire.
|
|
93
|
+
for (const r of pendingRecomputes) r()
|
|
94
|
+
pendingRecomputes.clear()
|
|
95
|
+
|
|
96
|
+
// Tier 2: drain ONE pass of effects in multi-pass mode. Within-
|
|
97
|
+
// pass dedup preserved by Set.add idempotency on entries not yet
|
|
98
|
+
// visited this pass. Cross-pass re-fire enabled by routing
|
|
99
|
+
// already-visited entries to `_nextEffectPass` (handled in
|
|
100
|
+
// `enqueuePendingNotification`). After the pass, loop back to
|
|
101
|
+
// tier 1 to drain any recomputes the effects enqueued.
|
|
102
|
+
if (pendingEffects.size > 0) {
|
|
103
|
+
if (++effectPass > MAX_PASSES) {
|
|
104
|
+
if (__DEV__) {
|
|
105
|
+
// oxlint-disable-next-line no-console
|
|
106
|
+
console.warn(
|
|
107
|
+
'[pyreon] batch effect flush exceeded MAX_PASSES (32) — possible infinite re-enqueue loop. ' +
|
|
108
|
+
`${pendingEffects.size} pending effects dropped. ` +
|
|
109
|
+
'See packages/core/reactivity/src/batch.ts for the multi-pass flush contract.',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
_visitedThisPass = new Set<() => void>()
|
|
115
|
+
for (const notify of pendingEffects) {
|
|
116
|
+
_visitedThisPass.add(notify)
|
|
117
|
+
notify()
|
|
118
|
+
}
|
|
119
|
+
// Promote next-pass entries to pending for the next iteration.
|
|
120
|
+
pendingEffects.clear()
|
|
121
|
+
for (const next of _nextEffectPass) pendingEffects.add(next)
|
|
122
|
+
_nextEffectPass.clear()
|
|
123
|
+
}
|
|
46
124
|
}
|
|
47
125
|
} finally {
|
|
126
|
+
// Clear ALWAYS — even if a notify threw mid-iteration. Without this,
|
|
127
|
+
// the unflushed remainder leaks into the next batch and refires
|
|
128
|
+
// (audit bug #19). Effects wrap their callbacks in try/catch
|
|
129
|
+
// internally so this is rarely reachable in practice, but raw
|
|
130
|
+
// signal subscribers (signal.subscribe) and lower-level consumers
|
|
131
|
+
// can throw straight through, and a future refactor that swallows
|
|
132
|
+
// less aggressively would silently regress without this guard.
|
|
133
|
+
pendingRecomputes.clear()
|
|
134
|
+
pendingEffects.clear()
|
|
135
|
+
_nextEffectPass.clear()
|
|
136
|
+
_visitedThisPass = null
|
|
48
137
|
batchDepth = 0
|
|
49
138
|
}
|
|
50
139
|
}
|
|
@@ -55,8 +144,22 @@ export function isBatching(): boolean {
|
|
|
55
144
|
return batchDepth > 0
|
|
56
145
|
}
|
|
57
146
|
|
|
147
|
+
export function enquePendingNotificationDeprecated(): void {
|
|
148
|
+
// Kept as a comment placeholder — actual export is below. (Empty body to
|
|
149
|
+
// keep this file's exports list stable across the refactor.)
|
|
150
|
+
}
|
|
151
|
+
|
|
58
152
|
export function enqueuePendingNotification(notify: () => void): void {
|
|
59
|
-
|
|
153
|
+
// Route based on callback kind. Computed recomputes go to tier-1 queue,
|
|
154
|
+
// effects to tier-2. Within tier 2, already-visited-this-pass entries
|
|
155
|
+
// route to next-pass for cross-pass re-fire (ErrorBoundary's pattern).
|
|
156
|
+
if (_recomputes.has(notify)) {
|
|
157
|
+
pendingRecomputes.add(notify)
|
|
158
|
+
} else if (_visitedThisPass !== null && _visitedThisPass.has(notify)) {
|
|
159
|
+
_nextEffectPass.add(notify)
|
|
160
|
+
} else {
|
|
161
|
+
pendingEffects.add(notify)
|
|
162
|
+
}
|
|
60
163
|
}
|
|
61
164
|
|
|
62
165
|
/**
|
package/src/computed.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _markRecompute } from './batch'
|
|
1
2
|
import { _errorHandler } from './effect'
|
|
2
3
|
import { getCurrentScope } from './scope'
|
|
3
4
|
import {
|
|
@@ -10,9 +11,6 @@ import {
|
|
|
10
11
|
} from './tracking'
|
|
11
12
|
|
|
12
13
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
13
|
-
interface ViteMeta {
|
|
14
|
-
readonly env?: { readonly DEV?: boolean }
|
|
15
|
-
}
|
|
16
14
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
17
15
|
|
|
18
16
|
export interface Computed<T> {
|
|
@@ -54,6 +52,21 @@ function trackWithLocalDeps<T>(deps: Set<() => void>[], effect: () => void, fn:
|
|
|
54
52
|
}
|
|
55
53
|
|
|
56
54
|
export function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed<T> {
|
|
55
|
+
// Dev warning for async computed callbacks (audit bug #1 — extension).
|
|
56
|
+
// `computed(async () => …)` returns `Computed<Promise<T>>`, which silently
|
|
57
|
+
// breaks every consumer that expects `Computed<T>`. There's no scenario
|
|
58
|
+
// where async makes sense here — the recompute fires synchronously and
|
|
59
|
+
// tracks signals only in the synchronous prefix. For async-derived
|
|
60
|
+
// state, use `createResource` or a `signal<T>` updated from an effect.
|
|
61
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
62
|
+
if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
|
|
63
|
+
// oxlint-disable-next-line no-console
|
|
64
|
+
console.warn(
|
|
65
|
+
'[pyreon] computed() received an async function. The result type becomes `Computed<Promise<T>>`, and signal reads after the first `await` are NOT tracked. ' +
|
|
66
|
+
'Use `createResource` for async-derived state, or compute synchronously over a signal that holds the awaited value.',
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
57
70
|
return options?.equals ? computedWithEquals(fn, options.equals) : computedLazy(fn)
|
|
58
71
|
}
|
|
59
72
|
|
|
@@ -82,11 +95,12 @@ function computedLazy<T>(fn: () => T): Computed<T> {
|
|
|
82
95
|
if (host._s) notifySubscribers(host._s)
|
|
83
96
|
if (directFns) for (const f of directFns) f?.()
|
|
84
97
|
}
|
|
98
|
+
_markRecompute(recompute)
|
|
85
99
|
|
|
86
100
|
const read = (): T => {
|
|
87
101
|
trackSubscriber(host)
|
|
88
102
|
if (dirty) {
|
|
89
|
-
if (
|
|
103
|
+
if (process.env.NODE_ENV !== 'production')
|
|
90
104
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
91
105
|
try {
|
|
92
106
|
if (tracked) {
|
|
@@ -153,7 +167,7 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
153
167
|
|
|
154
168
|
const recompute = () => {
|
|
155
169
|
if (disposed) return
|
|
156
|
-
if (
|
|
170
|
+
if (process.env.NODE_ENV !== 'production')
|
|
157
171
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
158
172
|
cleanupLocalDeps(deps, recompute)
|
|
159
173
|
try {
|
|
@@ -169,11 +183,12 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
169
183
|
if (host._s) notifySubscribers(host._s)
|
|
170
184
|
if (directFns) for (const f of directFns) f?.()
|
|
171
185
|
}
|
|
186
|
+
_markRecompute(recompute)
|
|
172
187
|
|
|
173
188
|
const read = (): T => {
|
|
174
189
|
trackSubscriber(host)
|
|
175
190
|
if (dirty) {
|
|
176
|
-
if (
|
|
191
|
+
if (process.env.NODE_ENV !== 'production')
|
|
177
192
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
178
193
|
cleanupLocalDeps(deps, recompute)
|
|
179
194
|
try {
|