@pyreon/reactivity 0.15.0 → 0.16.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/README.md CHANGED
@@ -56,7 +56,8 @@ batch(() => {
56
56
  ### Scopes
57
57
 
58
58
  - **`effectScope(): EffectScope`** -- Creates a scope that collects effects for bulk disposal. Internal arrays (`_effects`, `_updateHooks`) are lazy-allocated on first use -- scopes with no effects cost only the object itself.
59
- - **`getCurrentScope(): EffectScope | undefined`** -- Returns the active effect scope.
59
+ - **`getCurrentScope(): EffectScope | null`** -- Returns the active effect scope, or `null` if none.
60
+ - **`onScopeDispose(fn)`** -- Register a callback to run when the current scope stops (Vue 3 parity).
60
61
  - **`setCurrentScope(scope)`** -- Manually sets the current effect scope.
61
62
 
62
63
  ### Selectors and Resources
@@ -69,6 +70,8 @@ batch(() => {
69
70
  - **`createStore(initial)`** -- Creates a deeply reactive store object.
70
71
  - **`isStore(value): boolean`** -- Checks whether a value is a reactive store.
71
72
  - **`reconcile(target, source)`** -- Efficiently patches a store to match a new value.
73
+ - **`shallowReactive<T>(initial): T`** -- Creates a SHALLOWLY reactive store: top-level property writes notify, but nested object mutations don't (Vue 3 parity). Use for large object graphs where deep proxying would be wasteful.
74
+ - **`markRaw<T>(value): T`** -- Mark an object as RAW so `createStore` and `shallowReactive` return it unwrapped (Vue 3 parity). Useful for class instances, third-party objects, DOM nodes, or any shape that shouldn't be deeply proxied. Marking is one-way (no `unmarkRaw`); mark BEFORE the object enters a store.
72
75
 
73
76
  ## License
74
77
 
@@ -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":"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}};
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}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -30,7 +30,19 @@ function batch(fn) {
30
30
  pendingRecomputes.clear();
31
31
  if (pendingEffects.size > 0) {
32
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.`);
33
+ if (__DEV__) {
34
+ const droppedCount = pendingEffects.size;
35
+ const labels = [];
36
+ for (const notify of pendingEffects) {
37
+ const label = notify._label;
38
+ if (label) labels.push(label);
39
+ if (labels.length >= 5) break;
40
+ }
41
+ const labelHint = labels.length ? ` Sample labels: ${labels.join(", ")}${droppedCount > labels.length ? `, …${droppedCount - labels.length} more` : ""}.` : "";
42
+ console.warn(`[pyreon] batch effect flush exceeded MAX_PASSES (32) — possible infinite re-enqueue loop. ${droppedCount} pending effects dropped.${labelHint} Common cause: an effect that writes to a signal it also reads, without a guard. See packages/core/reactivity/src/batch.ts for the multi-pass flush contract.`);
43
+ }
44
+ pendingEffects.clear();
45
+ _nextEffectPass.clear();
34
46
  break;
35
47
  }
36
48
  _visitedThisPass = /* @__PURE__ */ new Set();
@@ -167,6 +179,7 @@ var EffectScope = class {
167
179
  }
168
180
  /** Register a callback to run after any reactive update in this scope. */
169
181
  addUpdateHook(fn) {
182
+ if (!this._active) return;
170
183
  if (this._updateHooks === null) this._updateHooks = [];
171
184
  this._updateHooks.push(fn);
172
185
  }
@@ -208,6 +221,31 @@ function setCurrentScope(scope) {
208
221
  function effectScope() {
209
222
  return new EffectScope();
210
223
  }
224
+ /**
225
+ * Register a callback to run when the current `EffectScope` stops. Vue 3
226
+ * parity. Must be called inside `scope.runInScope(fn)` — the registration
227
+ * captures the ambient scope, so calling outside any scope is a no-op (with
228
+ * a dev warning to surface the missing scope).
229
+ *
230
+ * Use to clean up resources tied to a scope's lifetime: timers, listeners,
231
+ * external subscriptions. Equivalent to calling `getCurrentScope()?.add({
232
+ * dispose: fn })` but with the scope capture handled.
233
+ *
234
+ * @example
235
+ * scope.runInScope(() => {
236
+ * const ws = new WebSocket(url)
237
+ * onScopeDispose(() => ws.close())
238
+ * // ws.close() runs when scope.stop() is called
239
+ * })
240
+ */
241
+ function onScopeDispose(fn) {
242
+ const scope = _currentScope;
243
+ if (!scope) {
244
+ if (process.env.NODE_ENV !== "production") console.warn("[pyreon] onScopeDispose() called without an active EffectScope — callback will never run. Wrap the call in `scope.runInScope(() => { ... })` or check `getCurrentScope()` before calling.");
245
+ return;
246
+ }
247
+ scope.add({ dispose: fn });
248
+ }
211
249
 
212
250
  //#endregion
213
251
  //#region src/tracking.ts
@@ -728,12 +766,18 @@ function notifyBucket(bucket) {
728
766
  * const isSelected = createSelector(selectedId)
729
767
  * // In each row:
730
768
  * class: () => (isSelected(row.id) ? "selected" : "")
769
+ *
770
+ * @example
771
+ * // Dynamic value spaces — call dispose() to release the per-value cache:
772
+ * const isCurrentTab = createSelector(() => currentTabId())
773
+ * onUnmount(() => isCurrentTab.dispose())
731
774
  */
732
775
  function createSelector(source) {
733
776
  const subs = /* @__PURE__ */ new Map();
734
777
  let current;
735
778
  let initialized = false;
736
- effect(() => {
779
+ let disposed = false;
780
+ const sourceEffect = effect(() => {
737
781
  const next = source();
738
782
  if (!initialized) {
739
783
  initialized = true;
@@ -749,20 +793,30 @@ function createSelector(source) {
749
793
  if (newBucket) notifyBucket(newBucket);
750
794
  });
751
795
  const hosts = /* @__PURE__ */ new Map();
752
- return (value) => {
753
- let host = hosts.get(value);
754
- if (!host) {
755
- let bucket = subs.get(value);
756
- if (!bucket) {
757
- bucket = /* @__PURE__ */ new Set();
758
- subs.set(value, bucket);
796
+ const selector = ((value) => {
797
+ if (!disposed) {
798
+ let host = hosts.get(value);
799
+ if (!host) {
800
+ let bucket = subs.get(value);
801
+ if (!bucket) {
802
+ bucket = /* @__PURE__ */ new Set();
803
+ subs.set(value, bucket);
804
+ }
805
+ host = { _s: bucket };
806
+ hosts.set(value, host);
759
807
  }
760
- host = { _s: bucket };
761
- hosts.set(value, host);
808
+ trackSubscriber(host);
762
809
  }
763
- trackSubscriber(host);
764
810
  return Object.is(current, value);
811
+ });
812
+ selector.dispose = () => {
813
+ if (disposed) return;
814
+ disposed = true;
815
+ sourceEffect.dispose();
816
+ subs.clear();
817
+ hosts.clear();
765
818
  };
819
+ return selector;
766
820
  }
767
821
 
768
822
  //#endregion
@@ -866,7 +920,11 @@ function _set(newValue) {
866
920
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("reactivity.signalWrite");
867
921
  const prev = this._v;
868
922
  this._v = newValue;
869
- if (isTracing()) _notifyTraceListeners(this, prev, newValue);
923
+ if (isTracing()) try {
924
+ _notifyTraceListeners(this, prev, newValue);
925
+ } catch (err) {
926
+ if (process.env.NODE_ENV !== "production") console.error("[pyreon] signal trace listener threw — listener is buggy. Subscribers continue uninterrupted.", err);
927
+ }
870
928
  if (isBatching()) {
871
929
  if (this._d) notifyDirect(this._d);
872
930
  if (this._s) notifySubscribers(this._s);
@@ -959,7 +1017,38 @@ function signal(initialValue, options) {
959
1017
  * state.items[0].text = "world" // only text-tracking effects re-run
960
1018
  */
961
1019
  const proxyCache = /* @__PURE__ */ new WeakMap();
1020
+ const shallowProxyCache = /* @__PURE__ */ new WeakMap();
962
1021
  const IS_STORE = Symbol("pyreon.store");
1022
+ const IS_RAW = Symbol("pyreon.raw");
1023
+ /**
1024
+ * Mark an object as RAW — `createStore` and `shallowReactive` will return it
1025
+ * unwrapped. Useful when storing class instances, third-party objects, or
1026
+ * other shapes that shouldn't be deeply proxied (Vue 3 parity).
1027
+ *
1028
+ * @example
1029
+ * const cm = markRaw(new CodeMirrorView(...))
1030
+ * const store = createStore({ editor: cm })
1031
+ * store.editor === cm // true (not wrapped)
1032
+ *
1033
+ * Note: marking is one-way — there's no `unmarkRaw`. Mark BEFORE the object
1034
+ * enters a store; marking after wrap doesn't unwrap an existing proxy.
1035
+ */
1036
+ function markRaw(value) {
1037
+ Object.defineProperty(value, IS_RAW, {
1038
+ value: true,
1039
+ enumerable: false,
1040
+ configurable: true,
1041
+ writable: false
1042
+ });
1043
+ return value;
1044
+ }
1045
+ /** Returns true if the value was marked with `markRaw()`. */
1046
+ function isMarkedRaw(value) {
1047
+ return value[IS_RAW] === true;
1048
+ }
1049
+ function isBuiltinNonProxiable(obj) {
1050
+ return obj instanceof Map || obj instanceof Set || obj instanceof WeakMap || obj instanceof WeakSet || obj instanceof Date || obj instanceof RegExp || obj instanceof Promise || obj instanceof Error;
1051
+ }
963
1052
  /** Returns true if the value is a createStore proxy. */
964
1053
  function isStore(value) {
965
1054
  return value !== null && typeof value === "object" && value[IS_STORE] === true;
@@ -969,10 +1058,33 @@ function isStore(value) {
969
1058
  * Returns a proxy — mutations to the proxy trigger fine-grained reactive updates.
970
1059
  */
971
1060
  function createStore(initial) {
972
- return wrap(initial);
1061
+ return wrap(initial, false);
973
1062
  }
974
- function wrap(raw) {
975
- const cached = proxyCache.get(raw);
1063
+ /**
1064
+ * Create a SHALLOW reactive store — only top-level mutations trigger updates.
1065
+ * Nested objects are NOT auto-wrapped; reading a nested object returns the
1066
+ * raw reference. Use when:
1067
+ * - the nested objects are immutable (frozen API responses)
1068
+ * - you want explicit control over which subtrees are reactive
1069
+ * - you need to store class instances or third-party objects without
1070
+ * paying the deep-proxy overhead
1071
+ *
1072
+ * @example
1073
+ * const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })
1074
+ * effect(() => console.log(store.user)) // tracks store.user reference
1075
+ * effect(() => console.log(store.count)) // tracks store.count
1076
+ * store.user.name = 'Bob' // does NOT trigger any effect
1077
+ * store.count = 5 // triggers the count effect
1078
+ * store.user = { name: 'Bob' } // triggers the user effect
1079
+ */
1080
+ function shallowReactive(initial) {
1081
+ return wrap(initial, true);
1082
+ }
1083
+ function wrap(raw, shallow) {
1084
+ if (isBuiltinNonProxiable(raw)) return raw;
1085
+ if (isMarkedRaw(raw)) return raw;
1086
+ const cache = shallow ? shallowProxyCache : proxyCache;
1087
+ const cached = cache.get(raw);
976
1088
  if (cached) return cached;
977
1089
  const propSignals = /* @__PURE__ */ new Map();
978
1090
  const isArray = Array.isArray(raw);
@@ -986,9 +1098,12 @@ function wrap(raw) {
986
1098
  if (key === IS_STORE) return true;
987
1099
  if (typeof key === "symbol") return target[key];
988
1100
  if (isArray && key === "length") return lengthSig?.();
989
- if (!Object.hasOwn(target, key)) return target[key];
1101
+ if (!Object.hasOwn(target, key)) {
1102
+ if (propSignals.has(key)) return propSignals.get(key)?.();
1103
+ return target[key];
1104
+ }
990
1105
  const value = getOrCreateSignal(key)();
991
- if (value !== null && typeof value === "object") return wrap(value);
1106
+ if (!shallow && value !== null && typeof value === "object") return wrap(value, false);
992
1107
  return value;
993
1108
  },
994
1109
  set(target, key, value) {
@@ -1009,10 +1124,7 @@ function wrap(raw) {
1009
1124
  },
1010
1125
  deleteProperty(target, key) {
1011
1126
  delete target[key];
1012
- if (typeof key !== "symbol" && propSignals.has(key)) {
1013
- propSignals.get(key)?.set(void 0);
1014
- propSignals.delete(key);
1015
- }
1127
+ if (typeof key !== "symbol" && propSignals.has(key)) propSignals.get(key)?.set(void 0);
1016
1128
  if (isArray) lengthSig?.set(target.length);
1017
1129
  return true;
1018
1130
  },
@@ -1026,7 +1138,7 @@ function wrap(raw) {
1026
1138
  return Reflect.getOwnPropertyDescriptor(target, key);
1027
1139
  }
1028
1140
  });
1029
- proxyCache.set(raw, proxy);
1141
+ cache.set(raw, proxy);
1030
1142
  return proxy;
1031
1143
  }
1032
1144
 
@@ -1117,7 +1229,8 @@ function createResource(source, fetcher) {
1117
1229
  loading.set(false);
1118
1230
  });
1119
1231
  };
1120
- effect(() => {
1232
+ let disposed = false;
1233
+ const sourceEffect = effect(() => {
1121
1234
  const param = source();
1122
1235
  runUntracked(() => doFetch(param));
1123
1236
  });
@@ -1126,7 +1239,14 @@ function createResource(source, fetcher) {
1126
1239
  loading,
1127
1240
  error,
1128
1241
  refetch() {
1242
+ if (disposed) return;
1129
1243
  runUntracked(() => doFetch(source()));
1244
+ },
1245
+ dispose() {
1246
+ if (disposed) return;
1247
+ disposed = true;
1248
+ requestId++;
1249
+ sourceEffect.dispose();
1130
1250
  }
1131
1251
  };
1132
1252
  }
@@ -1187,5 +1307,5 @@ function watch(source, callback, opts = {}) {
1187
1307
  }
1188
1308
 
1189
1309
  //#endregion
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 };
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 };
1191
1311
  //# sourceMappingURL=index.js.map
@@ -71,6 +71,19 @@ interface ComputedOptions<T> {
71
71
  declare function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed<T>;
72
72
  //#endregion
73
73
  //#region src/createSelector.d.ts
74
+ /** Selector predicate with a `dispose()` method to release internal state. */
75
+ interface Selector<T> {
76
+ (value: T): boolean;
77
+ /**
78
+ * Stop the source-tracking effect AND clear the per-value subscriber/host
79
+ * Maps. After dispose, calls to the selector return the last-known result
80
+ * but no longer track. Required for selectors over dynamic value spaces
81
+ * (UUIDs, ephemeral IDs) created outside an `EffectScope` — without it,
82
+ * each unique queried value adds a permanent entry to the internal Maps,
83
+ * leaking memory for the lifetime of the program. Idempotent.
84
+ */
85
+ dispose(): void;
86
+ }
74
87
  /**
75
88
  * Create an equality selector — returns a reactive predicate that is true
76
89
  * only for the currently selected value.
@@ -83,8 +96,13 @@ declare function computed<T>(fn: () => T, options?: ComputedOptions<T>): Compute
83
96
  * const isSelected = createSelector(selectedId)
84
97
  * // In each row:
85
98
  * class: () => (isSelected(row.id) ? "selected" : "")
99
+ *
100
+ * @example
101
+ * // Dynamic value spaces — call dispose() to release the per-value cache:
102
+ * const isCurrentTab = createSelector(() => currentTabId())
103
+ * onUnmount(() => isCurrentTab.dispose())
86
104
  */
87
- declare function createSelector<T>(source: () => T): (value: T) => boolean;
105
+ declare function createSelector<T>(source: () => T): Selector<T>;
88
106
  //#endregion
89
107
  //#region src/signal.d.ts
90
108
  interface SignalDebugInfo<T> {
@@ -120,7 +138,11 @@ interface Signal<T> {
120
138
  * Returns a disposer that nulls the slot.
121
139
  */
122
140
  direct(updater: () => void): () => void;
123
- /** Debug name — useful for devtools and logging. */
141
+ /**
142
+ * Debug name — useful for devtools and logging. Set via the `name` option at
143
+ * creation; can be reassigned at any time (`s.label = 'renamed'`) since it's
144
+ * stored as a regular own property on the signal function.
145
+ */
124
146
  label: string | undefined;
125
147
  /** Returns a snapshot of the signal's debug info (value, name, subscriber count). */
126
148
  debug(): SignalDebugInfo<T>;
@@ -286,6 +308,13 @@ interface Resource<T> {
286
308
  error: Signal<unknown>;
287
309
  /** Re-run the fetcher with the current source value. */
288
310
  refetch(): void;
311
+ /**
312
+ * Stop the source-tracking effect. After dispose(), source changes no
313
+ * longer trigger fetches and any in-flight response is ignored. Idempotent.
314
+ * Required for resources created outside an `EffectScope` to avoid leaking
315
+ * the source-tracking effect for the lifetime of the program.
316
+ */
317
+ dispose(): void;
289
318
  }
290
319
  /**
291
320
  * Async data primitive. Fetches data reactively whenever `source()` changes.
@@ -330,6 +359,24 @@ declare function getCurrentScope(): EffectScope | null;
330
359
  declare function setCurrentScope(scope: EffectScope | null): void;
331
360
  /** Create a new EffectScope. */
332
361
  declare function effectScope(): EffectScope;
362
+ /**
363
+ * Register a callback to run when the current `EffectScope` stops. Vue 3
364
+ * parity. Must be called inside `scope.runInScope(fn)` — the registration
365
+ * captures the ambient scope, so calling outside any scope is a no-op (with
366
+ * a dev warning to surface the missing scope).
367
+ *
368
+ * Use to clean up resources tied to a scope's lifetime: timers, listeners,
369
+ * external subscriptions. Equivalent to calling `getCurrentScope()?.add({
370
+ * dispose: fn })` but with the scope capture handled.
371
+ *
372
+ * @example
373
+ * scope.runInScope(() => {
374
+ * const ws = new WebSocket(url)
375
+ * onScopeDispose(() => ws.close())
376
+ * // ws.close() runs when scope.stop() is called
377
+ * })
378
+ */
379
+ declare function onScopeDispose(fn: () => void): void;
333
380
  //#endregion
334
381
  //#region src/store.d.ts
335
382
  /**
@@ -346,6 +393,20 @@ declare function effectScope(): EffectScope;
346
393
  * state.count++ // only the count effect re-runs
347
394
  * state.items[0].text = "world" // only text-tracking effects re-run
348
395
  */
396
+ /**
397
+ * Mark an object as RAW — `createStore` and `shallowReactive` will return it
398
+ * unwrapped. Useful when storing class instances, third-party objects, or
399
+ * other shapes that shouldn't be deeply proxied (Vue 3 parity).
400
+ *
401
+ * @example
402
+ * const cm = markRaw(new CodeMirrorView(...))
403
+ * const store = createStore({ editor: cm })
404
+ * store.editor === cm // true (not wrapped)
405
+ *
406
+ * Note: marking is one-way — there's no `unmarkRaw`. Mark BEFORE the object
407
+ * enters a store; marking after wrap doesn't unwrap an existing proxy.
408
+ */
409
+ declare function markRaw<T extends object>(value: T): T;
349
410
  /** Returns true if the value is a createStore proxy. */
350
411
  declare function isStore(value: unknown): boolean;
351
412
  /**
@@ -353,6 +414,24 @@ declare function isStore(value: unknown): boolean;
353
414
  * Returns a proxy — mutations to the proxy trigger fine-grained reactive updates.
354
415
  */
355
416
  declare function createStore<T extends object>(initial: T): T;
417
+ /**
418
+ * Create a SHALLOW reactive store — only top-level mutations trigger updates.
419
+ * Nested objects are NOT auto-wrapped; reading a nested object returns the
420
+ * raw reference. Use when:
421
+ * - the nested objects are immutable (frozen API responses)
422
+ * - you want explicit control over which subtrees are reactive
423
+ * - you need to store class instances or third-party objects without
424
+ * paying the deep-proxy overhead
425
+ *
426
+ * @example
427
+ * const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })
428
+ * effect(() => console.log(store.user)) // tracks store.user reference
429
+ * effect(() => console.log(store.count)) // tracks store.count
430
+ * store.user.name = 'Bob' // does NOT trigger any effect
431
+ * store.count = 5 // triggers the count effect
432
+ * store.user = { name: 'Bob' } // triggers the user effect
433
+ */
434
+ declare function shallowReactive<T extends object>(initial: T): T;
356
435
  //#endregion
357
436
  //#region src/tracking.d.ts
358
437
  /** Read signals without subscribing. Alias: `untrack`. */
@@ -386,5 +465,5 @@ interface WatchOptions {
386
465
  */
387
466
  declare function watch<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void), opts?: WatchOptions): () => void;
388
467
  //#endregion
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 };
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 };
390
469
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/reactivity",
3
- "version": "0.15.0",
3
+ "version": "0.16.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/batch.ts CHANGED
@@ -102,13 +102,33 @@ export function batch(fn: () => void): void {
102
102
  if (pendingEffects.size > 0) {
103
103
  if (++effectPass > MAX_PASSES) {
104
104
  if (__DEV__) {
105
+ // Surface labels of dropped effects when available — helps
106
+ // identify the offending effect in a real app. Falls back to
107
+ // bare count for anonymous effects.
108
+ const droppedCount = pendingEffects.size
109
+ const labels: string[] = []
110
+ for (const notify of pendingEffects) {
111
+ const label = (notify as { _label?: string })._label
112
+ if (label) labels.push(label)
113
+ if (labels.length >= 5) break
114
+ }
115
+ const labelHint = labels.length
116
+ ? ` Sample labels: ${labels.join(', ')}${droppedCount > labels.length ? `, …${droppedCount - labels.length} more` : ''}.`
117
+ : ''
105
118
  // oxlint-disable-next-line no-console
106
119
  console.warn(
107
120
  '[pyreon] batch effect flush exceeded MAX_PASSES (32) — possible infinite re-enqueue loop. ' +
108
- `${pendingEffects.size} pending effects dropped. ` +
121
+ `${droppedCount} pending effects dropped.${labelHint} ` +
122
+ 'Common cause: an effect that writes to a signal it also reads, without a guard. ' +
109
123
  'See packages/core/reactivity/src/batch.ts for the multi-pass flush contract.',
110
124
  )
111
125
  }
126
+ // Drop the queue so subsequent batches start clean — without
127
+ // this, the next batch would re-encounter the offending effect
128
+ // immediately on its first pass and trip MAX_PASSES instantly,
129
+ // making the original error harder to diagnose.
130
+ pendingEffects.clear()
131
+ _nextEffectPass.clear()
112
132
  break
113
133
  }
114
134
  _visitedThisPass = new Set<() => void>()
@@ -21,6 +21,20 @@ function notifyBucket(bucket: Set<() => void>): void {
21
21
  }
22
22
  }
23
23
 
24
+ /** Selector predicate with a `dispose()` method to release internal state. */
25
+ export interface Selector<T> {
26
+ (value: T): boolean
27
+ /**
28
+ * Stop the source-tracking effect AND clear the per-value subscriber/host
29
+ * Maps. After dispose, calls to the selector return the last-known result
30
+ * but no longer track. Required for selectors over dynamic value spaces
31
+ * (UUIDs, ephemeral IDs) created outside an `EffectScope` — without it,
32
+ * each unique queried value adds a permanent entry to the internal Maps,
33
+ * leaking memory for the lifetime of the program. Idempotent.
34
+ */
35
+ dispose(): void
36
+ }
37
+
24
38
  /**
25
39
  * Create an equality selector — returns a reactive predicate that is true
26
40
  * only for the currently selected value.
@@ -33,13 +47,19 @@ function notifyBucket(bucket: Set<() => void>): void {
33
47
  * const isSelected = createSelector(selectedId)
34
48
  * // In each row:
35
49
  * class: () => (isSelected(row.id) ? "selected" : "")
50
+ *
51
+ * @example
52
+ * // Dynamic value spaces — call dispose() to release the per-value cache:
53
+ * const isCurrentTab = createSelector(() => currentTabId())
54
+ * onUnmount(() => isCurrentTab.dispose())
36
55
  */
37
- export function createSelector<T>(source: () => T): (value: T) => boolean {
56
+ export function createSelector<T>(source: () => T): Selector<T> {
38
57
  const subs = new Map<T, Set<() => void>>()
39
58
  let current: T
40
59
  let initialized = false
60
+ let disposed = false
41
61
 
42
- effect(() => {
62
+ const sourceEffect = effect(() => {
43
63
  const next = source()
44
64
  if (!initialized) {
45
65
  initialized = true
@@ -60,18 +80,30 @@ export function createSelector<T>(source: () => T): (value: T) => boolean {
60
80
  // Reusable hosts per value — avoids allocating a closure per trackSubscriber call
61
81
  const hosts = new Map<T, { _s: Set<() => void> | null }>()
62
82
 
63
- return (value: T): boolean => {
64
- let host = hosts.get(value)
65
- if (!host) {
66
- let bucket = subs.get(value)
67
- if (!bucket) {
68
- bucket = new Set()
69
- subs.set(value, bucket)
83
+ const selector = ((value: T): boolean => {
84
+ if (!disposed) {
85
+ let host = hosts.get(value)
86
+ if (!host) {
87
+ let bucket = subs.get(value)
88
+ if (!bucket) {
89
+ bucket = new Set()
90
+ subs.set(value, bucket)
91
+ }
92
+ host = { _s: bucket }
93
+ hosts.set(value, host)
70
94
  }
71
- host = { _s: bucket }
72
- hosts.set(value, host)
95
+ trackSubscriber(host)
73
96
  }
74
- trackSubscriber(host)
75
97
  return Object.is(current, value)
98
+ }) as Selector<T>
99
+
100
+ selector.dispose = (): void => {
101
+ if (disposed) return
102
+ disposed = true
103
+ sourceEffect.dispose()
104
+ subs.clear()
105
+ hosts.clear()
76
106
  }
107
+
108
+ return selector
77
109
  }
package/src/index.ts CHANGED
@@ -17,7 +17,13 @@ export {
17
17
  } from './effect'
18
18
  export { reconcile } from './reconcile'
19
19
  export { createResource, type Resource } from './resource'
20
- export { EffectScope, effectScope, getCurrentScope, setCurrentScope } from './scope'
20
+ export {
21
+ EffectScope,
22
+ effectScope,
23
+ getCurrentScope,
24
+ onScopeDispose,
25
+ setCurrentScope,
26
+ } from './scope'
21
27
  export {
22
28
  type ReadonlySignal,
23
29
  type Signal,
@@ -25,6 +31,6 @@ export {
25
31
  type SignalOptions,
26
32
  signal,
27
33
  } from './signal'
28
- export { createStore, isStore } from './store'
34
+ export { createStore, isStore, markRaw, shallowReactive } from './store'
29
35
  export { runUntracked, runUntracked as untrack } from './tracking'
30
36
  export { type WatchOptions, watch } from './watch'