@pyreon/reactivity 0.18.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.
@@ -133,9 +133,9 @@ interface Signal<T> {
133
133
  subscribe(listener: () => void): () => void;
134
134
  /**
135
135
  * Register a direct updater — even lighter than subscribe().
136
- * Uses a flat array instead of Set. Disposal nulls the slot (no Set.delete).
137
136
  * Intended for compiler-emitted DOM bindings (_bindText, _bindDirect).
138
- * Returns a disposer that nulls the slot.
137
+ * Returns a disposer that removes the updater (O(1)); the live set
138
+ * stays bounded under register/dispose churn.
139
139
  */
140
140
  direct(updater: () => void): () => void;
141
141
  /**
@@ -209,6 +209,130 @@ 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
286
+ //#region src/reactive-trace.d.ts
287
+ /**
288
+ * Reactive trace — a bounded, dev-only ring buffer of recent signal
289
+ * writes. When a signal-based UI throws, the single most useful
290
+ * debugging question is "what reactive state changed in the run-up to
291
+ * the crash" — a point-in-time snapshot of every signal value can't
292
+ * answer that (it shows the end state, not the causal sequence). The
293
+ * ring buffer records the last N writes so an error report can attach
294
+ * the sequence that led into the bad state.
295
+ *
296
+ * Design constraints:
297
+ *
298
+ * - **Bounded memory.** Fixed-size circular buffer (`CAP` entries).
299
+ * Never grows. Old entries overwrite oldest-first.
300
+ * - **No value retention.** Stores a TRUNCATED STRING preview of
301
+ * prev / next, never the raw value. Holding raw references would
302
+ * retain large arrays / detached DOM / closures in the buffer and
303
+ * leak them for the buffer's lifetime. The preview is also what
304
+ * makes the trace safely serializable into an error report.
305
+ * - **Cheap.** No stack capture (that's the expensive part of the
306
+ * `onSignalUpdate` debug path — this is deliberately lighter so it
307
+ * can record every write in dev without a perf cost). One object
308
+ * literal + one array slot write per signal write.
309
+ * - **Zero production cost.** The single call site in `signal.ts`
310
+ * is inside the existing `process.env.NODE_ENV !== 'production'`
311
+ * gate, so the whole module tree-shakes out of prod bundles.
312
+ */
313
+ interface ReactiveTraceEntry {
314
+ /** Signal `label` (set via `signal(v, { name })` or the vite plugin's dev auto-naming). `undefined` for anonymous signals. */
315
+ name: string | undefined;
316
+ /** Bounded string preview of the value before the write. */
317
+ prev: string;
318
+ /** Bounded string preview of the value after the write. */
319
+ next: string;
320
+ /** `performance.now()` at write time (monotonic; survives clock changes). */
321
+ timestamp: number;
322
+ }
323
+ /**
324
+ * Returns the recorded writes in chronological order (oldest → newest),
325
+ * at most `CAP` entries. Empty array when nothing has been recorded.
326
+ * The returned array is a fresh copy — safe to retain / serialize
327
+ * without pinning the ring buffer.
328
+ *
329
+ * Consumed by `@pyreon/core`'s `reportError` to attach `reactiveTrace`
330
+ * to the error context.
331
+ */
332
+ declare function getReactiveTrace(): ReactiveTraceEntry[];
333
+ /** Clears the buffer. For test isolation; not part of the app-facing API. */
334
+ declare function clearReactiveTrace(): void;
335
+ //#endregion
212
336
  //#region src/effect.d.ts
213
337
  interface Effect {
214
338
  dispose(): void;
@@ -465,5 +589,5 @@ interface WatchOptions {
465
589
  */
466
590
  declare function watch<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void), opts?: WatchOptions): () => void;
467
591
  //#endregion
468
- export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReactiveSnapshotCapture, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
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 };
469
593
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/reactivity",
3
- "version": "0.18.0",
3
+ "version": "0.20.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,
@@ -87,21 +88,31 @@ function computedLazy<T>(fn: () => T): Computed<T> {
87
88
  let tracked = false
88
89
  const deps: Set<() => void>[] = []
89
90
  const host: { _s: Set<() => void> | null } = { _s: null }
90
- let directFns: ((() => void) | null)[] | null = null
91
+ // Set, not a never-compacted flat array. The array form's disposal
92
+ // only nulled the slot (`arr[idx] = null`) and never shrank, so a
93
+ // long-lived computed (a derived theme/locale/auth value, or one read
94
+ // inside churning `<For>` rows) bound by mount/unmount churn grew one
95
+ // permanent dead slot per ever-registered binding — app-lifetime
96
+ // memory growth AND `recompute` iterating O(total-ever) instead of
97
+ // O(live). Identical bug class already fixed for `signal._d`
98
+ // (signal.ts `_directFn`); `computed` was left on the broken pattern.
99
+ let directFns: Set<() => void> | null = null
91
100
 
92
101
  const recompute = () => {
93
102
  if (disposed || dirty) return
94
103
  dirty = true
95
104
  if (host._s) notifySubscribers(host._s)
96
- if (directFns) for (const f of directFns) f?.()
105
+ if (directFns) for (const f of directFns) f()
97
106
  }
98
107
  _markRecompute(recompute)
99
108
 
100
109
  const read = (): T => {
101
110
  trackSubscriber(host)
102
111
  if (dirty) {
103
- if (process.env.NODE_ENV !== 'production')
112
+ if (process.env.NODE_ENV !== 'production') {
104
113
  _countSink.__pyreon_count__?.('reactivity.computedRecompute')
114
+ _rdRecordFire(read)
115
+ }
105
116
  try {
106
117
  if (tracked) {
107
118
  // Deps already established from first run — skip adding to
@@ -136,16 +147,26 @@ function computedLazy<T>(fn: () => T): Computed<T> {
136
147
  enumerable: false,
137
148
  })
138
149
 
150
+ // @internal — mirrors `signal._d`. Lets tests deterministically assert
151
+ // the live direct-updater set stays BOUNDED under register/dispose
152
+ // churn (the never-compacted-array leak this fix removes).
153
+ Object.defineProperty(read, '_d', {
154
+ get: () => directFns,
155
+ enumerable: false,
156
+ })
157
+
139
158
  read.direct = (updater: () => void): (() => void) => {
140
- if (!directFns) directFns = []
141
- const arr = directFns
142
- const idx = arr.length
143
- arr.push(updater)
159
+ if (!directFns) directFns = new Set()
160
+ const set = directFns
161
+ set.add(updater)
144
162
  return () => {
145
- arr[idx] = null
163
+ set.delete(updater)
146
164
  }
147
165
  }
148
166
 
167
+ if (process.env.NODE_ENV !== 'production')
168
+ _rdRegister(read, 'derived', host, recompute, undefined)
169
+
149
170
  getCurrentScope()?.add({ dispose: read.dispose })
150
171
  return read as Computed<T>
151
172
  }
@@ -163,12 +184,22 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
163
184
  let disposed = false
164
185
  const deps: Set<() => void>[] = []
165
186
  const host: { _s: Set<() => void> | null } = { _s: null }
166
- let directFns: ((() => void) | null)[] | null = null
187
+ // Set, not a never-compacted flat array. The array form's disposal
188
+ // only nulled the slot (`arr[idx] = null`) and never shrank, so a
189
+ // long-lived computed (a derived theme/locale/auth value, or one read
190
+ // inside churning `<For>` rows) bound by mount/unmount churn grew one
191
+ // permanent dead slot per ever-registered binding — app-lifetime
192
+ // memory growth AND `recompute` iterating O(total-ever) instead of
193
+ // O(live). Identical bug class already fixed for `signal._d`
194
+ // (signal.ts `_directFn`); `computed` was left on the broken pattern.
195
+ let directFns: Set<() => void> | null = null
167
196
 
168
197
  const recompute = () => {
169
198
  if (disposed) return
170
- if (process.env.NODE_ENV !== 'production')
199
+ if (process.env.NODE_ENV !== 'production') {
171
200
  _countSink.__pyreon_count__?.('reactivity.computedRecompute')
201
+ _rdRecordFire(read)
202
+ }
172
203
  cleanupLocalDeps(deps, recompute)
173
204
  try {
174
205
  const next = trackWithLocalDeps(deps, recompute, fn)
@@ -181,7 +212,7 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
181
212
  return
182
213
  }
183
214
  if (host._s) notifySubscribers(host._s)
184
- if (directFns) for (const f of directFns) f?.()
215
+ if (directFns) for (const f of directFns) f()
185
216
  }
186
217
  _markRecompute(recompute)
187
218
 
@@ -216,16 +247,26 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
216
247
  enumerable: false,
217
248
  })
218
249
 
250
+ // @internal — mirrors `signal._d`. Lets tests deterministically assert
251
+ // the live direct-updater set stays BOUNDED under register/dispose
252
+ // churn (the never-compacted-array leak this fix removes).
253
+ Object.defineProperty(read, '_d', {
254
+ get: () => directFns,
255
+ enumerable: false,
256
+ })
257
+
219
258
  read.direct = (updater: () => void): (() => void) => {
220
- if (!directFns) directFns = []
221
- const arr = directFns
222
- const idx = arr.length
223
- arr.push(updater)
259
+ if (!directFns) directFns = new Set()
260
+ const set = directFns
261
+ set.add(updater)
224
262
  return () => {
225
- arr[idx] = null
263
+ set.delete(updater)
226
264
  }
227
265
  }
228
266
 
267
+ if (process.env.NODE_ENV !== 'production')
268
+ _rdRegister(read, 'derived', host, recompute, undefined)
269
+
229
270
  getCurrentScope()?.add({ dispose: read.dispose })
230
271
  return read as Computed<T>
231
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
 
@@ -102,6 +103,11 @@ interface PyreonErrorBridge {
102
103
  const _errorBridge = globalThis as PyreonErrorBridge
103
104
 
104
105
  function _defaultErrorHandler(err: unknown): void {
106
+ // Last-resort unhandled-effect-error reporter — MUST fire in
107
+ // production (silently swallowing uncaught effect errors is a
108
+ // serious bug; React/Vue/Solid all log uncaught errors in prod).
109
+ // Deliberately not __DEV__-gated.
110
+ // pyreon-lint-disable-next-line pyreon/dev-guard-warnings
105
111
  console.error('[pyreon] Unhandled effect error:', err)
106
112
  }
107
113
 
@@ -206,8 +212,10 @@ export function effect(fn: () => (() => void) | void): Effect {
206
212
 
207
213
  const run = () => {
208
214
  if (disposed) return
209
- if (process.env.NODE_ENV !== 'production')
215
+ if (process.env.NODE_ENV !== 'production') {
210
216
  _countSink.__pyreon_count__?.('reactivity.effectRun')
217
+ _rdRecordFire(run)
218
+ }
211
219
  // Run previous cleanup before re-running
212
220
  runCleanup()
213
221
  // Start a new inner-effect collection window. Effects created during
@@ -249,6 +257,9 @@ export function effect(fn: () => (() => void) | void): Effect {
249
257
  isFirstRun = false
250
258
  }
251
259
 
260
+ if (process.env.NODE_ENV !== 'production')
261
+ _rdRegister(run, 'effect', null, run, undefined)
262
+
252
263
  run()
253
264
 
254
265
  const e: Effect = {
@@ -404,6 +415,9 @@ export function renderEffect(fn: () => void): () => void {
404
415
  }
405
416
  }
406
417
 
418
+ if (process.env.NODE_ENV !== 'production')
419
+ _rdRegister(run, 'effect', null, run, undefined)
420
+
407
421
  run()
408
422
 
409
423
  const dispose = () => {
package/src/index.ts CHANGED
@@ -5,6 +5,22 @@ 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'
22
+ export type { ReactiveTraceEntry } from './reactive-trace'
23
+ export { clearReactiveTrace, getReactiveTrace } from './reactive-trace'
8
24
  export {
9
25
  _bind,
10
26
  type Effect,
package/src/manifest.ts CHANGED
@@ -58,7 +58,7 @@ effect(() => {
58
58
  'createStore() / reconcile() / isStore() — deeply reactive proxy stores + structural diff',
59
59
  'effectScope() / getCurrentScope() — scope-based lifecycle management',
60
60
  'untrack() — read without subscribing',
61
- 'onSignalUpdate() / inspectSignal() / why() — debug instrumentation',
61
+ 'onSignalUpdate() / inspectSignal() / why() / getReactiveTrace() — debug instrumentation',
62
62
  'setErrorHandler() — global hook for unhandled effect errors',
63
63
  'Standalone — zero DOM, zero JSX, zero framework dependency',
64
64
  ],
@@ -553,6 +553,27 @@ why() // disarm + dump transcript:
553
553
  ],
554
554
  seeAlso: ['onSignalUpdate', 'inspectSignal'],
555
555
  },
556
+ {
557
+ name: 'getReactiveTrace',
558
+ kind: 'function',
559
+ signature:
560
+ '() => Array<{ name: string | undefined; prev: string; next: string; timestamp: number }>',
561
+ summary:
562
+ 'Returns the last ~50 signal writes (chronological, oldest → newest) from a bounded dev-only ring buffer — the causal SEQUENCE of reactive state changes, not a point-in-time snapshot. `@pyreon/core` attaches this to `ErrorContext.reactiveTrace` automatically so error reports carry "what changed in the run-up to the crash". Entries hold bounded string previews of values (never raw refs — no memory pinning, always serializable). **Dev-only**: the recorder feeding the buffer is behind the production dead-code gate and tree-shakes out, so this returns `[]` in prod builds. Distinct from `onSignalUpdate` — that is opt-in and captures stacks; this is always-on, deliberately cheap, and exists to enrich error reports. `clearReactiveTrace()` resets it (test isolation).',
563
+ example: `import { getReactiveTrace, clearReactiveTrace, signal } from '@pyreon/reactivity'
564
+
565
+ const status = signal('idle', { name: 'status' })
566
+ status.set('submitting')
567
+ getReactiveTrace()
568
+ // [{ name: 'status', prev: '"idle"', next: '"submitting"', timestamp: 1234.5 }]
569
+ clearReactiveTrace() // → []`,
570
+ mistakes: [
571
+ 'Expecting it to return signal VALUES — it returns string PREVIEWS (truncated, safely stringified). For live values inspect the signal directly',
572
+ 'Relying on it in production — returns `[]` (the recorder is dev-gated and tree-shaken). Use it for dev tooling / error-report enrichment, not runtime logic',
573
+ 'Treating it as a snapshot of all signals — it is a bounded ring of recent WRITES; signals never written (or written before the ~50-entry window) are absent',
574
+ ],
575
+ seeAlso: ['onSignalUpdate', 'inspectSignal'],
576
+ },
556
577
  {
557
578
  name: 'setErrorHandler',
558
579
  kind: 'function',
@@ -575,6 +596,52 @@ count.set(101) // logs/reports via handler instead of crashing`,
575
596
  ],
576
597
  seeAlso: ['effect', 'renderEffect'],
577
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
+ },
578
645
  ],
579
646
  gotchas: [
580
647
  {