@pyreon/reactivity 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"3d8fcc8f-1","name":"batch.ts"},{"uid":"3d8fcc8f-3","name":"cell.ts"},{"uid":"3d8fcc8f-5","name":"scope.ts"},{"uid":"3d8fcc8f-7","name":"tracking.ts"},{"uid":"3d8fcc8f-9","name":"effect.ts"},{"uid":"3d8fcc8f-11","name":"computed.ts"},{"uid":"3d8fcc8f-13","name":"createSelector.ts"},{"uid":"3d8fcc8f-15","name":"debug.ts"},{"uid":"3d8fcc8f-17","name":"reactive-trace.ts"},{"uid":"3d8fcc8f-19","name":"signal.ts"},{"uid":"3d8fcc8f-21","name":"store.ts"},{"uid":"3d8fcc8f-23","name":"reconcile.ts"},{"uid":"3d8fcc8f-25","name":"resource.ts"},{"uid":"3d8fcc8f-27","name":"watch.ts"},{"uid":"3d8fcc8f-29","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"3d8fcc8f-1":{"renderedLength":3016,"gzipLength":1167,"brotliLength":0,"metaUid":"3d8fcc8f-0"},"3d8fcc8f-3":{"renderedLength":1636,"gzipLength":786,"brotliLength":0,"metaUid":"3d8fcc8f-2"},"3d8fcc8f-5":{"renderedLength":3026,"gzipLength":1226,"brotliLength":0,"metaUid":"3d8fcc8f-4"},"3d8fcc8f-7":{"renderedLength":2227,"gzipLength":858,"brotliLength":0,"metaUid":"3d8fcc8f-6"},"3d8fcc8f-9":{"renderedLength":7391,"gzipLength":2397,"brotliLength":0,"metaUid":"3d8fcc8f-8"},"3d8fcc8f-11":{"renderedLength":4716,"gzipLength":1476,"brotliLength":0,"metaUid":"3d8fcc8f-10"},"3d8fcc8f-13":{"renderedLength":2244,"gzipLength":981,"brotliLength":0,"metaUid":"3d8fcc8f-12"},"3d8fcc8f-15":{"renderedLength":2469,"gzipLength":1092,"brotliLength":0,"metaUid":"3d8fcc8f-14"},"3d8fcc8f-17":{"renderedLength":2721,"gzipLength":1363,"brotliLength":0,"metaUid":"3d8fcc8f-16"},"3d8fcc8f-19":{"renderedLength":3408,"gzipLength":1476,"brotliLength":0,"metaUid":"3d8fcc8f-18"},"3d8fcc8f-21":{"renderedLength":5232,"gzipLength":1867,"brotliLength":0,"metaUid":"3d8fcc8f-20"},"3d8fcc8f-23":{"renderedLength":2278,"gzipLength":940,"brotliLength":0,"metaUid":"3d8fcc8f-22"},"3d8fcc8f-25":{"renderedLength":1205,"gzipLength":524,"brotliLength":0,"metaUid":"3d8fcc8f-24"},"3d8fcc8f-27":{"renderedLength":1249,"gzipLength":582,"brotliLength":0,"metaUid":"3d8fcc8f-26"},"3d8fcc8f-29":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"3d8fcc8f-28"}},"nodeMetas":{"3d8fcc8f-0":{"id":"/src/batch.ts","moduleParts":{"index.js":"3d8fcc8f-1"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-18"},{"uid":"3d8fcc8f-6"}]},"3d8fcc8f-2":{"id":"/src/cell.ts","moduleParts":{"index.js":"3d8fcc8f-3"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-4":{"id":"/src/scope.ts","moduleParts":{"index.js":"3d8fcc8f-5"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-8"}]},"3d8fcc8f-6":{"id":"/src/tracking.ts","moduleParts":{"index.js":"3d8fcc8f-7"},"imported":[{"uid":"3d8fcc8f-0"}],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-12"},{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-24"},{"uid":"3d8fcc8f-18"}]},"3d8fcc8f-8":{"id":"/src/effect.ts","moduleParts":{"index.js":"3d8fcc8f-9"},"imported":[{"uid":"3d8fcc8f-4"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-12"},{"uid":"3d8fcc8f-24"},{"uid":"3d8fcc8f-26"}]},"3d8fcc8f-10":{"id":"/src/computed.ts","moduleParts":{"index.js":"3d8fcc8f-11"},"imported":[{"uid":"3d8fcc8f-0"},{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-4"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-12":{"id":"/src/createSelector.ts","moduleParts":{"index.js":"3d8fcc8f-13"},"imported":[{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-14":{"id":"/src/debug.ts","moduleParts":{"index.js":"3d8fcc8f-15"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-18"}]},"3d8fcc8f-16":{"id":"/src/reactive-trace.ts","moduleParts":{"index.js":"3d8fcc8f-17"},"imported":[],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-18"}]},"3d8fcc8f-18":{"id":"/src/signal.ts","moduleParts":{"index.js":"3d8fcc8f-19"},"imported":[{"uid":"3d8fcc8f-0"},{"uid":"3d8fcc8f-14"},{"uid":"3d8fcc8f-16"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-24"},{"uid":"3d8fcc8f-20"}]},"3d8fcc8f-20":{"id":"/src/store.ts","moduleParts":{"index.js":"3d8fcc8f-21"},"imported":[{"uid":"3d8fcc8f-18"}],"importedBy":[{"uid":"3d8fcc8f-28"},{"uid":"3d8fcc8f-22"}]},"3d8fcc8f-22":{"id":"/src/reconcile.ts","moduleParts":{"index.js":"3d8fcc8f-23"},"imported":[{"uid":"3d8fcc8f-20"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-24":{"id":"/src/resource.ts","moduleParts":{"index.js":"3d8fcc8f-25"},"imported":[{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-18"},{"uid":"3d8fcc8f-6"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-26":{"id":"/src/watch.ts","moduleParts":{"index.js":"3d8fcc8f-27"},"imported":[{"uid":"3d8fcc8f-8"}],"importedBy":[{"uid":"3d8fcc8f-28"}]},"3d8fcc8f-28":{"id":"/src/index.ts","moduleParts":{"index.js":"3d8fcc8f-29"},"imported":[{"uid":"3d8fcc8f-0"},{"uid":"3d8fcc8f-2"},{"uid":"3d8fcc8f-10"},{"uid":"3d8fcc8f-12"},{"uid":"3d8fcc8f-14"},{"uid":"3d8fcc8f-16"},{"uid":"3d8fcc8f-8"},{"uid":"3d8fcc8f-22"},{"uid":"3d8fcc8f-24"},{"uid":"3d8fcc8f-4"},{"uid":"3d8fcc8f-18"},{"uid":"3d8fcc8f-20"},{"uid":"3d8fcc8f-6"},{"uid":"3d8fcc8f-26"}],"importedBy":[],"isEntry":true}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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") _countSink$2.__pyreon_count__?.("reactivity.effectRun");
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") _countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
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") _countSink$1.__pyreon_count__?.("reactivity.computedRecompute");
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") _recordSignalWrite(this.label, prev, newValue);
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/reactivity",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Signals-based reactivity system for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/reactivity#readme",
6
6
  "bugs": {
package/src/computed.ts CHANGED
@@ -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
- expect(Object.keys(record).length).toBe(26)
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
+ })