@pyreon/reactivity 0.23.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/src/lpih.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Live Program Inlay Hints — runtime bridge.
3
+ *
4
+ * Writes the current `getFireSummaries()` snapshot to a JSON file that
5
+ * the LSP server reads via the `PYREON_LPIH_CACHE` env var. This is the
6
+ * file-cache bridge mechanism — chosen over IPC/WebSocket because:
7
+ *
8
+ * 1. LSP servers are stdio-only — they can't easily talk to a browser.
9
+ * 2. Filesystem is a universal lowest-common-denominator transport.
10
+ * 3. The runtime side writes (atomic rename); the LSP side reads.
11
+ * 4. The LSP re-reads the file on every inlay-hint request, so live
12
+ * edits land immediately without coordination.
13
+ *
14
+ * Two consumer modes:
15
+ *
16
+ * **Dev-server polled mode**: a dev-server hook calls
17
+ * `writeLpihCache(path)` on every signal write or at a regular interval
18
+ * (e.g. 250ms throttle). The LSP picks it up on next inlay-hint request.
19
+ *
20
+ * **On-demand mode**: a test harness or devtools UI calls
21
+ * `writeLpihCache(path)` explicitly when it wants the LSP to see the
22
+ * current state.
23
+ *
24
+ * Atomic write semantics: writes to `<path>.tmp.<pid>.<seq>` then renames
25
+ * to `<path>`. Readers (the LSP server) never see a half-written file.
26
+ *
27
+ * Zero-cost when devtools is inactive: `getFireSummaries()` returns []
28
+ * unless `activateReactiveDevtools()` has been called. So calling
29
+ * `writeLpihCache()` against an inactive registry writes an empty
30
+ * `{ fires: [] }` — cheap, correct.
31
+ */
32
+
33
+ import { getFireSummaries } from './reactive-devtools'
34
+
35
+ let _seq = 0
36
+
37
+ /**
38
+ * Canonical filename for the LPIH cache file. Co-located with the
39
+ * project — convention: `<cwd>/.pyreon-lpih.json`. The dot-prefix marks
40
+ * it as a hidden / generated file by filesystem convention; the
41
+ * extension makes its contents grep-able as JSON.
42
+ *
43
+ * @internal — exported for tests + symmetry with the LSP-side default.
44
+ */
45
+ export const LPIH_DEFAULT_FILENAME = '.pyreon-lpih.json'
46
+
47
+ /**
48
+ * Resolve the default LPIH cache path for the current process. The path
49
+ * is **`<cwd>/.pyreon-lpih.json`** — co-located with the project so the
50
+ * LSP can auto-discover it by walking up from any source file.
51
+ *
52
+ * Returns null in environments without `process.cwd()` (e.g. a fresh
53
+ * web worker without polyfills) — callers should fall back to an
54
+ * explicit path argument.
55
+ *
56
+ * @example
57
+ * import { startLpihPolling, getDefaultLpihCachePath } from '@pyreon/reactivity/lpih'
58
+ * console.log(getDefaultLpihCachePath()) // → '/Users/me/proj/.pyreon-lpih.json'
59
+ * startLpihPolling() // writes to that path
60
+ */
61
+ export function getDefaultLpihCachePath(): string | null {
62
+ if (typeof process === 'undefined') return null
63
+ // Pyreon's reactivity package narrows `process` to `{ env: ... }`.
64
+ // Cast through the runtime check so the call site typechecks under
65
+ // browser-target tsconfig while still working in Node where cwd exists.
66
+ const proc = process as unknown as { cwd?: () => string }
67
+ if (typeof proc.cwd !== 'function') return null
68
+ try {
69
+ const cwd = proc.cwd()
70
+ // Use forward-slash join; works on POSIX + Windows (Node accepts both).
71
+ return `${cwd.replace(/[/\\]+$/, '')}/${LPIH_DEFAULT_FILENAME}`
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Snapshot `getFireSummaries()` and write it to `path` atomically.
79
+ * Returns the number of fires written.
80
+ *
81
+ * **Path resolution**: when `path` is omitted, defaults to
82
+ * `<cwd>/.pyreon-lpih.json` (`getDefaultLpihCachePath()`). The LSP
83
+ * auto-discovers this convention by walking up from any source file to
84
+ * the nearest `package.json` — so projects that use the default need
85
+ * zero env-var configuration.
86
+ *
87
+ * Errors (filesystem permission, EACCES, etc.) are caught and re-thrown
88
+ * — the caller decides whether to swallow them. The runtime side wraps
89
+ * this in a try/catch when called from hot paths.
90
+ *
91
+ * Throws if `path` is omitted AND no default can be resolved (e.g.
92
+ * a web worker without `process.cwd()`).
93
+ *
94
+ * @example
95
+ * import { activateReactiveDevtools } from '@pyreon/reactivity'
96
+ * import { writeLpihCache } from '@pyreon/reactivity/lpih'
97
+ *
98
+ * activateReactiveDevtools()
99
+ * await writeLpihCache() // → writes to <cwd>/.pyreon-lpih.json
100
+ * // The LSP server auto-discovers this path; no env var needed.
101
+ */
102
+ export async function writeLpihCache(path?: string): Promise<number> {
103
+ const resolvedPath = path ?? getDefaultLpihCachePath()
104
+ if (resolvedPath === null) {
105
+ throw new Error(
106
+ '[lpih] writeLpihCache: no path provided and no default could be resolved ' +
107
+ '(process.cwd() unavailable). Pass an explicit path.',
108
+ )
109
+ }
110
+ return await _writeToPath(resolvedPath)
111
+ }
112
+
113
+ async function _writeToPath(path: string): Promise<number> {
114
+ const summaries = getFireSummaries()
115
+ const payload = {
116
+ fires: summaries.map((s) => ({
117
+ file: s.loc.file,
118
+ line: s.loc.line,
119
+ count: s.count,
120
+ kind: s.kind,
121
+ lastFire: s.lastFire,
122
+ rate1s: s.rate1s,
123
+ })),
124
+ }
125
+ const pid =
126
+ typeof process !== 'undefined' && 'pid' in process
127
+ ? (process as { pid?: number }).pid ?? 0
128
+ : 0
129
+ const tmp = `${path}.tmp.${pid}.${++_seq}`
130
+ const fs = await import('node:fs/promises')
131
+ // Single try/catch covering BOTH writeFile AND rename. The previous
132
+ // shape only guarded the rename — if `fs.writeFile` itself threw (disk
133
+ // full, EIO, EACCES, transient FS error), the partial tmp file leaked
134
+ // on disk with a unique PID+seq name. The same bug class lived in the
135
+ // vite-plugin's `writeLpihCacheFile` (R1); both fixed in lockstep.
136
+ try {
137
+ await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
138
+ await fs.rename(tmp, path)
139
+ } catch (err) {
140
+ // Rename / writeFile failed — clean up the tmp file so we don't leak
141
+ // it on disk. Covers BOTH paths: writeFile-failed (tmp may not exist
142
+ // → unlink ENOENT, swallowed) AND rename-failed (tmp exists). Common
143
+ // rename causes: cross-device link (rare; same dir → same FS), target
144
+ // is a directory, EACCES. The caller sees the original error; the
145
+ // cleanup is best-effort and silent (unlink may also fail if the FS
146
+ // is broken — re-throwing that would mask the real problem).
147
+ try {
148
+ await fs.unlink(tmp)
149
+ } catch {
150
+ /* swallow — original error is more useful */
151
+ }
152
+ throw err
153
+ }
154
+ return summaries.length
155
+ }
156
+
157
+ /**
158
+ * Polling helper: call `writeLpihCache(path)` every `intervalMs`. Returns
159
+ * a disposer that stops the timer.
160
+ *
161
+ * **Path resolution**: same as `writeLpihCache` — `path` defaults to
162
+ * `<cwd>/.pyreon-lpih.json` when omitted. The LSP auto-discovers this
163
+ * convention so projects need zero configuration.
164
+ *
165
+ * Useful for dev servers that want the LSP to see live updates. The
166
+ * interval is throttled (not debounced); a fast-firing signal won't
167
+ * generate one write per fire. 250-500ms is the recommended range.
168
+ *
169
+ * Throws synchronously if `path` is omitted AND no default can be
170
+ * resolved — the caller catches this once at startup rather than
171
+ * silently never writing.
172
+ *
173
+ * @example
174
+ * import { activateReactiveDevtools } from '@pyreon/reactivity'
175
+ * import { startLpihPolling } from '@pyreon/reactivity/lpih'
176
+ *
177
+ * if (import.meta.env.DEV) {
178
+ * activateReactiveDevtools()
179
+ * startLpihPolling() // writes to <cwd>/.pyreon-lpih.json every 250ms
180
+ * }
181
+ */
182
+ export function startLpihPolling(
183
+ path?: string,
184
+ intervalMs = 250,
185
+ ): () => void {
186
+ const resolvedPath = path ?? getDefaultLpihCachePath()
187
+ if (resolvedPath === null) {
188
+ throw new Error(
189
+ '[lpih] startLpihPolling: no path provided and no default could be resolved ' +
190
+ '(process.cwd() unavailable). Pass an explicit path.',
191
+ )
192
+ }
193
+ return _startPollingAt(resolvedPath, intervalMs)
194
+ }
195
+
196
+ function _startPollingAt(path: string, intervalMs: number): () => void {
197
+ let active = true
198
+ let timer: ReturnType<typeof setTimeout> | null = null
199
+ const tick = async (): Promise<void> => {
200
+ if (!active) return
201
+ try {
202
+ // Skip the default-resolution check on every tick — path is already
203
+ // resolved at startup.
204
+ await _writeToPath(path)
205
+ } catch {
206
+ // Swallow — polling continues. The LSP degrades gracefully if the
207
+ // file is missing or stale.
208
+ }
209
+ if (active) {
210
+ timer = setTimeout(tick, intervalMs)
211
+ // .unref() so a forgotten startLpihPolling() doesn't block process
212
+ // exit. Node-only; the setTimeout return type in browsers is a
213
+ // number with no .unref. Type-narrow defensively.
214
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
215
+ ;(timer as { unref(): void }).unref()
216
+ }
217
+ }
218
+ }
219
+ void tick()
220
+ return () => {
221
+ active = false
222
+ if (timer !== null) {
223
+ clearTimeout(timer)
224
+ timer = null
225
+ }
226
+ }
227
+ }
@@ -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
  }