# @pyreon/reactivity

## 0.44.0

### Patch Changes

- [#2166](https://github.com/pyreon/pyreon/pull/2166) [`d859370`](https://github.com/pyreon/pyreon/commit/d8593704b0941ef0e51a427147ebce2a385ecae3) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Verify-mode dependency reuse: effect/computed re-runs no longer tear down + rebuild their dependency subscriptions. Steady-state re-runs verify the previous dep list positionally (one identity compare per read — zero Set operations, zero allocations; preact-signals' versioned-node reuse semantics adapted to Pyreon's array/Set architecture with the signal-side notify path byte-identical). Measured: 2.0–2.2× effect propagation, 1.85× batch-50, 2.6× 10k-effect re-run throughput, memory-neutral. Internals-only — public API unchanged; effects/computeds now also keep an EXACT dependency list on every run, which fixes two latent bugs: a nested `effect()` creation no longer silently drops the outer effect's later `onCleanup()` registrations, and a dependency read after a dirty computed's first evaluation is now recorded (previously the subscription leaked past `dispose()`). Lazy computeds additionally unsubscribe stale conditional-branch dependencies on re-evaluation.

## 0.43.1

## 0.43.0

## 0.42.0

## 0.41.2

## 0.41.1

## 0.41.0

## 0.40.0

### Minor Changes

- [#2098](https://github.com/pyreon/pyreon/pull/2098) [`ed364d2`](https://github.com/pyreon/pyreon/commit/ed364d2a34f4b74df94c02f3c2e630b96a4f2e7f) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Add `signal.trigger()` — force subscribers to re-run WITHOUT changing the value.

  Signals gate on `Object.is`, so `set(sameReference)` is a no-op — which means
  mutating a held object IN PLACE (a `Map`, a class instance, an external store
  like a TanStack table) and re-setting the same reference never re-renders.
  `trigger()` is the escape hatch: mutate the object, then call `trigger()` to
  re-run everything that read the signal (Vue's `triggerRef` semantic, and the
  signal-side counterpart to `computed(fn, { equals })`).

  Zero cost on the default path: it's a shared `SignalProto` method (no per-signal
  field or memory change) and it duplicates `set()`'s batch-aware notify block
  rather than refactoring it, so the hot write path stays byte-identical.
  `wrapSignal` facades forward it to their base. Prefer immutable `set(newObject)`
  when practical; reach for `trigger()` only when you deliberately own a mutable
  value.

### Patch Changes

- [#2037](https://github.com/pyreon/pyreon/pull/2037) [`c184330`](https://github.com/pyreon/pyreon/commit/c184330594a7726c4f1f1095cc3a785cfe9ef3f7) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Add `_rdNodeId(x)` (dev-only) — reads the reactive-graph node id stashed on a signal/computed callable or `effect()` handle, returning `undefined` for non-reactive values or production builds. The `effect()` handle now also carries the id (mirrored from its internal run closure). This is the name-stable accessor `@pyreon/testing`'s reactive matchers target so they never reach for the internal `__pxRdId` property directly. Zero production impact — tree-shaken with the rest of the `__DEV__` reactive-devtools bridge.

## 0.39.0

### Minor Changes

- [#1968](https://github.com/pyreon/pyreon/pull/1968) [`fa95aba`](https://github.com/pyreon/pyreon/commit/fa95aba3aebc24d0178093cd89870b8807beca72) Thanks [@vitbokisch](https://github.com/vitbokisch)! - feat(reactivity): Reactive Coverage — "which reactive edges never fired?"

  New `@pyreon/reactivity/coverage` subpath. Code coverage tells you a _line_ ran; Reactive Coverage tells you a _reactive update fired_. Run your tests, then see every signal / computed / effect whose reactive behaviour was **never exercised** — dead reactivity (a signal nobody changes, an effect that only runs at mount) or an untested reactive path. No other framework has a name for this; Pyreon can measure it because the runtime already holds a precise model of the reactive graph (the always-on dev registry that powers LPIH).

  **API** (dev/test only — tree-shaken in production):

  - `startReactiveCoverage()` / `takeReactiveCoverage(): ReactiveCoverageReport` / `stopReactiveCoverage()` — session helpers. `start` resets a clean baseline, pins nodes so an unmounting component isn't GC-pruned out of the denominator, and enables reads.
  - `computeReactiveCoverage(nodes)` — the pure function behind the report (feed it `getReactiveGraph().nodes`); `classifyReactiveNode(node)` — the single-node classifier; `formatReactiveCoverage(report, opts?)` — a dependency-free text renderer.

  **Coverage is kind-aware.** A "fire" is a value-changing signal write / computed recompute / effect run (the initial run counts): signals are covered when they change (`fires ≥ 1`); effects and computeds are covered only when they RE-run past their mount run (`fires ≥ 2`). The `ran-once` reason flags a mounted effect/computed whose reactive re-run was never triggered — a reactive behaviour your suite never exercised, which a line-coverage tool reports as 100%.

  Try it: `bun scripts/demo-reactive-coverage.ts`.

- [#1975](https://github.com/pyreon/pyreon/pull/1975) [`794fb27`](https://github.com/pyreon/pyreon/commit/794fb27e6fa67e71608b603cd627cf4eff61a102) Thanks [@vitbokisch](https://github.com/vitbokisch)! - feat(reactivity): describeReactiveGraph — auto-generated behavioral docs from the reactive graph

  No framework auto-generates _behavioral_ (not API) docs from the reactive graph. Pyreon can — `describeReactiveGraph()` turns the live dependency graph into an English summary of what each change actually _does_, plus health insights only the graph shape can surface.

  ```text
  Reactive graph — 3 signals · 1 derived · 1 effect · 3 edges

  Signals:
    qty            changing it re-derives 1 value and runs 1 effect
    unitPrice      changing it re-derives 1 value and runs 1 effect
    shippingFlat   nothing reacts to it (no dependents)
  Derived:
    total          recomputes when qty, unitPrice change
  Effects:
    effect#5       runs when total changes

  Insights (1):
    ⚠ orphan-signal nothing depends on `shippingFlat` — dead reactivity or an unused signal
  ```

  `describeReactiveGraph(graph?)` returns `{ summary, nodes, insights }` — each `nodes[]` entry has an English `behavior` one-liner, and `insights` flag behavioral smells: `orphan-signal` (nothing depends on it), `high-fanout` (a change re-runs many effects — a hot signal), `deep-chain` (end of a long dependency chain). `formatGraphDescription(desc)` renders the block above. Pure over `getReactiveGraph()`; dev/test only (tree-shaken in production). Pairs with `getUpdateCause` — this describes the whole graph, that explains one update.

- [#1974](https://github.com/pyreon/pyreon/pull/1974) [`f7083e5`](https://github.com/pyreon/pyreon/commit/f7083e5a56768fb67e097ec9bc6ee6d1bc6e0d09) Thanks [@vitbokisch](https://github.com/vitbokisch)! - feat(reactivity): "why did this update?" — source-anchored causal traces

  `getUpdateCause(nodeId)` + `formatUpdateCause(cause)` reconstruct the exact causal chain that led to a reactive node's most recent update, at the source line — the thing React DevTools' whole-component "why did this render?" can't do. Pyreon can, because it holds both a precise dependency graph and a timestamped fire timeline.

  ```text
  Why did effect#4 (effect) update?
    qty (signal) changed  src/Cart.tsx:7:13
    → total (derived) recomputed  src/Cart.tsx:9:9
    → effect#4 (effect) ran   ← explained
  ```

  `getUpdateCause` returns `{ target, chain, rootReached }` — `chain` is root-first (`chain[0]` is the originating signal write), each `CauseLink` carries `{ id, kind, name, loc, ts }`. Also surfaced on `window.__PYREON_DEVTOOLS__.reactive.getUpdateCause` / `.formatUpdateCause`.

  **Zero hot-path cost** — purely read-time reconstruction over the existing `getReactiveGraph()` + `getReactiveFires()`. The dependency graph is the causal structure (not the fire timeline: a lazy computed recomputes DURING its subscriber's read, so temporal order ≠ causal order); reconstruction walks the graph from the target through the deps that fired in the same synchronous cascade. Exact for a synchronous update, best-effort across interleaved interactions, `rootReached: false` when earlier fires aged out of the ring buffer. Dev/test only (the registry is tree-shaken in production).

### Patch Changes

- [#1999](https://github.com/pyreon/pyreon/pull/1999) [`c82687c`](https://github.com/pyreon/pyreon/commit/c82687c07a2b2ba976787dea74bc891f72a1165a) Thanks [@vitbokisch](https://github.com/vitbokisch)! - perf(reactivity): −24% production bundle (9,254 → 7,014 B gz) — dev-tools machinery now fully tree-shakes

  The public dev-tools readers (`getReactiveGraph`, `getFireSummaries`, `getReactiveFires`, `getUpdateCause`, `describeReactiveGraph`, `getReactiveTrace`) are provably empty in production (the registry only fills under dev gates) — but as public exports they pinned ~2.2 KB gz of registry/stack-parse machinery into every whole-entry production bundle. Their bodies are now wrapped in dev-block guards that bundlers drop at parse time (an early prod-return only kills the bytes at minify time, AFTER tree-shaking's symbol-usage analysis — the machinery survived), and the `FinalizationRegistry` is `/* @__PURE__ */`-annotated so it DCEs once its dev-gated `register` call sites fold.

  Zero behavior change: in dev everything works exactly as before (675-test suite green); in production the readers return the same vacuous values they always did (`{nodes: [], edges: []}` / `[]` / `null`) — they just no longer ship the dead machinery behind them. Pure formatters (`formatUpdateCause`, `formatGraphDescription`) stay available in prod (a dev-captured cause can legitimately be formatted in a prod process, e.g. error-report tooling). Bundle budget ratcheted down 9,472 → 7,232 B so the win can't silently regress.

## 0.38.0

### Patch Changes

- [#1904](https://github.com/pyreon/pyreon/pull/1904) [`cfa422f`](https://github.com/pyreon/pyreon/commit/cfa422fdb6985e50c74e06cf0f4c1318213d6303) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Reactivity hardening (deep-analysis pass): fix three issues in the reactivity core.

  - **`createSelector` promoted-key leak** — `selector.subscribe`'s unsubscribe path deleted the updater from a promoted `boundSubs` Set but never dropped the now-empty key, so any key that ever had ≥2 bound subscribers leaked an empty `Set` for the selector's lifetime. The Set is now removed when its last subscriber leaves (matching the inline-1-subscriber branch).
  - **`EffectScope.stop()` owner-chain retention** — `stop()` now nulls `_parent` and `_contexts` alongside `_effects`/`_updateHooks`, so a disposed scope no longer retains its parent chain (and the parent's context Map) when a descendant scope outlives it. A stopped scope is never walked as a context owner, so this is pure GC cleanup with no behavior change.
  - **`batch.ts` `MAX_PASSES` silent production drop** — the cap-exceeded warning was dev-only, so production silently dropped queued effects, leaving the reactive graph half-applied. The condition now surfaces a `console.error` in production too.

- [#1901](https://github.com/pyreon/pyreon/pull/1901) [`0376a3d`](https://github.com/pyreon/pyreon/commit/0376a3ddc75dd1fbee582e7cabe98beb01d60073) Thanks [@vitbokisch](https://github.com/vitbokisch)! - perf: fast path for the explicit-`batch()` drain. Writes made INSIDE a `batch(() => …)` must coalesce, so they enqueue + drain via `drainQueuesLocked` instead of the inline-batch direct-dispatch path — and that drain paid an outer-loop + recompute-tier check + a per-pass `new Set()` even for the dominant case (a handful of non-cascading subscribers in a single pass), making a batched single-subscriber notify ~10× the unbatched fast path. The drain now (a) runs a single effects-only pass up front and returns if it produced no follow-up work — a cascade falls through to the general multi-pass loop as pass 2+, so run-counts + MAX_PASSES are identical; (b) reuses a persistent `_visitedScratch` Set instead of reallocating per pass. Provably equivalent (the fast pass IS pass 1 — same live-Set iteration + `_visitedThisPass` routing, so coalescing / diamond dedup / cross-pass self-re-fire are preserved); validated by the full reactivity suite (642) + runtime-dom suite (799) staying green + a dedicated fast-path parity suite (bisect-verified). Measured (controlled interleaved A/B): batched single-subscriber notify ~155 → ~126ns, 2-subscriber ~156 → ~122ns (~20%) — a framework-wide win for any `batch()` over subscribed signals (form bulk-updates, store patch on a subscribed store, etc.).

- [#1913](https://github.com/pyreon/pyreon/pull/1913) [`6ee46e7`](https://github.com/pyreon/pyreon/commit/6ee46e7dca1cb01aacaa7c61ef5dbbcf12b30668) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Reactivity perf-cleanup pass (deep-audit follow-up; every claim measured under `NODE_ENV=production`):

  - **`createStore` read-path speedups.** `wrap()` now checks the proxy cache before the 8 `instanceof` builtin checks + the `markRaw` symbol lookup (the cache hit is the dominant path — the `get` trap re-`wrap()`s every nested object on every read), and `getOrCreateSignal` does one `Map.get` instead of `has`+`get`. Measured: flat property read ~24.6 → 15.4 ns (−37%), deep nested read ~73 → 63 ns (−14%). Benefits every `createStore`/`shallowReactive`/`reconcile` consumer.
  - **Removed dead `effectDeps` WeakMap.** `trackSubscriber`'s WeakMap fallback branch was unreachable (every tracking path sets a deps collector before `activeEffect` goes live), so it never wrote the WeakMap and `cleanupEffect` always no-op'd. Removed the WeakMap, the dead branch, and `cleanupEffect`. −14 bytes off the `@pyreon/reactivity` minimal-import bundle, zero runtime change (it was dead code).

  No public API or behavior change.

## 0.37.1

## 0.37.0

## 0.36.0

## 0.35.0

## 0.34.0

### Patch Changes

- [#1601](https://github.com/pyreon/pyreon/pull/1601) [`66d44c5`](https://github.com/pyreon/pyreon/commit/66d44c58920bf81848e9ba858c413a88727a3c65) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Internal: remove provably-unreachable defensive branches + harden test coverage
  (no behavior change).

  `SizedMap.set`'s eviction and `Cell.listen`'s promote-to-Set both guarded a
  value that the surrounding invariant guarantees is always defined
  (`maxEntries >= 1` ⇒ non-empty map on evict; the promote branch only runs when
  a single listener exists). Replaced the dead `!== undefined` / truthy guards
  with a documented type assertion (the codebase's sanctioned pattern for
  provably-safe paths), eliminating uncoverable branches. SizedMap → 100% branch
  coverage; reactivity branch coverage improved. Added selector tests for the
  3rd-subscriber and selection-leaves-a-multi-subscriber-key paths.

  `@pyreon/head`'s `createNewTag` SSR guard is documented + `v8 ignore`d as the
  unreachable defensive guard it is (the only caller, `syncDom`, already returns
  on `document === undefined`); added a node-environment test that exercises the
  true SSR function-input path of `useHead`. head → 100% statements/functions/
  lines, 98.3% branches.

  `@pyreon/primitives`' web `<Button>` drops an uncoverable `?? {}` fallback in
  favor of a documented assertion (the `primary` key is statically defined).
  Added targeted tests for the residual web-primitive branches — plain-value
  (non-signal) `value`/`checked`, the asset-name `src` dispatch, and the defensive
  guard false-paths in Field/Text/Press/WebView. primitives → 100% across all four
  metrics.

  `@pyreon/runtime-server` gains SSR edge-case + dev-mode/prod-mode coverage
  (documenting that `__DEV__` is a module-load constant, so both gate sides need
  separate NODE_ENV runs) and three documented `v8 ignore`s for genuinely-
  unreachable defensive arms (the outside-ALS context-stack fallback, the
  For-symbol function-each the For component pre-resolves, the stream context-store
  nullish fallback). statements/functions/lines → 98%+, branches 88.4% → 95.2%
  (a pre-existing RED branch gate, now green). No behavior change.

  `@pyreon/create-zero`'s `listFiles` walk uses a plain `else` for the
  non-directory case (a template tree is files-or-dirs only — no symlinks), and
  gained `substitute` tests covering the unknown-`{{key}}`-kept-verbatim branch.
  create-zero → 100% statements/functions/lines, 98.7% branches (one defensive
  unreachable branch remains in the dep-version resolver).

## 0.33.0

## 0.32.0

### Minor Changes

- [#1503](https://github.com/pyreon/pyreon/pull/1503) [`0c1ea1e`](https://github.com/pyreon/pyreon/commit/0c1ea1e89e4228e84367efd5d2cb334808955a25) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Add canonical runtime environment flags `isServer` / `isClient` to `@pyreon/reactivity` (re-exported from `@pyreon/core`).

  `isServer` is `typeof document === 'undefined'` — the most reliable "is there a DOM" discriminator (more correct than `typeof window`, which misreports Deno and polyfilled Node). Plain runtime constants, evaluated once at module load: correct in every runtime with zero bundler configuration. Use them for small environment guards (module-level singletons, lazy globals, render output that differs server vs client); for heavy server-only code prefer a `/server` subpath export, and for DOM access inside a component prefer `onMount` / `effect` (which never run during SSR).

  Internally, this replaces seven hand-rolled `typeof window` / `typeof document` env consts across `router`, `hooks`, `url-state`, `elements`, `ui-core`, and `styler` with the single primitive — removing the drift (the copies disagreed on `window` vs `document`) and the inconsistency. Behavior is unchanged in browsers and Node; the `window` → `document` switch is a strict improvement for Deno / Web Workers.

  `@pyreon/lint`'s `no-window-in-ssr` rule now recognises an imported `isClient` / `isServer` (or `isBrowser` / `isSSR`) as an SSR guard — but only when imported from `@pyreon/reactivity` or `@pyreon/core`, so `if (isClient) window.x` / `if (isServer) return` / `if (!isClient) return` are clean while a same-named local `const isBrowser = true` or a foreign-source import stays flagged.

### Patch Changes

- [#1381](https://github.com/pyreon/pyreon/pull/1381) [`e36bbe5`](https://github.com/pyreon/pyreon/commit/e36bbe52e7f1417a703b4e6ce23281c448d9132f) Thanks [@vitbokisch](https://github.com/vitbokisch)! - perf(computed): ~45% smaller retained heap per computed via a prototype + plain-field shape

  `computed` and `computed(fn, { equals })` now store their state as plain fields
  on the read function and share `direct` + the `_v` getter via a single
  `ComputedProto` (mirroring `signal`'s `SignalProto`), instead of per-instance
  method closures plus three `Object.defineProperty` accessor getters
  (`_v`/`_d`/`_d1`). The accessor getters forced the function into V8 dictionary
  (slow-properties) mode; a structurally-faithful A/B (node `--expose-gc`,
  `NODE_ENV=production`, 100k items) measured the new shape at ~45% less retained
  heap per computed (lazy 1649→913 B, equals 1712→976 B, signal-inclusive).

  Behavior-identical and API-unchanged: 608 reactivity tests pass plus the
  runtime-dom / core / router / rx / store consumer suites; recompute and diamond
  throughput are at parity (within microbenchmark noise). `read` and `recompute`
  remain per-instance closures (their identity is stored in dependency subscriber
  Sets). `_d1`/`_d` are now plain data fields rather than accessor getters — code
  that reads them does so with identical syntax.

- [#1514](https://github.com/pyreon/pyreon/pull/1514) [`65ccdf2`](https://github.com/pyreon/pyreon/commit/65ccdf2ad95a16b676b58948acea51f957e5cf62) Thanks [@vitbokisch](https://github.com/vitbokisch)! - 5× faster effect propagation: unbatched signal writes now dispatch their notifications directly through an inline batch window instead of allocating a closure + round-tripping every callback through the pending queues. Single-subscriber notify drops from ~131ns to ~16ns; set→effect rerun from ~162ns to ~46ns. Semantics are unchanged by construction — cascades, diamond dedup, two-tier ordering, and multi-pass re-fire all drain through the same shared flush machinery (locked by 7 new parity specs + the existing property-based batch fuzz).

- [#1537](https://github.com/pyreon/pyreon/pull/1537) [`7f89196`](https://github.com/pyreon/pyreon/commit/7f89196dd3d99f61b0bba032481b9d389fdd8264) Thanks [@vitbokisch](https://github.com/vitbokisch)! - `createSelector` per-key subscriber storage uses an inline-first-subscriber slot (the signal `_d1` trick): the first `selector.subscribe(key, fn)` per key stores the bare function; a `Set` is allocated only when a second subscriber arrives for the same key. The dominant `<For>` + per-row `isSelected.subscribe(row.id, …)` shape has exactly one subscriber per key, so a 10k-row create previously allocated 10k single-entry Sets (measured at 14% of JS allocations in the bench profile). Measured: −1.0ms create-10k, −300µs create-1k (CI-clean) on the fair benchmark. Disposing a sole inline subscriber now deletes the key entry, also fixing unbounded Map growth across create/clear cycles with fresh keys.

## 0.31.0

## 0.30.0

### Minor Changes

- [#1338](https://github.com/pyreon/pyreon/pull/1338) [`960bb0f`](https://github.com/pyreon/pyreon/commit/960bb0f139839de49508d836878b98556b1c7d07) Thanks [@vitbokisch](https://github.com/vitbokisch)! - refactor(core): owner-based context — replace the global context stack

  Context resolution moved from a global mutable `Map[]` stack to an **owner
  chain**: each mounted component's `EffectScope` doubles as a context owner
  (`_parent` + `_contexts`), linked by the renderer so the chain mirrors the
  component tree. `provide()` writes onto the current owner; `useContext()` walks
  the owner chain; context is released when the scope is disposed.

  This deletes ~190 lines of snapshot / restore / dedup / identity-removal
  machinery whose only job was to fake tree-position across deferred mounts
  (`<Show>` / `<For>`) — and which was itself the source of the 321k-frame leak,
  the position-pop bug, and orphan frames. `@pyreon/core/src/context.ts` shrank
  425 → 236 lines, and the entire context-stack bug class is now structurally
  impossible.

  - **`@pyreon/reactivity`** (minor): `EffectScope` gains `_parent` / `_contexts`
    - `provideContext` / `lookupContext`; new exports `getContextOwner`,
      `setContextOwner`, `runWithContextOwner`.
  - **`@pyreon/core`** (minor): `provide` / `useContext` are owner-based
    (owner-first, stack-fallback for SSR + the `*-compat` layers' own
    stack-based provide/inject). The internal `captureContextStack`,
    `restoreContextStack`, and the `ContextSnapshot` type are no longer exported.
  - **`@pyreon/runtime-dom`** (patch): `mount` / `hydrate` establish the owner
    chain per component; `mountReactive` captures a single owner reference
    instead of a deduped stack snapshot.

  SSR is unchanged — it keeps the request-scoped stack (a synchronous top-down
  walk needs no band-aids). `provide` / `useContext` user APIs are unchanged.

  Perf (tight A/B vs the stack model): headline component create is neutral
  (within noise); the deferred-mount `<Show>` path is ~4% faster (the dedup +
  restore work is gone). Verified: ~3,200 unit tests + verify-modes 19/19 + 156
  real-Chromium e2e. A latent cross-test context leak (a `RouterContext` frame
  bleeding between tests) was exposed and fixed by the per-mount isolation.

- [#1345](https://github.com/pyreon/pyreon/pull/1345) [`b720267`](https://github.com/pyreon/pyreon/commit/b720267f0d9fbe260398c56d49834dc1dd2b09fb) Thanks [@vitbokisch](https://github.com/vitbokisch)! - feat(reactivity): `wrapSignal` primitive — fixes a latent state-tree bind bug + retires hand-rolled signal facades

  `@pyreon/reactivity` had no primitive for "a signal whose write runs a side
  effect" (persist, emit a patch, validate). So **two** packages hand-rolled the
  same signal-facade — `@pyreon/storage`'s `wrapBaseSignal` and
  `@pyreon/state-tree`'s `trackedSignal` — and a dedicated lint rule
  (`pyreon/storage-signal-v-forwarding`) existed only to police the contract.
  **A lint rule enforcing a wrapper invariant is the proof the primitive is
  missing.**

  New `wrapSignal(base, { set, update? })` builds a signal facade over `base`
  that delegates ALL reads — including the internal `_v` field and `.direct`
  that the compiler's `_bindText` / `_bindDirect` fast paths read directly,
  bypassing the call — by construction, and routes writes through `set`. The
  `_v`/`.direct` forwarding can no longer be forgotten.

  **Latent bug fixed:** `state-tree`'s `trackedSignal` forwarded neither
  `.direct` nor `_v`, so a model field bound via `{() => model.field()}` (the
  text fast path) rendered empty and stayed empty — the exact class
  `wrapBaseSignal` was created to prevent in storage, present and unfixed in
  state-tree. Routing it through `wrapSignal` fixes it. Bisect-verified by
  `state-tree/src/tests/tracked-signal-bind-contract.test.ts`.

  - `@pyreon/reactivity`: new `wrapSignal` + `WrapSignalOptions` exports.
  - `@pyreon/storage`: all 5 backends use `wrapSignal`; `wrap-base-signal.ts`
    deleted.
  - `@pyreon/state-tree`: `trackedSignal` uses `wrapSignal` (bug fix).

  `provide`/`useContext`-style user APIs are unchanged. The lint rule stays as
  defense for any future hand-rolled facade that bypasses the primitive.

### Patch Changes

- [#1346](https://github.com/pyreon/pyreon/pull/1346) [`6feb9d4`](https://github.com/pyreon/pyreon/commit/6feb9d4bc8cc873191bfe97fac0afb88d5135388) Thanks [@vitbokisch](https://github.com/vitbokisch)! - fix: Cloudflare (workerd) SSR deploy — guard singleton sentinel against undefined import.meta.url + inline the built SSR template

  Cloudflare Pages SSR (`mode: "ssr"` / `"isr"` with `adapter: "cloudflare"`) ran in workerd, where two assumptions of the SSR path broke (verified in the real runtime via `wrangler pages dev` — a Node-side test can't catch either):

  - **`@pyreon/reactivity`** — `normalizeLocation` no longer crashes when a runtime passes `undefined`/empty `import.meta.url` (workerd does). A bare `url.indexOf('?')` threw `Cannot read properties of undefined (reading 'indexOf')` at module init, taking down every `@pyreon`-based Cloudflare Worker at startup. The guard returns `"<unknown>"`; duplicate detection keys on the package name + location, so a single re-registering instance is idempotent (same-location early-return) and the only degraded case (two genuinely-distinct `<unknown>` instances → missed duplicate) is the documented safe failure mode and structurally unreachable in workerd's single bundle.
  - **`@pyreon/zero`** — the cloudflare adapter inlines the built `index.html` (with the hashed client entry) into `globalThis.__PYREON_SSR_TEMPLATE__` in `_worker.js` and dynamic-imports the handler, so the global is set before `createServer → readBuiltTemplate` evaluates. workerd has no filesystem, so the prior `readFileSync` template path couldn't reach the staged sibling → SSR rendered but shipped the dev `entry-client.ts` and never hydrated. `readBuiltTemplate` now reads the global first, falling back to `readFileSync` for Node runtimes (node/bun/vercel/netlify). Requires the `nodejs_compat` flag (the create-zero cloudflare scaffold sets it).

  Both `patch` — bug fixes, no public API change.

## 0.29.0

### Patch Changes

- [#1331](https://github.com/pyreon/pyreon/pull/1331) [`c54ce0f`](https://github.com/pyreon/pyreon/commit/c54ce0f284dab0335d9b597488ba75c6dea92b43) Thanks [@vitbokisch](https://github.com/vitbokisch)! - test(reactivity): add 17 real tests; branches 94.22% → 95.04% (clears MINIMUM_BRANCH_FLOOR=95)

  `branch-coverage-95-floor.test.ts` covers previously-uncov arms:

  - Signal idempotent dispose — second call hits `else if (self._d)` falsy arm (line 228)
  - Computed idempotent dispose for both computedLazy + computedWithEquals paths (lines 202, 342)
  - `NODE_ENV='production'` false arms in computedWithEquals via `vi.stubEnv` + `options.equals` dispatch (lines 268, 292, 358) — covers dev-counter `_rdRecordFire` and post-factory `_rdRegister` gates
  - renderEffect disposed-during-batch hits early-return (line 453 truthy arm) — write + dispose within same batch, run() flushes with disposed=true

  Threshold bumped: `branches: 94 → 95` in vitest.config.ts. Reactivity now clears the planned `MINIMUM_BRANCH_FLOOR=95` (PR [#1329](https://github.com/pyreon/pyreon/issues/1329)).

- [#1303](https://github.com/pyreon/pyreon/pull/1303) [`6d3e085`](https://github.com/pyreon/pyreon/commit/6d3e085183ec42883a842967afe22f806f0ea21d) Thanks [@vitbokisch](https://github.com/vitbokisch)! - test(reactivity): lift branches 88.03% → 94.22% via real tests (no v8-ignore)

  Added 96 real tests in `branch-coverage-real.test.ts` that exercise actually-uncovered branches via the public API. NO `/* v8 ignore */` annotations — every coverage gain comes from real observable behavior.

  Tests cover:

  - tracking.ts: cleanupEffect WeakMap path, notifySubscribers non-batching arms
  - batch.ts: MAX_PASSES bailout + labelled effects + production-mode gate
  - cell.ts: listen promotion, subscribe disposer across \_l/\_s
  - computed.ts: error catch in body, custom-equals short-circuit + throw, direct subscriber promotion-aware disposer
  - createSelector.ts: source effect short-circuit on Object.is-equal, dispose, subscribe-after-dispose
  - reconcile.ts: circular-source skip, raw-object assign, DANGEROUS_KEYS skip
  - scope.ts: addUpdateHook stopped/multi, notifyEffectRan dedup + throw, onScopeDispose no-scope
  - signal.ts: direct subscriber tiers, promotion-race disposer, signal-write-as-call warn, trace listener throw
  - singleton-sentinel.ts: PYREON_SINGLE_INSTANCE env override (warn/silent/throw), withSilent + withSilentSync refcount, legacy state backfill, negative-depth guard
  - lpih.ts: cwd-throw fallback, writeLpihCache + startLpihPolling fail paths, race-during-dispose
  - reactive-devtools.ts: same-location collision, host with null subs, JSC stack format, \_rdRecordFire on unregistered node, loc-resolution failure skip
  - debug.ts: inspectSignal subscriber-count fallback, anonymous-signal label
  - reactive-trace.ts: anonymous-function preview fallback
  - production-mode gates: dedicated `process.env.NODE_ENV='production'` test suite hitting dev-gate FALSE arms

  Adjust vitest thresholds to honest values:

  - statements: 95 → 98
  - branches: 88 → 94 (was 88 with comment "tracking.ts non-batching effectively unreachable"; sharpened to specifically document the 6 structurally-unreachable defensive arms)
  - lines: 94 → 99

  The remaining ~6 uncovered branches are structurally unreachable from the public API (notifyDirect non-batching, structurally-dead defensive arms, browser-only typeof process guards). Honestly named in the vitest.config.ts comment rather than papered over with annotations.

- [#1321](https://github.com/pyreon/pyreon/pull/1321) [`c2874df`](https://github.com/pyreon/pyreon/commit/c2874df8f2b07b19aaa7a64c2f9ff2ab6b11d2f0) Thanks [@vitbokisch](https://github.com/vitbokisch)! - fix: derive the singleton-sentinel version from package.json (was a stale hardcoded `0.24.6`)

  Every `@pyreon/*` package called `registerSingleton('@pyreon/X', '0.24.6', import.meta.url)`
  with a hardcoded version literal that the release process never bumped — so the
  duplicate-instance sentinel reported `0.24.6` for packages actually shipping
  `0.28.x`. The version is diagnostic-only (detection keys on module location, not
  version), but its diagnostic VALUE is exactly to surface a version skew between
  two installed copies — which a frozen literal silently defeats.

  Name + version are now derived from each package's own `package.json`
  (`import { name, version } from '../package.json' with { type: 'json' }`), so the
  diagnostic is always accurate and can never drift on release. The build inlines
  the strings (no `package.json` bloat); dev reads the live file. No new tooling
  needed — drift is structurally impossible.

## 0.28.1

### Patch Changes

- [#1210](https://github.com/pyreon/pyreon/pull/1210) [`9be0265`](https://github.com/pyreon/pyreon/commit/9be0265553ff756383b21f9c0ab556949d7cadb0) Thanks [@vitbokisch](https://github.com/vitbokisch)! - test(coverage): bulk-bump 31 packages' `statements` threshold 94 → 95 (already passing)

  PR 1 of the "whole-repo coverage ≥ 95%" initiative (user-approved sequence:
  by-gap-size, start with quick wins).

  Every package in this bump is **already reporting ≥ 95% actual** per
  `bun scripts/check-coverage.ts`. Locking the configured threshold in
  match prevents regressions and lets the `Coverage (Full)` CI gate enforce
  the new floor.

  **No runtime changes, no test additions** — pure config update.
  Drift-detection in `BELOW_FLOOR_EXEMPTIONS` was triggered for two
  exemption entries (`@pyreon/code`, `@pyreon/kinetic`) which had been
  listed with `currentStatements: 94`; updated to 95 with the new reason
  documenting the lift.

  Packages bumped (current actual in parens):

  - @pyreon/attrs (100), @pyreon/coolgrid (100), @pyreon/table (100), @pyreon/toast (100)
  - @pyreon/rocketstyle (99.41), @pyreon/primitives (99.26), @pyreon/i18n (99.21), @pyreon/validation (99.12)
  - @pyreon/rx (98.45), @pyreon/kinetic (98.24), @pyreon/feature (98.11), @pyreon/head (97.97), @pyreon/flow (97.94), @pyreon/form (97.94), @pyreon/document-primitives (97.82), @pyreon/preact-compat (97.68), @pyreon/server (97.54), @pyreon/svelte-compat (97.42), @pyreon/validate (98.69), @pyreon/dnd (97.33)
  - @pyreon/query (96.79), @pyreon/mcp (96.52), @pyreon/unistyle (96.36) [already 95], @pyreon/reactivity (96.13), @pyreon/connector-document (96.05), @pyreon/react-compat (96.03) [already 95]
  - @pyreon/storage (95.6), @pyreon/permissions (95.38), @pyreon/url-state (95.13), @pyreon/runtime-dom (95.02), @pyreon/code (95.02), @pyreon/core (95.68), @pyreon/vite-plugin (95.32)

  Pre-existing CI failures NOT addressed in this PR (separate follow-ups):

  - @pyreon/sized-map: 0% reported by check-coverage.ts (test detection bug — Tier 5)
  - @pyreon/styler: 93.16% < 94% threshold (Tier 3)
  - @pyreon/ui-core: 90.94% < 94% threshold (Tier 4)
  - @pyreon/zero: 91.65% < 94% threshold (Tier 4)
  - @pyreon/runtime-dom: branches 85.78% < 88% threshold (Tier 6)

  Next PR (Tier 2): close the < 1pt gaps on charts, elements, hooks,
  hotkeys, lint, router, state-tree with focused test additions.

- [#1208](https://github.com/pyreon/pyreon/pull/1208) [`3fb1733`](https://github.com/pyreon/pyreon/commit/3fb173327a5cda36f4150cf8ed66d5d97be4501c) Thanks [@vitbokisch](https://github.com/vitbokisch)! - test: lift @pyreon/reactivity branch coverage 87.07% → 88.03% to pass the gate

  `@pyreon/reactivity` was failing its own coverage gate (branches 87.07% vs
  the 90 default for `core` category). Two new test files target the
  specific uncovered branches:

  - `computed-direct-coverage.test.ts` — covers `computedWithEquals`'s
    `.direct()` two-tier subscriber path (lines 267, 280-281, 285,
    325, 329, 341-354 of computed.ts): disposed-guard early return,
    recompute-error path, multi-direct loop, `_d`/`_d1` getter access,
    Tier-1 dispose-after-promotion, Tier-2 promotion + dispose. Lifts
    computed.ts statements 87.09% → 98.7%, branches 78.37% → 87.83%.

  - `tracking-batch-coverage.test.ts` — covers `notifySubscribers`
    non-batching paths + batch's MAX_PASSES safeguard. `signal.set` always
    auto-wraps in `batch()`, so the non-batching branches in
    `notifySubscribers` (lines 81, 95-100 of tracking.ts) are only
    reachable from internal call sites — those tests document the contract
    but don't fully cover those lines via the public API.

  `vitest.config.ts` `coverageThresholds.branches` is set to 88 (matching
  actual 88.03%) — the remaining ~2pt gap to 90 is genuine "unreachable
  from public API" code (the non-batching `notifySubscribers` paths) that
  isn't worth the test-maintenance cost of synthesising internal call
  sites. Documented inline in the config.

  After this PR: reactivity passes its own gate; 477 tests pass; statements
  96.13%, branches 88.03%, functions 100%, lines 98.15%.

  Sets up the path toward raising the global floor 90 → 92 in follow-up
  PRs as other packages add comparable coverage work.

## 0.28.0

## 0.27.1

## 0.27.0

## 0.26.3

## 0.26.2

### Patch Changes

- [#1164](https://github.com/pyreon/pyreon/pull/1164) [`920b07b`](https://github.com/pyreon/pyreon/commit/920b07baf32770f28b5ecff2e9ea64d5d64e2bc8) Thanks [@vitbokisch](https://github.com/vitbokisch)! - **Synced 0.26.2 release** — first release after the release-pipeline hardening sequence ([#1160](https://github.com/pyreon/pyreon/issues/1160)–[#1163](https://github.com/pyreon/pyreon/issues/1163)).

  This release brings every publishable `@pyreon/*` package onto the same version trajectory and finalizes the bootstrap of 4 newly-published packages:

  - **Mainline 62 packages**: `0.26.1` → `0.26.2` (patch — no user-facing behavior change since 0.26.1; this release is the structural sync)
  - **Newly-bootstrapped packages**: catch up to the synced version
    - `@pyreon/validate`: `0.26.0` → `0.26.2`
    - `@pyreon/svelte-compat`: `0.17.0` → `0.26.2` (large jump — joins the framework's synced version line)
    - `@pyreon/primitives`: `0.1.0` → `0.26.2` (large jump — joins as part of PMTC Phase A)
    - `@pyreon/create-multiplatform`: `0.0.0` → `0.26.2` (first numbered release)
  - **Native compiler binaries (7)**: catch up from `0.24.2` to `0.26.2` via `release-native.yml` firing on the umbrella `v0.26.2` tag — closes the gap that opened when PR [#1153](https://github.com/pyreon/pyreon/issues/1153)'s cascade prevented the v0.26.1 tag from being pushed.

  After this release: all 66 publishable `@pyreon/*` packages on npm at one synced version. The release pipeline is now drift-protected by the `Release Readiness` CI gate ([#1162](https://github.com/pyreon/pyreon/issues/1162)) and self-healing against future first-publish 404s ([#1161](https://github.com/pyreon/pyreon/issues/1161)).

## 0.26.1

## 0.26.0

### Minor Changes

- [#1091](https://github.com/pyreon/pyreon/pull/1091) [`749c2f4`](https://github.com/pyreon/pyreon/commit/749c2f435909740ea43d528ebfc00a2155e64f74) Thanks [@vitbokisch](https://github.com/vitbokisch)! - perf(reactivity): share signal methods via prototype — drop 6 per-instance property assignments

  `signal()` used to assign all 6 methods (peek/set/update/subscribe/direct/debug) as own properties on every signal instance — 6 property writes per signal allocation. Replaced with a shared `SignalProto` object and a single `Object.setPrototypeOf(read, SignalProto)` call. Methods are now resolved via prototype chain.

  All signals share the same prototype → monomorphic call sites at every `signal.method()` invocation. V8 hidden classes stay stable across signals.

  Per-signal alloc cost: 6 method assignments + 3 state writes + 1 label write → 1 setPrototypeOf + 3 state writes + 1 label write.

  bench:fair (2 confirmation runs vs post-merge main baseline):

  | Test                      | Pyreon (compiled)       | Verdict                                               |
  | ------------------------- | ----------------------- | ----------------------------------------------------- |
  | create 1k                 | 12.90 → 10.50ms (−19%)  | 5-way tie → **OUTRIGHT LEADER**                       |
  | replace all rows          | 11.90 → 10.50ms (−12%)  | co-leader → still tied (was 17% behind Vue pre-merge) |
  | create 10k                | 124.25 → 113.80ms (−8%) | held outright                                         |
  | partial/select/swap/clear | within ±3% noise        | held                                                  |

  457/457 reactivity + 531 core + 683 runtime-dom + 543 router + 5 other downstream packages (3,241 tests total) pass with no failures.

### Patch Changes

- [#1052](https://github.com/pyreon/pyreon/pull/1052) [`885d6d9`](https://github.com/pyreon/pyreon/commit/885d6d95f02b9dd1b462c1ba1114ecf94350671a) Thanks [@vitbokisch](https://github.com/vitbokisch)! - perf(reactivity): cache `effect` / `_bind` re-run dispatch at setup (match `renderEffect`)

  `renderEffect` already pre-builds its tracked-fn closure at setup so re-runs skip the `snapshot !== null && _snapshotCapture` branch and the module-level read. `effect` and `_bind` did this work on every re-run — `effect` allocated a fresh `() => restore(snapshot, fn)` closure per non-first run; `_bind` re-evaluated the dual conditional every fire.

  Both now mirror `renderEffect`: the hook reference and snapshot are captured at setup, the dispatch closure is built once, and re-runs do a disposed-check + direct call. Behaviorally identical (457/457 reactivity tests pass, including the snapshot-capture-restore branch test). `bench:fair` against the post-[#1051](https://github.com/pyreon/pyreon/issues/1051) baseline shows no regression across the 7 js-framework-benchmark rows; absolute delta is within the bench's between-run noise (~±5%), so this is framed as a consistency / structural micro-opt, not a measured percentage win.

- [#982](https://github.com/pyreon/pyreon/pull/982) [`cc8e6ac`](https://github.com/pyreon/pyreon/commit/cc8e6ac08faaea4e486cbb09d1ea22404421e8b6) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Kanban audit (T4.2) — close all 6 walls (W18-W23).

  **W23 — P0 reactivity bug fix** (`@pyreon/reactivity`). `runUntracked`
  now suspends `_innerEffectCollector` in lock-step with `activeEffect`.
  Child component effects created inside `mountFor`'s `runUntracked` wrap
  (PR [#490](https://github.com/pyreon/pyreon/issues/490)) were auto-registered as inner effects of the For's outer
  effect, then silently disposed on the For's next re-run — breaking
  every effect-derived subscription in the child subtree on the first
  source-signal mutation. Was a SHOWSTOPPER for any Trello/Notion/Linear/
  spreadsheet-shaped app. Bisect-verified.

  **W21 — incidentally fixed by W23 patch.** For-with-computed-indirection
  shapes (nested inside outer For-with-mutating-source) now propagate
  correctly.

  **W22 — documented** (`@pyreon/core`). `For` JSDoc + `ForProps.children`
  JSDoc now carry the canonical fix pattern (pass ID, child reads its own
  data from store).

  **W18 — cross-list groupId** (`@pyreon/dnd`). `useSortable` accepts an
  optional `groupId` — two instances with the same `groupId` share a drop
  universe via `onCrossListDrop(item)` (source removes) +
  `onCrossListReceive(item, index)` (destination inserts). No `groupId`
  keeps per-instance isolation (backward compat).

  **W19 — auto-inject entry-client** (`@pyreon/zero`). `transformIndexHtml`
  hook injects `<script type="module" src="${entryClient}">` before
  `<!--pyreon-scripts-->` automatically. Configurable via
  `zero({ entryClient: '/src/main.ts' })` or `entryClient: false` to opt
  out. Default `/src/entry-client.ts`.

  **W20 — already covered** by existing `pyreon/no-map-in-jsx` rule —
  test extended for the reactive-accessor shape `{() => items().map(...)}`.

  Closes the kanban example end-to-end. Full add → delete → filter →
  multi-mutation → reload sequence is green in real-Chromium e2e.

- [#918](https://github.com/pyreon/pyreon/pull/918) [`ba09525`](https://github.com/pyreon/pyreon/commit/ba09525e947ebff5573222332bd0f1548fcfae77) Thanks [@vitbokisch](https://github.com/vitbokisch)! - perf(reactive-devtools): close LPIH capture caveat from [#913](https://github.com/pyreon/pyreon/issues/913) via deferred `.stack` parsing

  The PR-[#913](https://github.com/pyreon/pyreon/issues/913)-followup caveat ("`_captureCallerLocation` stays gated on `_active` — pre-activate signals lack runtime-captured `loc`") is closed: `_captureCallerLocation` is now always-on in `__DEV__` with a two-phase cost model.

  **At capture time** (every dev signal/computed/effect creation): a single cheap `new Error()` allocation (~0.14µs in V8/Bun — stack is captured but NOT formatted). For 10k signals that's ~1.4ms total, invisible.

  **At read time** (rare — only when a devtools consumer actually inspects a node): `getReactiveGraph()` / `getFireSummaries()` resolves the deferred handle on demand and memoizes the result on the `NodeRec`. The Error becomes GC-eligible after first resolve.

  Most user signals additionally pay 0µs at capture because `@pyreon/vite-plugin`'s `injectSignalLocations` rewrites `signal(0)` → `signal(0, { __sourceLocation })` at build time, short-circuiting the runtime capture path entirely.

  Verified end-to-end against `examples/perf-dashboard`: 477/477 (100%) of pre-existing signals get `loc` populated when devtools attaches AFTER mount; the activate-after-creation user workflow + LPIH editor inlay-hint surfaces work uniformly with no eager-`.stack` cost. Production unchanged — the entire instrumentation chain still tree-shakes to dead code under `NODE_ENV=production`.

  Internal-only changes; no public API impact. `_captureCallerLocation` now returns a `DeferredLocation` handle instead of a `SourceLocation`, but it's exported under `@internal` and consumed only by framework code via `_rdRegister` (which transparently handles both shapes).

- [#913](https://github.com/pyreon/pyreon/pull/913) [`a31f7dd`](https://github.com/pyreon/pyreon/commit/a31f7dd8f8ddba6864c69bbf53117d36ddd477a3) Thanks [@vitbokisch](https://github.com/vitbokisch)! - fix(reactive-devtools): always-register in `__DEV__` so the Foundation surfaces a populated graph when devtools attaches AFTER mount

  Pre-fix, `_rdRegister` and `_rdRecordFire` early-returned on `!_active`. The opt-in design meant that signals/computeds/effects created BEFORE the devtools panel opened were never recorded — the live registry was empty by the time `activateReactiveDevtools()` fired. The Signals / Graph / Effects / Profiler tabs showed empty bodies against any real-world app (e.g. perf-dashboard's 958-element / 477-signal app, captured 0 / 0 / 0 / 0 in a controlled 4-scenario experiment).

  This change inverts the gating: `_rdRegister` and `_rdRecordFire` always run in `__DEV__` (the caller-side `process.env.NODE_ENV !== 'production'` gate is unchanged — production still tree-shakes the entire call chain to dead code). `_active` is now a READ gate: `getReactiveGraph()` / `getReactiveFires()` / `getFireSummaries()` return empty when no client has attached, so non-attached consumers see nothing even though the registry is populated.

  Behavioural changes:

  - A devtools panel opened AFTER the app mounts now sees the full live graph immediately on `activate()` — matches user expectation and fixes the empty-tabs UX.
  - `deactivateReactiveDevtools()` no longer clears the registry. The registry tracks the LIVE app state, which a subsequent `activate()` should still see (matches a "close + reopen panel" workflow). Clearing on deactivate would re-create the same bug at the close/reopen boundary.
  - Added `__resetReactiveDevtoolsForTesting()` (internal) for cross-test isolation. Production `deactivate()` only flips the read gate.
  - `_captureCallerLocation` stays gated on `_active` — stack parsing (~2.2µs/call) is the expensive part, and build-time-injected loc (via `@pyreon/vite-plugin`) is free and always-on, so most dev signals get loc anyway.

  Cost in `__DEV__`: a `Map.set` + `WeakRef` + `WeakMap.set` + `finalizer.register` per node (~hundreds of ns) and a counter bump + bounded ring-buffer append per fire (~ns). Production is unchanged (every entry point is dead-coded by the caller-side `NODE_ENV` gate).

  Companion change in `@pyreon/devtools`'s `scripts/verify-extension.ts`: the step-4 reactive-graph assertion is now a hard `fail()` instead of an informational `info()` line. This was the gap that allowed PR [#900](https://github.com/pyreon/pyreon/issues/900)'s first verification to pass while shipping a broken Foundation against real-world apps — closed by the same PR that fixes the underlying bug.

  Verified end-to-end with a 4-scenario controlled experiment against `examples/perf-dashboard` (958 components, 477 live signals/computeds/effects, 112 dependency edges):

  | Scenario                                 | Nodes | Edges | Fires | Components |
  | ---------------------------------------- | ----: | ----: | ----: | ---------: |
  | A. activate BEFORE mount                 |   477 |   112 |    16 |        958 |
  | B. activate AFTER mount (no interaction) |   477 |   112 |    16 |        958 |
  | C. activate AFTER mount + app activity   |   477 |   112 |    22 |        958 |
  | D. pre-activate (reload workaround)      |   477 |   112 |    16 |        958 |

  Pre-fix every scenario was `0 / 0 / 0`. Bisect-verified by reverting the `_active` early-returns: broken state reproduces `0 / 0 / 0` across all four scenarios; restoring brings them back to the post-fix numbers above.

- [#1149](https://github.com/pyreon/pyreon/pull/1149) [`71901d4`](https://github.com/pyreon/pyreon/commit/71901d4366e993542a0a8252647b7a4b0e8ec3d2) Thanks [@vitbokisch](https://github.com/vitbokisch)! - fix(reactivity): defer `_rdRecordFire` EWMA from always-on capture path to read-time reconstruction

  `_rdRecordFire` runs on every signal write / computed recompute / effect run in `__DEV__`, regardless of whether a devtools panel is attached. Pre-fix it maintained an incremental EWMA via `Math.exp(-dt/TAU)` on every fire, plus a `rec.rate1s` field on each `NodeRec`. A 60Hz animation signal in dev burned 60 `Math.exp` calls per second when devtools was closed — multiplicatively worse than the per-creation `_rdRegister` overhead (already deferred per `[deferred-parse-for-always-on-capture]`).

  The naive fix ("restore `if (!_active) return`") would break the attach-after-mount workflow that PR [#913](https://github.com/pyreon/pyreon/issues/913) deliberately enabled — the panel needs to see fires that happened BEFORE it opened. Identified in the post v0.25.1 framework audit as the remaining always-on cost.

  **Fix**: move EWMA computation from capture to read time. The pre-existing fire ring buffer (`_fireBuf`, 512 entries) already stores per-fire `(id, ts)` pairs. `getFireSummaries()` now builds a per-id EWMA accumulator in one pass over the buffer at read time — only when devtools is active (the function is `_active`-gated). The incremental recurrence `r_n = r_{n-1} * exp(-dt/TAU) + 1` unfolds to `sum_i exp(-(t_n - t_i) / TAU)`; decay-to-now then yields `sum_i exp(-(now - t_i) / TAU)` — exactly what the read-time loop computes. **Mathematically identical to the pre-fix value** within FP rounding, modulo fires evicted by the 512-entry ring buffer window (fires older than ~5×TAU contribute <0.7% of their weight, and 512 fires in <5s implies >100Hz — structurally bounded undercount at extreme rates, identical at typical rates).

  Capture path is now `rec.fires++` + ring-buffer write only — zero float ops, zero branches per fire.

  ## API contract

  Unchanged on the public surface:

  - `FireSummary.rate1s` field preserved.
  - `getFireSummaries()` returns the same shape.
  - `getReactiveGraph()` / `getReactiveFires()` unchanged.

  Internal-only changes:

  - `NodeRec.rate1s` field removed (no longer needed — rate is reconstructed at read time).
  - `_rdRecordFire` body simplified.

  Verified consumer surfaces still work: `@pyreon/compiler` (1429 specs), `@pyreon/lint` (921 specs), `@pyreon/vite-plugin` (252 specs) — all read `FireSummary.rate1s` via LPIH integration; all green post-fix.

  ## Bisect-verify

  3 new structural specs in `packages/core/reactivity/src/tests/rdrecord-fire-microbench.test.ts` that count `Math.exp` calls during fire capture (via patched-prototype interception). Each asserts the capture path makes ZERO calls to `Math.exp`. Reverting the EWMA block inside `_rdRecordFire` fails the specs with `AssertionError: expected 999 to be +0` (1000 fires = 999 EWMA decays, one skipped on the first-fire `lastFire === null` branch) and `AssertionError: expected 9900 to be +0` (10000 fires - 100 first-fires = 9900). Restoring → 3/3 microbench specs green, 464/464 reactivity green, all 23 `LPIH — rate1s EWMA tracking` specs in `lpih-source-location.test.ts` still pass (proving the read-time reconstruction is mathematically equivalent).

- [#1142](https://github.com/pyreon/pyreon/pull/1142) [`1921168`](https://github.com/pyreon/pyreon/commit/192116843a0547c777e884f0254ffc51a69bfae1) Thanks [@vitbokisch](https://github.com/vitbokisch)! - fix(reactivity): restore `signal instanceof Function === true` (regression introduced by SignalProto shared-proto allocation)

  When the `SignalProto` shared-allocation optimization landed, `SignalProto` was declared as a bare object literal `{ peek, set, update, ... }`. An object literal's `[[Prototype]]` is `Object.prototype`, so `Object.setPrototypeOf(read, SignalProto)` produced the chain `read → SignalProto → Object.prototype`. The read function used to have `Function.prototype` as its `[[Prototype]]` (every function does), so the result was: **every signal silently lost `instanceof Function === true`** (was `true` pre-optimization, became `false`).

  This is a silent breaking change across the ecosystem. Consumers using `x instanceof Function` to discriminate signals from plain values include perf-harness, devtools, the framework's own compiler helpers, third-party libraries, and user code. All of them silently flipped to the opposite branch.

  **Fix**: one line — `Object.setPrototypeOf(SignalProto, Function.prototype)` after the SignalProto declaration. Restores the full chain `read → SignalProto → Function.prototype → Object.prototype`. The monomorphic shared-proto allocation win is preserved (still one shared proto object; still one `setPrototypeOf` per signal).

  Surfaced by an audit of all framework commits since v0.25.1 (sequential 7-agent workflow). Bisect-verified: reverting the new `setPrototypeOf` line makes 2 of 4 regression specs fail with `AssertionError: expected false to be true` for `s instanceof Function` and `AssertionError: expected {} to be [Function]` for the prototype-chain check. Restoring the line returns the suite to 461/461 green.

  Regression coverage in `packages/core/reactivity/src/tests/signal.test.ts` — 4 specs covering `signal instanceof Function`, `computed instanceof Function`, the prototype-chain shape, and a sanity check that method dispatch through the chain still works.

## 0.25.1

### Patch Changes

- [#901](https://github.com/pyreon/pyreon/pull/901) [`c862965`](https://github.com/pyreon/pyreon/commit/c8629652a94ca7d1e8622cd2de5b4ac009874dbf) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Bundle-size shrink across browser-shipped packages — **~7 KB gzipped saved** total. A typical Pyreon app shipping `runtime-dom + reactivity + core + router` is now **~5.7 KB lighter**.

  ## Wins (gzipped, measured at the production-define bundle level)

  | Package               | Before | After | Saved                      |
  | --------------------- | ------ | ----- | -------------------------- |
  | `@pyreon/runtime-dom` | 12,655 | 9,719 | **−2,936 B (−23%)**        |
  | `@pyreon/reactivity`  | 7,870  | 6,328 | **−1,542 B (−20%)**        |
  | `@pyreon/core`        | 4,972  | 4,191 | **−781 B (−16%)**          |
  | `@pyreon/router`      | 10,148 | 9,582 | **−566 B (−6%)**           |
  | `@pyreon/rocketstyle` | 4,390  | 3,992 | **−398 B (−9%)**           |
  | `@pyreon/styler`      | 5,624  | 5,453 | **−171 B (−3%)**           |
  | `@pyreon/server`      | 3,575  | 3,431 | **−144 B (−4%)**           |
  | `@pyreon/attrs`       | 1,017  | 915   | **−102 B (−10%)**          |
  | (8 more)              | ...    | ...   | smaller wins (1–98 B each) |

  17 packages shrunk total. Net **−7,153 B** gzipped across the published Pyreon footprint.

  ## Two complementary fixes

  **1. `check-bundle-budgets.ts` now measures the PRODUCTION-stripped size.** The script's `Bun.build` invocation was missing `define: { 'process.env.NODE_ENV': '"production"' }`. As a result, the budget measurement INCLUDED every `if (process.env.NODE_ENV !== 'production') console.warn(...)` string from `lib/` — overstating the real consumer bundle by 5–20% per package and forcing budget bumps for dev-only diagnostic growth that never reaches end users. Real consumers (Vite/Webpack/esbuild) all set this define at their build time; the measurement now matches what they actually ship.

  **2. Removed the `const __DEV__ = process.env.NODE_ENV !== 'production'` alias** from 22 files across 7 browser-shipped packages, in favor of the bare gate `if (process.env.NODE_ENV !== 'production')` at the use site. The alias pattern is recognized by `dev-guard-warnings` lint rule but is silently worse for downstream bundle size — Bun.build and several esbuild configurations don't propagate the const-folded value through the alias even when the production define is set. The bare gate folds reliably at the use site because the bundler replaces the expression with a literal `false` directly. This is the bundler-agnostic library convention used by React, Vue, Preact, Solid.

  Pure internal optimization — no API change, no behavior change. DEV mode behavior unchanged (warnings still fire identically in development). The migration is locked in by `pyreon/no-process-dev-gate` lint rule and the regenerated `scripts/bundle-budgets.json` floor.

  ## QA

  - All 1,378 compiler tests + 680 runtime-dom tests + 521 router tests + 168 server tests + 998 zero tests pass (storage test failures are pre-existing on main, unrelated to this PR)
  - Whole-repo `bun run lint` + `typecheck` clean
  - `gen-docs --check` clean
  - `bench:fair` (real-Chromium across 8 frameworks): Pyreon at top of tied cluster on 4 of 7 tests (create-1k, replace-all, partial-update, create-10k), tied in cluster on the other 3 — no regression
  - One pre-existing test (`dev-gate-treeshake.test.ts non-Vite consumer runtime correctness`) updated to reflect the new bare-gate contract: esbuild's `platform: 'browser'` default replacement (`process.env.NODE_ENV = "development"`) folds the bare gate AND the minifier strips the warn body — strictly better than the old `__DEV__` alias pattern the test was guarding

- [#902](https://github.com/pyreon/pyreon/pull/902) [`b87fbac`](https://github.com/pyreon/pyreon/commit/b87fbaced0cbeb7304bdc1d358040818e4b1491e) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Ship source maps in published tarballs.

  Every `@pyreon/*` package now ships its `.js.map` and `.d.ts.map` files. The previous `!lib/**/*.map` exclusion in each package's `files` array left every emitted JS file pointing at a `//# sourceMappingURL=*.map` that wasn't actually published — causing Vite (and other bundlers) to log a "Failed to load source map" warning per file on every cold dev start. Real bug in shipped tarballs, not just dev-noise theory.

  The fix is shipping the maps. They make framework stack traces readable: `at mountChild (node_modules/@pyreon/runtime-dom/src/nodes.ts:147)` instead of `at e (node_modules/@pyreon/runtime-dom/lib/index.js:1:42857)`. This matters most when a user hits a framework bug, opens devtools, or sees an unreadable production error from a server-side render. Sentry / Bugsnag / Rollbar can also translate framework frames using the shipped maps; without them, the framework's part of every captured stack stays opaque.

  Cost: ~350KB-1MB per package in `node_modules`. Bundlers (Vite, Webpack, Rollup, esbuild) strip source maps from production builds automatically; they never reach end users. Every comparable library (React, Vue, Solid, Preact, Svelte, TanStack) does this.

  No API changes. The `check-distribution` CI gate inverts to enforce the new contract (maps must be present, not absent).

## 0.25.0

### Minor Changes

- [#858](https://github.com/pyreon/pyreon/pull/858) [`7da5b2b`](https://github.com/pyreon/pyreon/commit/7da5b2bcbc2aebd9600cb8fdefb763ace7f78c1a) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Extract `defineCrossModuleState(key, init)` helper. The 5 inlined `Symbol.for(...) ?? init; if (!g[KEY]) g[KEY] = …` blocks in `@pyreon/core`'s `lifecycle.ts` / `component.ts` / `context.ts` / `telemetry.ts` / `props.ts` (from [#855](https://github.com/pyreon/pyreon/issues/855)) collapse to one helper call per state var. Same `Symbol.for` keys preserved — byte-identical runtime behavior; the existing regression tests in `cross-module-state.test.ts` pass unchanged.

  The helper lives in `@pyreon/reactivity` (the lowest layer in the dep order — standalone, every other package transitively depends on it) so EVERY package can apply the same pattern. `@pyreon/core` re-exports it for backward-compat with the previous PR. Follow-up PRs will use this to harden `@pyreon/reactivity`'s own module-level state (activeEffect, batch state, scope, tracking deps), and then `@pyreon/router`, `@pyreon/store`, `@pyreon/storage`, etc.

- [#897](https://github.com/pyreon/pyreon/pull/897) [`bc145f3`](https://github.com/pyreon/pyreon/commit/bc145f3dd6ff8414ab3d36f7723d7f1217d19835) Thanks [@vitbokisch](https://github.com/vitbokisch)! - `createSelector` gains a new `.subscribe(value, updater)` method — the **effect-free fast path** for the `<For>` + selector pattern.

  ## What

  ```ts
  const isSelected = createSelector(selectedId);
  // In each row's template:
  const dispose = isSelected.subscribe(row.id, (matches) => {
    rowEl.className = matches ? "selected" : "";
  });
  ```

  Equivalent to `renderEffect(() => updater(selector(key)))` but skips the `renderEffect` machinery entirely: no `deps` array, no `withTracking` / `setDepsCollector`, no `run` closure allocation, no scope `add({ dispose })` wrapper. The selector's source effect stores the user's updater DIRECTLY in a per-key bound bucket and calls it with the resolved boolean (`true` on selection added, `false` on selection removed).

  ## Per-row alloc

  |                  | Old `_bind(() => className = isSelected(id) ? ... : ...)`                                         | New `isSelected.subscribe(id, m => ...)` |
  | ---------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------- |
  | Allocations      | `deps[]` + `run` closure + `dispose` closure + `{dispose}` wrapper + `trackedFn` closure = **~5** | 1 Set.add + 1 dispose closure = **2**    |
  | Effect machinery | full `renderEffect` setup + tracking stack push/pop per first-run                                 | none                                     |
  | Per-fire cost    | re-run with `withTracking` + selector lookup + Object.is + ternary                                | direct call with pre-resolved boolean    |

  ## Measured impact (3-run medians on the JS Framework Benchmark `bench:fair` harness)

  | Test                     | Pyreon (compiled) BEFORE | AFTER `.subscribe` | Δ                                                              |
  | ------------------------ | ------------------------ | ------------------ | -------------------------------------------------------------- |
  | create 1,000 rows        | 11.20ms                  | **10.40ms**        | **-0.80ms (-7%)**                                              |
  | replace all rows         | 10.90ms                  | **10.20ms**        | **-0.70ms (-6%)**                                              |
  | create 10,000 rows       | 116.95ms                 | **112.40ms**       | **-4.55ms (-4%)**                                              |
  | partial / select / clear | unchanged                | unchanged          | noise washed                                                   |
  | swap rows                | 4.75ms                   | 5.20ms             | +0.45ms (within CI95 overlap of Vue 4.80 — statistically tied) |

  **Result**: Pyreon (compiled) goes from "tied with Vue/Solid on every test" → **OUTRIGHT LEADER on `create-1k`, `replace-all`, `clear-rows`, and `create-10k`** (the last by ~7ms vs Vue, ~10ms vs Solid). Still tied on partial/select/swap.

  ## Naming

  Named `.subscribe` (not `.bind`) to avoid `Function.prototype.bind` collision — `Selector<T>` is a callable interface and TypeScript inherits `Function.prototype.bind` which would clash with a method overload at the interface level. `.subscribe` is also consistent vocabulary with `signal.subscribe(fn)`.

  ## Test coverage

  10 new specs in `createSelector.test.ts` covering: initial inline call with correct match state, updater fires on selection change crossing this key (both directions), updater does NOT fire on unrelated selection changes, dispose removes from bucket, post-dispose `.subscribe` calls updater with last-known + returns no-op, O(1) bucket fire (1000-row stress: total updater calls scales with selection changes, not row count), multiple `.subscribe` calls on same key share the bucket, interop with existing `selector(key)` inside an `effect`.

  ## Backwards-compatible

  Pure addition — the existing `selector(value)` API + `dispose()` method are unchanged. Apps not using `.subscribe` see no behavior change.

- [#895](https://github.com/pyreon/pyreon/pull/895) [`f71fb4c`](https://github.com/pyreon/pyreon/commit/f71fb4c1b219e19189a58afeadcd6a7c9f5957fb) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Post-audit fixes for the bullet-proof cross-module-instance architecture (PRs [#883](https://github.com/pyreon/pyreon/issues/883)/[#884](https://github.com/pyreon/pyreon/issues/884)/[#886](https://github.com/pyreon/pyreon/issues/886)/[#889](https://github.com/pyreon/pyreon/issues/889)). Closes 1 HIGH-severity race condition + 2 correctness bugs surfaced by the deep release-readiness audit.

  **1. HIGH — race condition in sentinel opt-out under concurrent `Promise.all`** (`@pyreon/reactivity` + `@pyreon/zero` + `@pyreon/vite-plugin`).

  The env-var dance pattern (`process.env.PYREON_SINGLE_INSTANCE = 'silent'` / capture+restore) used by `ssrLoadModuleQuiet`, SSG-plugin's built-handler import, and rocketstyle-collapse's nested-SSR resolver was race-prone under `Promise.all` of N opt-out scopes:

  1. Call A: captures `prev=undefined`, sets `'silent'`
  2. Call B: captures `prev='silent'` (post-A's write), sets `'silent'`
  3. A's `finally` deletes env (prev was undefined)
  4. B's `finally` restores `'silent'` ← **leaked permanently**

  Effect: the sentinel was silently disabled for the entire dev / SSG / collapse-resolver process lifetime. Bisect-verified with a focused reproducer; the leak fires with 5 concurrent scopes in `renderSsr`.

  **Fix**: `@pyreon/reactivity` ships two new exports:

  - `withSilent(fn): Promise<T>` — async refcount-based scope. Increments `silentDepth` on the sentinel state, awaits the fn, decrements in `finally`. Order-independent under concurrency.
  - `withSilentSync(fn): T` — sync variant.

  All three call sites updated to use `withSilent` instead of the env-var dance. The env-var (`PYREON_SINGLE_INSTANCE`) is preserved as the documented user-facing escape hatch for browser extensions / micro-frontends.

  `@pyreon/vite-plugin` gains a runtime dep on `@pyreon/reactivity` (rocketstyle-collapse).

  **2. BUG — pnpm v9 peer-suffix false-positive duplicate** (`@pyreon/cli`).

  `pyreon doctor --check-dedup`'s `_parsePnpmLock` regex parsed `/@pyreon/core@1.0.0(react@19.0.0):` keys with the peer suffix INCLUDED in the version. Two installs sharing the same `1.0.0` but resolved against different peers were counted as TWO distinct versions → false-positive `multiple-versions` finding.

  **Fix**: strip the `(...)` suffix when adding to the version set. Build-metadata versions (`1.0.0+build.42` — no `(`) round-trip unchanged. Genuine multi-version dups remain detectable. 3 new regression specs.

  **3. BUG — `PYREON_DISABLE_DEDUPE` only triggered on literal `'1'`** (`@pyreon/vite-plugin`).

  Users reaching for an escape-hatch env var under stress reach for `true` / `yes` / `on` first. The strict `=== '1'` check silently no-op'd those alternatives — worst-of-both-worlds (escape hatch present but doesn't fire).

  **Fix**: `_isTruthyEnv(v)` accepts `1` / `true` / `yes` / `on` case-insensitively. 11 new specs covering both positive (truthy) and negative (falsy / unrecognized) values.

  All three fixes are bisect-verified — neutralizing each fails its dedicated test(s); restored passes. Full repo validation: 3,978 tests pass across 10 affected packages (`reactivity` 444, `core` 531, `router` 521, `runtime-dom` 681, `runtime-server` 150, `head` 115, `server` 168, `cli` 177, `vite-plugin` 193, `zero` 998). `pyreon doctor` clean on all changed files. Bundle budgets clean.

### Patch Changes

- [#883](https://github.com/pyreon/pyreon/pull/883) [`6075127`](https://github.com/pyreon/pyreon/commit/60751278894a6ff843c0f6f6c4894c76bcb6a720) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Singleton sentinel default-on across every `@pyreon/*` package with module-level state (PR A of the bullet-proof cross-module-instance plan, `.claude/plans/jaunty-herding-kazoo.md`).

  Each package's `src/index.ts` now calls `registerSingleton('@pyreon/<name>', <version>, import.meta.url)` at module load. The first registration records a marker on `globalThis`; a second registration with a DIFFERENT normalized location triggers detection. Default mode throws an actionable Error naming both file paths and three concrete fixes (Vite `resolve.dedupe`, `npm ls`, `bun ls`). `PYREON_SINGLE_INSTANCE=warn` demotes to `console.error`; `PYREON_SINGLE_INSTANCE=silent` opts out entirely (browser extensions, micro-frontends, nested SSR via `rocketstyle-collapse`).

  **HMR-aware.** Vite re-evaluates modules with the SAME path but possibly different query params (`?v=12345`, `?t=12345`, `?import`). The sentinel normalizes the location (strips query string) before comparing — same normalized location → HMR re-eval → silently allowed; different location → genuine dual-instance → throws.

  **Per-package detection.** The earlier prototype put the sentinel only in `@pyreon/reactivity` — insufficient because `@pyreon/core` (and every other package) has its own module-level state that can be silently corrupted under dual-load. The full plan requires per-package registration, which this PR ships.

  **Zero behavior change in correct setups.** Apps that already have a single instance of each `@pyreon/*` package (the overwhelmingly common case) see no runtime change. Apps with silently-tolerated duplicates today (sub-dep version mismatch, custom bundler config) will see their app throw at startup after upgrading with an error message naming the fix. `PYREON_SINGLE_INSTANCE=warn` is the immediate mitigation for any consumer surprised by the change.

  **Test coverage.** Contract tests at `packages/core/reactivity/src/tests/singleton-sentinel.test.ts` (57 specs) exercise the sentinel directly with synthetic `file://` URLs: default-mode throw + actionable error message, HMR re-eval allowance, `PYREON_SINGLE_INSTANCE=warn` / `=silent` escape hatches, per-package coverage across all 24 registered packages, and cross-package isolation. Bisect-verified — neutralizing the throw branch fails 49 positive-case tests; restored passes all 57. The synthetic-URL approach replaces the heavier filesystem dual-load reproducer (it's the sentinel's normalized-string comparison that matters, not Node's ESM loader behaviour).

## 0.24.6

## 0.24.5

## 0.24.4

## 0.24.3

## 0.24.2

## 0.24.1

## 0.24.0

### Minor Changes

- [#785](https://github.com/pyreon/pyreon/pull/785) [`b8fb31c`](https://github.com/pyreon/pyreon/commit/b8fb31cf1a59578fc33f27d539695d2bc164b2f1) Thanks [@vitbokisch](https://github.com/vitbokisch)! - LPIH: build-time `__sourceLocation` injection now covers `computed()` and `effect()` calls (R8 — extension of R4). Previously only `signal()` got the build-time literal; `computed()` and `effect()` still paid the runtime `new Error().stack` capture cost (~2.2 µs per creation when devtools is active).

  Three forms covered by the extended `injectSignalNames`:

  - `const x = signal(...)` → `signal(..., { name: "x", __sourceLocation: {...} })`
  - `const d = computed(() => ...)` → `computed(..., { name: "d", __sourceLocation: {...} })`
  - `effect(() => ...)` (unbound) → `effect(..., { __sourceLocation: {...} })` (no `name` — anonymous effects have no binding to derive from)

  Unbound `signal()` / `computed()` are left untouched (rare anonymous patterns). The unbound-effect pass uses negative lookbehind `(?<![\w$.])` to skip member-access (`obj.effect()`) and identifier-suffix (`sideEffect()`) false-positives.

  `@pyreon/reactivity` exposes the matching surface on the runtime side:

  - `ComputedOptions<T>` gains an `@internal __sourceLocation` field; `computed()` threads it through to both internal paths (`computedLazy` / `computedWithEquals`), preferring it over `_captureCallerLocation(2)` in `_rdRegister`
  - new `EffectOptions` interface with the same `@internal __sourceLocation` field; `effect(fn, options?)` accepts the second arg

  Bisect-verified: narrowing the bound regex to `signal`-only AND disabling the unbound-effect pass fails 6 of the 11 new R8 tests with the expected error shapes (e.g. `expected to have a length of 4 but got 1` on the multi-primitive injection count); restored → 26/26 (15 R4 + 11 R8) pass. No `TEMP BISECT` remnants in source.

  Full suites green: `@pyreon/reactivity` 377/377, `@pyreon/vite-plugin` 130/130.

  Closes R8 from the LPIH foundation PR ([#769](https://github.com/pyreon/pyreon/issues/769)) followups queue.

- [#777](https://github.com/pyreon/pyreon/pull/777) [`f400e85`](https://github.com/pyreon/pyreon/commit/f400e85282a370276d5ae0266ba501c41dce4f3e) Thanks [@vitbokisch](https://github.com/vitbokisch)! - LPIH: zero-config cache path convention. `startLpihPolling()` and `writeLpihCache()` now default to `<cwd>/.pyreon-lpih.json` when called with no path; the LSP server auto-discovers the same file by walking up from the source file to the nearest `package.json`. No env var required for the common case.

  ```ts
  // Before (foundation PR):
  import { startLpihPolling } from "@pyreon/reactivity/lpih";
  startLpihPolling("/tmp/pyreon-lpih.json", 250);
  // + set PYREON_LPIH_CACHE=/tmp/pyreon-lpih.json on the LSP

  // Now (zero config):
  import { startLpihPolling } from "@pyreon/reactivity/lpih";
  startLpihPolling(); // writes to <cwd>/.pyreon-lpih.json
  // LSP auto-discovers; no env var needed
  ```

  **`@pyreon/reactivity/lpih`**:

  - `writeLpihCache(path?)` — `path` is now optional, defaults to `getDefaultLpihCachePath()` (which returns `<cwd>/.pyreon-lpih.json`)
  - `startLpihPolling(path?, intervalMs?)` — same default; throws synchronously if no default can be resolved AND no path given (better than silently never writing)
  - New export `getDefaultLpihCachePath(): string | null` — returns the resolved path or null in environments without `process.cwd()` (web workers, etc.)
  - New export `LPIH_DEFAULT_FILENAME = '.pyreon-lpih.json'` — canonical filename constant

  **`@pyreon/lint`** LSP:

  - `_resolveLpihCachePath(filePath)` — new helper that resolves the cache path for a given source file. Priority: `PYREON_LPIH_CACHE` env (explicit override) → `<project-root>/.pyreon-lpih.json` discovered by walking up to nearest `package.json` (zero-config default) → `undefined` (LPIH inactive)
  - `_findProjectRoot(filePath, maxDepth?)` — memoized walk-up helper. Caches results per-file for the LSP-process lifetime; cleared on `_resetOpenDocuments()`. Synchronous (one `existsSync` per level, typically <10 levels = negligible cost).
  - `_LPIH_DEFAULT_FILENAME` — exported constant locked to `.pyreon-lpih.json` (matches `@pyreon/reactivity/lpih`'s `LPIH_DEFAULT_FILENAME` — a drift gate test in `lsp-lpih.test.ts` validates the agreement).

  **Discovery priority** (matches across writer + reader):

  1. `PYREON_LPIH_CACHE` env var on the LSP (explicit override) — unchanged
  2. `<project-root>/.pyreon-lpih.json` (auto-discovered) — new default
  3. No cache → LPIH inactive (degrades to static Reactivity-Lens hints only) — unchanged

  **Multi-session safety**: each project gets its own cache file under its own `package.json` boundary. Two dev sessions in different projects can't collide silently (was a footgun with the previous shared `/tmp/pyreon-lpih.json` convention from the foundation docs).

  **Tests**: +18 new tests across both packages (8 for the runtime default + 10 for LSP discovery), all green. Bisect-verified: removing the `_resolveLpihCachePath` wiring breaks the "auto-discover" LSP integration test.

  **Docs**: `docs/docs/lpih.md` quickstart updated to the zero-config flow; `.gitignore` mention added; custom-path / env-override examples preserved at the bottom of the page.

- [#769](https://github.com/pyreon/pyreon/pull/769) [`891ca43`](https://github.com/pyreon/pyreon/commit/891ca4300727119dafd66ceaacd7cb39e68f3b4e) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Live Program Inlay Hints (LPIH) — runtime + compiler + LSP foundation. A new category of editor surface: **live runtime data displayed at the source line, the same way TypeScript shows inferred types**. No reactive framework today shows fire counts / subscriber counts / effect re-run rates at the cursor — developers context-switch to a separate devtools panel. LPIH closes that gap.

  ```tsx
  function App() {
    const count = signal(0); // 🔥 signal fired 240×
    const doubled = computed(() => count() * 2); // 🔥 derived fired 240×
    effect(() => console.log(doubled())); // 🔥 effect fired 241×
    return <div>{count()}</div>;
  }
  ```

  **`@pyreon/reactivity`**: source-location capture at every `signal()` / `computed()` / `effect()` creation, wired through `_rdRegister` and exposed via `getFireSummaries()`. The runtime bridge ships at the new subpath export `@pyreon/reactivity/lpih`: `writeLpihCache(path)` + `startLpihPolling(path, intervalMs)` writes the current fire snapshot to a JSON cache file atomically (tmp + rename — readers never see a half-written file; failed renames clean up the tmp). Subpath keeps the main entry slim — bridge depends on `node:fs/promises` (Node-only) and is dev-mode glue, not a core primitive. New main-entry exports: `SourceLocation`, `FireSummary`, `getFireSummaries`. New `/lpih` subpath exports: `writeLpihCache`, `startLpihPolling`. **Zero production cost** (existing `process.env.NODE_ENV !== 'production'` gate tree-shakes the entire capture path — verified by the existing `reactive-devtools-treeshake.test.ts`). Dev-mode opt-in cost: `_active === true` triggers `new Error().stack` capture (~2.2µs per creation). At realistic real-app creation rates (100-1000 signals total / 100/sec peak), per-session cost is **0.2-2.3ms** — invisible. Stack-parser handles V8, JSC, and SpiderMonkey formats. 21 new tests (15 source-location + 6 bridge).

  **`@pyreon/compiler`**: two new pure functions that bridge runtime fire data to LSP inlay hints. `mergeFireDataIntoFindings(findings, fires, file)` enriches static Reactivity-Lens findings with fire counts at matching source lines. `firesToCreationSiteFindings(fires, file)` synthesizes inlay-hint findings DIRECTLY from fires — creation-line hints showing `signal fired 240×` at the line where `signal()` was called. New exports: `mergeFireDataIntoFindings`, `firesToCreationSiteFindings`, `LPIHFireDatum`, `LPIHMergeOptions`. 24 new tests covering merge semantics, kind filtering (footguns/static spans NOT enriched), file normalization, aggregation, custom formatters, plus end-to-end `analyzeReactivity + merge` integration.

  **`@pyreon/lint`**: LSP `textDocument/inlayHint` handler reads `PYREON_LPIH_CACHE` env var on each request, parses the cache file (silent failure on missing/malformed JSON), and emits creation-site inlay hints with the `🔥 signal fired N×` label. Opt-in via env var — when unset, LPIH path is a no-op and existing static Reactivity-Lens hints work unchanged. New internal exports: `_readLpihCache`, `LPIHCacheEntry`, `LPIHCacheFile`. 15 new JSON-RPC roundtrip tests covering cache file parsing (malformed JSON, missing entries, shape validation), LSP handler integration (env-var-driven cache read, visible-range filtering with LPIH active, graceful degradation), end-to-end `initialize → didOpen → inlayHint` with real cache file.

  **Measured impact (reproducible via `bun .claude/experiments/lpih-measurement.ts`)**:

  | Metric                                          | Value                                          |
  | ----------------------------------------------- | ---------------------------------------------- |
  | LSP roundtrip latency (median, 20-trial)        | **0.32 ms**                                    |
  | LSP roundtrip latency (p95)                     | **2.78 ms**                                    |
  | User-perceived save→hint (incl. 150ms debounce) | **~150 ms**                                    |
  | Bridge write (atomic JSON file)                 | **1.5 ms**                                     |
  | End-to-end bridge-to-editor                     | **~1.8 ms + 250ms poll interval**              |
  | Production overhead                             | **0 ns** (tree-shaken)                         |
  | Dev-mode active overhead                        | 2.2 µs per signal creation                     |
  | Workflow "which signal fires most?"             | 9 → 2 steps (**4.5× reduction**)               |
  | Workflow "is this effect over-running?"         | 8 → 2 steps (**4× reduction**)                 |
  | Workflow "did memoization help?"                | 10 → 4 steps (**2.5× reduction**)              |
  | Information surface per medium component        | ~9 hints inline vs 0 in editor (devtools-only) |

  **Architecture**: Three-layer (runtime captures source location → bridge writes JSON cache file → LSP reads + merges into inlay hints). Bisect-verified: reverting the LSP wiring fails 11/15 integration tests; restored, 15/15 pass. The cache-file bridge mechanism is filesystem-only (no IPC, no WebSocket) — chosen because LSP servers are stdio-only and filesystem is the universal lowest-common-denominator transport. The LSP re-reads on every inlay-hint request so live edits land immediately. Future build-time location injection via `@pyreon/vite-plugin` will replace stack capture with compile-time literals, eliminating the dev-mode 2.2µs/creation overhead entirely. The editor extension (VS Code / Neovim) that auto-bridges devtools fire data to the cache file is a follow-up.

  **Docs**: new VitePress page at [docs/docs/lpih.md](docs/docs/lpih.md) with quickstart, API reference, measured numbers, and 3 concrete bug-hunting scenarios (with vs without LPIH workflow comparison).

- [#780](https://github.com/pyreon/pyreon/pull/780) [`d4ec777`](https://github.com/pyreon/pyreon/commit/d4ec777643446ed2c51dedb1e74fbd8dce70bdfd) Thanks [@vitbokisch](https://github.com/vitbokisch)! - LPIH: sustained-rate hint via EWMA. Inlay-hint labels now show both cumulative fire count AND current fires/second when active — making hot-path debugging visible at a glance.

  ```tsx
  const count = signal(0); // 🔥 signal fired 240× (12/s) — active
  const stable = signal(0); // 🔥 signal fired 240×          — idle
  ```

  **Why**: cumulative count alone can't distinguish "this is firing right now" from "this fired a lot a few minutes ago." For hot-path debugging (the LPIH [#1](https://github.com/pyreon/pyreon/issues/1) use case), the user needs to see _current_ rate. Adding a decayed-EWMA rate alongside the cumulative count gives both signals without bloating the label.

  **Math**: per-node EWMA with 1-second time constant (`LPIH_RATE_TAU_MS = 1000`). On each fire:

  ```
  dt = ts - lastFire
  decay = exp(-dt / 1000)
  rate1s = rate1s * decay + 1
  ```

  At steady state of λ fires/sec, `rate1s → λ` (when λ·TAU ≫ 1 — true for any rate worth noticing). On read, decay-to-now applied: a node that stopped firing 1.5s ago shows ≈22% of its peak rate; 3s ago shows ≈5%; 5s ago shows ≈0.7% (below the visibility threshold).

  **`@pyreon/reactivity`**:

  - `FireSummary.rate1s: number` — new field, decayed to "now" at every `getFireSummaries()` call.
  - `NodeRec.rate1s` — internal per-node EWMA state, updated on every fire.
  - `LPIH_RATE_TAU_MS` — exported constant (1000 ms = 1 second time constant).
  - Bridge `writeLpihCache` now includes `rate1s` in each fire entry's JSON.

  **`@pyreon/compiler`**:

  - `LPIHFireDatum.rate1s?: number` — optional field; older runtimes that don't emit it produce labels without the rate suffix (backward-compatible).
  - `_LPIH_RATE_VISIBLE_THRESHOLD = 0.5` — rates below this are suppressed (don't show "0.1/s" or "0/s" noise from decayed-dormant nodes).
  - Default label formatter: `signal fired 240× (12/s)` when active, `signal fired 240×` when below threshold or no rate field.
  - Custom `formatDetail` callbacks receive the full `LPIHFireDatum` including `rate1s` for fully custom labels.
  - Multiple fires at the same line have their rates summed (consistent with the existing count-summing behavior).

  **`@pyreon/lint`**:

  - `LPIHCacheEntry.rate1s?: number` — round-trips through the cache; no LSP-side logic change beyond the type extension. The compiler's default formatter picks up the new field automatically.

  **Tests** (+12 new across all 3 packages, 2383 total, all green):

  - @pyreon/reactivity: 367 (+5 — rate1s captured, rises with bursts, decays after TAU, sums at same location, constant value lock)
  - @pyreon/compiler: 1316 (+7 — threshold-suppress, 1-decimal vs integer rounding, creation-site formatter, line-sum, custom formatter receives rate, missing-field passthrough)
  - @pyreon/lint: 700 (no new tests — rate1s is data-only round-trip through the cache; existing integration tests cover the path)

  **Memory + performance**: one extra `number` field per node (+8 bytes). One `Math.exp` per fire (~50 ns). One `Math.exp` per location per `getFireSummaries()` call. Bundle-budget impact: 0 (writeLpihCache code path was already in the subpath, this just adds one field to the JSON payload).

  **Bisect-verified**: stashing the EWMA update in `_rdRecordFire` fails the new "rate1s rises with rapid fires" + "rate1s for many rapid fires reflects fire density" tests.

  **Docs**: example block in `docs/docs/lpih.md` updated to show `(12/s)` rate suffix; new paragraph explaining the cumulative-count + current-rate split.

- [#781](https://github.com/pyreon/pyreon/pull/781) [`2abb672`](https://github.com/pyreon/pyreon/commit/2abb672d8a8bf7f4940af422bf8bf802aa129cdd) Thanks [@vitbokisch](https://github.com/vitbokisch)! - LPIH: build-time source-location injection via `@pyreon/vite-plugin`. Eliminates the runtime `new Error().stack` capture cost (~2.2 µs per signal creation) by embedding the source location as a compile-time literal.

  **Before** (foundation PR):

  ```ts
  // User source:
  const count = signal(0);

  // Runtime, when devtools active:
  // 1. new Error() + parse stack → ~2.2µs cost per creation
  // 2. Use parsed location for LPIH source-location capture
  ```

  **After** (this PR):

  ```ts
  // User source (unchanged):
  const count = signal(0);

  // Vite-transformed source (dev mode):
  const count = signal(0, {
    name: "count",
    __sourceLocation: { file: "app.tsx", line: 5, col: 14 },
  });

  // Runtime, when devtools active:
  // 1. Read options.__sourceLocation → ~0ns cost
  // 2. Use injected location directly — stack capture skipped
  ```

  **`@pyreon/reactivity`**:

  - `SignalOptions.__sourceLocation?: { file, line, col }` — new optional field (marked `@internal`, not part of the public API surface). When present, the runtime uses it directly and skips `_captureCallerLocation()` entirely.
  - 2 new tests proving the injected option is preferred over stack capture + the fallback still works when the option is absent.

  **`@pyreon/vite-plugin`**:

  - Extended `injectSignalNames` to ALSO inject `__sourceLocation` alongside the existing `name` field. Same regex, same transform pass — additive change.
  - New helpers `_computeLineStarts(code)` + `_offsetToLineCol(offset, starts)` — O(N) precompute + O(log N) per-signal binary search. Avoids O(N²) when many signals share a file.
  - The injected `file` is Vite's resolved module ID (absolute path) — the same path the runtime would have parsed from `new Error().stack`, so byte-identical behavior except for cost.
  - 15 new tests covering line/col math + injection at function-scope call sites + the 5 skip-cases (existing options, non-signal calls, multiline args, no-injection-for-doSomething, etc.).

  **Known limitation**: module-scope signals (`export const x = signal(0)`) get rewritten to `__hmr_signal()` first by the existing HMR injection pass. The location injection runs after and naturally skips them (regex matches `signal(` not `__hmr_signal(`). Module-scope signals still pay the runtime stack-capture cost. Function-scope signals (the dominant pattern in real Pyreon apps — signals declared inside components) get the full benefit. Module-scope follow-up tracked.

  **Tests** (+17 new across 2 packages, 481 total green):

  - `@pyreon/reactivity`: 362 (+2 — injected-location-preferred + stack-fallback-when-absent)
  - `@pyreon/vite-plugin`: 119 (+15 — line-starts utility, offset-to-line-col, 6 injection scenarios, existing-options skip, non-signal skip, multiline args)

  **Performance**:

  - Runtime cost (devtools active, function-scope signal): **0 ns** stack capture (was ~2.2 µs)
  - Build-time cost: ~10 µs per signal call site (one regex match + one binary search + ~80 bytes of literal output) — invisible on real-world builds
  - Bundle-budget impact: 0 (transform happens in dev-mode-only Vite plugin code path; no production bundle growth)

  **Bisect-verified**: removing the `__sourceLocation` literal from the injection emission makes the line/col-correctness tests fail with "expected to include `__sourceLocation`"; the runtime-side `signal() prefers __sourceLocation over stack capture` test verifies the runtime fast-path is actually wired (file path comes from the injected option, not the test file).

  This closes R4 from the [LPIH recommendations](https://github.com/pyreon/pyreon/blob/main/.claude/experiments/RECOMMENDATIONS.md). The 2.2 µs/creation overhead in the foundation PR's measurement is now eliminated for the majority of real-world signals.

### Patch Changes

- [#789](https://github.com/pyreon/pyreon/pull/789) [`67e1f37`](https://github.com/pyreon/pyreon/commit/67e1f371a20219481ee9564d2d7421ec2a0b5ddf) Thanks [@vitbokisch](https://github.com/vitbokisch)! - LPIH followups round audit — three real bugs found + fixed in lockstep:

  **1. Activation race in R1 auto-bridge (high impact)** — the injected `<script type="module">` used `import('@pyreon/reactivity').then(activate)`. Since `<script type="module">` tags execute in document order with `defer` semantics, dynamic-import-then resolves AFTER the module body completes — and the script body completed IMMEDIATELY since the `.then()` only registered a callback. Result: the app's entry script ran NEXT (document order), created its module-scope signals via `signal(0)` / `computed(...)`, those calls hit `_rdRegister` with `_active = false` (line 311 of `reactive-devtools.ts`), returned undefined → signals INVISIBLE to LPIH. The most common signal shape (top-of-file `const count = signal(0)`) was never tracked.

  Fix: top-level `await` on the dynamic import + `.catch(() => null)` for silent fallback when `@pyreon/reactivity` isn't in the dep graph. Top-level await delays module completion, so the LPIH script body doesn't finish until activation does — the next `<script type="module">` (the app entry) waits, signals get registered correctly.

  **2. Tmp file leak on `fs.writeFile` failure (low-medium impact)** — both `writeLpihCacheFile` (vite-plugin) AND `_writeToPath` (foundation `@pyreon/reactivity/lpih.ts`) had:

  ```ts
  await fs.writeFile(tmp, ...)   // outside try — partial tmp leaks if this throws
  try { await fs.rename(tmp, path) } catch { try { unlink(tmp) } catch {} }
  ```

  If `fs.writeFile` itself threw (disk full, EIO, EACCES, transient FS), the partial tmp file leaked on disk with a unique PID+seq name — accumulating forever. Fix: single try/catch covering both writeFile + rename; cleanup runs on either path's failure (ENOENT on the writeFile-failed path is swallowed, original error surfaces).

  Bisect-verifying THIS specific bug portably is hard (requires reliable disk-full or EIO reproduction), so the fix is structural — locked in by reading the diff. The companion `'cleans up tmp file when rename fails (rename onto a directory)'` test locks the pre-existing rename-failure path.

  **3. String-region false-positives in `injectSignalNames` (medium impact)** — the regexes `(?:const|let)\s+(\w+)\s*=\s*(signal|computed|effect)\(` (R4+R8 bound) and `(?<![\w$.])effect\(` (R8 unbound) matched anywhere in source text, including INSIDE string literals / template literals / comments. User code like:

  ```ts
  const docs = `effect(() => x)`;
  throw new Error("effect() must be called inside a component");
  // TODO: replace effect(() => log()) with watch()
  ```

  got `, { __sourceLocation: ... }` injected INTO the string/comment, corrupting runtime values and producing syntactically-broken docstrings.

  Fix: new `_maskStringsAndComments(code)` pre-pass produces a same-length copy of `code` with strings/comments blanked to spaces (newlines preserved so line numbers don't shift). Regexes run against the masked version; args extraction reads from the original. Template-literal `${...}` interpolations are PRESERVED as code (their bodies can contain real `signal()` calls worth catching). Bisect-verified: disabling the masking pre-pass fails 5 of the new false-positive guard tests.

  Test counts:

  - vite-plugin: 154 → 173 (+19): 11 `_maskStringsAndComments` unit tests, 6 false-positive guards, 1 top-level-await structural test, 1 rename-failure tmp cleanup test
  - reactivity: 377/377 unchanged (foundation tmp-leak fix doesn't add tests; bisect-verified structurally by reading the diff)

## 0.23.0

## 0.22.0

## 0.21.0

## 0.20.0

### Minor Changes

- [#703](https://github.com/pyreon/pyreon/pull/703) [`3499594`](https://github.com/pyreon/pyreon/commit/3499594585b7fcb650ac0f80be4bc355f741491b) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Reactive devtools bridge — an opt-in, leak-free introspection layer over
  the live signal / computed / effect graph.

  `@pyreon/reactivity` gains `activateReactiveDevtools()` /
  `deactivateReactiveDevtools()` / `isReactiveDevtoolsActive()` /
  `getReactiveGraph()` / `getReactiveFires()` (+ `ReactiveNode` /
  `ReactiveEdge` / `ReactiveGraph` / `ReactiveFire` types). It tracks the
  live reactive graph (nodes + dependency edges, derived fresh from the
  real subscriber Sets) and a bounded fire timeline.

  `@pyreon/runtime-dom` exposes it on `window.__PYREON_DEVTOOLS__.reactive`
  (`activate` / `deactivate` / `getGraph` / `getFires`), powering the
  `@pyreon/devtools` Signals / Graph / Effects / Console surfaces.

  Zero cost until a devtools client attaches: every instrumentation entry
  point early-returns on `!active`, sits inside the existing
  `process.env.NODE_ENV !== 'production'` gate (fully tree-shaken in
  production — verified by a minified-bundle regression test), and never
  retains a signal/computed/effect (WeakRef + FinalizationRegistry; the
  fire buffer holds only ids + timestamps). No behavior change when
  inactive (the default).

## 0.19.0

### Minor Changes

- [#598](https://github.com/pyreon/pyreon/pull/598) [`9f03747`](https://github.com/pyreon/pyreon/commit/9f037478763d9f8cd2365feb63dc87fda2545e5d) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Error reports now carry the reactive run-up to the crash.

  For a signal framework, the first question a crash raises isn't _what threw_ — the stack answers that — it's _what reactive state led there_. Pyreon's `ErrorContext` previously carried component / phase / props / error but nothing about the signal activity that produced the bad state.

  **New: `ErrorContext.reactiveTrace`** — the last ~50 signal writes (chronological, oldest → newest) leading up to the error. The causal _sequence_, not a point-in-time snapshot (a snapshot of every value can't explain _how_ the app reached the bad state; the order of writes can). Populated automatically — every registered error handler (Sentry/Datadog/console) gets it for free:

  ```ts
  registerErrorHandler((ctx) => {
    Sentry.captureException(ctx.error, {
      extra: { component: ctx.component, reactiveTrace: ctx.reactiveTrace },
      // e.g. [{ name: 'status', prev: '"idle"', next: '"submitting"' },
      //       { name: 'user',   prev: 'null',    next: 'User {id, …}' }]
    });
  });
  ```

  **New: `getReactiveTrace()` / `clearReactiveTrace()`** (`@pyreon/reactivity`) — read / reset the buffer directly (devtools, test isolation), plus the `ReactiveTraceEntry` type.

  Design properties:

  - **Zero production cost.** The recorder feeding the buffer sits behind the bundler-agnostic production dead-code gate in `signal.ts` `_set` and tree-shakes out of prod bundles. `reactiveTrace` is simply `undefined` in production. Verified: bundle budgets unchanged (all 54 within budget), perf-harness tree-shake regression passes.
  - **Bounded + leak-safe.** Fixed-size (~50-entry) ring buffer, oldest-evicted, never grows. Stores **truncated string previews** of values — never raw references — so it can't pin large arrays / detached DOM / closures, and is always safe to serialize into a report. Hostile values (throwing getters, cycles, huge strings, BigInt) are handled without throwing.
  - **Distinct from `onSignalUpdate`.** That is opt-in and captures stacks (expensive, for time-travel debugging). This is always-on in dev, deliberately cheap (no stack), and exists specifically to enrich error reports.
  - **Best-effort.** Trace capture in `reportError` is wrapped so a buggy/empty trace can never block the real error from reaching handlers. Caller-supplied `reactiveTrace` is never overwritten.

  Bisect-verified at both layers: (1) removed the `_recordSignalWrite` call → reactivity ring-buffer tests fail; (2) removed the `reportError` enrichment → `telemetry.test.ts > attaches recent signal writes` fails at `expect(captured?.reactiveTrace).toBeDefined()`; restored → all pass. Suites: `@pyreon/reactivity` 290, `@pyreon/core` 497.

### Patch Changes

- [#612](https://github.com/pyreon/pyreon/pull/612) [`c3d0a70`](https://github.com/pyreon/pyreon/commit/c3d0a7017ed2ef4468ec3fb4e4c09ec869d2917a) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Security / memory-leak / correctness hardening sweep across core, fundamentals, and zero. 12 source-grounded defects fixed; every fix has a bisect-verified regression test (revert → fail → restore → pass).

  **Security (prototype pollution / XSS / DoS)**

  - `@pyreon/reactivity` `reconcile()` + `createStore` set trap — a documented "apply an untrusted API response into a store" path (`reconcile(JSON.parse(body), store)`) had no `__proto__`/`constructor`/`prototype` guard. Added on both the write and stale-key-removal passes + defense-in-depth in the proxy set trap.
  - `@pyreon/i18n` `addMessages` — `nestFlatKeys` (dotted-key expansion) ran BEFORE `deepMerge`, so deepMerge's own pollution filter never saw the dotted form; `__proto__.x` walked into `Object.prototype` and wrote onto it. Message JSON is routinely CDN/community-sourced. Guarded.
  - `@pyreon/document` HTML renderer — `language` was interpolated raw into `<html lang="…">` and `styleStr` emitted string values raw into `style="…"`; a CMS/author-supplied value containing `"><script>` broke out → stored XSS. `lang` is now charset-restricted + escaped; style values route through the renderer's existing `sanitizeCss`.
  - `@pyreon/zero` rate-limit — `MAX_STORE_SIZE` was a declared-but-unenforced constant; the cleanup only evicted EXPIRED entries, so a flood of unique keys within one window (spoofable `X-Forwarded-For`) grew the Map unbounded — an unauthenticated memory-exhaustion DoS. Added a hard cap with oldest-first eviction (mirrors the ISR cache's proven `set()`).
  - `@pyreon/zero` ISR — the cache stored ANY response and replayed it as a 200 for the whole revalidate window: a transient 5xx/3xx became a self-inflicted outage, and a `Set-Cookie` response was replayed cross-user. Now only 2xx, cookie-free responses are cached; everything else passes through verbatim with its original status (`x-isr-cache: BYPASS`).
  - `@pyreon/server` `prerender` + `@pyreon/zero` SSG plugin (3 sites) — the path-traversal guard used a bare `startsWith(resolve(outDir))` (string-prefix, not path containment): a `getStaticPaths` slug resolving to the SIBLING `dist-evil/` passed and wrote outside the output root. Now separator-terminated containment (`isInsideDist`).
  - `@pyreon/zero` API-route matcher — dangerous param names from the route pattern guarded (defense-in-depth; consistent with the reconcile / i18n guards).

  **Memory leaks**

  - `@pyreon/reactivity` `signal._d` — direct-updater disposal nulled an array slot but never compacted, so a long-lived signal (theme/locale/auth, or signals read in `<For>` rows) bound by churning components accumulated one permanent dead slot per ever-mounted binding — an app-lifetime leak that ALSO degraded the signal-write hot path (`notifyDirect` iterated O(total-ever), not O(live)). Switched to a `Set` (same as `_s`): O(1) disposal, O(live) iteration, bounded growth. Proven structurally — `_d.size` stays 0 after 10 000 register/dispose cycles.
  - `@pyreon/dnd` `useSortable` — `itemRef` pushed every pdnd registration onto a shared array and the unmount (`ref(null)`) branch was a no-op, so a churning `<For>` sortable (todo list / kanban — the documented usage) leaked every removed item's draggable/dropTarget registration until the whole sortable unmounted. Now per-key disposal on unmount and re-register.
  - `@pyreon/zero` ISR — a hung revalidation handler pinned its key in the in-flight set forever (`finally` never ran), so the entry could never recover from stale. Background revalidation is now timeout-bounded (`ISRConfig.revalidateTimeoutMs`, default 30 s).

  **Correctness / silent-failure**

  - `@pyreon/router` `stringifyLoaderData` — the cycle detector used an all-seen `WeakSet` that was never pruned, so a shared (DAG) reference — extremely common, e.g. `{ author: user, lastEditor: user }` from an ORM — falsely threw "circular reference" and 500'd the SSR response. Replaced with true ancestor-path detection (the original code's own comment anticipated exactly this remedy). **Behaviour change (bug fix, strictly more permissive):** payloads that previously 500'd now serialize; real cycles still throw.
  - `@pyreon/server` `processTemplate` — used `String.prototype.replace` with string replacements, so rendered HTML containing literal `$&` / `$$` / `` $` `` / `$'` (prices, code, math) was corrupted by regex-pattern substitution. Switched to function replacements.
  - `@pyreon/i18n` `interpolate` — a serialization failure (circular value, throwing `toString`) was swallowed silently, rendering `{{key}}` to end users with no signal. Now dev-warns (fallback behaviour unchanged).
  - `@pyreon/query` `useSSE` — the reactive effect unconditionally reset `intentionalClose = false`, so an explicit `close()` was silently overridden by any later reactive `url`/`enabled` change. Now respects `intentionalClose` (mirrors `useSubscription`); `reconnect()` is the explicit resume.

  **Disclosures (honest scope)**

  - **An attempted SWR-swallow fix (surface the empty `.catch` via `__DEV__` warn + `_onError`) was REVERTED from this PR.** Probing empirically proved `revalidateSwrLoaders` is invoked **0 times** even by the canonical `staleWhileRevalidate` nav pattern: `resolveRoute` returns fresh `RouteRecord` objects per resolution, so `runLoaders`' `r.staleWhileRevalidate && router._loaderData.has(r)` gate is never true across navigations — the SWR branch is **dead code**, and the existing "revalidates in background" test's count actually comes from the blocking path running twice. Adding error-surfacing to provably-unreachable code is not hardening (and it dropped router coverage). **The real bug — `staleWhileRevalidate` is effectively non-functional for the nav-away/back case (record-identity-keyed gate)** — is a distinct, significant finding whose correct fix (key the gate by a stable path/loaderKey) is a non-trivial router behaviour change deserving its own focused, aligned PR. Documented in `router/src/tests/loader.test.ts` as a flagged follow-up; deliberately not bundled here (scope/risk).
  - One audit finding (`decodeKeyFromMarker`) was investigated and **dropped as a false positive** — `%2D` never appears in `encodeURIComponent` output, so the manual substitution is uniquely reversible.
  - Z5 (API-route param guard) is defense-in-depth: a string param value assigned to `__proto__` is a silent JS no-op (not exploitable); the guard prevents the real own-prop shadow for `constructor`/`prototype` and matches the repo-wide convention.

  Validation: lint 0 errors; typecheck clean (8 touched packages); gen-docs in sync; audit-types `--all --strict` 0 HIGH; bundle-budgets 54/54 within budget. Per-package suites all green (reactivity 294, router 520, server 78, i18n 155, document 269, dnd 111, query 151, zero 884).

- [#626](https://github.com/pyreon/pyreon/pull/626) [`ecd8e52`](https://github.com/pyreon/pyreon/commit/ecd8e526943a1e6b07957ff96f4410fa482baa0d) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Fix: `computed.direct()` no longer leaks (and no longer degrades `recompute`) under register/dispose churn.

  `computed`'s `directFns` was a flat array whose disposer only nulled the slot (`arr[idx] = null`) and never compacted. A long-lived computed (a derived theme/locale/auth value, or one read inside churning `<For>` rows) whose direct updaters register and dispose repeatedly therefore accumulated one **permanent dead slot per ever-registered binding** — app-lifetime memory growth — AND made `recompute` iterate **O(total-ever-registered)** instead of O(live), on the compiler-emitted `_bindText`/`_bindDirect` notify path.

  This is the **exact bug class already fixed for `signal._d`** (`signal.ts` `_directFn`, shipped + e2e-verified in [#612](https://github.com/pyreon/pyreon/issues/612)). `computed` was simply left on the broken array pattern. Fix: `directFns` is now a `Set` (same as `signal._d` / `host._s`) — O(1) add/delete, O(live) iteration, bounded growth.

  `computed` now also exposes the live set as an `@internal` `_d` accessor (mirroring `signal._d`) purely so the regression is deterministically assertable.

  **Verification.** New regression test: 10 000 `computed.direct()` register+dispose cycles on a long-lived computed → asserts the live set stays `size 0` (bounded), one live binding → `size 1` and still fires, disposed → `size 0` and not invoked. Bisect-verified: reverting to the array form fails the test (`_d` is an array → `.size` undefined); restored → 295/295 reactivity tests pass. `bun run coverage` exit 0 (`@pyreon/reactivity` 94.64 %); lint + typecheck clean. e2e coverage is inherited from the structurally-identical `signal._d` Set conversion already validated end-to-end in [#612](https://github.com/pyreon/pyreon/issues/612) (same compiler-binding notify path).

- [#589](https://github.com/pyreon/pyreon/pull/589) [`c4b6e9a`](https://github.com/pyreon/pyreon/commit/c4b6e9a5850196171c2197fc918163f736708aa8) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Release pipeline: `scripts/publish.ts` now creates the local `<name>@<version>` git tag before emitting each `New tag:` line.

  Without this, `changesets/action` would parse the `New tag:` lines (populating `outputs.published`) but then fail when its tag-push step tried `git push origin <name>@<version>` — the local tag didn't exist (`src refspec X does not match any`). The step exited non-zero, the gated umbrella GitHub Release step skipped, and `release-native.yml` never triggered → 7 platform compiler binaries never published.

  The 0.18.0 release hit exactly this path: all 55 npm packages published successfully, but the post-publish step failed → no v0.18.0 tag, no GitHub Release, no native binaries until manual recovery.

  Mirrors what `changeset publish` (the CLI command) does natively — emit `New tag:` AND create the local annotated tag. Idempotent: skips creation if the tag already exists locally (retried runs work).

  Also fixes the `actions/download-artifact` SHA pin in `release-native.yml` — the prior pin `cc20338…` was a transcription error; real v5.0.0 SHA is `634f93cb…`. Every Publish job in the v0.18.0 run 25873293958 failed at "Set up job" with `Unable to resolve action … unable to find version <SHA>` because of this. No prior release exercised the code path (previous workflow_dispatch runs used `publish: 'false'` which skips the publish matrix). Both bugs together blocked native binaries from publishing alongside the npm release.

- [#592](https://github.com/pyreon/pyreon/pull/592) [`fb40906`](https://github.com/pyreon/pyreon/commit/fb409066e49e44c42f77084a92a68103a4e6c5ef) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Release pipeline: `release-native.yml` Publish step now uses npm OIDC trusted publishing instead of `NODE_AUTH_TOKEN`.

  The prior shape (`NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`) resolved to empty string because the repo doesn't store an `NPM_TOKEN` secret — Pyreon's main release flow works without one because `changesets/action` does the OIDC exchange internally, but `release-native.yml`'s raw `npm publish` doesn't. setup-node then wrote `_authToken=` (empty) into `.npmrc`, and npm tried to use the empty token, failing with `ENEEDAUTH` instead of falling back to OIDC.

  Removing the env line lets npm 11+ (shipped with Node 24) perform the OIDC token exchange natively, with `id-token: write` already granted at the workflow level. No long-lived secret stored anywhere; per-publish tokens are scoped to workflow + package + commit SHA, and published tarballs gain provenance attestations.

  **One-time manual bootstrap required** (see `CONTRIBUTING.md` → "Native binary publishing"): the 7 `@pyreon/compiler-<triple>` packages have never been published, and npm trusted publishing is configured on a package's own settings page — it **cannot** be set up for a package that doesn't exist yet (npm has no account/org-level pre-registration flow). The OIDC path here works for every release _after_ a one-time manual first publish brings the 7 packages into existence; trusted publishing is then configured per-package and all subsequent releases are automated. `scripts/bootstrap-native-publish.ts` stages the CI-built binaries and prints the manual publish commands.

- [#631](https://github.com/pyreon/pyreon/pull/631) [`3374150`](https://github.com/pyreon/pyreon/commit/33741500499dfb487d031bbffe77723d74b8f261) Thanks [@vitbokisch](https://github.com/vitbokisch)! - Coverage-harden `@pyreon/reactivity` — close the RED coverage gate on the foundation package.

  **Finding (measured, not speculative).** `@pyreon/reactivity` — the package every other Pyreon package transitively depends on (signal / computed / effect / batch) — had drifted **below its own enforced coverage threshold**: branches at **87.38%**, under the 90% global threshold that `@vitus-labs/tools-vitest`'s `createVitestConfig()` sets. The package's own `bun run test` was exiting **non-zero** (`ERROR: Coverage for branches (87.38%) does not meet global threshold (90%)`). The previously-documented invariant "All packages maintain >95% on all 4 metrics" was doubly inaccurate: the enforced gate is 90% (not 95%), and reactivity wasn't even at 90%. Untested branches in the reactivity core are the single highest-leverage release-stability risk in the monorepo.

  **No bug found.** Every uncovered region was a genuine untested edge case in _correct_ code — error-handler branches (throwing computed / throwing inner-effect disposal), the batching-vs-inline dual notify paths, the `setSnapshotCapture` DI-hook restore branch, multi-dependency `renderEffect` cleanup, `Cell` single→Set listener promotion, `createSelector` bucket-notify branches, and `reactive-trace`'s `preview()` value-shape matrix (arrays / named instances / >4-key objects / unstringifiable revoked-Proxy / long-string truncation). This is coverage hardening + a doc-accuracy correction, NOT a fix PR — so the bisect-verify mandate (revert fix → assert failure) does not apply; there is no fix to revert. Each of the 16 added tests asserts real observable behaviour (notify ordering, recovered-after-throw values, restore-call sequencing), not coverage-gaming shape.

  **Result.** `packages/core/reactivity/src/tests/coverage-hardening.test.ts` (+16 tests). Reactivity coverage moved **statements 94.64 → 96.47 · branches 87.38 → 90.76 · functions 95.48 → 97.74 · lines 95.94 → 97.62**; all 4 metrics now ≥90%, `bun run test` **exits 0** (gate GREEN). 311/311 reactivity tests pass.

  **Doc-hygiene (same PR, per continuous-learning).** `.claude/rules/testing.md`'s "All packages maintain >95% on all 4 metrics" line was false on both counts; corrected to state the _real enforced contract_ (90% global threshold = the blocking gate; >95% is the aspiration, not a guaranteed invariant) with the reactivity drift as the worked example.

  **Known remaining (deliberate, tracked follow-ups — not silently dropped):**

  - `tracking.ts` 72-73 (the `cleanupEffect` WeakMap `effectDeps` branch) appears genuinely **unreachable** in the current codebase — both `effect` and `computed` always set a deps-collector around `withTracking`, so `trackSubscriber` never takes the WeakMap path. That is a _suspected-dead-code_ finding that needs its own rigorous reachability proof + removal PR ("understand before changing" / "one concern per PR") — not something to rip out of framework infrastructure inside a coverage PR.
  - `batch.ts` 104-116 (the `MAX_PASSES` infinite-re-enqueue dev guard) is exercised by a test that passes in isolation but is global-batch-state-fragile across the full suite; a deterministic, non-flaky cover for it is a separate hardening task (a flaky test is worse than an uncovered defensive `__DEV__` branch).
  - Other packages were not surveyed for coverage drift in this PR — scope was deliberately bounded to the one package whose gate was RED. A monorepo-wide coverage-drift sweep is a worthwhile separate effort.

## 0.18.0

## 0.17.0

## 0.16.0

## 0.14.0

## 0.13.0

## 0.12.15

## 0.12.14

## 0.12.13

## 0.12.12

## 0.12.11

## 0.7.2

## 0.7.1

## 0.7.0

### Minor Changes

- feat(reactivity): add `onCleanup()` for registering cleanup functions inside effects

## 0.6.0

## 0.5.7

### Patch Changes

- fix: update build tooling to produce correct .d.ts type declarations instead of JS implementation code

## 0.5.6

## 0.5.4

## 0.5.3

## 0.5.2

## 0.5.1

## 0.5.0

## 0.4.0

## 0.3.1

### Patch Changes

- Router performance: flattened route matching with first-segment dispatch index (39% faster at 200 routes). Core type fixes: export `ReadonlySignal<T>` from reactivity, widen `h()` component overloads to support optional children and generic components, add minimal `process` type declaration so consumers don't need `@types/node`.

## 0.3.0

### Minor Changes

- ### Performance

  - **2x faster signal creation** — removed `Object.defineProperty` that forced V8 dictionary mode
  - **Event delegation** — `el.__ev_click` instead of `addEventListener` for compiled templates
  - **`_bindText`** — direct signal→TextNode subscription with zero effect overhead
  - **`_bindDirect`** — single-signal attribute bindings bypass effect tracking entirely
  - **`signal.direct()`** — flat-array updater registration for compiler-emitted DOM bindings
  - **Batch Set pooling** — snapshot-free subscriber notification eliminates array allocations
  - **`createSelector` snapshot-free** — O(1) selection without copying subscriber maps
  - **`renderEffect` fast path** — lighter than full `effect()` for DOM bindings
  - **SSR `renderToString` micro-optimizations** — sequential loops, `for...in`, `escapeHtml` fast path
  - **Hydration optimizations** — reduced overhead during island hydration
  - **Nested `_tpl` support** — compiler emits nested `cloneNode(true)` templates

  ### Features

  - **True React compatibility** — `useState`, `useEffect`, `useMemo` with re-render model matching React semantics
  - **True Preact compatibility** — hooks with re-render model matching Preact semantics
  - **True Vue compatibility** — `ref`, `reactive`, `watch`, `computed` with re-render model matching Vue semantics
  - **True SolidJS compatibility** — signals with re-render model matching Solid semantics, children helper fixes

  ### Benchmark Results (Chromium)

  Pyreon (compiled) is fastest framework on 6 of 7 tests:

  - Create 1,000 rows: 9ms (1.00x) vs Solid 10ms, Vue 11ms, React 33ms
  - Replace all rows: 10ms (1.00x) vs Solid 10ms, Vue 11ms, React 31ms
  - Partial update: 5ms (1.00x) vs Solid 6ms, Vue 7ms, React 6ms
  - Select row: 5ms (1.00x) — tied with all signal frameworks
  - Create 10,000 rows: 103ms (1.00x) vs Solid 122ms, Vue 136ms, React 540ms

## 0.2.1

### Patch Changes

- Release 0.2.1
  - feat(vite-plugin): add `compat` option for zero-change framework migration
  - fix: resolve `workspace:^` dependencies correctly during publish
  - fix(vite-plugin): use `oxc` instead of deprecated `esbuild` option
  - fix(vite-plugin): bump vite peer dependency to `>=8.0.0`

## 0.2.0

### Minor Changes

- Release 0.2.0
  - fix(vite-plugin): use `oxc` instead of deprecated `esbuild` option for Vite 8
  - fix(vite-plugin): bump vite peer dependency to `>=8.0.0`

## 0.1.2

### Patch Changes

- Improve compat package types (eliminate unnecessary casts), add lint/typecheck CI for all workspaces, split example apps into individual component files.

## 0.1.1

### Patch Changes

- Fix workspace dependency resolution in published packages and add automated release pipeline with changesets + OIDC trusted publishing.
