@pyreon/reactivity 0.22.0 → 0.24.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.
@@ -27,6 +27,26 @@
27
27
 
28
28
  export type ReactiveNodeKind = 'signal' | 'derived' | 'effect'
29
29
 
30
+ /**
31
+ * Source location of a reactive node's creation — captured at registration
32
+ * time from the user's call stack. Powers "Live Program Inlay Hints" — the
33
+ * editor surfaces fire counts at the source line where the node was created.
34
+ *
35
+ * Captured ONLY when devtools is active (`_active === true`). Stack parsing
36
+ * is best-effort across V8 / JSC / SpiderMonkey; returns undefined when the
37
+ * stack format isn't recognized (older runtimes, minified prod, web workers
38
+ * without source maps). Dev gate is the existing `process.env.NODE_ENV` at
39
+ * each caller — production paths never run the capture.
40
+ */
41
+ export interface SourceLocation {
42
+ /** Absolute path or file URL parsed from the stack frame. */
43
+ file: string
44
+ /** 1-based line number. */
45
+ line: number
46
+ /** 1-based column number. */
47
+ col: number
48
+ }
49
+
30
50
  export interface ReactiveNode {
31
51
  id: number
32
52
  kind: ReactiveNodeKind
@@ -40,6 +60,13 @@ export interface ReactiveNode {
40
60
  fires: number
41
61
  /** `performance.now()` of the most recent fire, or null. */
42
62
  lastFire: number | null
63
+ /**
64
+ * Source location of the creation call (`signal(0)` / `computed(...)` /
65
+ * `effect(...)`). Undefined when devtools wasn't active at creation
66
+ * time OR the stack format wasn't parseable. Editor inlay-hint surfaces
67
+ * consume this to merge live fire counts onto static spans.
68
+ */
69
+ loc?: SourceLocation
43
70
  }
44
71
 
45
72
  export interface ReactiveEdge {
@@ -60,6 +87,46 @@ export interface ReactiveFire {
60
87
  ts: number
61
88
  }
62
89
 
90
+ /**
91
+ * Per-source-location fire-count summary. Aggregated from the fire ring
92
+ * buffer + node registry. The shape an editor / LSP inlay-hint consumer
93
+ * needs to merge "this signal at line N fires K times" onto static
94
+ * Reactivity-Lens spans. Pure data, JSON-serializable, no node refs.
95
+ */
96
+ export interface FireSummary {
97
+ loc: SourceLocation
98
+ /** Total fires in the visible ring buffer at this location. */
99
+ count: number
100
+ /** Most recent fire `performance.now()` at this location, or null. */
101
+ lastFire: number | null
102
+ /** Node kind that fired most recently at this location. */
103
+ kind: ReactiveNodeKind
104
+ /**
105
+ * Exponentially-weighted moving average of the fire rate at this
106
+ * location, in fires per second. Decayed to "now" at read time so a
107
+ * node that stopped firing N seconds ago shows a rate that's
108
+ * exponentially smaller than its steady-state value.
109
+ *
110
+ * Calculation uses a 1-second time constant (`LPIH_RATE_TAU_MS`):
111
+ * - On each fire: `r = r * exp(-dt/TAU) + 1`
112
+ * - Steady state at λ fires/sec converges to ≈ λ (when λ × TAU ≫ 1)
113
+ * - On read: `r_now = r * exp(-dt_since_last/TAU)`
114
+ *
115
+ * 0 when there have been no fires (or all fires were >>TAU ago).
116
+ */
117
+ rate1s: number
118
+ }
119
+
120
+ /**
121
+ * Time constant for the rate1s EWMA (milliseconds). Tuned for the "hot
122
+ * path debugging" use case: a 1-second time constant means a burst of
123
+ * fires shows up immediately, then decays to 1/e (~0.37×) after one
124
+ * second of silence, ~5% after 3 seconds, ~0.7% after 5 seconds.
125
+ *
126
+ * @internal — exported for tests + tunability.
127
+ */
128
+ export const LPIH_RATE_TAU_MS = 1000
129
+
63
130
  // ── Internal node record ─────────────────────────────────────────────────
64
131
 
65
132
  interface NodeRec {
@@ -72,6 +139,13 @@ interface NodeRec {
72
139
  hostRef: WeakRef<{ _s: Set<() => void> | null }> | null
73
140
  fires: number
74
141
  lastFire: number | null
142
+ /** Source location captured at registration. Undefined if stack parse failed. */
143
+ loc?: SourceLocation | undefined
144
+ /**
145
+ * EWMA-tracked fire rate (~fires/sec, 1s time constant). Updated on
146
+ * every fire; decayed to "now" at read time. See `FireSummary.rate1s`.
147
+ */
148
+ rate1s: number
75
149
  }
76
150
 
77
151
  let _active = false
@@ -154,6 +228,70 @@ export function isReactiveDevtoolsActive(): boolean {
154
228
  // ── Instrumentation entry points (called from the hot paths, but only
155
229
  // after the existing prod gate; each is a no-op until activated) ──────
156
230
 
231
+ /**
232
+ * Parse the user's call site from `new Error().stack`. Returns undefined
233
+ * when devtools isn't active (zero-cost early-return — no Error allocated)
234
+ * OR when the stack format isn't recognized.
235
+ *
236
+ * `skipFrames` is the number of caller-frames to skip past _captureCallerLocation
237
+ * itself. The framework's hot-path callers (signal / computedLazy / effect)
238
+ * pass their own depth so the captured frame is the USER's call to
239
+ * `signal()` / `computed()` / `effect()`, not the framework's internals.
240
+ *
241
+ * Recognized stack formats:
242
+ * - V8 (Chrome / Node / Bun): ` at fn (file:line:col)`
243
+ * - V8 (anonymous): ` at file:line:col`
244
+ * - JSC (Safari) + SpiderMonkey: `fn@file:line:col`
245
+ *
246
+ * @internal
247
+ */
248
+ export function _captureCallerLocation(skipFrames: number): SourceLocation | undefined {
249
+ if (!_active) return undefined
250
+ const err = new Error()
251
+ const raw = err.stack
252
+ if (!raw) return undefined
253
+ const lines = raw.split('\n')
254
+ // V8 prepends "Error\n"; JSC doesn't. Detect and offset.
255
+ const startIdx = lines[0] && lines[0].trim().startsWith('Error') ? 1 : 0
256
+ // Skip past _captureCallerLocation's own frame (always +1) + caller's depth.
257
+ const target = lines[startIdx + 1 + skipFrames]
258
+ if (!target) return undefined
259
+ return parseStackLine(target)
260
+ }
261
+
262
+ /** @internal — exported for unit testing across runtimes. */
263
+ export function _parseStackLine(line: string): SourceLocation | undefined {
264
+ return parseStackLine(line)
265
+ }
266
+
267
+ function parseStackLine(line: string): SourceLocation | undefined {
268
+ // V8 parenthesized form: " at fnName (file:line:col)"
269
+ const v8Paren = line.match(/\(([^()]+):(\d+):(\d+)\)\s*$/)
270
+ if (v8Paren && v8Paren[1] && v8Paren[2] && v8Paren[3]) {
271
+ const file = v8Paren[1]
272
+ const lineN = Number.parseInt(v8Paren[2], 10)
273
+ const col = Number.parseInt(v8Paren[3], 10)
274
+ if (Number.isFinite(lineN) && Number.isFinite(col)) return { file, line: lineN, col }
275
+ }
276
+ // V8 anonymous form: " at file:line:col"
277
+ const v8Bare = line.match(/at\s+([^\s()]+):(\d+):(\d+)\s*$/)
278
+ if (v8Bare && v8Bare[1] && v8Bare[2] && v8Bare[3]) {
279
+ const file = v8Bare[1]
280
+ const lineN = Number.parseInt(v8Bare[2], 10)
281
+ const col = Number.parseInt(v8Bare[3], 10)
282
+ if (Number.isFinite(lineN) && Number.isFinite(col)) return { file, line: lineN, col }
283
+ }
284
+ // JSC / SpiderMonkey form: "fnName@file:line:col"
285
+ const jsc = line.match(/@([^@\s]+):(\d+):(\d+)\s*$/)
286
+ if (jsc && jsc[1] && jsc[2] && jsc[3]) {
287
+ const file = jsc[1]
288
+ const lineN = Number.parseInt(jsc[2], 10)
289
+ const col = Number.parseInt(jsc[3], 10)
290
+ if (Number.isFinite(lineN) && Number.isFinite(col)) return { file, line: lineN, col }
291
+ }
292
+ return undefined
293
+ }
294
+
157
295
  /**
158
296
  * Register a signal/computed/effect node. `host` is the object carrying
159
297
  * the `_s` subscriber Set (the signal read fn itself, or a computed's
@@ -168,6 +306,7 @@ export function _rdRegister(
168
306
  host: { _s: Set<() => void> | null } | null,
169
307
  sub: object | null,
170
308
  label: string | undefined,
309
+ loc?: SourceLocation,
171
310
  ): number | undefined {
172
311
  if (!_active) return undefined
173
312
  const id = _nextId++
@@ -179,6 +318,8 @@ export function _rdRegister(
179
318
  hostRef: host ? new WeakRef(host) : null,
180
319
  fires: 0,
181
320
  lastFire: null,
321
+ loc,
322
+ rate1s: 0,
182
323
  })
183
324
  if (sub) _subId.set(sub, id)
184
325
  _finalizer.register(node, id)
@@ -212,6 +353,20 @@ export function _rdRecordFire(node: object): void {
212
353
  : Date.now()
213
354
  if (rec) {
214
355
  rec.fires++
356
+ // EWMA rate update — decay the prior estimate by exp(-dt/TAU), then
357
+ // add 1 for this fire. At steady state of λ fires/sec, rate1s
358
+ // converges to ≈ λ when λ·TAU ≫ 1. For TAU=1000ms, that means
359
+ // "fires per second" in the natural sense.
360
+ if (rec.lastFire !== null) {
361
+ const dt = ts - rec.lastFire
362
+ const decay = Math.exp(-dt / LPIH_RATE_TAU_MS)
363
+ rec.rate1s = rec.rate1s * decay + 1
364
+ } else {
365
+ // First-ever fire: a single isolated fire reads as "1 fire/s" until
366
+ // the decay-at-read brings it down. Caller can interpret 1.0 as
367
+ // "at least one recent fire."
368
+ rec.rate1s = 1
369
+ }
215
370
  rec.lastFire = ts
216
371
  }
217
372
  if (_fireBuf === null) _fireBuf = new Array<ReactiveFire>(FIRE_CAP)
@@ -255,6 +410,7 @@ export function getReactiveGraph(): ReactiveGraph {
255
410
  subscribers: subs?.size ?? 0,
256
411
  fires: rec.fires,
257
412
  lastFire: rec.lastFire,
413
+ ...(rec.loc ? { loc: rec.loc } : {}),
258
414
  })
259
415
  if (subs) {
260
416
  for (const cb of subs) {
@@ -266,6 +422,63 @@ export function getReactiveGraph(): ReactiveGraph {
266
422
  return { nodes, edges }
267
423
  }
268
424
 
425
+ /**
426
+ * Aggregate fire counts by source-location — powers Live Program Inlay
427
+ * Hints. Walks the live node registry, keys each node by its captured
428
+ * `loc`, and returns one summary per unique `file:line:col`. Nodes
429
+ * without a captured location are skipped (their fires are still
430
+ * visible via `getReactiveGraph()` and `getReactiveFires()` for the
431
+ * existing graph / timeline surfaces).
432
+ *
433
+ * Returns a fresh array, JSON-serializable, safe to ship across the
434
+ * devtools-host bridge or to write into an LSP cache file.
435
+ */
436
+ export function getFireSummaries(): FireSummary[] {
437
+ const byKey = new Map<string, FireSummary>()
438
+ // Snapshot "now" once per call — decay-at-read uses a consistent timestamp
439
+ // for all nodes, so two locations firing at the same rate show the same
440
+ // rate1s value even if iteration walks them in different orders.
441
+ const nowTs =
442
+ typeof performance !== 'undefined' && typeof performance.now === 'function'
443
+ ? performance.now()
444
+ : Date.now()
445
+ for (const rec of _byId.values()) {
446
+ if (!rec.loc) continue
447
+ if (!rec.ref.deref()) continue
448
+ const k = `${rec.loc.file}:${rec.loc.line}:${rec.loc.col}`
449
+ // Decay rate1s to "now" — a node that hasn't fired in 5×TAU shows
450
+ // ≈0.7% of its steady-state rate; in 10×TAU, basically 0. This is
451
+ // what makes "stopped firing" visible in the editor.
452
+ const decayedRate =
453
+ rec.lastFire !== null
454
+ ? rec.rate1s * Math.exp(-(nowTs - rec.lastFire) / LPIH_RATE_TAU_MS)
455
+ : 0
456
+ const existing = byKey.get(k)
457
+ if (existing) {
458
+ existing.count += rec.fires
459
+ // Sum rates at same location (e.g. two distinct signals on one
460
+ // line via destructuring). Latest-fire wins for kind / lastFire.
461
+ existing.rate1s += decayedRate
462
+ if (
463
+ rec.lastFire !== null &&
464
+ (existing.lastFire === null || rec.lastFire > existing.lastFire)
465
+ ) {
466
+ existing.lastFire = rec.lastFire
467
+ existing.kind = rec.kind
468
+ }
469
+ } else {
470
+ byKey.set(k, {
471
+ loc: rec.loc,
472
+ count: rec.fires,
473
+ lastFire: rec.lastFire,
474
+ kind: rec.kind,
475
+ rate1s: decayedRate,
476
+ })
477
+ }
478
+ }
479
+ return [...byKey.values()]
480
+ }
481
+
269
482
  /** Bounded recent-fire timeline (oldest → newest). Fresh copy. */
270
483
  export function getReactiveFires(): ReactiveFire[] {
271
484
  if (_fireBuf === null || _fireCount === 0) return []
package/src/signal.ts CHANGED
@@ -1,6 +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
+ import { _captureCallerLocation, _rdRecordFire, _rdRegister } from './reactive-devtools'
4
4
  import { _recordSignalWrite } from './reactive-trace'
5
5
  import { notifySubscribers, trackSubscriber } from './tracking'
6
6
 
@@ -55,6 +55,17 @@ export interface Signal<T> {
55
55
  export interface SignalOptions {
56
56
  /** Debug name for this signal — shows up in devtools and debug() output. */
57
57
  name?: string
58
+ /**
59
+ * @internal — source location injected by `@pyreon/vite-plugin` at build
60
+ * time. When present, the runtime skips the `new Error().stack` capture
61
+ * in `_rdRegister` — saves ~2.2µs per signal creation when devtools is
62
+ * active. Plain user code should NOT set this; the field is opaque
63
+ * (no public type) so it's not part of the public API surface.
64
+ *
65
+ * Shape: `{ file: string; line: number; col: number }` matching
66
+ * `@pyreon/reactivity`'s `SourceLocation`.
67
+ */
68
+ __sourceLocation?: { file: string; line: number; col: number }
58
69
  }
59
70
 
60
71
  // Internal shape of a signal function — state stored as properties on the
@@ -234,8 +245,17 @@ export function signal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
234
245
  read.debug = _debug as () => SignalDebugInfo<T>
235
246
  read.label = options?.name
236
247
 
237
- if (process.env.NODE_ENV !== 'production')
238
- _rdRegister(read, 'signal', read, null, read.label)
248
+ if (process.env.NODE_ENV !== 'production') {
249
+ // Prefer build-time-injected location (zero runtime cost) over the
250
+ // ~2.2µs stack-capture fallback. @pyreon/vite-plugin's
251
+ // `injectSignalLocations` rewrites `signal(0)` to
252
+ // `signal(0, { __sourceLocation: {...} })` at transform time so most
253
+ // dev-mode signals never pay the stack-capture cost.
254
+ const loc = options?.__sourceLocation
255
+ ? options.__sourceLocation
256
+ : _captureCallerLocation(1)
257
+ _rdRegister(read, 'signal', read, null, read.label, loc)
258
+ }
239
259
 
240
260
  return read as unknown as Signal<T>
241
261
  }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Live Program Inlay Hints — source-location capture for signal/computed/effect.
3
+ *
4
+ * Validates that:
5
+ * 1. When devtools is INACTIVE, no stack capture happens (zero cost).
6
+ * 2. When devtools is ACTIVE, every reactive creation captures the
7
+ * USER's call site (not the framework's internal frames).
8
+ * 3. `getFireSummaries()` aggregates fires by location with the right
9
+ * shape (count, lastFire, kind) and dedupes multiple nodes at the
10
+ * same location.
11
+ * 4. Stack-line parsing handles V8, JSC, and Firefox formats.
12
+ * 5. `loc` field on `ReactiveNode` is surfaced via `getReactiveGraph()`.
13
+ */
14
+ import { afterEach, describe, expect, it } from 'vitest'
15
+ import { computed } from '../computed'
16
+ import { effect } from '../effect'
17
+ import {
18
+ _captureCallerLocation,
19
+ _parseStackLine,
20
+ activateReactiveDevtools,
21
+ deactivateReactiveDevtools,
22
+ getFireSummaries,
23
+ getReactiveGraph,
24
+ } from '../reactive-devtools'
25
+ import { signal } from '../signal'
26
+
27
+ afterEach(() => {
28
+ deactivateReactiveDevtools()
29
+ })
30
+
31
+ describe('LPIH — stack-line parser', () => {
32
+ it('parses V8 parenthesized form', () => {
33
+ const loc = _parseStackLine(
34
+ ' at userCode (/Users/test/app.ts:42:7)',
35
+ )
36
+ expect(loc).toEqual({ file: '/Users/test/app.ts', line: 42, col: 7 })
37
+ })
38
+
39
+ it('parses V8 anonymous form', () => {
40
+ const loc = _parseStackLine(' at /Users/test/app.ts:42:7')
41
+ expect(loc).toEqual({ file: '/Users/test/app.ts', line: 42, col: 7 })
42
+ })
43
+
44
+ it('parses JSC / SpiderMonkey form', () => {
45
+ const loc = _parseStackLine('userCode@/Users/test/app.ts:42:7')
46
+ expect(loc).toEqual({ file: '/Users/test/app.ts', line: 42, col: 7 })
47
+ })
48
+
49
+ it('returns undefined for unparseable lines', () => {
50
+ expect(_parseStackLine('garbage')).toBeUndefined()
51
+ expect(_parseStackLine('')).toBeUndefined()
52
+ })
53
+
54
+ it('handles file paths with colons (Windows-like or URL schemes)', () => {
55
+ const loc = _parseStackLine(
56
+ ' at userCode (file:///Users/test/app.ts:42:7)',
57
+ )
58
+ // The regex captures the LAST ":line:col" pair — `file` includes any
59
+ // earlier colons (file:/// prefix preserved). Editors handle that.
60
+ expect(loc?.line).toBe(42)
61
+ expect(loc?.col).toBe(7)
62
+ expect(loc?.file).toContain('app.ts')
63
+ })
64
+ })
65
+
66
+ describe('LPIH — zero-cost when inactive', () => {
67
+ it('_captureCallerLocation returns undefined when inactive', () => {
68
+ expect(_captureCallerLocation(0)).toBeUndefined()
69
+ expect(_captureCallerLocation(5)).toBeUndefined()
70
+ })
71
+
72
+ it('node creation does not allocate Error when inactive', () => {
73
+ // Indirect proof: capture happens INSIDE the active guard.
74
+ // No throw, no stack, no observable cost in the path.
75
+ const s = signal(0)
76
+ s.set(1)
77
+ const c = computed(() => s() + 1)
78
+ c()
79
+ // No nodes registered because inactive.
80
+ expect(getReactiveGraph().nodes).toEqual([])
81
+ })
82
+ })
83
+
84
+ describe('LPIH — __sourceLocation option (R4 build-time injection)', () => {
85
+ it('signal() prefers __sourceLocation over stack capture', () => {
86
+ activateReactiveDevtools()
87
+ // Simulate what @pyreon/vite-plugin's injectSignalNames emits:
88
+ const injected = { file: '/some/build/path.tsx', line: 99, col: 42 }
89
+ const s = signal(0, { name: 'test', __sourceLocation: injected })
90
+ const nodes = getReactiveGraph().nodes
91
+ expect(nodes).toHaveLength(1)
92
+ // The captured location is the INJECTED one, not the test file's location.
93
+ expect(nodes[0]?.loc?.file).toBe('/some/build/path.tsx')
94
+ expect(nodes[0]?.loc?.line).toBe(99)
95
+ expect(nodes[0]?.loc?.col).toBe(42)
96
+ void s
97
+ })
98
+
99
+ it('signal() falls back to stack capture when __sourceLocation is absent', () => {
100
+ activateReactiveDevtools()
101
+ const s = signal(0)
102
+ const nodes = getReactiveGraph().nodes
103
+ expect(nodes).toHaveLength(1)
104
+ // Stack capture → location is the test file.
105
+ expect(nodes[0]?.loc?.file).toContain('lpih-source-location.test.ts')
106
+ void s
107
+ })
108
+ })
109
+
110
+ describe('LPIH — source-location capture for signals', () => {
111
+ it('captures the user call site for signal()', () => {
112
+ activateReactiveDevtools()
113
+ const s = signal(0) // ← this line
114
+ const nodes = getReactiveGraph().nodes
115
+ expect(nodes).toHaveLength(1)
116
+ expect(nodes[0]?.loc).toBeDefined()
117
+ expect(nodes[0]?.loc?.file).toContain('lpih-source-location.test.ts')
118
+ // The line number should point at THIS source file, not signal.ts internals.
119
+ expect(nodes[0]?.loc?.line).toBeGreaterThan(0)
120
+ void s
121
+ })
122
+
123
+ it('different signals get different locations', () => {
124
+ activateReactiveDevtools()
125
+ const a = signal(0)
126
+ const b = signal(0)
127
+ const nodes = getReactiveGraph().nodes
128
+ expect(nodes).toHaveLength(2)
129
+ const lineA = nodes[0]?.loc?.line
130
+ const lineB = nodes[1]?.loc?.line
131
+ expect(lineA).toBeDefined()
132
+ expect(lineB).toBeDefined()
133
+ expect(lineA).not.toBe(lineB)
134
+ void a
135
+ void b
136
+ })
137
+ })
138
+
139
+ describe('LPIH — source-location capture for computed', () => {
140
+ it('captures the user call site for computed()', () => {
141
+ activateReactiveDevtools()
142
+ const s = signal(1)
143
+ const c = computed(() => s() * 2) // ← user site
144
+ c()
145
+ const nodes = getReactiveGraph().nodes
146
+ const derived = nodes.find((n) => n.kind === 'derived')
147
+ expect(derived?.loc).toBeDefined()
148
+ expect(derived?.loc?.file).toContain('lpih-source-location.test.ts')
149
+ })
150
+ })
151
+
152
+ describe('LPIH — source-location capture for effect', () => {
153
+ it('captures the user call site for effect()', () => {
154
+ activateReactiveDevtools()
155
+ const s = signal(0)
156
+ const e = effect(() => {
157
+ s()
158
+ })
159
+ const nodes = getReactiveGraph().nodes
160
+ const eff = nodes.find((n) => n.kind === 'effect')
161
+ expect(eff?.loc).toBeDefined()
162
+ expect(eff?.loc?.file).toContain('lpih-source-location.test.ts')
163
+ e.dispose()
164
+ })
165
+ })
166
+
167
+ describe('LPIH — getFireSummaries()', () => {
168
+ it('returns empty when no nodes have locations', () => {
169
+ activateReactiveDevtools()
170
+ // No creations yet
171
+ expect(getFireSummaries()).toEqual([])
172
+ })
173
+
174
+ it('aggregates fires by location', () => {
175
+ activateReactiveDevtools()
176
+ const s = signal(0)
177
+ s.set(1)
178
+ s.set(2)
179
+ s.set(3)
180
+ const summaries = getFireSummaries()
181
+ expect(summaries).toHaveLength(1)
182
+ expect(summaries[0]?.count).toBe(3)
183
+ expect(summaries[0]?.kind).toBe('signal')
184
+ expect(summaries[0]?.lastFire).not.toBeNull()
185
+ expect(summaries[0]?.loc.file).toContain('lpih-source-location.test.ts')
186
+ void s
187
+ })
188
+
189
+ it('produces one summary per unique location', () => {
190
+ activateReactiveDevtools()
191
+ const a = signal(0)
192
+ const b = signal(0)
193
+ a.set(1)
194
+ a.set(2)
195
+ b.set(1)
196
+ const summaries = getFireSummaries()
197
+ expect(summaries).toHaveLength(2)
198
+ const total = summaries.reduce((acc, s) => acc + s.count, 0)
199
+ expect(total).toBe(3)
200
+ void a
201
+ void b
202
+ })
203
+
204
+ it('captures fires across signal + computed + effect', () => {
205
+ activateReactiveDevtools()
206
+ const s = signal(0)
207
+ const c = computed(() => s() * 2)
208
+ let observed = 0
209
+ const e = effect(() => {
210
+ observed = c()
211
+ })
212
+ s.set(5)
213
+ s.set(10)
214
+ const summaries = getFireSummaries()
215
+ // Expect at least 3 distinct locations (signal, computed, effect).
216
+ expect(summaries.length).toBeGreaterThanOrEqual(3)
217
+ const kinds = new Set(summaries.map((sum) => sum.kind))
218
+ expect(kinds.has('signal')).toBe(true)
219
+ expect(kinds.has('derived')).toBe(true)
220
+ expect(kinds.has('effect')).toBe(true)
221
+ expect(observed).toBe(20)
222
+ e.dispose()
223
+ })
224
+ })
225
+
226
+ describe('LPIH — rate1s EWMA tracking', () => {
227
+ it('rate1s is 0 for a node that has not fired', () => {
228
+ activateReactiveDevtools()
229
+ const s = signal(0)
230
+ const summaries = getFireSummaries()
231
+ // Signal was created but never written → rate1s = 0
232
+ const summary = summaries.find((x) => x.kind === 'signal')
233
+ expect(summary?.rate1s).toBe(0)
234
+ void s
235
+ })
236
+
237
+ it('rate1s rises with rapid fires', () => {
238
+ activateReactiveDevtools()
239
+ const s = signal(0)
240
+ for (let i = 0; i < 10; i++) s.set(i + 1)
241
+ const summaries = getFireSummaries()
242
+ const summary = summaries.find((x) => x.kind === 'signal')
243
+ expect(summary?.rate1s).toBeGreaterThan(0)
244
+ void s
245
+ })
246
+
247
+ it('rate1s for many rapid fires reflects fire density (>1)', () => {
248
+ activateReactiveDevtools()
249
+ const s = signal(0)
250
+ // 100 fires in rapid succession — dt → 0, decay ≈ 1.0, so each fire
251
+ // adds ~+1 to rate1s. At read time the value is decayed by the small
252
+ // elapsed time → still well above the threshold.
253
+ for (let i = 0; i < 100; i++) s.set(i + 1)
254
+ const summaries = getFireSummaries()
255
+ const summary = summaries.find((x) => x.kind === 'signal')
256
+ expect(summary?.rate1s).toBeGreaterThan(10)
257
+ void s
258
+ })
259
+
260
+ it('rate1s decays to ≈0 after the time constant elapses', async () => {
261
+ activateReactiveDevtools()
262
+ const s = signal(0)
263
+ s.set(1)
264
+ const initial = getFireSummaries().find((x) => x.kind === 'signal')
265
+ expect(initial?.rate1s).toBeGreaterThan(0.5)
266
+ // 1.5s = 1.5× TAU → rate1s should drop to exp(-1.5) ≈ 0.22× initial.
267
+ await new Promise((r) => setTimeout(r, 1500))
268
+ const decayed = getFireSummaries().find((x) => x.kind === 'signal')
269
+ expect(decayed?.rate1s).toBeLessThan(0.5)
270
+ void s
271
+ })
272
+
273
+ it('exported LPIH_RATE_TAU_MS constant equals 1000 (1 second)', async () => {
274
+ const { LPIH_RATE_TAU_MS } = await import('../reactive-devtools')
275
+ expect(LPIH_RATE_TAU_MS).toBe(1000)
276
+ })
277
+ })