@pyreon/reactivity 0.24.5 → 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.
Files changed (44) hide show
  1. package/package.json +1 -4
  2. package/src/batch.ts +0 -196
  3. package/src/cell.ts +0 -72
  4. package/src/computed.ts +0 -313
  5. package/src/createSelector.ts +0 -109
  6. package/src/debug.ts +0 -134
  7. package/src/effect.ts +0 -467
  8. package/src/env.d.ts +0 -6
  9. package/src/index.ts +0 -60
  10. package/src/lpih.ts +0 -227
  11. package/src/manifest.ts +0 -660
  12. package/src/reactive-devtools.ts +0 -494
  13. package/src/reactive-trace.ts +0 -142
  14. package/src/reconcile.ts +0 -118
  15. package/src/resource.ts +0 -84
  16. package/src/scope.ts +0 -123
  17. package/src/signal.ts +0 -261
  18. package/src/store.ts +0 -250
  19. package/src/tests/batch.test.ts +0 -751
  20. package/src/tests/bind.test.ts +0 -84
  21. package/src/tests/branches.test.ts +0 -343
  22. package/src/tests/cell.test.ts +0 -159
  23. package/src/tests/computed.test.ts +0 -436
  24. package/src/tests/coverage-hardening.test.ts +0 -471
  25. package/src/tests/createSelector.test.ts +0 -291
  26. package/src/tests/debug.test.ts +0 -196
  27. package/src/tests/effect.test.ts +0 -464
  28. package/src/tests/fanout-repro.test.ts +0 -179
  29. package/src/tests/lpih-source-location.test.ts +0 -277
  30. package/src/tests/lpih.test.ts +0 -351
  31. package/src/tests/manifest-snapshot.test.ts +0 -96
  32. package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
  33. package/src/tests/reactive-devtools.test.ts +0 -296
  34. package/src/tests/reactive-trace.test.ts +0 -102
  35. package/src/tests/reconcile-security.test.ts +0 -45
  36. package/src/tests/resource.test.ts +0 -326
  37. package/src/tests/scope.test.ts +0 -231
  38. package/src/tests/signal.test.ts +0 -368
  39. package/src/tests/store.test.ts +0 -286
  40. package/src/tests/tracking.test.ts +0 -158
  41. package/src/tests/vue-parity.test.ts +0 -191
  42. package/src/tests/watch.test.ts +0 -246
  43. package/src/tracking.ts +0 -139
  44. package/src/watch.ts +0 -68
@@ -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
-
@@ -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
- }