@pyreon/reactivity 0.24.4 → 0.24.6
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/package.json +1 -4
- package/src/batch.ts +0 -196
- package/src/cell.ts +0 -72
- package/src/computed.ts +0 -313
- package/src/createSelector.ts +0 -109
- package/src/debug.ts +0 -134
- package/src/effect.ts +0 -467
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -60
- package/src/lpih.ts +0 -227
- package/src/manifest.ts +0 -660
- package/src/reactive-devtools.ts +0 -494
- package/src/reactive-trace.ts +0 -142
- package/src/reconcile.ts +0 -118
- package/src/resource.ts +0 -84
- package/src/scope.ts +0 -123
- package/src/signal.ts +0 -261
- package/src/store.ts +0 -250
- package/src/tests/batch.test.ts +0 -751
- package/src/tests/bind.test.ts +0 -84
- package/src/tests/branches.test.ts +0 -343
- package/src/tests/cell.test.ts +0 -159
- package/src/tests/computed.test.ts +0 -436
- package/src/tests/coverage-hardening.test.ts +0 -471
- package/src/tests/createSelector.test.ts +0 -291
- package/src/tests/debug.test.ts +0 -196
- package/src/tests/effect.test.ts +0 -464
- package/src/tests/fanout-repro.test.ts +0 -179
- package/src/tests/lpih-source-location.test.ts +0 -277
- package/src/tests/lpih.test.ts +0 -351
- package/src/tests/manifest-snapshot.test.ts +0 -96
- package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
- package/src/tests/reactive-devtools.test.ts +0 -296
- package/src/tests/reactive-trace.test.ts +0 -102
- package/src/tests/reconcile-security.test.ts +0 -45
- package/src/tests/resource.test.ts +0 -326
- package/src/tests/scope.test.ts +0 -231
- package/src/tests/signal.test.ts +0 -368
- package/src/tests/store.test.ts +0 -286
- package/src/tests/tracking.test.ts +0 -158
- package/src/tests/vue-parity.test.ts +0 -191
- package/src/tests/watch.test.ts +0 -246
- package/src/tracking.ts +0 -139
- package/src/watch.ts +0 -68
package/src/reactive-devtools.ts
DELETED
|
@@ -1,494 +0,0 @@
|
|
|
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
|
-
/**
|
|
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
|
-
|
|
50
|
-
export interface ReactiveNode {
|
|
51
|
-
id: number
|
|
52
|
-
kind: ReactiveNodeKind
|
|
53
|
-
/** Explicit `.label` for signals; synthetic (`derived#id`) otherwise. */
|
|
54
|
-
name: string
|
|
55
|
-
/** Bounded string preview of the current value (signals/derived only). */
|
|
56
|
-
value: string
|
|
57
|
-
/** Live downstream subscriber count. */
|
|
58
|
-
subscribers: number
|
|
59
|
-
/** Total times this node has fired/recomputed since activation. */
|
|
60
|
-
fires: number
|
|
61
|
-
/** `performance.now()` of the most recent fire, or null. */
|
|
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
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface ReactiveEdge {
|
|
73
|
-
/** Source node id (the reactive value being read). */
|
|
74
|
-
from: number
|
|
75
|
-
/** Subscriber node id (the computed/effect that read it). */
|
|
76
|
-
to: number
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface ReactiveGraph {
|
|
80
|
-
nodes: ReactiveNode[]
|
|
81
|
-
edges: ReactiveEdge[]
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export interface ReactiveFire {
|
|
85
|
-
id: number
|
|
86
|
-
/** `performance.now()` at fire time. */
|
|
87
|
-
ts: number
|
|
88
|
-
}
|
|
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
|
-
|
|
130
|
-
// ── Internal node record ─────────────────────────────────────────────────
|
|
131
|
-
|
|
132
|
-
interface NodeRec {
|
|
133
|
-
id: number
|
|
134
|
-
kind: ReactiveNodeKind
|
|
135
|
-
name: string
|
|
136
|
-
/** Weak handle to the read fn (signal/computed) — never pins the node. */
|
|
137
|
-
ref: WeakRef<object>
|
|
138
|
-
/** Weak handle to the subscriber-set host (signal read fn / computed host). */
|
|
139
|
-
hostRef: WeakRef<{ _s: Set<() => void> | null }> | null
|
|
140
|
-
fires: number
|
|
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
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
let _active = false
|
|
152
|
-
let _nextId = 1
|
|
153
|
-
// id → record. Records are pruned by the FinalizationRegistry the moment
|
|
154
|
-
// the underlying node is GC'd, so this Map never retains a dead node.
|
|
155
|
-
const _byId = new Map<number, NodeRec>()
|
|
156
|
-
// Subscriber-callback identity → node id. Lets `getReactiveGraph()`
|
|
157
|
-
// resolve `_s` Set membership (anonymous `recompute`/`run` closures)
|
|
158
|
-
// back to graph nodes for edge extraction. A WeakMap so a disposed
|
|
159
|
-
// effect's closure doesn't keep its id mapping alive.
|
|
160
|
-
const _subId = new WeakMap<object, number>()
|
|
161
|
-
|
|
162
|
-
/** @internal — finalizer callback; prunes the record when a node is GC'd. */
|
|
163
|
-
export function _rdPrune(id: number): void {
|
|
164
|
-
_byId.delete(id)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// FinalizationRegistry is baseline since Node 14.6 / all modern browsers
|
|
168
|
-
// / Bun — the same universal-availability assumption the codebase already
|
|
169
|
-
// makes for WeakRef. No env guard (avoids an uncoverable dead branch).
|
|
170
|
-
const _finalizer = new FinalizationRegistry<number>(_rdPrune)
|
|
171
|
-
|
|
172
|
-
// Bounded fire ring buffer (Effects timeline). Same shape/rationale as
|
|
173
|
-
// reactive-trace.ts — fixed cap, primitives only, never grows.
|
|
174
|
-
const FIRE_CAP = 512
|
|
175
|
-
let _fireBuf: ReactiveFire[] | null = null
|
|
176
|
-
let _fireCount = 0
|
|
177
|
-
|
|
178
|
-
const PREVIEW_MAX = 60
|
|
179
|
-
|
|
180
|
-
function preview(v: unknown): string {
|
|
181
|
-
let s: string
|
|
182
|
-
try {
|
|
183
|
-
if (v === null) return 'null'
|
|
184
|
-
if (v === undefined) return 'undefined'
|
|
185
|
-
const t = typeof v
|
|
186
|
-
if (t === 'string') s = JSON.stringify(v) as string
|
|
187
|
-
else if (t === 'number' || t === 'boolean' || t === 'bigint') s = String(v)
|
|
188
|
-
else if (t === 'function')
|
|
189
|
-
s = `[Function ${(v as { name?: string }).name || 'anonymous'}]`
|
|
190
|
-
else if (t === 'symbol') s = (v as symbol).toString()
|
|
191
|
-
else if (Array.isArray(v)) s = `Array(${(v as unknown[]).length})`
|
|
192
|
-
else {
|
|
193
|
-
const ctor = (v as { constructor?: { name?: string } }).constructor?.name
|
|
194
|
-
let keys: string[] = []
|
|
195
|
-
try {
|
|
196
|
-
keys = Object.keys(v as object).slice(0, 3)
|
|
197
|
-
} catch {
|
|
198
|
-
keys = []
|
|
199
|
-
}
|
|
200
|
-
s = `${ctor && ctor !== 'Object' ? `${ctor} ` : ''}{${keys.join(', ')}${keys.length === 3 ? ', …' : ''}}`
|
|
201
|
-
}
|
|
202
|
-
} catch {
|
|
203
|
-
s = '[unstringifiable]'
|
|
204
|
-
}
|
|
205
|
-
return s.length > PREVIEW_MAX ? `${s.slice(0, PREVIEW_MAX)}…` : s
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Activate the bridge. Idempotent. Called when a devtools client attaches. */
|
|
209
|
-
export function activateReactiveDevtools(): void {
|
|
210
|
-
_active = true
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Deactivate + drop all retained state. Called when the devtools client
|
|
215
|
-
* disconnects so a closed panel leaves zero residue.
|
|
216
|
-
*/
|
|
217
|
-
export function deactivateReactiveDevtools(): void {
|
|
218
|
-
_active = false
|
|
219
|
-
_byId.clear()
|
|
220
|
-
_fireBuf = null
|
|
221
|
-
_fireCount = 0
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export function isReactiveDevtoolsActive(): boolean {
|
|
225
|
-
return _active
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ── Instrumentation entry points (called from the hot paths, but only
|
|
229
|
-
// after the existing prod gate; each is a no-op until activated) ──────
|
|
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
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Register a signal/computed/effect node. `host` is the object carrying
|
|
297
|
-
* the `_s` subscriber Set (the signal read fn itself, or a computed's
|
|
298
|
-
* internal host). `sub` is the notify closure (`recompute`/`run`) whose
|
|
299
|
-
* identity appears in upstream `_s` Sets — used to resolve edges.
|
|
300
|
-
*
|
|
301
|
-
* @internal
|
|
302
|
-
*/
|
|
303
|
-
export function _rdRegister(
|
|
304
|
-
node: object,
|
|
305
|
-
kind: ReactiveNodeKind,
|
|
306
|
-
host: { _s: Set<() => void> | null } | null,
|
|
307
|
-
sub: object | null,
|
|
308
|
-
label: string | undefined,
|
|
309
|
-
loc?: SourceLocation,
|
|
310
|
-
): number | undefined {
|
|
311
|
-
if (!_active) return undefined
|
|
312
|
-
const id = _nextId++
|
|
313
|
-
_byId.set(id, {
|
|
314
|
-
id,
|
|
315
|
-
kind,
|
|
316
|
-
name: label ?? `${kind === 'signal' ? 'signal' : kind}#${id}`,
|
|
317
|
-
ref: new WeakRef(node),
|
|
318
|
-
hostRef: host ? new WeakRef(host) : null,
|
|
319
|
-
fires: 0,
|
|
320
|
-
lastFire: null,
|
|
321
|
-
loc,
|
|
322
|
-
rate1s: 0,
|
|
323
|
-
})
|
|
324
|
-
if (sub) _subId.set(sub, id)
|
|
325
|
-
_finalizer.register(node, id)
|
|
326
|
-
// Stash the id on the node so fire events correlate in O(1). Every node
|
|
327
|
-
// we register is a framework-created function/closure (signal/computed
|
|
328
|
-
// `read`, effect `run`) — always extensible, so defineProperty cannot
|
|
329
|
-
// throw here; no defensive try/catch (it would be an uncoverable dead
|
|
330
|
-
// branch).
|
|
331
|
-
Object.defineProperty(node, '__pxRdId', {
|
|
332
|
-
value: id,
|
|
333
|
-
enumerable: false,
|
|
334
|
-
configurable: true,
|
|
335
|
-
})
|
|
336
|
-
return id
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Record that a node fired (signal write / computed recompute / effect
|
|
341
|
-
* run). Bumps counters + appends to the bounded fire buffer.
|
|
342
|
-
*
|
|
343
|
-
* @internal
|
|
344
|
-
*/
|
|
345
|
-
export function _rdRecordFire(node: object): void {
|
|
346
|
-
if (!_active) return
|
|
347
|
-
const id = (node as { __pxRdId?: number }).__pxRdId
|
|
348
|
-
if (id === undefined) return
|
|
349
|
-
const rec = _byId.get(id)
|
|
350
|
-
const ts =
|
|
351
|
-
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
352
|
-
? performance.now()
|
|
353
|
-
: Date.now()
|
|
354
|
-
if (rec) {
|
|
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
|
-
}
|
|
370
|
-
rec.lastFire = ts
|
|
371
|
-
}
|
|
372
|
-
if (_fireBuf === null) _fireBuf = new Array<ReactiveFire>(FIRE_CAP)
|
|
373
|
-
_fireBuf[_fireCount % FIRE_CAP] = { id, ts }
|
|
374
|
-
_fireCount++
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ── Snapshot API (consumed by the devtools hook) ─────────────────────────
|
|
378
|
-
|
|
379
|
-
function resolveSubId(sub: () => void): number | undefined {
|
|
380
|
-
const direct = (sub as { __pxRdId?: number }).__pxRdId
|
|
381
|
-
if (direct !== undefined) return direct
|
|
382
|
-
return _subId.get(sub)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Fresh snapshot of the live reactive graph. Edges are recomputed from
|
|
387
|
-
* each live node's current subscriber Set — always consistent with the
|
|
388
|
-
* framework's real subscription state, no incremental drift.
|
|
389
|
-
*/
|
|
390
|
-
export function getReactiveGraph(): ReactiveGraph {
|
|
391
|
-
const nodes: ReactiveNode[] = []
|
|
392
|
-
const edges: ReactiveEdge[] = []
|
|
393
|
-
for (const rec of _byId.values()) {
|
|
394
|
-
const node = rec.ref.deref()
|
|
395
|
-
if (!node) continue
|
|
396
|
-
const host = rec.hostRef?.deref() ?? null
|
|
397
|
-
const subs = host?._s ?? null
|
|
398
|
-
// `preview()` is total (its own try/catch returns '[unstringifiable]'),
|
|
399
|
-
// and `_v` on our registered nodes is a plain property (signal) or a
|
|
400
|
-
// getter that never throws (computed's getter routes errors through
|
|
401
|
-
// `_errorHandler` and returns the stale value). No defensive wrapper
|
|
402
|
-
// here — it would be an uncoverable dead branch.
|
|
403
|
-
const valueStr =
|
|
404
|
-
rec.kind === 'effect' ? '' : preview((node as { _v?: unknown })._v)
|
|
405
|
-
nodes.push({
|
|
406
|
-
id: rec.id,
|
|
407
|
-
kind: rec.kind,
|
|
408
|
-
name: rec.name,
|
|
409
|
-
value: valueStr,
|
|
410
|
-
subscribers: subs?.size ?? 0,
|
|
411
|
-
fires: rec.fires,
|
|
412
|
-
lastFire: rec.lastFire,
|
|
413
|
-
...(rec.loc ? { loc: rec.loc } : {}),
|
|
414
|
-
})
|
|
415
|
-
if (subs) {
|
|
416
|
-
for (const cb of subs) {
|
|
417
|
-
const to = resolveSubId(cb)
|
|
418
|
-
if (to !== undefined) edges.push({ from: rec.id, to })
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
return { nodes, edges }
|
|
423
|
-
}
|
|
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
|
-
|
|
482
|
-
/** Bounded recent-fire timeline (oldest → newest). Fresh copy. */
|
|
483
|
-
export function getReactiveFires(): ReactiveFire[] {
|
|
484
|
-
if (_fireBuf === null || _fireCount === 0) return []
|
|
485
|
-
if (_fireCount <= FIRE_CAP) return _fireBuf.slice(0, _fireCount)
|
|
486
|
-
const start = _fireCount % FIRE_CAP
|
|
487
|
-
const out: ReactiveFire[] = []
|
|
488
|
-
for (let i = 0; i < FIRE_CAP; i++) {
|
|
489
|
-
const e = _fireBuf[(start + i) % FIRE_CAP]
|
|
490
|
-
if (e) out.push(e)
|
|
491
|
-
}
|
|
492
|
-
return out
|
|
493
|
-
}
|
|
494
|
-
|
package/src/reactive-trace.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactive trace — a bounded, dev-only ring buffer of recent signal
|
|
3
|
-
* writes. When a signal-based UI throws, the single most useful
|
|
4
|
-
* debugging question is "what reactive state changed in the run-up to
|
|
5
|
-
* the crash" — a point-in-time snapshot of every signal value can't
|
|
6
|
-
* answer that (it shows the end state, not the causal sequence). The
|
|
7
|
-
* ring buffer records the last N writes so an error report can attach
|
|
8
|
-
* the sequence that led into the bad state.
|
|
9
|
-
*
|
|
10
|
-
* Design constraints:
|
|
11
|
-
*
|
|
12
|
-
* - **Bounded memory.** Fixed-size circular buffer (`CAP` entries).
|
|
13
|
-
* Never grows. Old entries overwrite oldest-first.
|
|
14
|
-
* - **No value retention.** Stores a TRUNCATED STRING preview of
|
|
15
|
-
* prev / next, never the raw value. Holding raw references would
|
|
16
|
-
* retain large arrays / detached DOM / closures in the buffer and
|
|
17
|
-
* leak them for the buffer's lifetime. The preview is also what
|
|
18
|
-
* makes the trace safely serializable into an error report.
|
|
19
|
-
* - **Cheap.** No stack capture (that's the expensive part of the
|
|
20
|
-
* `onSignalUpdate` debug path — this is deliberately lighter so it
|
|
21
|
-
* can record every write in dev without a perf cost). One object
|
|
22
|
-
* literal + one array slot write per signal write.
|
|
23
|
-
* - **Zero production cost.** The single call site in `signal.ts`
|
|
24
|
-
* is inside the existing `process.env.NODE_ENV !== 'production'`
|
|
25
|
-
* gate, so the whole module tree-shakes out of prod bundles.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
export interface ReactiveTraceEntry {
|
|
29
|
-
/** Signal `label` (set via `signal(v, { name })` or the vite plugin's dev auto-naming). `undefined` for anonymous signals. */
|
|
30
|
-
name: string | undefined
|
|
31
|
-
/** Bounded string preview of the value before the write. */
|
|
32
|
-
prev: string
|
|
33
|
-
/** Bounded string preview of the value after the write. */
|
|
34
|
-
next: string
|
|
35
|
-
/** `performance.now()` at write time (monotonic; survives clock changes). */
|
|
36
|
-
timestamp: number
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Ring-buffer capacity. 50 entries is enough to see the causal chain
|
|
41
|
-
* for a crash (the writes in the few ticks before the throw) without
|
|
42
|
-
* the buffer itself becoming a memory concern — each entry is a small
|
|
43
|
-
* object with two short strings.
|
|
44
|
-
*/
|
|
45
|
-
const CAP = 50
|
|
46
|
-
|
|
47
|
-
// Lazily allocated — apps that never write a signal in dev (rare) pay
|
|
48
|
-
// nothing until the first write. `_count` is the monotonic total write
|
|
49
|
-
// count; `_count % CAP` is the next slot. Reading reconstructs
|
|
50
|
-
// chronological order from the wrapped buffer.
|
|
51
|
-
let _buf: (ReactiveTraceEntry | undefined)[] | null = null
|
|
52
|
-
let _count = 0
|
|
53
|
-
|
|
54
|
-
/** Max characters of a value preview before truncation. Keeps the buffer + serialized report small. */
|
|
55
|
-
const PREVIEW_MAX = 80
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Safe, bounded stringification. Never throws (a getter or `toJSON`
|
|
59
|
-
* that throws must not break the trace recorder), never returns more
|
|
60
|
-
* than `PREVIEW_MAX` chars + an ellipsis marker.
|
|
61
|
-
*/
|
|
62
|
-
function preview(v: unknown): string {
|
|
63
|
-
let s: string
|
|
64
|
-
try {
|
|
65
|
-
if (v === null) return 'null'
|
|
66
|
-
if (v === undefined) return 'undefined'
|
|
67
|
-
const t = typeof v
|
|
68
|
-
if (t === 'string') s = JSON.stringify(v) as string
|
|
69
|
-
else if (t === 'number' || t === 'boolean' || t === 'bigint') s = String(v)
|
|
70
|
-
else if (t === 'function') s = `[Function ${(v as { name?: string }).name || 'anonymous'}]`
|
|
71
|
-
else if (t === 'symbol') s = (v as symbol).toString()
|
|
72
|
-
else if (Array.isArray(v)) s = `Array(${(v as unknown[]).length})`
|
|
73
|
-
else {
|
|
74
|
-
// Plain-ish object: show the constructor name + a shallow key
|
|
75
|
-
// hint. Avoid full JSON.stringify — it can be huge or throw on
|
|
76
|
-
// cycles / BigInt / getters.
|
|
77
|
-
const ctor = (v as { constructor?: { name?: string } }).constructor?.name
|
|
78
|
-
const keys = (() => {
|
|
79
|
-
try {
|
|
80
|
-
return Object.keys(v as object).slice(0, 4)
|
|
81
|
-
} catch {
|
|
82
|
-
return []
|
|
83
|
-
}
|
|
84
|
-
})()
|
|
85
|
-
s = `${ctor && ctor !== 'Object' ? ctor + ' ' : ''}{${keys.join(', ')}${keys.length === 4 ? ', …' : ''}}`
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
s = '[unstringifiable]'
|
|
89
|
-
}
|
|
90
|
-
return s.length > PREVIEW_MAX ? s.slice(0, PREVIEW_MAX) + '…' : s
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Record one signal write. Called from `signal.ts` `_set`, already
|
|
95
|
-
* inside the prod-gate, so this never runs in production builds.
|
|
96
|
-
*
|
|
97
|
-
* @internal
|
|
98
|
-
*/
|
|
99
|
-
export function _recordSignalWrite(name: string | undefined, prev: unknown, next: unknown): void {
|
|
100
|
-
if (_buf === null) _buf = new Array(CAP)
|
|
101
|
-
_buf[_count % CAP] = {
|
|
102
|
-
name,
|
|
103
|
-
prev: preview(prev),
|
|
104
|
-
next: preview(next),
|
|
105
|
-
timestamp:
|
|
106
|
-
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
107
|
-
? performance.now()
|
|
108
|
-
: Date.now(),
|
|
109
|
-
}
|
|
110
|
-
_count++
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Returns the recorded writes in chronological order (oldest → newest),
|
|
115
|
-
* at most `CAP` entries. Empty array when nothing has been recorded.
|
|
116
|
-
* The returned array is a fresh copy — safe to retain / serialize
|
|
117
|
-
* without pinning the ring buffer.
|
|
118
|
-
*
|
|
119
|
-
* Consumed by `@pyreon/core`'s `reportError` to attach `reactiveTrace`
|
|
120
|
-
* to the error context.
|
|
121
|
-
*/
|
|
122
|
-
export function getReactiveTrace(): ReactiveTraceEntry[] {
|
|
123
|
-
if (_buf === null || _count === 0) return []
|
|
124
|
-
if (_count <= CAP) {
|
|
125
|
-
// Buffer not yet wrapped — entries 0.._count-1 are in order.
|
|
126
|
-
return _buf.slice(0, _count) as ReactiveTraceEntry[]
|
|
127
|
-
}
|
|
128
|
-
// Wrapped: oldest entry is at `_count % CAP`, walk forward CAP slots.
|
|
129
|
-
const start = _count % CAP
|
|
130
|
-
const out: ReactiveTraceEntry[] = []
|
|
131
|
-
for (let i = 0; i < CAP; i++) {
|
|
132
|
-
const e = _buf[(start + i) % CAP]
|
|
133
|
-
if (e) out.push(e)
|
|
134
|
-
}
|
|
135
|
-
return out
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Clears the buffer. For test isolation; not part of the app-facing API. */
|
|
139
|
-
export function clearReactiveTrace(): void {
|
|
140
|
-
_buf = null
|
|
141
|
-
_count = 0
|
|
142
|
-
}
|