@pyreon/reactivity 0.15.0 → 0.18.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 +4 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +145 -25
- package/lib/types/index.d.ts +82 -3
- package/package.json +1 -1
- package/src/batch.ts +21 -1
- package/src/createSelector.ts +44 -12
- package/src/index.ts +8 -2
- package/src/manifest.ts +372 -5
- package/src/reconcile.ts +9 -1
- package/src/resource.ts +19 -1
- package/src/scope.ts +38 -0
- package/src/signal.ts +26 -2
- package/src/store.ts +111 -11
- package/src/tests/batch.test.ts +187 -0
- package/src/tests/computed.test.ts +54 -0
- package/src/tests/createSelector.test.ts +59 -0
- package/src/tests/fanout-repro.test.ts +179 -0
- package/src/tests/manifest-snapshot.test.ts +17 -1
- package/src/tests/resource.test.ts +93 -0
- package/src/tests/scope.test.ts +29 -0
- package/src/tests/signal.test.ts +108 -0
- package/src/tests/store.test.ts +54 -0
- package/src/tests/vue-parity.test.ts +191 -0
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 |
|
|
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":"
|
|
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__)
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
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())
|
|
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
|
-
|
|
975
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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):
|
|
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
|
-
/**
|
|
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
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
|
-
`${
|
|
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>()
|
package/src/createSelector.ts
CHANGED
|
@@ -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):
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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 {
|
|
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'
|