@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.
@@ -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":"1ddc8440-1","name":"batch.ts"},{"uid":"1ddc8440-3","name":"cell.ts"},{"uid":"1ddc8440-5","name":"scope.ts"},{"uid":"1ddc8440-7","name":"tracking.ts"},{"uid":"1ddc8440-9","name":"effect.ts"},{"uid":"1ddc8440-11","name":"computed.ts"},{"uid":"1ddc8440-13","name":"createSelector.ts"},{"uid":"1ddc8440-15","name":"debug.ts"},{"uid":"1ddc8440-17","name":"signal.ts"},{"uid":"1ddc8440-19","name":"store.ts"},{"uid":"1ddc8440-21","name":"reconcile.ts"},{"uid":"1ddc8440-23","name":"resource.ts"},{"uid":"1ddc8440-25","name":"watch.ts"},{"uid":"1ddc8440-27","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"1ddc8440-1":{"renderedLength":1079,"gzipLength":520,"brotliLength":0,"metaUid":"1ddc8440-0"},"1ddc8440-3":{"renderedLength":1636,"gzipLength":786,"brotliLength":0,"metaUid":"1ddc8440-2"},"1ddc8440-5":{"renderedLength":1977,"gzipLength":786,"brotliLength":0,"metaUid":"1ddc8440-4"},"1ddc8440-7":{"renderedLength":2227,"gzipLength":858,"brotliLength":0,"metaUid":"1ddc8440-6"},"1ddc8440-9":{"renderedLength":5389,"gzipLength":1826,"brotliLength":0,"metaUid":"1ddc8440-8"},"1ddc8440-11":{"renderedLength":4068,"gzipLength":1253,"brotliLength":0,"metaUid":"1ddc8440-10"},"1ddc8440-13":{"renderedLength":1810,"gzipLength":833,"brotliLength":0,"metaUid":"1ddc8440-12"},"1ddc8440-15":{"renderedLength":2469,"gzipLength":1092,"brotliLength":0,"metaUid":"1ddc8440-14"},"1ddc8440-17":{"renderedLength":2671,"gzipLength":1169,"brotliLength":0,"metaUid":"1ddc8440-16"},"1ddc8440-19":{"renderedLength":2879,"gzipLength":1056,"brotliLength":0,"metaUid":"1ddc8440-18"},"1ddc8440-21":{"renderedLength":2109,"gzipLength":867,"brotliLength":0,"metaUid":"1ddc8440-20"},"1ddc8440-23":{"renderedLength":1029,"gzipLength":475,"brotliLength":0,"metaUid":"1ddc8440-22"},"1ddc8440-25":{"renderedLength":1249,"gzipLength":582,"brotliLength":0,"metaUid":"1ddc8440-24"},"1ddc8440-27":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"1ddc8440-26"}},"nodeMetas":{"1ddc8440-0":{"id":"/src/batch.ts","moduleParts":{"index.js":"1ddc8440-1"},"imported":[],"importedBy":[{"uid":"1ddc8440-26"},{"uid":"1ddc8440-16"},{"uid":"1ddc8440-6"}]},"1ddc8440-2":{"id":"/src/cell.ts","moduleParts":{"index.js":"1ddc8440-3"},"imported":[],"importedBy":[{"uid":"1ddc8440-26"}]},"1ddc8440-4":{"id":"/src/scope.ts","moduleParts":{"index.js":"1ddc8440-5"},"imported":[],"importedBy":[{"uid":"1ddc8440-26"},{"uid":"1ddc8440-10"},{"uid":"1ddc8440-8"}]},"1ddc8440-6":{"id":"/src/tracking.ts","moduleParts":{"index.js":"1ddc8440-7"},"imported":[{"uid":"1ddc8440-0"}],"importedBy":[{"uid":"1ddc8440-26"},{"uid":"1ddc8440-10"},{"uid":"1ddc8440-12"},{"uid":"1ddc8440-8"},{"uid":"1ddc8440-22"},{"uid":"1ddc8440-16"}]},"1ddc8440-8":{"id":"/src/effect.ts","moduleParts":{"index.js":"1ddc8440-9"},"imported":[{"uid":"1ddc8440-4"},{"uid":"1ddc8440-6"}],"importedBy":[{"uid":"1ddc8440-26"},{"uid":"1ddc8440-10"},{"uid":"1ddc8440-12"},{"uid":"1ddc8440-22"},{"uid":"1ddc8440-24"}]},"1ddc8440-10":{"id":"/src/computed.ts","moduleParts":{"index.js":"1ddc8440-11"},"imported":[{"uid":"1ddc8440-8"},{"uid":"1ddc8440-4"},{"uid":"1ddc8440-6"}],"importedBy":[{"uid":"1ddc8440-26"}]},"1ddc8440-12":{"id":"/src/createSelector.ts","moduleParts":{"index.js":"1ddc8440-13"},"imported":[{"uid":"1ddc8440-8"},{"uid":"1ddc8440-6"}],"importedBy":[{"uid":"1ddc8440-26"}]},"1ddc8440-14":{"id":"/src/debug.ts","moduleParts":{"index.js":"1ddc8440-15"},"imported":[],"importedBy":[{"uid":"1ddc8440-26"},{"uid":"1ddc8440-16"}]},"1ddc8440-16":{"id":"/src/signal.ts","moduleParts":{"index.js":"1ddc8440-17"},"imported":[{"uid":"1ddc8440-0"},{"uid":"1ddc8440-14"},{"uid":"1ddc8440-6"}],"importedBy":[{"uid":"1ddc8440-26"},{"uid":"1ddc8440-22"},{"uid":"1ddc8440-18"}]},"1ddc8440-18":{"id":"/src/store.ts","moduleParts":{"index.js":"1ddc8440-19"},"imported":[{"uid":"1ddc8440-16"}],"importedBy":[{"uid":"1ddc8440-26"},{"uid":"1ddc8440-20"}]},"1ddc8440-20":{"id":"/src/reconcile.ts","moduleParts":{"index.js":"1ddc8440-21"},"imported":[{"uid":"1ddc8440-18"}],"importedBy":[{"uid":"1ddc8440-26"}]},"1ddc8440-22":{"id":"/src/resource.ts","moduleParts":{"index.js":"1ddc8440-23"},"imported":[{"uid":"1ddc8440-8"},{"uid":"1ddc8440-16"},{"uid":"1ddc8440-6"}],"importedBy":[{"uid":"1ddc8440-26"}]},"1ddc8440-24":{"id":"/src/watch.ts","moduleParts":{"index.js":"1ddc8440-25"},"imported":[{"uid":"1ddc8440-8"}],"importedBy":[{"uid":"1ddc8440-26"}]},"1ddc8440-26":{"id":"/src/index.ts","moduleParts":{"index.js":"1ddc8440-27"},"imported":[{"uid":"1ddc8440-0"},{"uid":"1ddc8440-2"},{"uid":"1ddc8440-10"},{"uid":"1ddc8440-12"},{"uid":"1ddc8440-14"},{"uid":"1ddc8440-8"},{"uid":"1ddc8440-20"},{"uid":"1ddc8440-22"},{"uid":"1ddc8440-4"},{"uid":"1ddc8440-16"},{"uid":"1ddc8440-18"},{"uid":"1ddc8440-6"},{"uid":"1ddc8440-24"}],"importedBy":[],"isEntry":true}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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 setA = /* @__PURE__ */ new Set();
4
- const setB = /* @__PURE__ */ new Set();
5
- let pendingNotifications = setA;
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 && pendingNotifications.size > 0) {
24
+ if (batchDepth === 0 && (pendingRecomputes.size > 0 || pendingEffects.size > 0)) {
13
25
  batchDepth = 1;
14
26
  try {
15
- while (pendingNotifications.size > 0) {
16
- const flush = pendingNotifications;
17
- pendingNotifications = flush === setA ? setB : setA;
18
- for (const notify of flush) notify();
19
- flush.clear();
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
- pendingNotifications.add(notify);
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
- let _errorHandler = (err) => {
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
- _errorHandler = fn;
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 (import.meta.env?.DEV === true) _countSink$2.__pyreon_count__?.("reactivity.effectRun");
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
- fn();
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, fn);
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 (import.meta.env?.DEV === true) _countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
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 (import.meta.env?.DEV === true) _countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
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 (import.meta.env?.DEV === true) _countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
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 (import.meta.env?.DEV === true) _countSink.__pyreon_count__?.("reactivity.signalWrite");
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 (import.meta.env?.DEV === true) _countSink.__pyreon_count__?.("reactivity.signalCreate");
926
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("reactivity.signalCreate");
864
927
  const read = ((...args) => {
865
- if (__DEV__ && 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.");
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
@@ -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.14.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
- // Uses a Set so the same subscriber is never flushed more than once per batch,
3
- // even if multiple signals it depends on change within the same batch.
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 pre-allocated Sets swapped on each flush — avoids allocating a new Set()
8
- // on every batch exit. The "active" set collects enqueued notifications; on flush
9
- // we swap to the other set and iterate the captured one, then clear it for reuse.
10
- const setA = new Set<() => void>()
11
- const setB = new Set<() => void>()
12
- let pendingNotifications = setA
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 && pendingNotifications.size > 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 pending Set (and dedupe
23
- // against what's already queued) instead of firing inline. The
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
- while (pendingNotifications.size > 0) {
39
- // Swap to the other pre-allocated Set before flushing so new
40
- // enqueues during notification land in the alternate Set, not
41
- // mixed into the current iteration.
42
- const flush = pendingNotifications
43
- pendingNotifications = flush === setA ? setB : setA
44
- for (const notify of flush) notify()
45
- flush.clear()
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
- pendingNotifications.add(notify)
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 ((import.meta as ViteMeta).env?.DEV === true)
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 ((import.meta as ViteMeta).env?.DEV === true)
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 ((import.meta as ViteMeta).env?.DEV === true)
191
+ if (process.env.NODE_ENV !== 'production')
177
192
  _countSink.__pyreon_count__?.('reactivity.computedRecompute')
178
193
  cleanupLocalDeps(deps, recompute)
179
194
  try {