@pyreon/reactivity 0.16.0 → 0.19.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":"a1cf068b-1","name":"batch.ts"},{"uid":"a1cf068b-3","name":"cell.ts"},{"uid":"a1cf068b-5","name":"scope.ts"},{"uid":"a1cf068b-7","name":"tracking.ts"},{"uid":"a1cf068b-9","name":"effect.ts"},{"uid":"a1cf068b-11","name":"computed.ts"},{"uid":"a1cf068b-13","name":"createSelector.ts"},{"uid":"a1cf068b-15","name":"debug.ts"},{"uid":"a1cf068b-17","name":"signal.ts"},{"uid":"a1cf068b-19","name":"store.ts"},{"uid":"a1cf068b-21","name":"reconcile.ts"},{"uid":"a1cf068b-23","name":"resource.ts"},{"uid":"a1cf068b-25","name":"watch.ts"},{"uid":"a1cf068b-27","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"a1cf068b-1":{"renderedLength":3016,"gzipLength":1167,"brotliLength":0,"metaUid":"a1cf068b-0"},"a1cf068b-3":{"renderedLength":1636,"gzipLength":786,"brotliLength":0,"metaUid":"a1cf068b-2"},"a1cf068b-5":{"renderedLength":3026,"gzipLength":1226,"brotliLength":0,"metaUid":"a1cf068b-4"},"a1cf068b-7":{"renderedLength":2227,"gzipLength":858,"brotliLength":0,"metaUid":"a1cf068b-6"},"a1cf068b-9":{"renderedLength":7391,"gzipLength":2397,"brotliLength":0,"metaUid":"a1cf068b-8"},"a1cf068b-11":{"renderedLength":4548,"gzipLength":1464,"brotliLength":0,"metaUid":"a1cf068b-10"},"a1cf068b-13":{"renderedLength":2244,"gzipLength":981,"brotliLength":0,"metaUid":"a1cf068b-12"},"a1cf068b-15":{"renderedLength":2469,"gzipLength":1092,"brotliLength":0,"metaUid":"a1cf068b-14"},"a1cf068b-17":{"renderedLength":2818,"gzipLength":1191,"brotliLength":0,"metaUid":"a1cf068b-16"},"a1cf068b-19":{"renderedLength":5143,"gzipLength":1835,"brotliLength":0,"metaUid":"a1cf068b-18"},"a1cf068b-21":{"renderedLength":2109,"gzipLength":867,"brotliLength":0,"metaUid":"a1cf068b-20"},"a1cf068b-23":{"renderedLength":1205,"gzipLength":524,"brotliLength":0,"metaUid":"a1cf068b-22"},"a1cf068b-25":{"renderedLength":1249,"gzipLength":582,"brotliLength":0,"metaUid":"a1cf068b-24"},"a1cf068b-27":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"a1cf068b-26"}},"nodeMetas":{"a1cf068b-0":{"id":"/src/batch.ts","moduleParts":{"index.js":"a1cf068b-1"},"imported":[],"importedBy":[{"uid":"a1cf068b-26"},{"uid":"a1cf068b-10"},{"uid":"a1cf068b-16"},{"uid":"a1cf068b-6"}]},"a1cf068b-2":{"id":"/src/cell.ts","moduleParts":{"index.js":"a1cf068b-3"},"imported":[],"importedBy":[{"uid":"a1cf068b-26"}]},"a1cf068b-4":{"id":"/src/scope.ts","moduleParts":{"index.js":"a1cf068b-5"},"imported":[],"importedBy":[{"uid":"a1cf068b-26"},{"uid":"a1cf068b-10"},{"uid":"a1cf068b-8"}]},"a1cf068b-6":{"id":"/src/tracking.ts","moduleParts":{"index.js":"a1cf068b-7"},"imported":[{"uid":"a1cf068b-0"}],"importedBy":[{"uid":"a1cf068b-26"},{"uid":"a1cf068b-10"},{"uid":"a1cf068b-12"},{"uid":"a1cf068b-8"},{"uid":"a1cf068b-22"},{"uid":"a1cf068b-16"}]},"a1cf068b-8":{"id":"/src/effect.ts","moduleParts":{"index.js":"a1cf068b-9"},"imported":[{"uid":"a1cf068b-4"},{"uid":"a1cf068b-6"}],"importedBy":[{"uid":"a1cf068b-26"},{"uid":"a1cf068b-10"},{"uid":"a1cf068b-12"},{"uid":"a1cf068b-22"},{"uid":"a1cf068b-24"}]},"a1cf068b-10":{"id":"/src/computed.ts","moduleParts":{"index.js":"a1cf068b-11"},"imported":[{"uid":"a1cf068b-0"},{"uid":"a1cf068b-8"},{"uid":"a1cf068b-4"},{"uid":"a1cf068b-6"}],"importedBy":[{"uid":"a1cf068b-26"}]},"a1cf068b-12":{"id":"/src/createSelector.ts","moduleParts":{"index.js":"a1cf068b-13"},"imported":[{"uid":"a1cf068b-8"},{"uid":"a1cf068b-6"}],"importedBy":[{"uid":"a1cf068b-26"}]},"a1cf068b-14":{"id":"/src/debug.ts","moduleParts":{"index.js":"a1cf068b-15"},"imported":[],"importedBy":[{"uid":"a1cf068b-26"},{"uid":"a1cf068b-16"}]},"a1cf068b-16":{"id":"/src/signal.ts","moduleParts":{"index.js":"a1cf068b-17"},"imported":[{"uid":"a1cf068b-0"},{"uid":"a1cf068b-14"},{"uid":"a1cf068b-6"}],"importedBy":[{"uid":"a1cf068b-26"},{"uid":"a1cf068b-22"},{"uid":"a1cf068b-18"}]},"a1cf068b-18":{"id":"/src/store.ts","moduleParts":{"index.js":"a1cf068b-19"},"imported":[{"uid":"a1cf068b-16"}],"importedBy":[{"uid":"a1cf068b-26"},{"uid":"a1cf068b-20"}]},"a1cf068b-20":{"id":"/src/reconcile.ts","moduleParts":{"index.js":"a1cf068b-21"},"imported":[{"uid":"a1cf068b-18"}],"importedBy":[{"uid":"a1cf068b-26"}]},"a1cf068b-22":{"id":"/src/resource.ts","moduleParts":{"index.js":"a1cf068b-23"},"imported":[{"uid":"a1cf068b-8"},{"uid":"a1cf068b-16"},{"uid":"a1cf068b-6"}],"importedBy":[{"uid":"a1cf068b-26"}]},"a1cf068b-24":{"id":"/src/watch.ts","moduleParts":{"index.js":"a1cf068b-25"},"imported":[{"uid":"a1cf068b-8"}],"importedBy":[{"uid":"a1cf068b-26"}]},"a1cf068b-26":{"id":"/src/index.ts","moduleParts":{"index.js":"a1cf068b-27"},"imported":[{"uid":"a1cf068b-0"},{"uid":"a1cf068b-2"},{"uid":"a1cf068b-10"},{"uid":"a1cf068b-12"},{"uid":"a1cf068b-14"},{"uid":"a1cf068b-8"},{"uid":"a1cf068b-20"},{"uid":"a1cf068b-22"},{"uid":"a1cf068b-4"},{"uid":"a1cf068b-16"},{"uid":"a1cf068b-18"},{"uid":"a1cf068b-6"},{"uid":"a1cf068b-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":"3d8fcc8f-1","name":"batch.ts"},{"uid":"3d8fcc8f-3","name":"cell.ts"},{"uid":"3d8fcc8f-5","name":"scope.ts"},{"uid":"3d8fcc8f-7","name":"tracking.ts"},{"uid":"3d8fcc8f-9","name":"effect.ts"},{"uid":"3d8fcc8f-11","name":"computed.ts"},{"uid":"3d8fcc8f-13","name":"createSelector.ts"},{"uid":"3d8fcc8f-15","name":"debug.ts"},{"uid":"3d8fcc8f-17","name":"reactive-trace.ts"},{"uid":"3d8fcc8f-19","name":"signal.ts"},{"uid":"3d8fcc8f-21","name":"store.ts"},{"uid":"3d8fcc8f-23","name":"reconcile.ts"},{"uid":"3d8fcc8f-25","name":"resource.ts"},{"uid":"3d8fcc8f-27","name":"watch.ts"},{"uid":"3d8fcc8f-29","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"3d8fcc8f-1":{"renderedLength":3016,"gzipLength":1167,"brotliLength":0,"metaUid":"3d8fcc8f-0"},"3d8fcc8f-3":{"renderedLength":1636,"gzipLength":786,"brotliLength":0,"metaUid":"3d8fcc8f-2"},"3d8fcc8f-5":{"renderedLength":3026,"gzipLength":1226,"brotliLength":0,"metaUid":"3d8fcc8f-4"},"3d8fcc8f-7":{"renderedLength":2227,"gzipLength":858,"brotliLength":0,"metaUid":"3d8fcc8f-6"},"3d8fcc8f-9":{"renderedLength":7391,"gzipLength":2397,"brotliLength":0,"metaUid":"3d8fcc8f-8"},"3d8fcc8f-11":{"renderedLength":4716,"gzipLength":1476,"brotliLength":0,"metaUid":"3d8fcc8f-10"},"3d8fcc8f-13":{"renderedLength":2244,"gzipLength":981,"brotliLength":0,"metaUid":"3d8fcc8f-12"},"3d8fcc8f-15":{"renderedLength":2469,"gzipLength":1092,"brotliLength":0,"metaUid":"3d8fcc8f-14"},"3d8fcc8f-17":{"renderedLength":2721,"gzipLength":1363,"brotliLength":0,"metaUid":"3d8fcc8f-16"},"3d8fcc8f-19":{"renderedLength":3408,"gzipLength":1476,"brotliLength":0,"metaUid":"3d8fcc8f-18"},"3d8fcc8f-21":{"renderedLength":5232,"gzipLength":1867,"brotliLength":0,"metaUid":"3d8fcc8f-20"},"3d8fcc8f-23":{"renderedLength":2278,"gzipLength":940,"brotliLength":0,"metaUid":"3d8fcc8f-22"},"3d8fcc8f-25":{"renderedLength":1205,"gzipLength":524,"brotliLength":0,"metaUid":"3d8fcc8f-24"},"3d8fcc8f-27":{"renderedLength":1249,"gzipLength":582,"brotliLength":0,"metaUid":"3d8fcc8f-26"},"3d8fcc8f-29":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"3d8fcc8f-28"}},"nodeMetas":{"3d8fcc8f-0":{"id":"/src/batch.ts","moduleParts":{"index.js":"3d8fcc8f-1"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-18"},{"uid":"3d8fcc8f-6"}]},"3d8fcc8f-2":{"id":"/src/cell.ts","moduleParts":{"index.js":"3d8fcc8f-3"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-4":{"id":"/src/scope.ts","moduleParts":{"index.js":"3d8fcc8f-5"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-8"}]},"3d8fcc8f-6":{"id":"/src/tracking.ts","moduleParts":{"index.js":"3d8fcc8f-7"},"imported":[{"uid":"3d8fcc8f-0"}],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-12"},{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-24"},{"uid":"3d8fcc8f-18"}]},"3d8fcc8f-8":{"id":"/src/effect.ts","moduleParts":{"index.js":"3d8fcc8f-9"},"imported":[{"uid":"3d8fcc8f-4"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-12"},{"uid":"3d8fcc8f-24"},{"uid":"3d8fcc8f-26"}]},"3d8fcc8f-10":{"id":"/src/computed.ts","moduleParts":{"index.js":"3d8fcc8f-11"},"imported":[{"uid":"3d8fcc8f-0"},{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-4"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-12":{"id":"/src/createSelector.ts","moduleParts":{"index.js":"3d8fcc8f-13"},"imported":[{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-14":{"id":"/src/debug.ts","moduleParts":{"index.js":"3d8fcc8f-15"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-18"}]},"3d8fcc8f-16":{"id":"/src/reactive-trace.ts","moduleParts":{"index.js":"3d8fcc8f-17"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-18"}]},"3d8fcc8f-18":{"id":"/src/signal.ts","moduleParts":{"index.js":"3d8fcc8f-19"},"imported":[{"uid":"3d8fcc8f-0"},{"uid":"3d8fcc8f-14"},{"uid":"3d8fcc8f-16"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-24"},{"uid":"3d8fcc8f-20"}]},"3d8fcc8f-20":{"id":"/src/store.ts","moduleParts":{"index.js":"3d8fcc8f-21"},"imported":[{"uid":"3d8fcc8f-18"}],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-22"}]},"3d8fcc8f-22":{"id":"/src/reconcile.ts","moduleParts":{"index.js":"3d8fcc8f-23"},"imported":[{"uid":"3d8fcc8f-20"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-24":{"id":"/src/resource.ts","moduleParts":{"index.js":"3d8fcc8f-25"},"imported":[{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-18"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-26":{"id":"/src/watch.ts","moduleParts":{"index.js":"3d8fcc8f-27"},"imported":[{"uid":"3d8fcc8f-8"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-28":{"id":"/src/index.ts","moduleParts":{"index.js":"3d8fcc8f-29"},"imported":[{"uid":"3d8fcc8f-0"},{"uid":"3d8fcc8f-2"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-12"},{"uid":"3d8fcc8f-14"},{"uid":"3d8fcc8f-16"},{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-22"},{"uid":"3d8fcc8f-24"},{"uid":"3d8fcc8f-4"},{"uid":"3d8fcc8f-18"},{"uid":"3d8fcc8f-20"},{"uid":"3d8fcc8f-6"},{"uid":"3d8fcc8f-26"}],"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
@@ -615,7 +615,7 @@ function computedLazy(fn) {
615
615
  if (disposed || dirty) return;
616
616
  dirty = true;
617
617
  if (host._s) notifySubscribers(host._s);
618
- if (directFns) for (const f of directFns) f?.();
618
+ if (directFns) for (const f of directFns) f();
619
619
  };
620
620
  _markRecompute(recompute);
621
621
  const read = () => {
@@ -649,13 +649,16 @@ function computedLazy(fn) {
649
649
  },
650
650
  enumerable: false
651
651
  });
652
+ Object.defineProperty(read, "_d", {
653
+ get: () => directFns,
654
+ enumerable: false
655
+ });
652
656
  read.direct = (updater) => {
653
- if (!directFns) directFns = [];
654
- const arr = directFns;
655
- const idx = arr.length;
656
- arr.push(updater);
657
+ if (!directFns) directFns = /* @__PURE__ */ new Set();
658
+ const set = directFns;
659
+ set.add(updater);
657
660
  return () => {
658
- arr[idx] = null;
661
+ set.delete(updater);
659
662
  };
660
663
  };
661
664
  getCurrentScope()?.add({ dispose: read.dispose });
@@ -690,7 +693,7 @@ function computedWithEquals(fn, equals) {
690
693
  return;
691
694
  }
692
695
  if (host._s) notifySubscribers(host._s);
693
- if (directFns) for (const f of directFns) f?.();
696
+ if (directFns) for (const f of directFns) f();
694
697
  };
695
698
  _markRecompute(recompute);
696
699
  const read = () => {
@@ -720,13 +723,16 @@ function computedWithEquals(fn, equals) {
720
723
  },
721
724
  enumerable: false
722
725
  });
726
+ Object.defineProperty(read, "_d", {
727
+ get: () => directFns,
728
+ enumerable: false
729
+ });
723
730
  read.direct = (updater) => {
724
- if (!directFns) directFns = [];
725
- const arr = directFns;
726
- const idx = arr.length;
727
- arr.push(updater);
731
+ if (!directFns) directFns = /* @__PURE__ */ new Set();
732
+ const set = directFns;
733
+ set.add(updater);
728
734
  return () => {
729
- arr[idx] = null;
735
+ set.delete(updater);
730
736
  };
731
737
  };
732
738
  getCurrentScope()?.add({ dispose: read.dispose });
@@ -909,6 +915,93 @@ function inspectSignal(sig) {
909
915
  return info;
910
916
  }
911
917
 
918
+ //#endregion
919
+ //#region src/reactive-trace.ts
920
+ /**
921
+ * Ring-buffer capacity. 50 entries is enough to see the causal chain
922
+ * for a crash (the writes in the few ticks before the throw) without
923
+ * the buffer itself becoming a memory concern — each entry is a small
924
+ * object with two short strings.
925
+ */
926
+ const CAP = 50;
927
+ let _buf = null;
928
+ let _count = 0;
929
+ /** Max characters of a value preview before truncation. Keeps the buffer + serialized report small. */
930
+ const PREVIEW_MAX = 80;
931
+ /**
932
+ * Safe, bounded stringification. Never throws (a getter or `toJSON`
933
+ * that throws must not break the trace recorder), never returns more
934
+ * than `PREVIEW_MAX` chars + an ellipsis marker.
935
+ */
936
+ function preview(v) {
937
+ let s;
938
+ try {
939
+ if (v === null) return "null";
940
+ if (v === void 0) return "undefined";
941
+ const t = typeof v;
942
+ if (t === "string") s = JSON.stringify(v);
943
+ else if (t === "number" || t === "boolean" || t === "bigint") s = String(v);
944
+ else if (t === "function") s = `[Function ${v.name || "anonymous"}]`;
945
+ else if (t === "symbol") s = v.toString();
946
+ else if (Array.isArray(v)) s = `Array(${v.length})`;
947
+ else {
948
+ const ctor = v.constructor?.name;
949
+ const keys = (() => {
950
+ try {
951
+ return Object.keys(v).slice(0, 4);
952
+ } catch {
953
+ return [];
954
+ }
955
+ })();
956
+ s = `${ctor && ctor !== "Object" ? ctor + " " : ""}{${keys.join(", ")}${keys.length === 4 ? ", …" : ""}}`;
957
+ }
958
+ } catch {
959
+ s = "[unstringifiable]";
960
+ }
961
+ return s.length > PREVIEW_MAX ? s.slice(0, PREVIEW_MAX) + "…" : s;
962
+ }
963
+ /**
964
+ * Record one signal write. Called from `signal.ts` `_set`, already
965
+ * inside the prod-gate, so this never runs in production builds.
966
+ *
967
+ * @internal
968
+ */
969
+ function _recordSignalWrite(name, prev, next) {
970
+ if (_buf === null) _buf = new Array(CAP);
971
+ _buf[_count % CAP] = {
972
+ name,
973
+ prev: preview(prev),
974
+ next: preview(next),
975
+ timestamp: typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now()
976
+ };
977
+ _count++;
978
+ }
979
+ /**
980
+ * Returns the recorded writes in chronological order (oldest → newest),
981
+ * at most `CAP` entries. Empty array when nothing has been recorded.
982
+ * The returned array is a fresh copy — safe to retain / serialize
983
+ * without pinning the ring buffer.
984
+ *
985
+ * Consumed by `@pyreon/core`'s `reportError` to attach `reactiveTrace`
986
+ * to the error context.
987
+ */
988
+ function getReactiveTrace() {
989
+ if (_buf === null || _count === 0) return [];
990
+ if (_count <= CAP) return _buf.slice(0, _count);
991
+ const start = _count % CAP;
992
+ const out = [];
993
+ for (let i = 0; i < CAP; i++) {
994
+ const e = _buf[(start + i) % CAP];
995
+ if (e) out.push(e);
996
+ }
997
+ return out;
998
+ }
999
+ /** Clears the buffer. For test isolation; not part of the app-facing API. */
1000
+ function clearReactiveTrace() {
1001
+ _buf = null;
1002
+ _count = 0;
1003
+ }
1004
+
912
1005
  //#endregion
913
1006
  //#region src/signal.ts
914
1007
  const _countSink = globalThis;
@@ -920,6 +1013,7 @@ function _set(newValue) {
920
1013
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("reactivity.signalWrite");
921
1014
  const prev = this._v;
922
1015
  this._v = newValue;
1016
+ if (process.env.NODE_ENV !== "production") _recordSignalWrite(this.label, prev, newValue);
923
1017
  if (isTracing()) try {
924
1018
  _notifyTraceListeners(this, prev, newValue);
925
1019
  } catch (err) {
@@ -943,28 +1037,33 @@ function _subscribe(listener) {
943
1037
  }
944
1038
  /**
945
1039
  * Register a direct updater — lighter than subscribe().
946
- * Uses a flat array instead of Set. Disposal nulls the slot (no Set.delete overhead).
947
1040
  * Used by compiler-emitted _bindText/_bindDirect for zero-overhead DOM bindings.
1041
+ *
1042
+ * Backed by a `Set` (same as `_s`), NOT a flat array. The array form
1043
+ * disposed by nulling the slot (`arr[idx] = null`) but never compacted —
1044
+ * so a long-lived signal (theme/locale/auth, or a signal read inside
1045
+ * `<For>` rows) bound by churning components accumulated one permanent
1046
+ * dead slot per ever-mounted binding. That is an app-lifetime memory
1047
+ * leak AND degrades the signal-write hot path: `notifyDirect` iterated
1048
+ * O(total-ever-registered), not O(live). A Set bounds growth to the live
1049
+ * set and keeps disposal + iteration O(live); the "Set.delete overhead"
1050
+ * the array form optimised for is negligible against an unbounded array.
948
1051
  */
949
1052
  function _directFn(updater) {
950
- if (!this._d) this._d = [];
951
- const arr = this._d;
952
- const idx = arr.length;
953
- arr.push(updater);
1053
+ if (!this._d) this._d = /* @__PURE__ */ new Set();
1054
+ const set = this._d;
1055
+ set.add(updater);
954
1056
  return () => {
955
- arr[idx] = null;
1057
+ set.delete(updater);
956
1058
  };
957
1059
  }
958
1060
  /**
959
- * Notify direct updaters — flat array iteration, batch-aware.
960
- * Null slots (from disposed updaters) are skipped.
1061
+ * Notify direct updaters — set iteration, batch-aware. Disposed updaters
1062
+ * are already absent from the set (O(1) delete on disposal).
961
1063
  */
962
1064
  function notifyDirect(updaters) {
963
- if (isBatching()) for (let i = 0; i < updaters.length; i++) {
964
- const fn = updaters[i];
965
- if (fn) enqueuePendingNotification(fn);
966
- }
967
- else for (let i = 0; i < updaters.length; i++) updaters[i]?.();
1065
+ if (isBatching()) for (const fn of updaters) enqueuePendingNotification(fn);
1066
+ else for (const fn of updaters) fn();
968
1067
  }
969
1068
  function _debug() {
970
1069
  return {
@@ -1111,6 +1210,7 @@ function wrap(raw, shallow) {
1111
1210
  target[key] = value;
1112
1211
  return true;
1113
1212
  }
1213
+ if (key === "__proto__" || key === "constructor" || key === "prototype") return true;
1114
1214
  const prevLength = isArray ? target.length : 0;
1115
1215
  target[key] = value;
1116
1216
  if (isArray && key === "length") {
@@ -1164,6 +1264,11 @@ function wrap(raw, shallow) {
1164
1264
  * Arrays are reconciled by index — elements at the same index are recursively
1165
1265
  * diffed rather than replaced wholesale. Excess old elements are removed.
1166
1266
  */
1267
+ const DANGEROUS_KEYS = new Set([
1268
+ "__proto__",
1269
+ "constructor",
1270
+ "prototype"
1271
+ ]);
1167
1272
  function reconcile(source, target) {
1168
1273
  _reconcileInner(source, target, /* @__PURE__ */ new WeakSet());
1169
1274
  }
@@ -1188,6 +1293,7 @@ function _reconcileObject(source, target, seen) {
1188
1293
  const sourceKeys = Object.keys(source);
1189
1294
  const targetKeys = new Set(Object.keys(target));
1190
1295
  for (const key of sourceKeys) {
1296
+ if (DANGEROUS_KEYS.has(key)) continue;
1191
1297
  const sv = source[key];
1192
1298
  const tv = target[key];
1193
1299
  if (sv !== null && typeof sv === "object" && tv !== null && typeof tv === "object") if (isStore(tv)) _reconcileInner(sv, tv, seen);
@@ -1195,7 +1301,10 @@ function _reconcileObject(source, target, seen) {
1195
1301
  else target[key] = sv;
1196
1302
  targetKeys.delete(key);
1197
1303
  }
1198
- for (const key of targetKeys) delete target[key];
1304
+ for (const key of targetKeys) {
1305
+ if (DANGEROUS_KEYS.has(key)) continue;
1306
+ delete target[key];
1307
+ }
1199
1308
  }
1200
1309
 
1201
1310
  //#endregion
@@ -1307,5 +1416,5 @@ function watch(source, callback, opts = {}) {
1307
1416
  }
1308
1417
 
1309
1418
  //#endregion
1310
- export { Cell, EffectScope, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
1419
+ export { Cell, EffectScope, _bind, batch, cell, clearReactiveTrace, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, getReactiveTrace, inspectSignal, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
1311
1420
  //# sourceMappingURL=index.js.map
@@ -133,9 +133,9 @@ interface Signal<T> {
133
133
  subscribe(listener: () => void): () => void;
134
134
  /**
135
135
  * Register a direct updater — even lighter than subscribe().
136
- * Uses a flat array instead of Set. Disposal nulls the slot (no Set.delete).
137
136
  * Intended for compiler-emitted DOM bindings (_bindText, _bindDirect).
138
- * Returns a disposer that nulls the slot.
137
+ * Returns a disposer that removes the updater (O(1)); the live set
138
+ * stays bounded under register/dispose churn.
139
139
  */
140
140
  direct(updater: () => void): () => void;
141
141
  /**
@@ -209,6 +209,56 @@ declare function why(): void;
209
209
  */
210
210
  declare function inspectSignal<T>(sig: Signal<T>): SignalDebugInfo<T>;
211
211
  //#endregion
212
+ //#region src/reactive-trace.d.ts
213
+ /**
214
+ * Reactive trace — a bounded, dev-only ring buffer of recent signal
215
+ * writes. When a signal-based UI throws, the single most useful
216
+ * debugging question is "what reactive state changed in the run-up to
217
+ * the crash" — a point-in-time snapshot of every signal value can't
218
+ * answer that (it shows the end state, not the causal sequence). The
219
+ * ring buffer records the last N writes so an error report can attach
220
+ * the sequence that led into the bad state.
221
+ *
222
+ * Design constraints:
223
+ *
224
+ * - **Bounded memory.** Fixed-size circular buffer (`CAP` entries).
225
+ * Never grows. Old entries overwrite oldest-first.
226
+ * - **No value retention.** Stores a TRUNCATED STRING preview of
227
+ * prev / next, never the raw value. Holding raw references would
228
+ * retain large arrays / detached DOM / closures in the buffer and
229
+ * leak them for the buffer's lifetime. The preview is also what
230
+ * makes the trace safely serializable into an error report.
231
+ * - **Cheap.** No stack capture (that's the expensive part of the
232
+ * `onSignalUpdate` debug path — this is deliberately lighter so it
233
+ * can record every write in dev without a perf cost). One object
234
+ * literal + one array slot write per signal write.
235
+ * - **Zero production cost.** The single call site in `signal.ts`
236
+ * is inside the existing `process.env.NODE_ENV !== 'production'`
237
+ * gate, so the whole module tree-shakes out of prod bundles.
238
+ */
239
+ interface ReactiveTraceEntry {
240
+ /** Signal `label` (set via `signal(v, { name })` or the vite plugin's dev auto-naming). `undefined` for anonymous signals. */
241
+ name: string | undefined;
242
+ /** Bounded string preview of the value before the write. */
243
+ prev: string;
244
+ /** Bounded string preview of the value after the write. */
245
+ next: string;
246
+ /** `performance.now()` at write time (monotonic; survives clock changes). */
247
+ timestamp: number;
248
+ }
249
+ /**
250
+ * Returns the recorded writes in chronological order (oldest → newest),
251
+ * at most `CAP` entries. Empty array when nothing has been recorded.
252
+ * The returned array is a fresh copy — safe to retain / serialize
253
+ * without pinning the ring buffer.
254
+ *
255
+ * Consumed by `@pyreon/core`'s `reportError` to attach `reactiveTrace`
256
+ * to the error context.
257
+ */
258
+ declare function getReactiveTrace(): ReactiveTraceEntry[];
259
+ /** Clears the buffer. For test isolation; not part of the app-facing API. */
260
+ declare function clearReactiveTrace(): void;
261
+ //#endregion
212
262
  //#region src/effect.d.ts
213
263
  interface Effect {
214
264
  dispose(): void;
@@ -465,5 +515,5 @@ interface WatchOptions {
465
515
  */
466
516
  declare function watch<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void), opts?: WatchOptions): () => void;
467
517
  //#endregion
468
- 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, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
518
+ export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReactiveSnapshotCapture, type ReactiveTraceEntry, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, batch, cell, clearReactiveTrace, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, getReactiveTrace, inspectSignal, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
469
519
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/reactivity",
3
- "version": "0.16.0",
3
+ "version": "0.19.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": {
package/src/computed.ts CHANGED
@@ -87,13 +87,21 @@ function computedLazy<T>(fn: () => T): Computed<T> {
87
87
  let tracked = false
88
88
  const deps: Set<() => void>[] = []
89
89
  const host: { _s: Set<() => void> | null } = { _s: null }
90
- let directFns: ((() => void) | null)[] | null = null
90
+ // Set, not a never-compacted flat array. The array form's disposal
91
+ // only nulled the slot (`arr[idx] = null`) and never shrank, so a
92
+ // long-lived computed (a derived theme/locale/auth value, or one read
93
+ // inside churning `<For>` rows) bound by mount/unmount churn grew one
94
+ // permanent dead slot per ever-registered binding — app-lifetime
95
+ // memory growth AND `recompute` iterating O(total-ever) instead of
96
+ // O(live). Identical bug class already fixed for `signal._d`
97
+ // (signal.ts `_directFn`); `computed` was left on the broken pattern.
98
+ let directFns: Set<() => void> | null = null
91
99
 
92
100
  const recompute = () => {
93
101
  if (disposed || dirty) return
94
102
  dirty = true
95
103
  if (host._s) notifySubscribers(host._s)
96
- if (directFns) for (const f of directFns) f?.()
104
+ if (directFns) for (const f of directFns) f()
97
105
  }
98
106
  _markRecompute(recompute)
99
107
 
@@ -136,13 +144,20 @@ function computedLazy<T>(fn: () => T): Computed<T> {
136
144
  enumerable: false,
137
145
  })
138
146
 
147
+ // @internal — mirrors `signal._d`. Lets tests deterministically assert
148
+ // the live direct-updater set stays BOUNDED under register/dispose
149
+ // churn (the never-compacted-array leak this fix removes).
150
+ Object.defineProperty(read, '_d', {
151
+ get: () => directFns,
152
+ enumerable: false,
153
+ })
154
+
139
155
  read.direct = (updater: () => void): (() => void) => {
140
- if (!directFns) directFns = []
141
- const arr = directFns
142
- const idx = arr.length
143
- arr.push(updater)
156
+ if (!directFns) directFns = new Set()
157
+ const set = directFns
158
+ set.add(updater)
144
159
  return () => {
145
- arr[idx] = null
160
+ set.delete(updater)
146
161
  }
147
162
  }
148
163
 
@@ -163,7 +178,15 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
163
178
  let disposed = false
164
179
  const deps: Set<() => void>[] = []
165
180
  const host: { _s: Set<() => void> | null } = { _s: null }
166
- let directFns: ((() => void) | null)[] | null = null
181
+ // Set, not a never-compacted flat array. The array form's disposal
182
+ // only nulled the slot (`arr[idx] = null`) and never shrank, so a
183
+ // long-lived computed (a derived theme/locale/auth value, or one read
184
+ // inside churning `<For>` rows) bound by mount/unmount churn grew one
185
+ // permanent dead slot per ever-registered binding — app-lifetime
186
+ // memory growth AND `recompute` iterating O(total-ever) instead of
187
+ // O(live). Identical bug class already fixed for `signal._d`
188
+ // (signal.ts `_directFn`); `computed` was left on the broken pattern.
189
+ let directFns: Set<() => void> | null = null
167
190
 
168
191
  const recompute = () => {
169
192
  if (disposed) return
@@ -181,7 +204,7 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
181
204
  return
182
205
  }
183
206
  if (host._s) notifySubscribers(host._s)
184
- if (directFns) for (const f of directFns) f?.()
207
+ if (directFns) for (const f of directFns) f()
185
208
  }
186
209
  _markRecompute(recompute)
187
210
 
@@ -216,13 +239,20 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
216
239
  enumerable: false,
217
240
  })
218
241
 
242
+ // @internal — mirrors `signal._d`. Lets tests deterministically assert
243
+ // the live direct-updater set stays BOUNDED under register/dispose
244
+ // churn (the never-compacted-array leak this fix removes).
245
+ Object.defineProperty(read, '_d', {
246
+ get: () => directFns,
247
+ enumerable: false,
248
+ })
249
+
219
250
  read.direct = (updater: () => void): (() => void) => {
220
- if (!directFns) directFns = []
221
- const arr = directFns
222
- const idx = arr.length
223
- arr.push(updater)
251
+ if (!directFns) directFns = new Set()
252
+ const set = directFns
253
+ set.add(updater)
224
254
  return () => {
225
- arr[idx] = null
255
+ set.delete(updater)
226
256
  }
227
257
  }
228
258
 
package/src/effect.ts CHANGED
@@ -102,6 +102,11 @@ interface PyreonErrorBridge {
102
102
  const _errorBridge = globalThis as PyreonErrorBridge
103
103
 
104
104
  function _defaultErrorHandler(err: unknown): void {
105
+ // Last-resort unhandled-effect-error reporter — MUST fire in
106
+ // production (silently swallowing uncaught effect errors is a
107
+ // serious bug; React/Vue/Solid all log uncaught errors in prod).
108
+ // Deliberately not __DEV__-gated.
109
+ // pyreon-lint-disable-next-line pyreon/dev-guard-warnings
105
110
  console.error('[pyreon] Unhandled effect error:', err)
106
111
  }
107
112
 
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ export { Cell, cell } from './cell'
5
5
  export { type Computed, type ComputedOptions, computed } from './computed'
6
6
  export { createSelector } from './createSelector'
7
7
  export { inspectSignal, onSignalUpdate, why } from './debug'
8
+ export type { ReactiveTraceEntry } from './reactive-trace'
9
+ export { clearReactiveTrace, getReactiveTrace } from './reactive-trace'
8
10
  export {
9
11
  _bind,
10
12
  type Effect,
package/src/manifest.ts CHANGED
@@ -58,7 +58,7 @@ effect(() => {
58
58
  'createStore() / reconcile() / isStore() — deeply reactive proxy stores + structural diff',
59
59
  'effectScope() / getCurrentScope() — scope-based lifecycle management',
60
60
  'untrack() — read without subscribing',
61
- 'onSignalUpdate() / inspectSignal() / why() — debug instrumentation',
61
+ 'onSignalUpdate() / inspectSignal() / why() / getReactiveTrace() — debug instrumentation',
62
62
  'setErrorHandler() — global hook for unhandled effect errors',
63
63
  'Standalone — zero DOM, zero JSX, zero framework dependency',
64
64
  ],
@@ -553,6 +553,27 @@ why() // disarm + dump transcript:
553
553
  ],
554
554
  seeAlso: ['onSignalUpdate', 'inspectSignal'],
555
555
  },
556
+ {
557
+ name: 'getReactiveTrace',
558
+ kind: 'function',
559
+ signature:
560
+ '() => Array<{ name: string | undefined; prev: string; next: string; timestamp: number }>',
561
+ summary:
562
+ 'Returns the last ~50 signal writes (chronological, oldest → newest) from a bounded dev-only ring buffer — the causal SEQUENCE of reactive state changes, not a point-in-time snapshot. `@pyreon/core` attaches this to `ErrorContext.reactiveTrace` automatically so error reports carry "what changed in the run-up to the crash". Entries hold bounded string previews of values (never raw refs — no memory pinning, always serializable). **Dev-only**: the recorder feeding the buffer is behind the production dead-code gate and tree-shakes out, so this returns `[]` in prod builds. Distinct from `onSignalUpdate` — that is opt-in and captures stacks; this is always-on, deliberately cheap, and exists to enrich error reports. `clearReactiveTrace()` resets it (test isolation).',
563
+ example: `import { getReactiveTrace, clearReactiveTrace, signal } from '@pyreon/reactivity'
564
+
565
+ const status = signal('idle', { name: 'status' })
566
+ status.set('submitting')
567
+ getReactiveTrace()
568
+ // [{ name: 'status', prev: '"idle"', next: '"submitting"', timestamp: 1234.5 }]
569
+ clearReactiveTrace() // → []`,
570
+ mistakes: [
571
+ 'Expecting it to return signal VALUES — it returns string PREVIEWS (truncated, safely stringified). For live values inspect the signal directly',
572
+ 'Relying on it in production — returns `[]` (the recorder is dev-gated and tree-shaken). Use it for dev tooling / error-report enrichment, not runtime logic',
573
+ 'Treating it as a snapshot of all signals — it is a bounded ring of recent WRITES; signals never written (or written before the ~50-entry window) are absent',
574
+ ],
575
+ seeAlso: ['onSignalUpdate', 'inspectSignal'],
576
+ },
556
577
  {
557
578
  name: 'setErrorHandler',
558
579
  kind: 'function',