@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.
- package/README.md +141 -36
- package/lib/_chunks/reactive-devtools-BCpGoGZ5.js +280 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +16 -173
- package/lib/lpih.js +177 -0
- package/lib/types/index.d.ts +116 -2
- package/lib/types/lpih.d.ts +111 -0
- package/package.json +6 -1
- package/src/computed.ts +47 -6
- package/src/effect.ts +33 -4
- package/src/index.ts +8 -0
- package/src/lpih.ts +227 -0
- package/src/reactive-devtools.ts +213 -0
- package/src/signal.ts +23 -3
- package/src/tests/lpih-source-location.test.ts +277 -0
- package/src/tests/lpih.test.ts +351 -0
package/src/reactive-devtools.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|