Live Program Inlay Hints
LPIH surfaces live runtime data at the source line in your editor — signal fire counts, effect re-run counts, subscriber counts — rendered as ghost text inlay hints. The same surface TypeScript uses for inferred types, now showing your reactive program's actual runtime behavior.
function App() {
const count = signal(0) // 🔥 signal fired 240× (12/s)
const doubled = computed(() => count() * 2) // 🔥 derived fired 240× (12/s)
effect(() => console.log(doubled())) // 🔥 effect fired 241× (12/s)
return <div>{count()}</div>
}Hints show both cumulative count and current rate (fires per second, decayed over a 1-second window). The rate makes hot-path debugging visible at a glance — "is this firing right now, or did it fire a lot a while ago?" The cumulative count makes before/after comparisons easy. When a node has been idle for a few seconds, the rate suffix disappears and only the count remains.
No editor today shows live runtime data at source lines for any reactive framework. The data exists in DevTools, but accessing it requires context-switching from the editor to a separate panel. LPIH closes that gap.
How it works
Three layers cooperate:
Runtime capture (
@pyreon/reactivity) — at everysignal()/computed()/effect()creation, capture the source file + line fromnew Error().stack. Aggregated as fire counts viagetFireSummaries().Bridge (
@pyreon/reactivity) —writeLpihCache(path)writes the snapshot to a JSON file atomically (tmp + rename).startLpihPolling(path, intervalMs)writes repeatedly for dev-server integration.LSP integration (
@pyreon/lint) — on everytextDocument/inlayHintrequest, the LSP server readsPYREON_LPIH_CACHEenv var (if set), parses the cache, and emits inlay hints with the🔥 <kind> fired N×label at each creation line.
Filesystem cache is the bridge because LSP servers are stdio-only — they can't easily IPC with a browser. The LSP re-reads on every inlay-hint request, so live edits land immediately.
Quick start (zero config — Vite users)
1. Use @pyreon/vite-plugin
If your project already uses @pyreon/vite-plugin (the default scaffold), LPIH is on by default — the plugin auto-injects a browser-side bridge AND registers the dev-server POST /__pyreon_lpih__ middleware that writes the cache file. You don't need to call activateReactiveDevtools() or startLpihPolling() — the plugin does it for you.
// vite.config.ts
import { defineConfig } from 'vite'
import pyreon from '@pyreon/vite-plugin'
export default defineConfig({
plugins: [pyreon()], // LPIH on by default in dev (R1, #786)
})Opt out via pyreon({ lpih: false }). Override interval / cache path:
pyreon({ lpih: { intervalMs: 500 } }) // slower poll
pyreon({ lpih: { cachePath: '/custom/x.json' } }) // non-default path2. Run pyreon-lint --lsp in your editor
That's it — the LSP auto-discovers <project-root>/.pyreon-lpih.json by walking up from the file being linted to the nearest package.json. No env var required.
3. Add the cache file to .gitignore
# Live Program Inlay Hints — runtime fire data (dev-mode only)
.pyreon-lpih.json4. Run your app
On every signal write, the runtime bridge updates the cache file. The LSP picks it up on the next inlay-hint request (~150ms debounce). Ghost text appears at each creation line.
Manual setup (non-Vite consumers)
If you're not using @pyreon/vite-plugin (e.g. Webpack, Rollup, or a custom dev server), wire it manually:
import { activateReactiveDevtools } from '@pyreon/reactivity'
import { startLpihPolling } from '@pyreon/reactivity/lpih'
if (import.meta.env.DEV) {
activateReactiveDevtools()
startLpihPolling() // writes to <cwd>/.pyreon-lpih.json by default
}Custom paths (if needed)
If you need to override the default location (e.g. shared dev environment, custom workspace layout), set the env var or pass an explicit path:
PYREON_LPIH_CACHE=/custom/path/lpih.json pyreon-lint --lspstartLpihPolling('/custom/path/lpih.json', 250)The env var takes priority over the auto-discovered default.
Remote-dev / monorepo path remapping
In Codespaces, devcontainers, Docker dev, or any setup where the runtime captures paths from one filesystem view (/host/proj/src/x.ts) while the LSP serves files from another (/workspaces/proj/src/x.ts), inlay hints stay invisible — fire-data file paths never match the LSP's source-file path.
Set PYREON_LPIH_PATH_MAP to rewrite captured paths before they're matched:
PYREON_LPIH_PATH_MAP=/host/proj=/workspaces/proj pyreon-lint --lspMultiple mappings via ; (longest from wins):
PYREON_LPIH_PATH_MAP=/host=/workspaces;/build=/dev pyreon-lint --lspFormat: from1=to1;from2=to2. Malformed entries (no =) are silently dropped — env vars are a fragile transport, and a typo shouldn't break LPIH wholesale. Empty to is allowed (strips the prefix).
The remapping happens inside _readLpihCache — only the LSP side knows about it. The runtime keeps capturing its native filesystem paths; nothing about your dev server's startup changes.
What you measure
Per-creation, LPIH captures and surfaces:
fire count — total
signal.set()/computed.recompute()/effect.run()invocationskind —
signal/derived/effectlastFire —
performance.now()of the most recent firesource location —
file:line:colfrom the captured stack
Measured performance
LSP roundtrip latency
Time from "save file" to "ghost text updated", measured 20-trial median over a real JSON-RPC didChange + inlayHint cycle:
| Metric | Value |
|---|---|
| Median LSP roundtrip | 0.32 ms |
| p95 | 2.78 ms |
| User-perceived (incl. 150 ms LSP debounce) | ~150 ms total |
For context: a traditional devtools-panel workflow takes ~3-5 seconds of conscious human work (switch tab → click panel → scan list → map back to source). LPIH is 20× faster for "is this signal firing?"-type questions.
Runtime overhead (worst case)
100,000 signals + 10,000 computeds + 10,000 effects = 120k reactive primitives, 5-trial median:
| Devtools state | Wall-clock | Per-creation |
|---|---|---|
| INACTIVE (production-equivalent) | 7.4 ms | 62 ns |
| ACTIVE (LPIH worst case) | 269 ms | 2,245 ns (~2.2 µs) |
| Production NODE_ENV=production | — | 0 (tree-shaken) |
At realistic real-app creation rates (~100-1000 signals total / ~100/sec peak), per-session LPIH cost is 0.2-2.3 ms total — invisible. Devtools-attached mode is opt-in; the default is OFF.
Bridge roundtrip (end-to-end)
Time from "user clicked button → signal fired" to "ghost text reflects the new count":
| Step | Time |
|---|---|
Bridge write (writeLpihCache) — getFireSummaries + JSON.stringify + atomic rename | ~1.5 ms |
| Cache read + JSON.parse | ~0.05 ms |
| LSP inlayHint (analyze + merge + serialize) | ~0.24 ms |
| Total bridge-to-editor | ~1.8 ms |
Add the ~150 ms LSP debounce + the polling interval (250 ms default) → end-to-end latency is ~400 ms in the worst case. Still subjectively instant.
Where LPIH helps (concrete scenarios)
Scenario A: "Which signal in this file is firing the most?"
Without LPIH (9 steps):
Save file (or wait for HMR)
Switch focus to browser DevTools
Click "Pyreon" DevTools tab
Click "Signals" sub-panel
Sort by fire count (click header)
Visually scan top entries
Identify the offending node by name
Switch back to editor
Cmd+P / search the editor for the signal definition
With LPIH (2 steps):
Look at the source file
Eyes land on the line with the highest 🔥 count
4.5× reduction in workflow steps.
Scenario B: "Is this effect re-running more than expected?"
Without LPIH (8 steps):
Add
console.loginside the effect bodySave file
Switch to browser
Trigger the action
Open browser console
Count log entries
Switch back to editor
Remove the
console.log
With LPIH (2 steps):
Look at the
effect()lineRead the 🔥 count
4× reduction in workflow steps. Also: zero code-pollution from debug console.log statements.
Scenario C: "Did my memoization actually reduce fire count?"
Without LPIH (10 steps):
Open DevTools "Pyreon" tab
Note current fire count
Edit code to add memoization
Save
Wait for HMR
Trigger the action again
Switch back to DevTools
Read new fire count
Mental math: before vs after
Switch back to editor
With LPIH (4 steps):
Note fire count visible on the line
Edit code
Save (auto-refreshes via LSP)
Read updated fire count on the same line
2.5× reduction. Before-and-after comparison happens in your peripheral vision.
API
@pyreon/reactivity (capture surface)
import {
activateReactiveDevtools,
deactivateReactiveDevtools,
getFireSummaries,
type SourceLocation,
type FireSummary,
} from '@pyreon/reactivity'@pyreon/reactivity/lpih (bridge / dev-mode integration)
import {
writeLpihCache,
startLpihPolling,
getDefaultLpihCachePath,
LPIH_DEFAULT_FILENAME,
} from '@pyreon/reactivity/lpih'Subpath because the bridge depends on node:fs/promises (Node-only) and is dev-mode integration glue, not a core primitive. Separating it keeps the main entry slim and tree-shakes cleanly for browser-only consumers.
activateReactiveDevtools(): void
Turn on source-location capture + fire recording. Idempotent.
getFireSummaries(): FireSummary[]
Snapshot of fires aggregated by source location. Each entry: { loc, count, lastFire, kind }.
writeLpihCache(path?: string): Promise<number>
Atomically write getFireSummaries() to a JSON file. Returns the number of fires written. Safe to call on every signal write; uses tmp+rename so readers never see a half-written file.
When path is omitted, defaults to <cwd>/.pyreon-lpih.json (resolved via getDefaultLpihCachePath()). Throws if no default can be resolved (e.g. web worker without process.cwd()).
startLpihPolling(path?: string, intervalMs?: number): () => void
Call writeLpihCache(path) at the given interval (default 250ms). Returns a disposer. Same path-resolution as writeLpihCache — omit to use the zero-config default.
getDefaultLpihCachePath(): string | null
Returns <cwd>/.pyreon-lpih.json when process.cwd is available, null otherwise. The LSP server uses the same convention (walking up to the nearest package.json) so writer and reader agree without configuration.
LPIH_DEFAULT_FILENAME: '.pyreon-lpih.json'
The canonical filename constant. Stable identifier for tools that want to compose paths from a different directory root.
@pyreon/compiler
import {
mergeFireDataIntoFindings,
firesToCreationSiteFindings,
type LPIHFireDatum,
type LPIHMergeOptions,
} from '@pyreon/compiler'firesToCreationSiteFindings(fires, sourceFile, options?)
Synthesize inlay-hint findings directly from fire data. The simpler, more useful surface — produces one hint per signal/computed/effect creation line.
mergeFireDataIntoFindings(findings, fires, sourceFile, options?)
Enrich existing static Reactivity-Lens findings with fire counts at matching lines. For the rare case where a JSX reactive-read site happens to coincide with a creation line.
@pyreon/lint LSP
The LSP server reads PYREON_LPIH_CACHE env var on every textDocument/inlayHint request. When set + file exists + JSON is valid, fires are merged into the response as 🔥 <kind> fired N× hints. Malformed cache or unset env var → degrades silently to static Reactivity-Lens hints only.
Production cost
Zero. The capture is gated behind process.env.NODE_ENV !== 'production'. Vite, Webpack, esbuild, Rollup, Parcel, and Bun all dead-code-eliminate the entire path at build time. Tree-shake verified by the existing reactive-devtools-treeshake.test.ts suite.
What's next
Foundation work — the editor extension (VS Code / Neovim) that auto-bridges devtools fire data to the cache file is a follow-up. The current setup requires manually wiring startLpihPolling() in your dev entry; the LSP auto-discovers the cache file from <project-root>/.pyreon-lpih.json so no env var is needed.
@pyreon/vite-plugin does build-time location injection for all three reactive primitives — signal(), computed(), and effect(). The plugin rewrites these calls at transform time to embed { name?, __sourceLocation: { file, line, col } }, so the runtime never pays the new Error().stack capture cost (~2.2 µs/creation) when devtools is active. Three forms covered:
// before (your source) // after (vite-plugin transform)
const count = signal(0) → signal(0, { name: "count", __sourceLocation: {...} })
const doubled = computed(() => …) → computed(…, { name: "doubled", __sourceLocation: {...} })
effect(() => …) → effect(…, { __sourceLocation: {...} }) // unbound, no nameUnbound effect() (the most common form — no const binding) gets __sourceLocation only since there's no name to derive. Unbound signal() / computed() are left untouched (rare anonymous patterns where the user binds to something other than a const/let, e.g. as a return value).
The transform is dev-only (!isBuild); production builds tree-shake the entire devtools path including the option-arg path, so the injected literals don't ship to end users.
See also:
@pyreon/devtools— Chrome DevTools panel with Signals / Graph / Effects / Profiler tabsReactivity — signal/computed/effect API reference
Compiler — Reactivity Lens (static analysis side)