@pyreon/reactivity 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +185 -5
- package/lib/types/index.d.ts +75 -1
- package/package.json +1 -1
- package/src/computed.ts +13 -2
- package/src/effect.ts +10 -1
- package/src/index.ts +14 -0
- package/src/manifest.ts +46 -0
- package/src/reactive-devtools.ts +281 -0
- package/src/signal.ts +7 -1
- package/src/tests/manifest-snapshot.test.ts +3 -2
- package/src/tests/reactive-devtools-treeshake.test.ts +48 -0
- package/src/tests/reactive-devtools.test.ts +296 -0
|
@@ -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":"c60e3531-1","name":"batch.ts"},{"uid":"c60e3531-3","name":"cell.ts"},{"uid":"c60e3531-5","name":"reactive-devtools.ts"},{"uid":"c60e3531-7","name":"scope.ts"},{"uid":"c60e3531-9","name":"tracking.ts"},{"uid":"c60e3531-11","name":"effect.ts"},{"uid":"c60e3531-13","name":"computed.ts"},{"uid":"c60e3531-15","name":"createSelector.ts"},{"uid":"c60e3531-17","name":"debug.ts"},{"uid":"c60e3531-19","name":"reactive-trace.ts"},{"uid":"c60e3531-21","name":"signal.ts"},{"uid":"c60e3531-23","name":"store.ts"},{"uid":"c60e3531-25","name":"reconcile.ts"},{"uid":"c60e3531-27","name":"resource.ts"},{"uid":"c60e3531-29","name":"watch.ts"},{"uid":"c60e3531-31","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"c60e3531-1":{"renderedLength":3016,"gzipLength":1167,"brotliLength":0,"metaUid":"c60e3531-0"},"c60e3531-3":{"renderedLength":1636,"gzipLength":786,"brotliLength":0,"metaUid":"c60e3531-2"},"c60e3531-5":{"renderedLength":4438,"gzipLength":1940,"brotliLength":0,"metaUid":"c60e3531-4"},"c60e3531-7":{"renderedLength":3026,"gzipLength":1226,"brotliLength":0,"metaUid":"c60e3531-6"},"c60e3531-9":{"renderedLength":2227,"gzipLength":858,"brotliLength":0,"metaUid":"c60e3531-8"},"c60e3531-11":{"renderedLength":7605,"gzipLength":2433,"brotliLength":0,"metaUid":"c60e3531-10"},"c60e3531-13":{"renderedLength":4983,"gzipLength":1524,"brotliLength":0,"metaUid":"c60e3531-12"},"c60e3531-15":{"renderedLength":2244,"gzipLength":981,"brotliLength":0,"metaUid":"c60e3531-14"},"c60e3531-17":{"renderedLength":2469,"gzipLength":1092,"brotliLength":0,"metaUid":"c60e3531-16"},"c60e3531-19":{"renderedLength":2721,"gzipLength":1363,"brotliLength":0,"metaUid":"c60e3531-18"},"c60e3531-21":{"renderedLength":3535,"gzipLength":1513,"brotliLength":0,"metaUid":"c60e3531-20"},"c60e3531-23":{"renderedLength":5232,"gzipLength":1867,"brotliLength":0,"metaUid":"c60e3531-22"},"c60e3531-25":{"renderedLength":2278,"gzipLength":940,"brotliLength":0,"metaUid":"c60e3531-24"},"c60e3531-27":{"renderedLength":1205,"gzipLength":524,"brotliLength":0,"metaUid":"c60e3531-26"},"c60e3531-29":{"renderedLength":1249,"gzipLength":582,"brotliLength":0,"metaUid":"c60e3531-28"},"c60e3531-31":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"c60e3531-30"}},"nodeMetas":{"c60e3531-0":{"id":"/src/batch.ts","moduleParts":{"index.js":"c60e3531-1"},"imported":[],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-12"},{"uid":"c60e3531-20"},{"uid":"c60e3531-8"}]},"c60e3531-2":{"id":"/src/cell.ts","moduleParts":{"index.js":"c60e3531-3"},"imported":[],"importedBy":[{"uid":"c60e3531-30"}]},"c60e3531-4":{"id":"/src/reactive-devtools.ts","moduleParts":{"index.js":"c60e3531-5"},"imported":[],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-12"},{"uid":"c60e3531-10"},{"uid":"c60e3531-20"}]},"c60e3531-6":{"id":"/src/scope.ts","moduleParts":{"index.js":"c60e3531-7"},"imported":[],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-12"},{"uid":"c60e3531-10"}]},"c60e3531-8":{"id":"/src/tracking.ts","moduleParts":{"index.js":"c60e3531-9"},"imported":[{"uid":"c60e3531-0"}],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-12"},{"uid":"c60e3531-14"},{"uid":"c60e3531-10"},{"uid":"c60e3531-26"},{"uid":"c60e3531-20"}]},"c60e3531-10":{"id":"/src/effect.ts","moduleParts":{"index.js":"c60e3531-11"},"imported":[{"uid":"c60e3531-4"},{"uid":"c60e3531-6"},{"uid":"c60e3531-8"}],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-12"},{"uid":"c60e3531-14"},{"uid":"c60e3531-26"},{"uid":"c60e3531-28"}]},"c60e3531-12":{"id":"/src/computed.ts","moduleParts":{"index.js":"c60e3531-13"},"imported":[{"uid":"c60e3531-0"},{"uid":"c60e3531-10"},{"uid":"c60e3531-4"},{"uid":"c60e3531-6"},{"uid":"c60e3531-8"}],"importedBy":[{"uid":"c60e3531-30"}]},"c60e3531-14":{"id":"/src/createSelector.ts","moduleParts":{"index.js":"c60e3531-15"},"imported":[{"uid":"c60e3531-10"},{"uid":"c60e3531-8"}],"importedBy":[{"uid":"c60e3531-30"}]},"c60e3531-16":{"id":"/src/debug.ts","moduleParts":{"index.js":"c60e3531-17"},"imported":[],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-20"}]},"c60e3531-18":{"id":"/src/reactive-trace.ts","moduleParts":{"index.js":"c60e3531-19"},"imported":[],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-20"}]},"c60e3531-20":{"id":"/src/signal.ts","moduleParts":{"index.js":"c60e3531-21"},"imported":[{"uid":"c60e3531-0"},{"uid":"c60e3531-16"},{"uid":"c60e3531-4"},{"uid":"c60e3531-18"},{"uid":"c60e3531-8"}],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-26"},{"uid":"c60e3531-22"}]},"c60e3531-22":{"id":"/src/store.ts","moduleParts":{"index.js":"c60e3531-23"},"imported":[{"uid":"c60e3531-20"}],"importedBy":[{"uid":"c60e3531-30"},{"uid":"c60e3531-24"}]},"c60e3531-24":{"id":"/src/reconcile.ts","moduleParts":{"index.js":"c60e3531-25"},"imported":[{"uid":"c60e3531-22"}],"importedBy":[{"uid":"c60e3531-30"}]},"c60e3531-26":{"id":"/src/resource.ts","moduleParts":{"index.js":"c60e3531-27"},"imported":[{"uid":"c60e3531-10"},{"uid":"c60e3531-20"},{"uid":"c60e3531-8"}],"importedBy":[{"uid":"c60e3531-30"}]},"c60e3531-28":{"id":"/src/watch.ts","moduleParts":{"index.js":"c60e3531-29"},"imported":[{"uid":"c60e3531-10"}],"importedBy":[{"uid":"c60e3531-30"}]},"c60e3531-30":{"id":"/src/index.ts","moduleParts":{"index.js":"c60e3531-31"},"imported":[{"uid":"c60e3531-0"},{"uid":"c60e3531-2"},{"uid":"c60e3531-12"},{"uid":"c60e3531-14"},{"uid":"c60e3531-16"},{"uid":"c60e3531-4"},{"uid":"c60e3531-18"},{"uid":"c60e3531-10"},{"uid":"c60e3531-24"},{"uid":"c60e3531-26"},{"uid":"c60e3531-6"},{"uid":"c60e3531-20"},{"uid":"c60e3531-22"},{"uid":"c60e3531-8"},{"uid":"c60e3531-28"}],"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
|
@@ -149,6 +149,169 @@ function cell(value) {
|
|
|
149
149
|
return new Cell(value);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/reactive-devtools.ts
|
|
154
|
+
let _active = false;
|
|
155
|
+
let _nextId = 1;
|
|
156
|
+
const _byId = /* @__PURE__ */ new Map();
|
|
157
|
+
const _subId = /* @__PURE__ */ new WeakMap();
|
|
158
|
+
/** @internal — finalizer callback; prunes the record when a node is GC'd. */
|
|
159
|
+
function _rdPrune(id) {
|
|
160
|
+
_byId.delete(id);
|
|
161
|
+
}
|
|
162
|
+
const _finalizer = new FinalizationRegistry(_rdPrune);
|
|
163
|
+
const FIRE_CAP = 512;
|
|
164
|
+
let _fireBuf = null;
|
|
165
|
+
let _fireCount = 0;
|
|
166
|
+
const PREVIEW_MAX$1 = 60;
|
|
167
|
+
function preview$1(v) {
|
|
168
|
+
let s;
|
|
169
|
+
try {
|
|
170
|
+
if (v === null) return "null";
|
|
171
|
+
if (v === void 0) return "undefined";
|
|
172
|
+
const t = typeof v;
|
|
173
|
+
if (t === "string") s = JSON.stringify(v);
|
|
174
|
+
else if (t === "number" || t === "boolean" || t === "bigint") s = String(v);
|
|
175
|
+
else if (t === "function") s = `[Function ${v.name || "anonymous"}]`;
|
|
176
|
+
else if (t === "symbol") s = v.toString();
|
|
177
|
+
else if (Array.isArray(v)) s = `Array(${v.length})`;
|
|
178
|
+
else {
|
|
179
|
+
const ctor = v.constructor?.name;
|
|
180
|
+
let keys = [];
|
|
181
|
+
try {
|
|
182
|
+
keys = Object.keys(v).slice(0, 3);
|
|
183
|
+
} catch {
|
|
184
|
+
keys = [];
|
|
185
|
+
}
|
|
186
|
+
s = `${ctor && ctor !== "Object" ? `${ctor} ` : ""}{${keys.join(", ")}${keys.length === 3 ? ", …" : ""}}`;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
s = "[unstringifiable]";
|
|
190
|
+
}
|
|
191
|
+
return s.length > PREVIEW_MAX$1 ? `${s.slice(0, PREVIEW_MAX$1)}…` : s;
|
|
192
|
+
}
|
|
193
|
+
/** Activate the bridge. Idempotent. Called when a devtools client attaches. */
|
|
194
|
+
function activateReactiveDevtools() {
|
|
195
|
+
_active = true;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Deactivate + drop all retained state. Called when the devtools client
|
|
199
|
+
* disconnects so a closed panel leaves zero residue.
|
|
200
|
+
*/
|
|
201
|
+
function deactivateReactiveDevtools() {
|
|
202
|
+
_active = false;
|
|
203
|
+
_byId.clear();
|
|
204
|
+
_fireBuf = null;
|
|
205
|
+
_fireCount = 0;
|
|
206
|
+
}
|
|
207
|
+
function isReactiveDevtoolsActive() {
|
|
208
|
+
return _active;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Register a signal/computed/effect node. `host` is the object carrying
|
|
212
|
+
* the `_s` subscriber Set (the signal read fn itself, or a computed's
|
|
213
|
+
* internal host). `sub` is the notify closure (`recompute`/`run`) whose
|
|
214
|
+
* identity appears in upstream `_s` Sets — used to resolve edges.
|
|
215
|
+
*
|
|
216
|
+
* @internal
|
|
217
|
+
*/
|
|
218
|
+
function _rdRegister(node, kind, host, sub, label) {
|
|
219
|
+
if (!_active) return void 0;
|
|
220
|
+
const id = _nextId++;
|
|
221
|
+
_byId.set(id, {
|
|
222
|
+
id,
|
|
223
|
+
kind,
|
|
224
|
+
name: label ?? `${kind === "signal" ? "signal" : kind}#${id}`,
|
|
225
|
+
ref: new WeakRef(node),
|
|
226
|
+
hostRef: host ? new WeakRef(host) : null,
|
|
227
|
+
fires: 0,
|
|
228
|
+
lastFire: null
|
|
229
|
+
});
|
|
230
|
+
if (sub) _subId.set(sub, id);
|
|
231
|
+
_finalizer.register(node, id);
|
|
232
|
+
Object.defineProperty(node, "__pxRdId", {
|
|
233
|
+
value: id,
|
|
234
|
+
enumerable: false,
|
|
235
|
+
configurable: true
|
|
236
|
+
});
|
|
237
|
+
return id;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Record that a node fired (signal write / computed recompute / effect
|
|
241
|
+
* run). Bumps counters + appends to the bounded fire buffer.
|
|
242
|
+
*
|
|
243
|
+
* @internal
|
|
244
|
+
*/
|
|
245
|
+
function _rdRecordFire(node) {
|
|
246
|
+
if (!_active) return;
|
|
247
|
+
const id = node.__pxRdId;
|
|
248
|
+
if (id === void 0) return;
|
|
249
|
+
const rec = _byId.get(id);
|
|
250
|
+
const ts = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
251
|
+
if (rec) {
|
|
252
|
+
rec.fires++;
|
|
253
|
+
rec.lastFire = ts;
|
|
254
|
+
}
|
|
255
|
+
if (_fireBuf === null) _fireBuf = new Array(FIRE_CAP);
|
|
256
|
+
_fireBuf[_fireCount % FIRE_CAP] = {
|
|
257
|
+
id,
|
|
258
|
+
ts
|
|
259
|
+
};
|
|
260
|
+
_fireCount++;
|
|
261
|
+
}
|
|
262
|
+
function resolveSubId(sub) {
|
|
263
|
+
const direct = sub.__pxRdId;
|
|
264
|
+
if (direct !== void 0) return direct;
|
|
265
|
+
return _subId.get(sub);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Fresh snapshot of the live reactive graph. Edges are recomputed from
|
|
269
|
+
* each live node's current subscriber Set — always consistent with the
|
|
270
|
+
* framework's real subscription state, no incremental drift.
|
|
271
|
+
*/
|
|
272
|
+
function getReactiveGraph() {
|
|
273
|
+
const nodes = [];
|
|
274
|
+
const edges = [];
|
|
275
|
+
for (const rec of _byId.values()) {
|
|
276
|
+
const node = rec.ref.deref();
|
|
277
|
+
if (!node) continue;
|
|
278
|
+
const subs = (rec.hostRef?.deref() ?? null)?._s ?? null;
|
|
279
|
+
const valueStr = rec.kind === "effect" ? "" : preview$1(node._v);
|
|
280
|
+
nodes.push({
|
|
281
|
+
id: rec.id,
|
|
282
|
+
kind: rec.kind,
|
|
283
|
+
name: rec.name,
|
|
284
|
+
value: valueStr,
|
|
285
|
+
subscribers: subs?.size ?? 0,
|
|
286
|
+
fires: rec.fires,
|
|
287
|
+
lastFire: rec.lastFire
|
|
288
|
+
});
|
|
289
|
+
if (subs) for (const cb of subs) {
|
|
290
|
+
const to = resolveSubId(cb);
|
|
291
|
+
if (to !== void 0) edges.push({
|
|
292
|
+
from: rec.id,
|
|
293
|
+
to
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
nodes,
|
|
299
|
+
edges
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** Bounded recent-fire timeline (oldest → newest). Fresh copy. */
|
|
303
|
+
function getReactiveFires() {
|
|
304
|
+
if (_fireBuf === null || _fireCount === 0) return [];
|
|
305
|
+
if (_fireCount <= FIRE_CAP) return _fireBuf.slice(0, _fireCount);
|
|
306
|
+
const start = _fireCount % FIRE_CAP;
|
|
307
|
+
const out = [];
|
|
308
|
+
for (let i = 0; i < FIRE_CAP; i++) {
|
|
309
|
+
const e = _fireBuf[(start + i) % FIRE_CAP];
|
|
310
|
+
if (e) out.push(e);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
152
315
|
//#endregion
|
|
153
316
|
//#region src/scope.ts
|
|
154
317
|
var EffectScope = class {
|
|
@@ -437,7 +600,10 @@ function effect(fn) {
|
|
|
437
600
|
};
|
|
438
601
|
const run = () => {
|
|
439
602
|
if (disposed) return;
|
|
440
|
-
if (process.env.NODE_ENV !== "production")
|
|
603
|
+
if (process.env.NODE_ENV !== "production") {
|
|
604
|
+
_countSink$2.__pyreon_count__?.("reactivity.effectRun");
|
|
605
|
+
_rdRecordFire(run);
|
|
606
|
+
}
|
|
441
607
|
runCleanup();
|
|
442
608
|
const outerCollector = _innerEffectCollector;
|
|
443
609
|
const myInners = [];
|
|
@@ -462,6 +628,7 @@ function effect(fn) {
|
|
|
462
628
|
if (!isFirstRun) scope?.notifyEffectRan();
|
|
463
629
|
isFirstRun = false;
|
|
464
630
|
};
|
|
631
|
+
if (process.env.NODE_ENV !== "production") _rdRegister(run, "effect", null, run, void 0);
|
|
465
632
|
run();
|
|
466
633
|
const e = { dispose() {
|
|
467
634
|
runCleanup();
|
|
@@ -560,6 +727,7 @@ function renderEffect(fn) {
|
|
|
560
727
|
}
|
|
561
728
|
} else renderEffectFullTrack(deps, run, trackedFn);
|
|
562
729
|
};
|
|
730
|
+
if (process.env.NODE_ENV !== "production") _rdRegister(run, "effect", null, run, void 0);
|
|
563
731
|
run();
|
|
564
732
|
const dispose = () => {
|
|
565
733
|
if (disposed) return;
|
|
@@ -621,7 +789,10 @@ function computedLazy(fn) {
|
|
|
621
789
|
const read = () => {
|
|
622
790
|
trackSubscriber(host);
|
|
623
791
|
if (dirty) {
|
|
624
|
-
if (process.env.NODE_ENV !== "production")
|
|
792
|
+
if (process.env.NODE_ENV !== "production") {
|
|
793
|
+
_countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
|
|
794
|
+
_rdRecordFire(read);
|
|
795
|
+
}
|
|
625
796
|
try {
|
|
626
797
|
if (tracked) {
|
|
627
798
|
setSkipDepsCollection(true);
|
|
@@ -661,6 +832,7 @@ function computedLazy(fn) {
|
|
|
661
832
|
set.delete(updater);
|
|
662
833
|
};
|
|
663
834
|
};
|
|
835
|
+
if (process.env.NODE_ENV !== "production") _rdRegister(read, "derived", host, recompute, void 0);
|
|
664
836
|
getCurrentScope()?.add({ dispose: read.dispose });
|
|
665
837
|
return read;
|
|
666
838
|
}
|
|
@@ -680,7 +852,10 @@ function computedWithEquals(fn, equals) {
|
|
|
680
852
|
let directFns = null;
|
|
681
853
|
const recompute = () => {
|
|
682
854
|
if (disposed) return;
|
|
683
|
-
if (process.env.NODE_ENV !== "production")
|
|
855
|
+
if (process.env.NODE_ENV !== "production") {
|
|
856
|
+
_countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
|
|
857
|
+
_rdRecordFire(read);
|
|
858
|
+
}
|
|
684
859
|
cleanupLocalDeps(deps, recompute);
|
|
685
860
|
try {
|
|
686
861
|
const next = trackWithLocalDeps(deps, recompute, fn);
|
|
@@ -735,6 +910,7 @@ function computedWithEquals(fn, equals) {
|
|
|
735
910
|
set.delete(updater);
|
|
736
911
|
};
|
|
737
912
|
};
|
|
913
|
+
if (process.env.NODE_ENV !== "production") _rdRegister(read, "derived", host, recompute, void 0);
|
|
738
914
|
getCurrentScope()?.add({ dispose: read.dispose });
|
|
739
915
|
return read;
|
|
740
916
|
}
|
|
@@ -1013,7 +1189,10 @@ function _set(newValue) {
|
|
|
1013
1189
|
if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("reactivity.signalWrite");
|
|
1014
1190
|
const prev = this._v;
|
|
1015
1191
|
this._v = newValue;
|
|
1016
|
-
if (process.env.NODE_ENV !== "production")
|
|
1192
|
+
if (process.env.NODE_ENV !== "production") {
|
|
1193
|
+
_recordSignalWrite(this.label, prev, newValue);
|
|
1194
|
+
_rdRecordFire(this);
|
|
1195
|
+
}
|
|
1017
1196
|
if (isTracing()) try {
|
|
1018
1197
|
_notifyTraceListeners(this, prev, newValue);
|
|
1019
1198
|
} catch (err) {
|
|
@@ -1096,6 +1275,7 @@ function signal(initialValue, options) {
|
|
|
1096
1275
|
read.direct = _directFn;
|
|
1097
1276
|
read.debug = _debug;
|
|
1098
1277
|
read.label = options?.name;
|
|
1278
|
+
if (process.env.NODE_ENV !== "production") _rdRegister(read, "signal", read, null, read.label);
|
|
1099
1279
|
return read;
|
|
1100
1280
|
}
|
|
1101
1281
|
|
|
@@ -1416,5 +1596,5 @@ function watch(source, callback, opts = {}) {
|
|
|
1416
1596
|
}
|
|
1417
1597
|
|
|
1418
1598
|
//#endregion
|
|
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 };
|
|
1599
|
+
export { Cell, EffectScope, _bind, activateReactiveDevtools, batch, cell, clearReactiveTrace, computed, createResource, createSelector, createStore, deactivateReactiveDevtools, effect, effectScope, getCurrentScope, getReactiveFires, getReactiveGraph, getReactiveTrace, inspectSignal, isReactiveDevtoolsActive, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
|
|
1420
1600
|
//# sourceMappingURL=index.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -209,6 +209,80 @@ declare function why(): void;
|
|
|
209
209
|
*/
|
|
210
210
|
declare function inspectSignal<T>(sig: Signal<T>): SignalDebugInfo<T>;
|
|
211
211
|
//#endregion
|
|
212
|
+
//#region src/reactive-devtools.d.ts
|
|
213
|
+
/**
|
|
214
|
+
* Reactive devtools bridge — an OPT-IN, leak-free introspection layer
|
|
215
|
+
* over the live signal / computed / effect graph.
|
|
216
|
+
*
|
|
217
|
+
* Powers the `@pyreon/devtools` Signals / Graph / Effects / Console
|
|
218
|
+
* surfaces. Design constraints (mirroring `reactive-trace.ts`):
|
|
219
|
+
*
|
|
220
|
+
* - **Zero cost until attached.** Every instrumentation entry point
|
|
221
|
+
* early-returns on `!_active`. The registry is empty and no work
|
|
222
|
+
* happens until a devtools client calls `activateReactiveDevtools()`.
|
|
223
|
+
* The single call site per creation/track sits inside the existing
|
|
224
|
+
* `process.env.NODE_ENV !== 'production'` gate (tree-shaken in prod)
|
|
225
|
+
* and is structurally identical to the perf-harness counter calls
|
|
226
|
+
* and `_recordSignalWrite` already on those paths.
|
|
227
|
+
* - **No retention / no leak.** Nodes are held via `WeakRef` and
|
|
228
|
+
* pruned by a `FinalizationRegistry`. The registry never pins a
|
|
229
|
+
* signal/computed/effect alive. Edges + the fire ring buffer hold
|
|
230
|
+
* only numeric ids and primitives, never node references or values.
|
|
231
|
+
* - **Snapshot on demand.** `getReactiveGraph()` recomputes the edge
|
|
232
|
+
* set fresh from the live subscriber Sets — no incremental
|
|
233
|
+
* bookkeeping to drift out of sync with `cleanupEffect`.
|
|
234
|
+
*
|
|
235
|
+
* Names: signals carry `.label` (set explicitly or by the vite plugin's
|
|
236
|
+
* dev auto-naming). Computeds/effects have no name in the framework, so
|
|
237
|
+
* they get a stable synthetic label (`derived#12` / `effect#7`).
|
|
238
|
+
*/
|
|
239
|
+
type ReactiveNodeKind = 'signal' | 'derived' | 'effect';
|
|
240
|
+
interface ReactiveNode {
|
|
241
|
+
id: number;
|
|
242
|
+
kind: ReactiveNodeKind;
|
|
243
|
+
/** Explicit `.label` for signals; synthetic (`derived#id`) otherwise. */
|
|
244
|
+
name: string;
|
|
245
|
+
/** Bounded string preview of the current value (signals/derived only). */
|
|
246
|
+
value: string;
|
|
247
|
+
/** Live downstream subscriber count. */
|
|
248
|
+
subscribers: number;
|
|
249
|
+
/** Total times this node has fired/recomputed since activation. */
|
|
250
|
+
fires: number;
|
|
251
|
+
/** `performance.now()` of the most recent fire, or null. */
|
|
252
|
+
lastFire: number | null;
|
|
253
|
+
}
|
|
254
|
+
interface ReactiveEdge {
|
|
255
|
+
/** Source node id (the reactive value being read). */
|
|
256
|
+
from: number;
|
|
257
|
+
/** Subscriber node id (the computed/effect that read it). */
|
|
258
|
+
to: number;
|
|
259
|
+
}
|
|
260
|
+
interface ReactiveGraph {
|
|
261
|
+
nodes: ReactiveNode[];
|
|
262
|
+
edges: ReactiveEdge[];
|
|
263
|
+
}
|
|
264
|
+
interface ReactiveFire {
|
|
265
|
+
id: number;
|
|
266
|
+
/** `performance.now()` at fire time. */
|
|
267
|
+
ts: number;
|
|
268
|
+
}
|
|
269
|
+
/** Activate the bridge. Idempotent. Called when a devtools client attaches. */
|
|
270
|
+
declare function activateReactiveDevtools(): void;
|
|
271
|
+
/**
|
|
272
|
+
* Deactivate + drop all retained state. Called when the devtools client
|
|
273
|
+
* disconnects so a closed panel leaves zero residue.
|
|
274
|
+
*/
|
|
275
|
+
declare function deactivateReactiveDevtools(): void;
|
|
276
|
+
declare function isReactiveDevtoolsActive(): boolean;
|
|
277
|
+
/**
|
|
278
|
+
* Fresh snapshot of the live reactive graph. Edges are recomputed from
|
|
279
|
+
* each live node's current subscriber Set — always consistent with the
|
|
280
|
+
* framework's real subscription state, no incremental drift.
|
|
281
|
+
*/
|
|
282
|
+
declare function getReactiveGraph(): ReactiveGraph;
|
|
283
|
+
/** Bounded recent-fire timeline (oldest → newest). Fresh copy. */
|
|
284
|
+
declare function getReactiveFires(): ReactiveFire[];
|
|
285
|
+
//#endregion
|
|
212
286
|
//#region src/reactive-trace.d.ts
|
|
213
287
|
/**
|
|
214
288
|
* Reactive trace — a bounded, dev-only ring buffer of recent signal
|
|
@@ -515,5 +589,5 @@ interface WatchOptions {
|
|
|
515
589
|
*/
|
|
516
590
|
declare function watch<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void), opts?: WatchOptions): () => void;
|
|
517
591
|
//#endregion
|
|
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 };
|
|
592
|
+
export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReactiveEdge, type ReactiveFire, type ReactiveGraph, type ReactiveNode, type ReactiveNodeKind, type ReactiveSnapshotCapture, type ReactiveTraceEntry, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, activateReactiveDevtools, batch, cell, clearReactiveTrace, computed, createResource, createSelector, createStore, deactivateReactiveDevtools, effect, effectScope, getCurrentScope, getReactiveFires, getReactiveGraph, getReactiveTrace, inspectSignal, isReactiveDevtoolsActive, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
|
|
519
593
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
package/src/computed.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { _markRecompute } from './batch'
|
|
2
2
|
import { _errorHandler } from './effect'
|
|
3
|
+
import { _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
3
4
|
import { getCurrentScope } from './scope'
|
|
4
5
|
import {
|
|
5
6
|
cleanupEffect,
|
|
@@ -108,8 +109,10 @@ function computedLazy<T>(fn: () => T): Computed<T> {
|
|
|
108
109
|
const read = (): T => {
|
|
109
110
|
trackSubscriber(host)
|
|
110
111
|
if (dirty) {
|
|
111
|
-
if (process.env.NODE_ENV !== 'production')
|
|
112
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
112
113
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
114
|
+
_rdRecordFire(read)
|
|
115
|
+
}
|
|
113
116
|
try {
|
|
114
117
|
if (tracked) {
|
|
115
118
|
// Deps already established from first run — skip adding to
|
|
@@ -161,6 +164,9 @@ function computedLazy<T>(fn: () => T): Computed<T> {
|
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
166
|
|
|
167
|
+
if (process.env.NODE_ENV !== 'production')
|
|
168
|
+
_rdRegister(read, 'derived', host, recompute, undefined)
|
|
169
|
+
|
|
164
170
|
getCurrentScope()?.add({ dispose: read.dispose })
|
|
165
171
|
return read as Computed<T>
|
|
166
172
|
}
|
|
@@ -190,8 +196,10 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
190
196
|
|
|
191
197
|
const recompute = () => {
|
|
192
198
|
if (disposed) return
|
|
193
|
-
if (process.env.NODE_ENV !== 'production')
|
|
199
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
194
200
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
201
|
+
_rdRecordFire(read)
|
|
202
|
+
}
|
|
195
203
|
cleanupLocalDeps(deps, recompute)
|
|
196
204
|
try {
|
|
197
205
|
const next = trackWithLocalDeps(deps, recompute, fn)
|
|
@@ -256,6 +264,9 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
256
264
|
}
|
|
257
265
|
}
|
|
258
266
|
|
|
267
|
+
if (process.env.NODE_ENV !== 'production')
|
|
268
|
+
_rdRegister(read, 'derived', host, recompute, undefined)
|
|
269
|
+
|
|
259
270
|
getCurrentScope()?.add({ dispose: read.dispose })
|
|
260
271
|
return read as Computed<T>
|
|
261
272
|
}
|
package/src/effect.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
1
2
|
import { getCurrentScope } from './scope'
|
|
2
3
|
import { _restoreActiveEffect, _setActiveEffect, setDepsCollector, withTracking } from './tracking'
|
|
3
4
|
|
|
@@ -211,8 +212,10 @@ export function effect(fn: () => (() => void) | void): Effect {
|
|
|
211
212
|
|
|
212
213
|
const run = () => {
|
|
213
214
|
if (disposed) return
|
|
214
|
-
if (process.env.NODE_ENV !== 'production')
|
|
215
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
215
216
|
_countSink.__pyreon_count__?.('reactivity.effectRun')
|
|
217
|
+
_rdRecordFire(run)
|
|
218
|
+
}
|
|
216
219
|
// Run previous cleanup before re-running
|
|
217
220
|
runCleanup()
|
|
218
221
|
// Start a new inner-effect collection window. Effects created during
|
|
@@ -254,6 +257,9 @@ export function effect(fn: () => (() => void) | void): Effect {
|
|
|
254
257
|
isFirstRun = false
|
|
255
258
|
}
|
|
256
259
|
|
|
260
|
+
if (process.env.NODE_ENV !== 'production')
|
|
261
|
+
_rdRegister(run, 'effect', null, run, undefined)
|
|
262
|
+
|
|
257
263
|
run()
|
|
258
264
|
|
|
259
265
|
const e: Effect = {
|
|
@@ -409,6 +415,9 @@ export function renderEffect(fn: () => void): () => void {
|
|
|
409
415
|
}
|
|
410
416
|
}
|
|
411
417
|
|
|
418
|
+
if (process.env.NODE_ENV !== 'production')
|
|
419
|
+
_rdRegister(run, 'effect', null, run, undefined)
|
|
420
|
+
|
|
412
421
|
run()
|
|
413
422
|
|
|
414
423
|
const dispose = () => {
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,20 @@ 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 {
|
|
9
|
+
ReactiveEdge,
|
|
10
|
+
ReactiveFire,
|
|
11
|
+
ReactiveGraph,
|
|
12
|
+
ReactiveNode,
|
|
13
|
+
ReactiveNodeKind,
|
|
14
|
+
} from './reactive-devtools'
|
|
15
|
+
export {
|
|
16
|
+
activateReactiveDevtools,
|
|
17
|
+
deactivateReactiveDevtools,
|
|
18
|
+
getReactiveFires,
|
|
19
|
+
getReactiveGraph,
|
|
20
|
+
isReactiveDevtoolsActive,
|
|
21
|
+
} from './reactive-devtools'
|
|
8
22
|
export type { ReactiveTraceEntry } from './reactive-trace'
|
|
9
23
|
export { clearReactiveTrace, getReactiveTrace } from './reactive-trace'
|
|
10
24
|
export {
|
package/src/manifest.ts
CHANGED
|
@@ -596,6 +596,52 @@ count.set(101) // logs/reports via handler instead of crashing`,
|
|
|
596
596
|
],
|
|
597
597
|
seeAlso: ['effect', 'renderEffect'],
|
|
598
598
|
},
|
|
599
|
+
{
|
|
600
|
+
name: 'activateReactiveDevtools',
|
|
601
|
+
kind: 'function',
|
|
602
|
+
signature:
|
|
603
|
+
'activateReactiveDevtools(): void · deactivateReactiveDevtools(): void · isReactiveDevtoolsActive(): boolean',
|
|
604
|
+
summary:
|
|
605
|
+
'Opt-in lifecycle for the reactive-devtools bridge — the live signal/computed/effect graph the `@pyreon/devtools` Signals/Graph/Effects/Profiler tabs consume (surfaced on the browser hook as `window.__PYREON_DEVTOOLS__.reactive`). **Zero cost until activated**: every per-primitive instrumentation point early-returns on the inactive flag and sits inside the production dead-code gate, so it tree-shakes out of prod builds entirely (locked by a minified-bundle test) and, in dev, costs one predicted-false branch until a devtools client calls `activate()` — the same risk profile as the adjacent reactive-trace / perf-harness calls. `deactivate()` drops all retained registry + fire-buffer state (a closed panel leaves zero residue). Leak-free by construction: nodes are held via `WeakRef` + `FinalizationRegistry`, never pinned.',
|
|
606
|
+
example: `import { activateReactiveDevtools, getReactiveGraph } from '@pyreon/reactivity'
|
|
607
|
+
|
|
608
|
+
// Only AFTER activation are subsequently-created signals tracked.
|
|
609
|
+
activateReactiveDevtools()
|
|
610
|
+
const price = signal(10, { name: '$price' })
|
|
611
|
+
const total = computed(() => price() * 2)
|
|
612
|
+
effect(() => total())
|
|
613
|
+
getReactiveGraph().nodes // → [$price (signal), derived, effect]
|
|
614
|
+
deactivateReactiveDevtools() // → registry cleared`,
|
|
615
|
+
mistakes: [
|
|
616
|
+
'Expecting nodes created BEFORE `activate()` to appear — registration is gated on the active flag (mirrors a devtools panel attaching). Activate first, then build/observe the graph',
|
|
617
|
+
'Calling it in production for app logic — the whole bridge is dev-gated and tree-shaken; `getReactiveGraph()` returns an empty graph in prod builds',
|
|
618
|
+
'Assuming it tracks compiler-emitted DOM bindings — only user `signal()` / `computed()` / `effect()` are registered; `renderEffect` / `_bind` plumbing is intentionally excluded (it would flood the graph and tax the hottest path)',
|
|
619
|
+
],
|
|
620
|
+
seeAlso: ['getReactiveGraph', 'onSignalUpdate', 'getReactiveTrace'],
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: 'getReactiveGraph',
|
|
624
|
+
kind: 'function',
|
|
625
|
+
signature:
|
|
626
|
+
'getReactiveGraph(): { nodes: ReactiveNode[]; edges: { from: number; to: number }[] } · getReactiveFires(): { id: number; ts: number }[]',
|
|
627
|
+
summary:
|
|
628
|
+
'Fresh snapshot of the live reactive graph + a bounded recent-fire timeline, for the reactive-devtools tabs. `getReactiveGraph()` returns every tracked node (`{ id, kind: "signal"|"derived"|"effect", name, value, subscribers, fires, lastFire }`) plus dependency edges recomputed on demand from the real subscriber `_s` Sets (source → subscriber: signal→derived, derived→effect) — always consistent with the framework’s actual subscription state, no incremental drift. `getReactiveFires()` returns a fixed-size ring buffer of recent fires (`{ id, ts }`, oldest → newest) powering the Effects/Profiler tabs. Both require `activateReactiveDevtools()` first and return empty otherwise. Names come from `signal(v, { name })` / the vite-plugin dev auto-naming; anonymous computeds/effects get a synthetic `derived#id` / `effect#id`.',
|
|
629
|
+
example: `activateReactiveDevtools()
|
|
630
|
+
const a = signal(1, { name: '$a' })
|
|
631
|
+
const b = computed(() => a() + 1)
|
|
632
|
+
effect(() => b())
|
|
633
|
+
a.set(2)
|
|
634
|
+
getReactiveGraph()
|
|
635
|
+
// nodes: [{ name:'$a', kind:'signal', value:'2', … }, { kind:'derived', … }, { kind:'effect', … }]
|
|
636
|
+
// edges: [{ from:$a, to:derived }, { from:derived, to:effect }]
|
|
637
|
+
getReactiveFires() // → [{ id, ts }, …] (bounded, chronological)`,
|
|
638
|
+
mistakes: [
|
|
639
|
+
'Holding the returned arrays expecting them to update — they are point-in-time snapshots; call again (the devtools panel polls)',
|
|
640
|
+
'Reading `node.value` for non-string state as the real value — it is a bounded, safely-stringified PREVIEW (never a raw ref — no pinning). Inspect the signal directly for the live value',
|
|
641
|
+
'Expecting fires for every write in a long-running app — `getReactiveFires()` is a fixed-size ring; older entries roll off',
|
|
642
|
+
],
|
|
643
|
+
seeAlso: ['activateReactiveDevtools', 'getReactiveTrace', 'onSignalUpdate'],
|
|
644
|
+
},
|
|
599
645
|
],
|
|
600
646
|
gotchas: [
|
|
601
647
|
{
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive devtools bridge — an OPT-IN, leak-free introspection layer
|
|
3
|
+
* over the live signal / computed / effect graph.
|
|
4
|
+
*
|
|
5
|
+
* Powers the `@pyreon/devtools` Signals / Graph / Effects / Console
|
|
6
|
+
* surfaces. Design constraints (mirroring `reactive-trace.ts`):
|
|
7
|
+
*
|
|
8
|
+
* - **Zero cost until attached.** Every instrumentation entry point
|
|
9
|
+
* early-returns on `!_active`. The registry is empty and no work
|
|
10
|
+
* happens until a devtools client calls `activateReactiveDevtools()`.
|
|
11
|
+
* The single call site per creation/track sits inside the existing
|
|
12
|
+
* `process.env.NODE_ENV !== 'production'` gate (tree-shaken in prod)
|
|
13
|
+
* and is structurally identical to the perf-harness counter calls
|
|
14
|
+
* and `_recordSignalWrite` already on those paths.
|
|
15
|
+
* - **No retention / no leak.** Nodes are held via `WeakRef` and
|
|
16
|
+
* pruned by a `FinalizationRegistry`. The registry never pins a
|
|
17
|
+
* signal/computed/effect alive. Edges + the fire ring buffer hold
|
|
18
|
+
* only numeric ids and primitives, never node references or values.
|
|
19
|
+
* - **Snapshot on demand.** `getReactiveGraph()` recomputes the edge
|
|
20
|
+
* set fresh from the live subscriber Sets — no incremental
|
|
21
|
+
* bookkeeping to drift out of sync with `cleanupEffect`.
|
|
22
|
+
*
|
|
23
|
+
* Names: signals carry `.label` (set explicitly or by the vite plugin's
|
|
24
|
+
* dev auto-naming). Computeds/effects have no name in the framework, so
|
|
25
|
+
* they get a stable synthetic label (`derived#12` / `effect#7`).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type ReactiveNodeKind = 'signal' | 'derived' | 'effect'
|
|
29
|
+
|
|
30
|
+
export interface ReactiveNode {
|
|
31
|
+
id: number
|
|
32
|
+
kind: ReactiveNodeKind
|
|
33
|
+
/** Explicit `.label` for signals; synthetic (`derived#id`) otherwise. */
|
|
34
|
+
name: string
|
|
35
|
+
/** Bounded string preview of the current value (signals/derived only). */
|
|
36
|
+
value: string
|
|
37
|
+
/** Live downstream subscriber count. */
|
|
38
|
+
subscribers: number
|
|
39
|
+
/** Total times this node has fired/recomputed since activation. */
|
|
40
|
+
fires: number
|
|
41
|
+
/** `performance.now()` of the most recent fire, or null. */
|
|
42
|
+
lastFire: number | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ReactiveEdge {
|
|
46
|
+
/** Source node id (the reactive value being read). */
|
|
47
|
+
from: number
|
|
48
|
+
/** Subscriber node id (the computed/effect that read it). */
|
|
49
|
+
to: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ReactiveGraph {
|
|
53
|
+
nodes: ReactiveNode[]
|
|
54
|
+
edges: ReactiveEdge[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ReactiveFire {
|
|
58
|
+
id: number
|
|
59
|
+
/** `performance.now()` at fire time. */
|
|
60
|
+
ts: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Internal node record ─────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
interface NodeRec {
|
|
66
|
+
id: number
|
|
67
|
+
kind: ReactiveNodeKind
|
|
68
|
+
name: string
|
|
69
|
+
/** Weak handle to the read fn (signal/computed) — never pins the node. */
|
|
70
|
+
ref: WeakRef<object>
|
|
71
|
+
/** Weak handle to the subscriber-set host (signal read fn / computed host). */
|
|
72
|
+
hostRef: WeakRef<{ _s: Set<() => void> | null }> | null
|
|
73
|
+
fires: number
|
|
74
|
+
lastFire: number | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let _active = false
|
|
78
|
+
let _nextId = 1
|
|
79
|
+
// id → record. Records are pruned by the FinalizationRegistry the moment
|
|
80
|
+
// the underlying node is GC'd, so this Map never retains a dead node.
|
|
81
|
+
const _byId = new Map<number, NodeRec>()
|
|
82
|
+
// Subscriber-callback identity → node id. Lets `getReactiveGraph()`
|
|
83
|
+
// resolve `_s` Set membership (anonymous `recompute`/`run` closures)
|
|
84
|
+
// back to graph nodes for edge extraction. A WeakMap so a disposed
|
|
85
|
+
// effect's closure doesn't keep its id mapping alive.
|
|
86
|
+
const _subId = new WeakMap<object, number>()
|
|
87
|
+
|
|
88
|
+
/** @internal — finalizer callback; prunes the record when a node is GC'd. */
|
|
89
|
+
export function _rdPrune(id: number): void {
|
|
90
|
+
_byId.delete(id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// FinalizationRegistry is baseline since Node 14.6 / all modern browsers
|
|
94
|
+
// / Bun — the same universal-availability assumption the codebase already
|
|
95
|
+
// makes for WeakRef. No env guard (avoids an uncoverable dead branch).
|
|
96
|
+
const _finalizer = new FinalizationRegistry<number>(_rdPrune)
|
|
97
|
+
|
|
98
|
+
// Bounded fire ring buffer (Effects timeline). Same shape/rationale as
|
|
99
|
+
// reactive-trace.ts — fixed cap, primitives only, never grows.
|
|
100
|
+
const FIRE_CAP = 512
|
|
101
|
+
let _fireBuf: ReactiveFire[] | null = null
|
|
102
|
+
let _fireCount = 0
|
|
103
|
+
|
|
104
|
+
const PREVIEW_MAX = 60
|
|
105
|
+
|
|
106
|
+
function preview(v: unknown): string {
|
|
107
|
+
let s: string
|
|
108
|
+
try {
|
|
109
|
+
if (v === null) return 'null'
|
|
110
|
+
if (v === undefined) return 'undefined'
|
|
111
|
+
const t = typeof v
|
|
112
|
+
if (t === 'string') s = JSON.stringify(v) as string
|
|
113
|
+
else if (t === 'number' || t === 'boolean' || t === 'bigint') s = String(v)
|
|
114
|
+
else if (t === 'function')
|
|
115
|
+
s = `[Function ${(v as { name?: string }).name || 'anonymous'}]`
|
|
116
|
+
else if (t === 'symbol') s = (v as symbol).toString()
|
|
117
|
+
else if (Array.isArray(v)) s = `Array(${(v as unknown[]).length})`
|
|
118
|
+
else {
|
|
119
|
+
const ctor = (v as { constructor?: { name?: string } }).constructor?.name
|
|
120
|
+
let keys: string[] = []
|
|
121
|
+
try {
|
|
122
|
+
keys = Object.keys(v as object).slice(0, 3)
|
|
123
|
+
} catch {
|
|
124
|
+
keys = []
|
|
125
|
+
}
|
|
126
|
+
s = `${ctor && ctor !== 'Object' ? `${ctor} ` : ''}{${keys.join(', ')}${keys.length === 3 ? ', …' : ''}}`
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
s = '[unstringifiable]'
|
|
130
|
+
}
|
|
131
|
+
return s.length > PREVIEW_MAX ? `${s.slice(0, PREVIEW_MAX)}…` : s
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Activate the bridge. Idempotent. Called when a devtools client attaches. */
|
|
135
|
+
export function activateReactiveDevtools(): void {
|
|
136
|
+
_active = true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Deactivate + drop all retained state. Called when the devtools client
|
|
141
|
+
* disconnects so a closed panel leaves zero residue.
|
|
142
|
+
*/
|
|
143
|
+
export function deactivateReactiveDevtools(): void {
|
|
144
|
+
_active = false
|
|
145
|
+
_byId.clear()
|
|
146
|
+
_fireBuf = null
|
|
147
|
+
_fireCount = 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isReactiveDevtoolsActive(): boolean {
|
|
151
|
+
return _active
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Instrumentation entry points (called from the hot paths, but only
|
|
155
|
+
// after the existing prod gate; each is a no-op until activated) ──────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Register a signal/computed/effect node. `host` is the object carrying
|
|
159
|
+
* the `_s` subscriber Set (the signal read fn itself, or a computed's
|
|
160
|
+
* internal host). `sub` is the notify closure (`recompute`/`run`) whose
|
|
161
|
+
* identity appears in upstream `_s` Sets — used to resolve edges.
|
|
162
|
+
*
|
|
163
|
+
* @internal
|
|
164
|
+
*/
|
|
165
|
+
export function _rdRegister(
|
|
166
|
+
node: object,
|
|
167
|
+
kind: ReactiveNodeKind,
|
|
168
|
+
host: { _s: Set<() => void> | null } | null,
|
|
169
|
+
sub: object | null,
|
|
170
|
+
label: string | undefined,
|
|
171
|
+
): number | undefined {
|
|
172
|
+
if (!_active) return undefined
|
|
173
|
+
const id = _nextId++
|
|
174
|
+
_byId.set(id, {
|
|
175
|
+
id,
|
|
176
|
+
kind,
|
|
177
|
+
name: label ?? `${kind === 'signal' ? 'signal' : kind}#${id}`,
|
|
178
|
+
ref: new WeakRef(node),
|
|
179
|
+
hostRef: host ? new WeakRef(host) : null,
|
|
180
|
+
fires: 0,
|
|
181
|
+
lastFire: null,
|
|
182
|
+
})
|
|
183
|
+
if (sub) _subId.set(sub, id)
|
|
184
|
+
_finalizer.register(node, id)
|
|
185
|
+
// Stash the id on the node so fire events correlate in O(1). Every node
|
|
186
|
+
// we register is a framework-created function/closure (signal/computed
|
|
187
|
+
// `read`, effect `run`) — always extensible, so defineProperty cannot
|
|
188
|
+
// throw here; no defensive try/catch (it would be an uncoverable dead
|
|
189
|
+
// branch).
|
|
190
|
+
Object.defineProperty(node, '__pxRdId', {
|
|
191
|
+
value: id,
|
|
192
|
+
enumerable: false,
|
|
193
|
+
configurable: true,
|
|
194
|
+
})
|
|
195
|
+
return id
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Record that a node fired (signal write / computed recompute / effect
|
|
200
|
+
* run). Bumps counters + appends to the bounded fire buffer.
|
|
201
|
+
*
|
|
202
|
+
* @internal
|
|
203
|
+
*/
|
|
204
|
+
export function _rdRecordFire(node: object): void {
|
|
205
|
+
if (!_active) return
|
|
206
|
+
const id = (node as { __pxRdId?: number }).__pxRdId
|
|
207
|
+
if (id === undefined) return
|
|
208
|
+
const rec = _byId.get(id)
|
|
209
|
+
const ts =
|
|
210
|
+
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
211
|
+
? performance.now()
|
|
212
|
+
: Date.now()
|
|
213
|
+
if (rec) {
|
|
214
|
+
rec.fires++
|
|
215
|
+
rec.lastFire = ts
|
|
216
|
+
}
|
|
217
|
+
if (_fireBuf === null) _fireBuf = new Array<ReactiveFire>(FIRE_CAP)
|
|
218
|
+
_fireBuf[_fireCount % FIRE_CAP] = { id, ts }
|
|
219
|
+
_fireCount++
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Snapshot API (consumed by the devtools hook) ─────────────────────────
|
|
223
|
+
|
|
224
|
+
function resolveSubId(sub: () => void): number | undefined {
|
|
225
|
+
const direct = (sub as { __pxRdId?: number }).__pxRdId
|
|
226
|
+
if (direct !== undefined) return direct
|
|
227
|
+
return _subId.get(sub)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Fresh snapshot of the live reactive graph. Edges are recomputed from
|
|
232
|
+
* each live node's current subscriber Set — always consistent with the
|
|
233
|
+
* framework's real subscription state, no incremental drift.
|
|
234
|
+
*/
|
|
235
|
+
export function getReactiveGraph(): ReactiveGraph {
|
|
236
|
+
const nodes: ReactiveNode[] = []
|
|
237
|
+
const edges: ReactiveEdge[] = []
|
|
238
|
+
for (const rec of _byId.values()) {
|
|
239
|
+
const node = rec.ref.deref()
|
|
240
|
+
if (!node) continue
|
|
241
|
+
const host = rec.hostRef?.deref() ?? null
|
|
242
|
+
const subs = host?._s ?? null
|
|
243
|
+
// `preview()` is total (its own try/catch returns '[unstringifiable]'),
|
|
244
|
+
// and `_v` on our registered nodes is a plain property (signal) or a
|
|
245
|
+
// getter that never throws (computed's getter routes errors through
|
|
246
|
+
// `_errorHandler` and returns the stale value). No defensive wrapper
|
|
247
|
+
// here — it would be an uncoverable dead branch.
|
|
248
|
+
const valueStr =
|
|
249
|
+
rec.kind === 'effect' ? '' : preview((node as { _v?: unknown })._v)
|
|
250
|
+
nodes.push({
|
|
251
|
+
id: rec.id,
|
|
252
|
+
kind: rec.kind,
|
|
253
|
+
name: rec.name,
|
|
254
|
+
value: valueStr,
|
|
255
|
+
subscribers: subs?.size ?? 0,
|
|
256
|
+
fires: rec.fires,
|
|
257
|
+
lastFire: rec.lastFire,
|
|
258
|
+
})
|
|
259
|
+
if (subs) {
|
|
260
|
+
for (const cb of subs) {
|
|
261
|
+
const to = resolveSubId(cb)
|
|
262
|
+
if (to !== undefined) edges.push({ from: rec.id, to })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { nodes, edges }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Bounded recent-fire timeline (oldest → newest). Fresh copy. */
|
|
270
|
+
export function getReactiveFires(): ReactiveFire[] {
|
|
271
|
+
if (_fireBuf === null || _fireCount === 0) return []
|
|
272
|
+
if (_fireCount <= FIRE_CAP) return _fireBuf.slice(0, _fireCount)
|
|
273
|
+
const start = _fireCount % FIRE_CAP
|
|
274
|
+
const out: ReactiveFire[] = []
|
|
275
|
+
for (let i = 0; i < FIRE_CAP; i++) {
|
|
276
|
+
const e = _fireBuf[(start + i) % FIRE_CAP]
|
|
277
|
+
if (e) out.push(e)
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
package/src/signal.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { batch, enqueuePendingNotification, isBatching } from './batch'
|
|
2
2
|
import { _notifyTraceListeners, isTracing } from './debug'
|
|
3
|
+
import { _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
3
4
|
import { _recordSignalWrite } from './reactive-trace'
|
|
4
5
|
import { notifySubscribers, trackSubscriber } from './tracking'
|
|
5
6
|
|
|
@@ -95,8 +96,10 @@ function _set(this: SignalFn<unknown>, newValue: unknown) {
|
|
|
95
96
|
// is opt-in (requires an onSignalUpdate listener) and captures a
|
|
96
97
|
// stack (expensive); this is always-on in dev and intentionally
|
|
97
98
|
// cheap (string preview, no stack).
|
|
98
|
-
if (process.env.NODE_ENV !== 'production')
|
|
99
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
99
100
|
_recordSignalWrite(this.label, prev, newValue)
|
|
101
|
+
_rdRecordFire(this)
|
|
102
|
+
}
|
|
100
103
|
if (isTracing()) {
|
|
101
104
|
// Trace listeners are user-supplied debug code that fires on every
|
|
102
105
|
// signal write. A throwing listener here would leave `_v` updated but
|
|
@@ -231,5 +234,8 @@ export function signal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
|
|
|
231
234
|
read.debug = _debug as () => SignalDebugInfo<T>
|
|
232
235
|
read.label = options?.name
|
|
233
236
|
|
|
237
|
+
if (process.env.NODE_ENV !== 'production')
|
|
238
|
+
_rdRegister(read, 'signal', read, null, read.label)
|
|
239
|
+
|
|
234
240
|
return read as unknown as Signal<T>
|
|
235
241
|
}
|
|
@@ -74,8 +74,9 @@ describe('gen-docs — reactivity snapshot', () => {
|
|
|
74
74
|
// isStore, effectScope, getCurrentScope, setCurrentScope,
|
|
75
75
|
// onSignalUpdate, inspectSignal, why, setErrorHandler) + 3 from M4
|
|
76
76
|
// Vue parity (markRaw, shallowReactive, onScopeDispose) + 1
|
|
77
|
-
// getReactiveTrace (reactive-trace error-report enrichment)
|
|
78
|
-
|
|
77
|
+
// getReactiveTrace (reactive-trace error-report enrichment) + 2
|
|
78
|
+
// reactive-devtools bridge (activateReactiveDevtools, getReactiveGraph).
|
|
79
|
+
expect(Object.keys(record).length).toBe(28)
|
|
79
80
|
expect(Object.keys(record)).toContain('reactivity/signal')
|
|
80
81
|
expect(Object.keys(record)).toContain('reactivity/createResource')
|
|
81
82
|
// Spot-check the flagship API — signal is the core primitive
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree-shake regression lock for the reactive-devtools instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* `signal()` / `computed()` / `effect()` gained `_rdRegister` /
|
|
5
|
+
* `_rdRecordFire` calls on their hot paths, each inside the existing
|
|
6
|
+
* `process.env.NODE_ENV !== 'production'` gate. The framework's perf
|
|
7
|
+
* claims rest on those calls compiling to NOTHING in production builds
|
|
8
|
+
* (benchmarks run prod bundles). This test bundles each instrumented
|
|
9
|
+
* module through esbuild with the prod define + minify (what every
|
|
10
|
+
* modern bundler does for a release build) and asserts every trace of
|
|
11
|
+
* the devtools bridge is gone — then bundles it dev-mode and asserts
|
|
12
|
+
* the instrumentation IS present, so the test can't pass for the wrong
|
|
13
|
+
* reason (the PR #200 bisect lesson).
|
|
14
|
+
*/
|
|
15
|
+
import { build } from 'esbuild'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { describe, expect, it } from 'vitest'
|
|
19
|
+
|
|
20
|
+
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
+
const MARKERS = /RecordFire|RdRegister|pxRdId|reactive-devtools/
|
|
22
|
+
|
|
23
|
+
async function bundle(entry: string, env: 'production' | 'development') {
|
|
24
|
+
const r = await build({
|
|
25
|
+
entryPoints: [join(SRC, entry)],
|
|
26
|
+
bundle: true,
|
|
27
|
+
minify: true,
|
|
28
|
+
write: false,
|
|
29
|
+
format: 'esm',
|
|
30
|
+
logLevel: 'silent',
|
|
31
|
+
define: { 'process.env.NODE_ENV': JSON.stringify(env) },
|
|
32
|
+
})
|
|
33
|
+
return r.outputFiles[0]!.text
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('reactive-devtools — prod tree-shake', () => {
|
|
37
|
+
for (const entry of ['signal.ts', 'computed.ts', 'effect.ts']) {
|
|
38
|
+
it(`${entry}: instrumentation is fully eliminated in production`, async () => {
|
|
39
|
+
const prod = await bundle(entry, 'production')
|
|
40
|
+
expect(prod).not.toMatch(MARKERS)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it(`${entry}: instrumentation IS present in development (anti-false-pass)`, async () => {
|
|
44
|
+
const dev = await bundle(entry, 'development')
|
|
45
|
+
expect(dev).toMatch(MARKERS)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
})
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { computed } from '../computed'
|
|
3
|
+
import { effect } from '../effect'
|
|
4
|
+
import {
|
|
5
|
+
_rdPrune,
|
|
6
|
+
activateReactiveDevtools,
|
|
7
|
+
deactivateReactiveDevtools,
|
|
8
|
+
getReactiveFires,
|
|
9
|
+
getReactiveGraph,
|
|
10
|
+
isReactiveDevtoolsActive,
|
|
11
|
+
} from '../reactive-devtools'
|
|
12
|
+
import { signal } from '../signal'
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
deactivateReactiveDevtools()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('reactive-devtools — opt-in contract', () => {
|
|
19
|
+
it('is inactive by default and tracks nothing until activated', () => {
|
|
20
|
+
expect(isReactiveDevtoolsActive()).toBe(false)
|
|
21
|
+
const s = signal(1)
|
|
22
|
+
s.set(2)
|
|
23
|
+
const c = computed(() => s() + 1)
|
|
24
|
+
c()
|
|
25
|
+
expect(getReactiveGraph().nodes).toEqual([])
|
|
26
|
+
expect(getReactiveFires()).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('activate() then deactivate() is idempotent and clears state', () => {
|
|
30
|
+
activateReactiveDevtools()
|
|
31
|
+
expect(isReactiveDevtoolsActive()).toBe(true)
|
|
32
|
+
activateReactiveDevtools() // idempotent
|
|
33
|
+
expect(isReactiveDevtoolsActive()).toBe(true)
|
|
34
|
+
const s = signal(0, { name: 'x' })
|
|
35
|
+
s()
|
|
36
|
+
expect(getReactiveGraph().nodes.length).toBe(1)
|
|
37
|
+
deactivateReactiveDevtools()
|
|
38
|
+
expect(isReactiveDevtoolsActive()).toBe(false)
|
|
39
|
+
expect(getReactiveGraph().nodes).toEqual([])
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('reactive-devtools — node registry', () => {
|
|
44
|
+
it('registers a named signal with kind + value preview', () => {
|
|
45
|
+
activateReactiveDevtools()
|
|
46
|
+
const count = signal(42, { name: 'count' })
|
|
47
|
+
void count()
|
|
48
|
+
const g = getReactiveGraph()
|
|
49
|
+
const node = g.nodes.find((n) => n.name === 'count')
|
|
50
|
+
expect(node).toBeDefined()
|
|
51
|
+
expect(node!.kind).toBe('signal')
|
|
52
|
+
expect(node!.value).toBe('42')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('synthesizes a label for anonymous derived/effect nodes', () => {
|
|
56
|
+
activateReactiveDevtools()
|
|
57
|
+
const s = signal(1)
|
|
58
|
+
const d = computed(() => s() * 2)
|
|
59
|
+
void d()
|
|
60
|
+
effect(() => void s())
|
|
61
|
+
const g = getReactiveGraph()
|
|
62
|
+
expect(g.nodes.some((n) => n.kind === 'derived' && /^derived#\d+$/.test(n.name))).toBe(true)
|
|
63
|
+
expect(g.nodes.some((n) => n.kind === 'effect' && /^effect#\d+$/.test(n.name))).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('previews non-primitive signal values without throwing', () => {
|
|
67
|
+
activateReactiveDevtools()
|
|
68
|
+
const obj = signal({ a: 1, b: 2 }, { name: 'o' })
|
|
69
|
+
void obj()
|
|
70
|
+
const arr = signal([1, 2, 3], { name: 'arr' })
|
|
71
|
+
void arr()
|
|
72
|
+
const g = getReactiveGraph()
|
|
73
|
+
expect(g.nodes.find((n) => n.name === 'o')!.value).toContain('{')
|
|
74
|
+
expect(g.nodes.find((n) => n.name === 'arr')!.value).toBe('Array(3)')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('reactive-devtools — edges from live subscriber sets', () => {
|
|
79
|
+
it('captures signal → derived → effect edges', () => {
|
|
80
|
+
activateReactiveDevtools()
|
|
81
|
+
const s = signal(1, { name: 's' })
|
|
82
|
+
const d = computed(() => s() + 1)
|
|
83
|
+
let seen = 0
|
|
84
|
+
effect(() => {
|
|
85
|
+
seen = d()
|
|
86
|
+
})
|
|
87
|
+
expect(seen).toBe(2)
|
|
88
|
+
|
|
89
|
+
const g = getReactiveGraph()
|
|
90
|
+
const sId = g.nodes.find((n) => n.name === 's')!.id
|
|
91
|
+
const dNode = g.nodes.find((n) => n.kind === 'derived')!
|
|
92
|
+
const eNode = g.nodes.find((n) => n.kind === 'effect')!
|
|
93
|
+
|
|
94
|
+
// s is read by d; d is read by the effect.
|
|
95
|
+
expect(g.edges).toContainEqual({ from: sId, to: dNode.id })
|
|
96
|
+
expect(g.edges).toContainEqual({ from: dNode.id, to: eNode.id })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('reflects subscriber count + reacts to writes (fires + lastFire)', () => {
|
|
100
|
+
activateReactiveDevtools()
|
|
101
|
+
const s = signal(0, { name: 'live' })
|
|
102
|
+
effect(() => void s())
|
|
103
|
+
s.set(1)
|
|
104
|
+
s.set(2)
|
|
105
|
+
const node = getReactiveGraph().nodes.find((n) => n.name === 'live')!
|
|
106
|
+
expect(node.subscribers).toBe(1)
|
|
107
|
+
expect(node.fires).toBe(2)
|
|
108
|
+
expect(node.lastFire).not.toBeNull()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('reactive-devtools — value preview branches', () => {
|
|
113
|
+
it('previews every primitive + edge shape', () => {
|
|
114
|
+
activateReactiveDevtools()
|
|
115
|
+
const cases: [string, unknown, (v: string) => void][] = [
|
|
116
|
+
['s_str', 'hello', (v) => expect(v).toBe('"hello"')],
|
|
117
|
+
['s_num', 7, (v) => expect(v).toBe('7')],
|
|
118
|
+
['s_bool', true, (v) => expect(v).toBe('true')],
|
|
119
|
+
['s_big', 10n, (v) => expect(v).toBe('10')],
|
|
120
|
+
['s_null', null, (v) => expect(v).toBe('null')],
|
|
121
|
+
['s_undef', undefined, (v) => expect(v).toBe('undefined')],
|
|
122
|
+
['s_sym', Symbol('z'), (v) => expect(v).toContain('Symbol')],
|
|
123
|
+
['s_fn', function named() {}, (v) => expect(v).toContain('[Function named]')],
|
|
124
|
+
[
|
|
125
|
+
's_long',
|
|
126
|
+
'x'.repeat(200),
|
|
127
|
+
(v) => expect(v.endsWith('…') && v.length <= 61).toBe(true),
|
|
128
|
+
],
|
|
129
|
+
]
|
|
130
|
+
for (const [name, val] of cases) {
|
|
131
|
+
const s = signal(val, { name })
|
|
132
|
+
void s()
|
|
133
|
+
}
|
|
134
|
+
const g = getReactiveGraph()
|
|
135
|
+
for (const [name, , assertFn] of cases) {
|
|
136
|
+
assertFn(g.nodes.find((n) => n.name === name)!.value)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('never throws on a value whose property access throws', () => {
|
|
141
|
+
activateReactiveDevtools()
|
|
142
|
+
const hostile = new Proxy(
|
|
143
|
+
{},
|
|
144
|
+
{
|
|
145
|
+
ownKeys() {
|
|
146
|
+
throw new Error('boom')
|
|
147
|
+
},
|
|
148
|
+
get() {
|
|
149
|
+
throw new Error('boom')
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
const s = signal(hostile, { name: 'hostile' })
|
|
154
|
+
void s()
|
|
155
|
+
const node = getReactiveGraph().nodes.find((n) => n.name === 'hostile')!
|
|
156
|
+
expect(typeof node.value).toBe('string')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('handles a value whose ownKeys throws but ctor read succeeds', () => {
|
|
160
|
+
activateReactiveDevtools()
|
|
161
|
+
// `.constructor` resolves fine (default get), but Object.keys() trips
|
|
162
|
+
// the inner keys try/catch.
|
|
163
|
+
const keysHostile = new Proxy(
|
|
164
|
+
{},
|
|
165
|
+
{
|
|
166
|
+
ownKeys() {
|
|
167
|
+
throw new Error('no keys')
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
const s = signal(keysHostile, { name: 'kh' })
|
|
172
|
+
void s()
|
|
173
|
+
const node = getReactiveGraph().nodes.find((n) => n.name === 'kh')!
|
|
174
|
+
expect(node.value).toBe('{}')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('effect nodes carry no value preview', () => {
|
|
178
|
+
activateReactiveDevtools()
|
|
179
|
+
const s = signal(1)
|
|
180
|
+
effect(() => void s())
|
|
181
|
+
const eff = getReactiveGraph().nodes.find((n) => n.kind === 'effect')!
|
|
182
|
+
expect(eff.value).toBe('')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('reactive-devtools — resilience', () => {
|
|
187
|
+
it('a stale __pxRdId (registry cleared, node re-fires) is buffered, not crashed', () => {
|
|
188
|
+
activateReactiveDevtools()
|
|
189
|
+
const s = signal(0, { name: 'stale' })
|
|
190
|
+
void s()
|
|
191
|
+
deactivateReactiveDevtools()
|
|
192
|
+
// Re-activate: _byId is empty but `s` still carries its old __pxRdId.
|
|
193
|
+
activateReactiveDevtools()
|
|
194
|
+
expect(() => s.set(1)).not.toThrow()
|
|
195
|
+
// Fire is still buffered even though no record exists for the id.
|
|
196
|
+
expect(getReactiveFires().length).toBe(1)
|
|
197
|
+
// …and it does not appear as a node (record was cleared).
|
|
198
|
+
expect(getReactiveGraph().nodes.find((n) => n.name === 'stale')).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('getReactiveFires is empty before any fire', () => {
|
|
202
|
+
activateReactiveDevtools()
|
|
203
|
+
expect(getReactiveFires()).toEqual([])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('_rdPrune removes a record (FinalizationRegistry callback path)', () => {
|
|
207
|
+
activateReactiveDevtools()
|
|
208
|
+
const s = signal(1, { name: 'pruneme' })
|
|
209
|
+
void s()
|
|
210
|
+
const before = getReactiveGraph().nodes.find((n) => n.name === 'pruneme')
|
|
211
|
+
expect(before).toBeDefined()
|
|
212
|
+
_rdPrune(before!.id)
|
|
213
|
+
expect(getReactiveGraph().nodes.find((n) => n.name === 'pruneme')).toBeUndefined()
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('reactive-devtools — bounded fire timeline', () => {
|
|
218
|
+
it('records signal writes + computed recomputes in order', () => {
|
|
219
|
+
activateReactiveDevtools()
|
|
220
|
+
const s = signal(0, { name: 't' })
|
|
221
|
+
const d = computed(() => s() + 1)
|
|
222
|
+
effect(() => void d())
|
|
223
|
+
s.set(1)
|
|
224
|
+
s.set(2)
|
|
225
|
+
const fires = getReactiveFires()
|
|
226
|
+
expect(fires.length).toBeGreaterThanOrEqual(2)
|
|
227
|
+
// monotonic, non-decreasing timestamps
|
|
228
|
+
for (let i = 1; i < fires.length; i++) {
|
|
229
|
+
expect(fires[i]!.ts).toBeGreaterThanOrEqual(fires[i - 1]!.ts)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('caps the ring buffer (no unbounded growth)', () => {
|
|
234
|
+
activateReactiveDevtools()
|
|
235
|
+
const s = signal(0, { name: 'spin' })
|
|
236
|
+
for (let i = 1; i <= 700; i++) s.set(i)
|
|
237
|
+
expect(getReactiveFires().length).toBeLessThanOrEqual(512)
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('reactive-devtools — preview() edge branches (coverage lock)', () => {
|
|
242
|
+
// Lifts reactive-devtools.ts off the 8 uncovered `preview()` /
|
|
243
|
+
// performance-fallback branches that landed with #703 and dragged
|
|
244
|
+
// @pyreon/reactivity global branch coverage to 89.75% (< the 90%
|
|
245
|
+
// gate). With these: 90.7% (478/527) — the Coverage CI gate passes.
|
|
246
|
+
const valueOf = (name: string) =>
|
|
247
|
+
getReactiveGraph().nodes.find((n) => n.name === name)?.value
|
|
248
|
+
|
|
249
|
+
it('anonymous function → [Function anonymous] (|| fallback arm)', () => {
|
|
250
|
+
activateReactiveDevtools()
|
|
251
|
+
const s = signal<unknown>((() => () => {})(), { name: 'anonFn' })
|
|
252
|
+
void s()
|
|
253
|
+
expect(valueOf('anonFn')).toBe('[Function anonymous]')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('plain object whose ctor IS Object → no ctor prefix (empty-arm)', () => {
|
|
257
|
+
activateReactiveDevtools()
|
|
258
|
+
const s = signal<unknown>({ a: 1 }, { name: 'plainObj' })
|
|
259
|
+
void s()
|
|
260
|
+
expect(valueOf('plainObj')).toBe('{a}')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('object with more than 3 keys → truncates with ellipsis', () => {
|
|
264
|
+
activateReactiveDevtools()
|
|
265
|
+
const s = signal<unknown>({ a: 1, b: 2, c: 3, d: 4 }, { name: 'bigObj' })
|
|
266
|
+
void s()
|
|
267
|
+
expect(valueOf('bigObj')).toBe('{a, b, c, …}')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('classed object → keeps the ctor prefix (truthy arm)', () => {
|
|
271
|
+
class Box {
|
|
272
|
+
x = 1
|
|
273
|
+
}
|
|
274
|
+
activateReactiveDevtools()
|
|
275
|
+
const s = signal<unknown>(new Box(), { name: 'boxObj' })
|
|
276
|
+
void s()
|
|
277
|
+
expect(valueOf('boxObj')).toBe('Box {x}')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('records the Date.now fallback when performance is unavailable', () => {
|
|
281
|
+
const realPerf = globalThis.performance
|
|
282
|
+
try {
|
|
283
|
+
// Exercise the `typeof performance === 'undefined'` defensive arm.
|
|
284
|
+
delete (globalThis as { performance?: unknown }).performance
|
|
285
|
+
activateReactiveDevtools()
|
|
286
|
+
const s = signal(0, { name: 'noPerf' })
|
|
287
|
+
void s()
|
|
288
|
+
expect(() => s.set(1)).not.toThrow()
|
|
289
|
+
const fires = getReactiveFires()
|
|
290
|
+
expect(fires.length).toBeGreaterThanOrEqual(1)
|
|
291
|
+
expect(typeof fires[0]!.ts).toBe('number')
|
|
292
|
+
} finally {
|
|
293
|
+
;(globalThis as { performance?: unknown }).performance = realPerf
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
})
|