# Anti-Patterns

**MCP `validate` coverage (T2.5.2)**: the `detectPyreonPatterns` detector in `@pyreon/compiler` (see `packages/core/compiler/src/pyreon-intercept.ts`) flags 16 of the patterns below statically, and the MCP `validate` tool surfaces them alongside the React detector. Patterns with a `[detector: <code>]` suffix (for example `[detector: for-missing-by]`) are caught automatically — new code in a session that runs the MCP server against `validate({ code })` will see them diagnosed without anyone having to re-read this file. Patterns without a tag require deeper semantic analysis (scope tracking, type inference, or runtime shape) that a lightweight AST walker can't do reliably; those remain doc-only and are the high-risk cliff for new contributors. `packages/core/compiler/src/tests/detector-tag-consistency.test.ts` is the drift guard: every tag must reference a real `PyreonDiagnosticCode`, and every code must appear at least once as a tag.

## Reactivity Mistakes

- **Bare signal in JSX text**: `{count()}` → wrap in `{() => count()}` or let the compiler handle it
- **[FIXED, 2026-07] Module-level tracking collectors set-then-NULLED instead of saved-then-RESTORED around nested reactive frames.** `effect`'s run body + a computed's first eval each set a module-level thread-local (cleanup collector / deps collector) and reset it to a FIXED value (`null`) on exit — correct only at depth 1. Real shapes nest: a nested `effect()` created inside an outer effect's body nulled the cleanup collector on its run's exit, silently DROPPING every outer `onCleanup()` registered after that line; a dirty computed's FIRST eval inside an effect run nulled the deps collector, so the effect's subsequent signal reads were subscribed but never RECORDED → subscription retained after `dispose()` (leak class B). **Rule: module-level thread-local frame state (collector/owner/index) must be captured into locals at frame entry and RESTORED — never reset to a constant — at frame exit.** The `tracking.ts` frame helpers (`runCollect`/`runVerify`) centralize the save/restore; never hand-roll a set/null pair around a tracked evaluation. Both shapes bisect-locked in `packages/core/reactivity/src/tests/verify-deps.test.ts` ("frame-restore regressions").
- **Stale closures**: `signal.peek()` captured in long-lived closures loses reactivity
- **Missing batch**: 3+ signal updates without `batch()` → unnecessary re-renders
- **Nested effects**: `effect()` inside `effect()` → use `computed()` for derived values
- **Signals in hot paths**: Creating signals inside render functions or loops → create once at component setup
- **Reading `.peek()` in effects/computeds**: Bypasses tracking, creates stale reads
- **Ternary short-circuit hiding signal tracking inside a reactive accessor**: `{() => fields.title.touched() ? fields.title.error() ?? '' : ''}` is a quietly broken pattern. When `touched` is `false` on first render the ternary short-circuits → `error()` is NEVER read → the effect doesn't subscribe to it → a later `error.set('...')` doesn't trigger re-render. The accessor re-runs when `touched` flips, but the validator that flipped it may set the error in the SAME batch — at re-run time `error()` is still `undefined`, and now the effect subscribes only to the current value (`undefined`), not future ones. Same for `cond && sig()`. **Fix**: read both signals into a `const` BEFORE the conditional, so the effect subscribes to both on first render: `{() => { const t = fields.title.touched(); const e = fields.title.error(); return t ? e ?? '' : '' }}`. This is fundamental fine-grained reactivity (matches Solid / Preact-signals / MobX) — not a Pyreon-specific bug. Real-world hit: HN-clone audit #942 W11 — form field-errors stayed empty after submit even though the schema validator correctly set them. Documented in `docs/src/content/docs/reactivity-rules.md` "Conditional Reads Hide Tracking". Hard to detect statically with high precision (any conditional with a signal call in one branch is suspect, but false-positive risk is high — many such expressions ARE correct by construction because both signals come from a shared upstream).
- **[FIXED, 2026-07] A whole-form schema error keyed by a path that doesn't match a top-level field is silently dropped → the form submits invalid data.** `@pyreon/form`'s `validate()` applied `schemaErrors[name]` only for `name` in the top-level `fieldEntries`. But the zod/valibot/arktype adapters (`issuesToRecord`) flatten a nested issue to a DOT-PATH key (`"address.city"`), and path-less/whole-form errors land under the `""` key — neither matches a top-level field, so the error was discarded and `validate()` returned `true` (VALID) while the schema had REJECTED. A form with an object-valued field (`initialValues: { address: { city: '' } }`) + a nested schema reported valid + fired `onSubmit` with empty data (verified with a runnable repro: `validate() ⇒ true`, `errors() ⇒ {}`). **Fix (both the submit `validate()` AND the blur `runSchemaForField` paths)**: `matchSchemaErrorForField(schemaErrors, name)` matches an EXACT key OR the first nested key whose top-level segment is this field (`address.city` → `address`), so a nested rejection surfaces on its ancestor object field; `orphanSchemaErrorKeys(schemaErrors, fieldNames)` collects keys whose top segment matches NO field (typo / `""` whole-form error) → those mark the form INVALID + set `submitError` + dev-warn. **General rule: any code that maps a validator's keyed error record onto a fixed field set must handle keys that match no field — route them (to an ancestor or a form-level error), never drop them; "matched no field" must mean INVALID, not valid.** Both helpers are pure + unit-tested; the end-to-end drop is bisect-verified (revert → 5 specs fail, `validate()` returns true). Reference: `packages/fundamentals/form/src/use-form.ts:matchSchemaErrorForField`/`orphanSchemaErrorKeys` + `tests/schema-error-routing.test.tsx`. Flat model still means a nested error shows on the ANCESTOR field, not a per-leaf field (per-leaf needs nested field paths — tracked separately).
- **Destructuring props** `[detector: props-destructured]` (parameter shape) `[detector: props-destructured-body]` (body shape): `const { state } = props` (or `({ state }) => …`) captures getter values once — loses reactivity. The compiler emits signal-driven props as getters, so the destructured locals are dead snapshots that never update when the parent rewrites them. Use `props.state` directly inside the reactive scope (JSX / effect / computed), or `splitProps(props, ['state'])` to carve out a group while preserving reactivity. **Both shapes are now statically caught** by `detectPyreonPatterns` (the TS-compiler-API detector — full AST + scope, NOT the lightweight oxc lint walker the prior "doc-only cliff" note referred to): `detectPropsDestructured` flags the parameter-destructure shape `function C({ state }){…}`; `detectPropsDestructuredBody` flags the body-scope shape `const { state } = props` written SYNCHRONOUSLY in a component body. **Body-scope precision** (zero false positives is the priority): PascalCase JSX-rendering functions PLUS the component-by-POSITION HOC shape — an anonymous RETURN-POSITION arrow whose first param is literally `props` (`const withX = (W) => (props) => {…}`, concise or block body; the shipped gap where the anonymous inner component got "No issues found" until named — `isReturnedPropsComponent`; render-prop children and `.map` callbacks are ARGUMENTS not returns → excluded, and the recommended-fix accessor `return (() => …)` has no params → excluded); only `= props` where `props` is the bare first-parameter identifier (unwrapped through `as` / `satisfies` / `!` / parens); the destructure must be at the component-body top scope — a nested-function boundary (`onClick` handler, `effect(() => …)`, a returned reactive accessor) re-reads `props` per invocation and is reactivity-correct, so the walk does NOT descend into nested functions. **Known limitations** (deliberate, conservative): `const { x } = props.nested` and `= someOtherObject` are NOT flagged (rarer shapes); a `const { x } = props` inside `onMount(() => …)` is not flagged (different/rarer shape — runs once post-mount). The **Reactivity Lens** (`analyzeReactivity` in `@pyreon/compiler`; LSP inlay hints via `@pyreon/lint --lsp`) complements the detector downstream: it renders `static` ghost-text on `<div>{state}</div>` when the compiler proves that expression is NOT reactive — making the captured-once death visible at the cursor even for the limitation shapes the detector deliberately doesn't name.
- **JSX spread on a component value-copies getter-shaped reactive props** (compiler-fixed end-to-end as of 2026-05-14): esbuild's automatic JSX runtime compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`. The JS-level object spread fires every getter on `source` and stores resolved values — collapsing compiler-emitted reactive props (`_rp(() => signal())` wrappers converted to getters by `makeReactiveProps`) to static values before `Comp` ever sees them. **Fix lives in the Pyreon compiler** (both JS path in `packages/core/compiler/src/jsx.ts:handleJsxSpreadAttribute` AND Rust binary in `packages/core/compiler/native/src/lib.rs:handle_jsx_spread_attribute`): for any component JSX with `{...source}`, the compiler emits `<Comp {..._wrapSpread(source)}>`. `_wrapSpread` (`packages/core/core/src/props.ts`) walks `source`'s own keys via `Reflect.ownKeys` (no getter firing) and returns a new object whose getter-shaped values are re-branded as `_rp` thunks pointing back at the original. JS spread then copies the brands as plain data property values; `makeReactiveProps` converts them back to getters in the mount pipeline — preserving the reactive subscription end-to-end. Runtime fast path: sources with no getters return unchanged, so static spreads pay zero cost. DOM-element spreads (`<div {...rest}>`) are untouched — they go through the template path's `_applyProps` which already handles reactivity. Bisect-verified: reverting the JS path's `replacements.push` line fails `JSX transform — component elements > spread props on component are wrapped with _wrapSpread to preserve reactivity` with `expected 'const w = <Wrapper {...rest}…' to contain '{..._wrapSpread(rest)}'`.

- **Manual prop-pipeline wrappers value-copying getter-shaped reactive props** (framework-internal fixes; user code now handled by the compiler-level fix above): Pyreon's reactive-prop contract is that `<Comp prop={signal()}>` compiles to `h(Comp, { prop: _rp(() => signal()) })`, `mount.ts` runs `makeReactiveProps` to convert `_rp`-branded thunks into property GETTERS, and any downstream consumer reading `props.prop` inside a tracking scope (JSX accessor, effect, computed) subscribes to the underlying signal. **Wrapper / HOC pipelines that COPY props via `result[key] = source[key]` in plain JS (not JSX spread) break this contract** — the value-read fires the getter at HOC setup time (outside any tracking scope), captures the resolved value, and stores it as a data property on the new object. Every downstream JSX accessor reading `props.prop` then sees the captured-once value, never re-subscribing to the signal. Same for spread (`{...A, ...B}`) and `Object.assign` — both are value-read + value-write. **Fix shape**: use `Object.getOwnPropertyDescriptors(source)` + `Object.defineProperty(target, key, descriptor)` to copy DESCRIPTORS instead of values. For data properties this is identical; for getter properties it preserves the live getter on the destination object. **Real-world hit**: `@pyreon/rocketstyle`'s `removeUndefinedProps` + 4 spread sites (HOC + `EnhancedComponent`) AND `@pyreon/styler`'s `buildProps` AND `@pyreon/ui-core`'s `omit` / `pick` all value-copied reactive props from package inception. Every signal-driven prop on every rocketstyle-wrapped component (the whole of `@pyreon/ui-components`, plus user-defined ones) silently lost reactivity. No existing test caught it because no test passed a signal-valued ordinary prop through the rocketstyle pipeline and asserted reactive DOM patching. **`@pyreon/kinetic` was the last residual hit** (correctness long-tail sweep): `createKineticComponent`'s prop split was a `for (const key in props) { htmlProps[key] = props[key] }` value-copy, the children carve-out was `const { children, ...restHtml } = htmlProps` (rest-destructure = value-copy), AND all four renderers re-spread `{ ...htmlProps }` into their `h(config.tag, …)` call — so every reactive HTML attr on every `kinetic('div')`-wrapped component (transition / collapse / stagger / group) froze at first render from package inception. Fixed by routing the split through `splitProps(props, [...KINETIC_KEYS])` + a second `splitProps(…, ['children'])` for the children carve-out, and replacing the renderer re-spreads with by-reference pass (Stagger/Group — no extra props) or `mergeProps(htmlProps, { ref, style })` (Collapse/Transition — ref + animation-controlled style override last-wins). The descriptor survives to `h(config.tag, htmlProps)` where `runtime-dom`'s `applyProps` detects `descriptor.get` and wraps the read in `renderEffect`. Reference: `packages/ui-system/kinetic/src/kinetic/createKineticComponent.tsx` + `{Collapse,Group,Stagger,Transition}Renderer.tsx`; bisect-verified at the real-Chromium layer (`packages/ui-system/kinetic/src/__tests__/kinetic.browser.test.tsx` — broken: `expected 'a' to be 'b'` on 3 reactive-forwarding specs, 5 non-reactive specs still pass; restored: 8/8). Reference: `packages/ui-system/rocketstyle/src/utils/attrs.ts:removeUndefinedProps` + the canonical `mergeProps` from `@pyreon/core` (which superseded the former rocketstyle/attrs-internal `mergeDescriptors` helpers — a sweep consolidated every framework descriptor-safe-merge call site onto the single canonical API), `packages/ui-system/styler/src/forward.ts:buildProps`, `packages/ui-system/ui-core/src/utils.ts:omit` / `pick`. **General rule for any wrapper that forwards user props**: copy descriptors, never values. Plain `result[key] = source[key]` is correct only when the source is known to carry no getters — which user-supplied props can never satisfy because the compiler emits them as `_rp` thunks that `makeReactiveProps` converts to getters. Companion writes (`finalProps.ref = ...`, `finalProps.X = newValue`) must use `Object.defineProperty` with a data descriptor — plain assignment to a getter-only descriptor is a silent no-op in non-strict mode and throws in strict mode. **The descriptor-aware "drop undefined keys, keep getters" filter is now a single `@pyreon/core` primitive — `removeUndefinedProps`, sitting next to `mergeProps` / `splitProps` / `makeReactiveProps` (it operates on core's own `_rp` encoding).** `@pyreon/attrs` and `@pyreon/rocketstyle` previously hand-rolled it identically, and the `@pyreon/attrs` copy historically shipped as a value-copy that silently broke reactive forwarding for `attrs(Component)` consumers — the exact divergence a single canonical home prevents. Both packages' `utils/attrs.ts` now re-export it from core. When you need to filter undefined keys off a props object before merging, use `removeUndefinedProps` from `@pyreon/core` — never write a fresh value-copy or descriptor-copy loop. **2026-07 whole-class sweep (`@pyreon/elements`, the Text.label fix's siblings) — the same eager-read freeze hides in FOUR more shapes beyond prop-copying loops**: (1) body/parameter DESTRUCTURES (`Iterator`'s 8-prop body destructure + one-shot `return renderItems()`, `useOverlay`'s parameter destructure — `<Overlay disabled={busy()}>` never re-enabled, `Util`); (2) render-prop objects built with EAGER signal reads (`Overlay`'s trigger got `active: active()` at setup — `aria-expanded` frozen at `false` forever, an a11y bug; fix = pass `_rp(() => active())` accessors so the binding is live WITHOUT re-rendering the trigger, which would destroy the focus-restore target); (3) values baked into styling-layer bundles at setup (`Element`/`Wrapper`/`Content`'s interned `$element` + `Text`'s `$text` froze every signal-driven layout/css prop — fix = a two-path design: getter-shaped props detected via a cheap descriptor scan (`hasGetterProps`) switch the bundle to an ACCESSOR the styler's `DynamicStyled` now treats as a reactive axis exactly like `$rocketstyle` — class swap on the same element, static case byte-identical; the reactive path must BYPASS identity-keyed class caches + CPSE, whose per-instance style vars a class-swap can't update); (4) slot-EXISTENCE branches pinned at setup (`isSimpleElement = !own.beforeContent && !own.afterContent` — fix = run the body in a reactive accessor when slots are getter-shaped; structural remount is correct when the DOM shape changes). Reactive-Iterator corollary: a reactive list body must NOT return fully-keyed vnode arrays baked from plain props — `mountKeyedList` never re-renders surviving keys, so index-keyed rows would FREEZE on data flips; whole-list replacement (keys removed) is the correct semantic, and per-item injectors must be pure (the mount pipeline samples the accessor before the tracked run). Reference: `packages/ui-system/elements/src/utils.ts:hasGetterProps` + `styler/src/styled.tsx` (`isReactiveEl`/`bypassElCacheAndCpse`); regression locks `elements/src/__tests__/reactive-prop-class.test.tsx` (bisect-verified per instance) + `reactive-prop-class-sweep.browser.test.tsx` (real Chromium: computed-style flips, trigger identity + focus restore).
- **Resolving a getter-valued content-prop fallback chain (`children ?? content ?? label`) EAGERLY at setup instead of inside an accessor**: distinct from the descriptor-copy class above — here the props ARE preserved as getters through `splitProps` (descriptors copied verbatim), but a wrapper that computes its rendered child as `const child = own.children ?? own.content ?? own.label` (or passes `children: own.children ?? own.label` as a plain override VALUE) reads each getter ONCE via `??` at component setup (components run once), capturing the resolved value. A compiler `_rp()`-getter — what `<Text label={sig()} />` lowers to — is then frozen; the signal changes but the text never updates. **`<Comp>{sig()}</Comp>` (a JSX-CHILD accessor) is unaffected** — the child arrives already wrapped as `() => sig()`, a function that `mountChild` mounts reactively; the bug is specific to the getter-valued `label`/`content`/`children` **PROP** resolved by `??`. **Fix**: pass `children` as an ACCESSOR — `children: () => own.children ?? own.content ?? own.label` — so `mountChild` re-reads the fallback (and each underlying getter) on every change. **Real-world hit**: `@pyreon/elements`' `Text` shipped `children: own.children ?? own.label` from inception; PR #1168 fixed the sibling REST-prop boundary (href/title via `mergeProps` descriptor copy) but left this children read eager, so `Text label={sig()}` stayed non-reactive. `Element` already did it right (`getChildren = () => own.children ?? own.content ?? own.label`, passed as `children: () => resolveSlot(getChildren())`) — the two diverged. Fixed by wrapping Text's override in an accessor. **General rule**: any wrapper that resolves a content/children fallback chain over props MUST wrap the `??` chain in an accessor (`() => …`), never read it at setup — the `??` fires the getters, and a getter-valued reactive prop dies the moment it's read outside a tracking scope. **Detection trap**: a mock-vnode unit test asserting `result.props.children` "toBe" the resolved value CODIFIES the broken eager shape (it compares against the value, and once children is an accessor it's a function — assert `props.children()` instead). The load-bearing test is a real-Chromium mount that flips a `_rp(() => sig())`-valued `label` and asserts `el.textContent` updates. Reference: `packages/ui-system/elements/src/Text/component.tsx` (accessor children) vs `Element/component.tsx:getChildren`; bisect-verified in `__tests__/reactive-prop-through-element.browser.test.tsx` (revert → `expected 'live-1' to be 'live-2'`).
- **Detecting "has direct subscribers" by reading `signal._d` alone**: PR #1177 added a two-tier subscriber store on `signal()` — `_d1` is an inline slot for the FIRST direct subscriber (~all per-row label/class bindings inside `<For>` rows fall here); `_d` is a `Set` allocated lazily only on PROMOTION when a 2nd subscriber arrives. Any consumer that checks `if (signal._d && signal._d.size > 0)` to decide "is anyone subscribed?" gets a **false-negative** on every single-subscriber signal — `_d` is null when only `_d1` is in use. **Real-world hit**: `@pyreon/solid-compat`'s `sweepUnusedSignals` (`packages/tools/solid-compat/src/index.ts`) was about to evict LIVE store-backing signals because every per-row signal has exactly 1 direct subscriber — caught and fixed during the PR #1177 cross-package audit. **Fix shape**: check BOTH tiers — `const hasDirect = sigInternal._d1 !== null || (sigInternal._d && sigInternal._d.size > 0)`. **Why no exported helper today**: solid-compat is the only known consumer; cost-benefit doesn't justify a new internal API surface yet. If a second consumer surfaces, extract `_hasDirectSubscribers(sig)` from `@pyreon/reactivity` and migrate both. Until then, any code reading `_d` MUST also check `_d1`. Reference: `packages/core/reactivity/src/signal.ts` interface `SignalFn._d1` / `_d`; the canonical fix is in `packages/tools/solid-compat/src/index.ts:894-899`.
- **Signal-like wrapper callables missing the internal `_v` field** (enforced by `@pyreon/lint` rule `pyreon/storage-signal-v-forwarding`): The compiler's `_bindText` / `_bindDirect` fast paths read `source._v` directly (not `source()`) for zero-call-overhead initial-value reads on cached signals. If you write a custom callable that wraps a base signal — delegating `.direct` / `.subscribe` / `.peek` to the underlying `sig` — you MUST also forward `_v` via getter: `Object.defineProperty(wrapper, '_v', { get: () => sig._v, configurable: true })`. Without this, the binding initializes the text/attribute to `''` (because `undefined` coerces to empty string) AND every subsequent `direct` callback reads `undefined` again — the binding fires, but writes the same empty value every time. The bug is invisible in unit tests that call `wrapper()` directly (which DOES delegate correctly via the function call) but fires in any consumer of the compiler-emitted fast path. **Real-world hit**: `@pyreon/storage`'s `useStorage` / `useSessionStorage` / `useCookie` / `useMemoryStorage` / `useIndexedDB` all shipped without `_v` forwarding from inception; SSR rendered `<strong>light</strong>` correctly but post-hydration the strong went empty and stayed empty even after `theme.set('dark')` updated localStorage. Reference: `packages/fundamentals/storage/src/local.ts:createStorageSignal` for the canonical fix shape; `_bindText` contract documented at `packages/core/runtime-dom/src/template.ts:_bindText` ("source has .direct() implies source has ._v"). The compiler triggers the fast path for the JSX shape `{() => identifier()}` where `identifier` resolves to a callable — including anything that quacks signal-like. **Canonical fix — use `wrapSignal(base, { set })` from `@pyreon/reactivity`** instead of hand-rolling the facade: it forwards `_v` / `.direct` / `.peek` / `.subscribe` / `.label` by construction, so the contract is impossible to forget. `@pyreon/storage` (all 5 backends) and `@pyreon/state-tree` (`trackedSignal`) now use it — and that surfaced the bug class in state-tree: its hand-rolled `trackedSignal` forwarded neither `.direct` NOR `_v`, so a model field bound via `{() => model.field()}` rendered empty (regression-locked by `state-tree/src/tests/tracked-signal-bind-contract.test.ts`, bisect-verified). The lint rule remains for any future hand-rolled facade that bypasses the primitive.

## JSX Mistakes

- **`key` on `<For>`** `[detector: for-with-key]`: Use `by` not `key` — JSX reserves `key` for VNode reconciliation
- **Missing `by` on `<For>`** `[detector: for-missing-by]`: `<For each={...}>` without a `by` prop defeats keyed reconciliation — every update remounts the full list. Always supply `by={item => item.id}`.
- **`.map()` in JSX**: Use `<For>` for reactive list rendering, not `.map()`
- **[FIXED — universal VNode[] mount, 2026-07] A VNode-returning call as a bare `{…}` child under a DOM-element parent**: `const tab = (f) => <button>{f}</button>` then `<div>{tab('all')}{tab('active')}</div>` historically rendered **`[object Object]`**, not buttons — under a DOM-element parent the compiler classified a bare CallExpression child (`tab('all')`) as a reactive TEXT expression and stringified the returned VNode. It typechecked and built clean; only a real-browser render showed it, and SSR mounted the shape CORRECTLY (so it was also a guaranteed SSR↔client mismatch). **The compiler now MOUNTS the shape for IN-FILE helpers**: JSX-returning function bindings (`const cell = (v) => <b>{v}</b>`, `function cell(v) { return <b>{v}</b> }` — incl. conditional `string|VNode` returns) are tracked (scope-aware, same shadowing discipline as the signal auto-call pass), and a call child (`{cell(x)}`, incl. the accessor form `{() => cell(x)}`) routes through `_mountSlot(() => (cell(x)), …)` — reactive when args read signals, byte-identical in both backends, client now matching SSR. **Update — universal VNode[] child mounting (PR #2121) closed the general case:** the general reactive-text path now lowers to `bindPolymorphicText(() => expr, …)`, which detects a returned VNode/VNode[] at runtime and MOUNTS it — so a CROSS-FILE / general VNode-returning call (`{cell(x)}` where `cell` is imported, `{obj.render(x)}`) now renders correctly too (in-file helpers still take the `_mountSlot` route; array-literal / `.map()` consts take `_mountSlot`). The `[object Object]` stringify is essentially eliminated for a VNode in text position. **The residual `{sig()}`-holds-a-VNode footgun is CLOSED too (the `_bindText` VNode upgrade)**: the single-signal fast path is now text-FIRST, not text-only — on the first VNode-shaped value the RUNTIME permanently upgrades the binding to a subtree mount (swap core shared with `bindPolymorphicText`; string updates before any VNode stay byte-identical, the check runs only on the value-actually-changed branch). NOTE the previously-documented workaround was wrong: `{() => sig()}` never avoided the fast path (`tryDirectSignalRef` unwraps the accessor form) — which is exactly why the fix lives in `_bindText`, not in compiler classification. The dev warning now fires only for a DETACHED bound text node (nowhere to mount). Locked by `runtime-dom/src/tests/bindtext-vnode-upgrade.test.tsx` (compiled through the REAL transform; incl. SSR→hydrate parity + later-upgrade context-owner specs, bisect-verified). **Style guidance stands: prefer the JSX ELEMENT form** — extract a real component and use `<Tab f="all" />` (works cross-file, clearer intent); PascalCase does NOT change a bare CALL's classification (`{MarkerGlyph(m, pts)}` — the fix is the element form `<MarkerGlyph … />` when the helper lives in another file). Historical instances (why this entry exists): the `build-an-app` tutorial's filter tabs; `@pyreon/flow`'s `MarkerDef` edge-arrowhead glyph shipped broken because the package's vitest-browser tests use a JSX transform that does NOT hit the stringify path — only the real-compiler e2e caught it. Reference: `packages/core/compiler/src/jsx.ts` `jsxFnVars`/`isJsxHelperCall` + `native/src/lib.rs` mirrors; regression locks (bisect-verified): `compiler/src/tests/template-child-classification.test.ts` + `runtime-dom/src/tests/template-child-classification.test.tsx` (mount/reactive-arg/SSR-hydrate, compiled through the REAL transform).
- **[FIXED, 2026-07] TS casts opaque to the template classifier — a wrapped accessor rendered its SOURCE.** Historical bug shape (kept for old-version diagnosis): `{(() => name()) as never}` — and even plain parens `{(() => name())}` — fell through to the STATIC bake arm (`__root.textContent = (() => name()) as never`), rendering the function SOURCE as literal text; `title={(() => x()) as never}` setAttribute'd the source string. Without the wrapper both were correct live bindings — the classifier simply never unwrapped TS type-only layers the way the signal auto-call pass already did ("parens/TS-layer transparent"). **The fix**: both backends unwrap `unwrapTypeLayers` at the child seam (`processOneChild`) AND the attr seam (`emitAttrExpression`), so `(expr) as never` compiles byte-identically to `expr` — casts are runtime-erased, so this is semantics-preserving by construction; the seeded fuzz grammar generates cast/paren wrappers around children + attrs to police the seam. **Style guidance: never cast an accessor to `as never`** — accessor-typed children/attrs accept the function form directly, and the cast hides real type errors (see also the `as-unknown-as-vnodechild` detector for the sibling cast smell). Reference: `packages/core/compiler/src/jsx.ts:processOneChild`/`emitAttrExpression` + `native/src/lib.rs` mirrors; regression locks (bisect-verified — reverted seams render `<div>() =&gt; name()</div>`): `compiler/src/tests/template-child-classification.test.ts` + `runtime-dom/src/tests/template-child-classification.test.tsx`.
- **[FIXED — compiler ref-hoist, 2026-07] Static-element reactive-attr/text refs computed AFTER a sibling `_mountSlot` (dynamic array / conditional child) → broken element-ref walk.** Historical bug shape (kept for the lesson + old-version diagnosis): when a DOM/SVG element had a DYNAMIC child (`{arr.map(...)}`, `{cond && <x/>}`, `{cond() ? <A/> : <B/>}`) positioned BETWEEN or BEFORE STATIC siblings carrying reactive attrs or interpolated text, the compiler emitted those siblings' refs as `.nextSibling`/`.nextElementSibling` walks computed in source order — but `_mountSlot` for the dynamic child REMOVES its `<!>` placeholder + inserts content + a `<!--pyreon-->` marker BEFORE the trailing ref was computed (net sibling-count delta ≠ 0), so the walk landed on the wrong node (`Cannot read properties of undefined (reading 'setProperty')` when `_setStyle` hit the marker comment; `null (reading 'setAttribute')` / `null (reading 'data')` when it walked past the end). **The class was WORSE than misbind/crash — delayed sibling-slot anchor destruction: with TWO adjacent slots, the second slot's own inline placeholder walk resolved to the FIRST slot's reactive marker, which `_mountSlot` then REMOVED; slot 0's next falsy→truthy re-flip threw `insertBefore … is not a child of this node` (unhandled effect error) and SILENTLY LOST the subtree.** Failure was initial-state-dependent (single flips accidentally correct — only the double flip fired the subtree loss, which is why the e2e gates never caught it; fires on both plain client mount AND post-hydration). **The fix (both backends, byte-identical): two-phase template-bind emission** — phase 1 (`refLines`) captures EVERY pristine-clone node reference (element walks `const __eN = …`, sole-text captures `const __tN = X.firstChild`, hoisted placeholder consts `const __pN = <walk>` for `_mountSlot` args + `replaceChild` targets) BEFORE phase 2 (`bindLines`) runs any mutation — phase-2 ops are identity-based, hence order-independent w.r.t. sibling structure. Reference: `packages/core/compiler/src/jsx.ts:buildTemplateCall` (refLines/bindLines + `hoistPlaceholderRef`) + `native/src/lib.rs:TemplateBuilder.ref_lines`; regression locks (bisect-verified): `compiler/src/tests/template-ref-hoist.test.ts` (emit ordering) + `runtime-dom/src/tests/slot-before-sibling-refs.test.tsx` (mount/flip/hydrate behavior incl. the double-flip subtree-loss shape, compiled through the REAL transform). The old static-wrapper workaround (`<g>{nodes.map(...)}</g>` / `<div style="display:contents">{conditionals}</div>`) is no longer required on current versions — still valid guidance for apps pinned to older compilers. Real historical instances: `@pyreon/flow` MiniMap (`{nodes.map}` between two `<rect>`s) + Controls (conditional buttons before the reactive zoom-% `<div>`). **General lesson (still live):** the flow package's vitest-browser tests use a JSX transform that does NOT match the real `@pyreon/vite-plugin` compiler, so they MASK this class of real-compiler template bugs (markers, MiniMap, Controls all shipped broken + green-in-vitest). A real-compiler e2e (or `transformJSX` from `@pyreon/compiler` directly) is the only reliable gate for template-codegen correctness — the new regression suites compile through the REAL transform for exactly this reason.
- **[root cause FIXED by the compiler ref-hoist above] Conditional WRAPPER child (`{cond && <div>…</div>}`) before a static sibling that gets ref-walked → `HierarchyRequestError` on a FRESH client mount (same class as the entry above; CodeBlock instance).** A component that conditionally renders a leading wrapper — `<root>{props.x && <header/>}<body>…</body></root>` — lowers the conditional to a `_mountSlot`, and `body`'s ref was `root.firstElementChild.nextElementSibling` (or `.nextSibling`) emitted AFTER that slot. On the FIRST client mount (the SPA-navigation path — NOT the in-place-hydrate path, which is why it hid until you clicked into the page) the absent conditional made the slot remove its `<!>` placeholder, the sibling walk over-shot, and a later `_mountSlot.insertBefore` ran against a Comment-node parent → `HierarchyRequestError: insertBefore … node type does not support this method` (or `Cannot read properties of null (reading 'nextSibling')`, depending on slot timing). Real instance: `@pyreon/zero-content`'s `<CodeBlock>` (conditional `{filename && <header>}` + `{showLineNumbers && <gutter>}`) — every docs code sample rendered broken on landing→docs nav. **The compiler ref-hoist (entry above) removes the bug class at the root** — sibling refs now resolve against the pristine clone before any slot mutates it. CodeBlock's local fix predates it and stays as-shipped (**always-RENDERED static wrappers** with a `--empty` modifier class — harmless, still valid, no churn needed). Two gotchas the fix surfaced, both worth their own rule: **(a) a dynamic boolean attribute on a templated element is buggy via the compiled binding** — the template path emits a raw `el.setAttribute("hidden", value)` with NO boolean-attr presence/absence guard, so `hidden={false}` sets `hidden="false"` (attribute PRESENT → still hidden); use a static prop-derived CLASS (className IS normalized) for per-instance show/hide, not a dynamic `hidden`/`disabled`/`checked` attr, until the compiler's template attr-setter learns boolean attrs (separate JS+Rust follow-up). **(b) [NOW MOUNTS — universal VNode[] mounting] a bare array-typed `const` child (`{gutter}` / `{() => gutter}`)** used to bake to `textContent` and stringify VNodes to `[object Object]`; it now routes through `_mountSlot(gutter, …)` (array-literal / `.map()` consts mount, and any other VNode/VNode[] source mounts via `_setChild`/`bindPolymorphicText`), so the elements render. The CodeBlock `dangerouslySetInnerHTML` workaround is no longer required (it stays as-shipped, harmless); for a reactive keyed list still prefer `<For>`. Masking caveat identical to the entry above: zero-content's vitest transform did NOT reproduce the crash (only a real-compiler e2e — landing→docs in real Chromium — caught it). Reference: `packages/zero/zero-content/src/components/CodeBlock.tsx` STRUCTURE NOTE; regression `e2e/docs.spec.ts` ("renders code blocks without a setup crash", bisect-verified) + the always-rendered `--empty` header/gutter unit specs.
- **[FIXED by the compiler ref-hoist above — bisect-verified in real Chromium] Flow overlay child order — `<Controls>` before a sibling `<MiniMap>` failed to render (a SIBLING-LEVEL manifestation of the slot-ordering bug above).** Distinct from the within-component case above: here the two overlays are SIBLING `<Flow>` children. `<Flow>` renders `{children}` (the user's `[Background, Controls, MiniMap, Panel]` array) as ONE dynamic slot, followed by a sibling reactive `{() => viewport}` accessor whose stale inline placeholder walk this class corrupted. Historically, when `<Controls>` (a component returning a reactive `() => <div>` accessor) was mounted BEFORE `<MiniMap>` in that array, Controls resolved its flow instance fine but its DOM was never mounted (silently — zero console errors) — never root-caused at the time, but consistent with the two-adjacent-slots anchor-destruction variant (the second slot's stale walk resolved to a node inside the first slot's mounted content and removed it). **The compiler ref-hoist fixes it — verified with the full dev-server bisect recipe** (fixed compiler lib+native, Controls-first flip of `FlowDemo.tsx`, real Chromium against `vite dev`: BOTH `.pyreon-flow-minimap` and `.pyreon-flow-controls` visible + live zoom text, zero console errors; reverted compiler: `controls: 0` — the exact historical failure; restored: both visible). **The MiniMap-before-Controls ordering constraint is no longer required on current compiler versions.** The shipped examples + the real-Chromium e2e (`app-showcase-flow.spec.ts`, `new-demos.spec.ts`, `docs.spec.ts`) still order MiniMap-first and assert both overlays visible — KEEP those assertions (they now lock the fix rather than the workaround); flipping an example to Controls-first is safe but not required. Apps pinned to older compilers still need MiniMap-first. Same masking caveat as the entry above: the failure was invisible to vitest-browser (wrong JSX transform) — only real-compiler e2e/dev-server checks catch this class.
- **A custom-node interactive control (toolbar button, input) starting a node drag and swallowing its click.** A flow/diagram node's pointerdown handler that starts a drag (esp. with `setPointerCapture`) must FIRST bail when the target is inside an interactive control — `target.closest('.pyreon-flow-node-toolbar, .nodrag, button, input, textarea, select, a')` → return without dragging (React Flow's `.nodrag` convention). Otherwise pointerdown-on-button starts a drag, captures the pointer, and the button's `click` never fires. Reference: `@pyreon/flow` `flow-component.tsx` node `onPointerDown`. **Sibling rule — a container-level `keydown` handler that owns shortcuts (Delete / Cmd-A / Cmd-C / Cmd-V / Cmd-Z) must FIRST bail when `e.target` is an editable element** (`INPUT` / `TEXTAREA` / `SELECT` / `isContentEditable`) — guard ALL shortcut branches, not just Delete. Otherwise typing in an editable field inside a node hits the container's `keydown` (events bubble) and `Cmd-A` selects all NODES instead of the field's text, `Cmd-Z` undoes the diagram, etc. `@pyreon/flow`'s `handleKeyDown` shipped with only the Delete branch guarded; the fix is a single top-of-handler editable-target early-return — same principle as the `.nodrag` bail (a container interaction handler must respect when the user is interacting with an editable descendant).
- **`className`/`htmlFor`**: Use `class` and `for` — standard HTML attributes
- **`onChange` on inputs**: Use `onInput` for keypress-by-keypress updates (native DOM events)
- **Ternary for conditionals**: Use `<Show>` for signal-driven conditions (more efficient)
- **Wrapping signal reads in String()**: `{String(count())}` is unnecessary — `{count()}` works directly in JSX text, numbers auto-coerce. The compiler wraps signal reads reactively regardless.
- **Function accessors for dimension props**: `state={() => expr}` is wrong — rocketstyle dimension props (`state`, `size`, `variant`) accept string values, not function accessors. Use `state={expr}` and let the compiler handle reactivity via `_rp()` wrapping.
- **Treating a `<For>`/render-callback param as reactive component props (compiler-internal)**: a JSX-child render callback — `<For each={rows}>{(row) => <td>{row.id}</td>}</For>` (also `<Index>`, `<Show>`, `<Switch>`) — receives a runtime ITEM the framework passes per row, NOT reactive component `props`. `maybeRegisterComponentProps` MUST skip it (the param is not props), or every bare item-property read (`row.id`) is misclassified as reactive → wrapped in a per-row `_bind(() => …)` renderEffect instead of a one-time static `textContent =`. A 1k–10k-row list then allocates 1k–10k unnecessary renderEffects + disposer closures (retained until unmount; CPU + heap waste; `row.id` never changes for a mounted keyed row, so the effect never even re-fires — pure waste). The skip condition: parent is a `JSXExpressionContainer` whose grandparent is a `JSXElement`/`JSXFragment` (a JSX-CHILD render callback). **Do NOT skip attribute-value functions** (`component={(p) => …}` — grandparent is `JSXAttribute`) — those can be real inline components receiving props. Signal-valued item reads (`row.label()`, `() => row.x`) and real components (`function Row(props) { return <td>{props.x}</td> }`) stay reactive via their own paths. Both backends mirror this (JS `maybeRegisterComponentProps`; Rust `Ctx.in_jsx_child_callback`). Reference: `packages/core/compiler/src/jsx.ts:maybeRegisterComponentProps`; regression at `static-text-baking.test.ts` (`<For> render-callback item params` — self-discriminating, bisect-verified).
- **Boolean ARIA-STATE attributes render as presence-only `""`, NOT `"true"` (a11y bug)**: `aria-checked={checked()}` / `aria-selected={isActive()}` / `aria-expanded={isOpen()}` / `aria-disabled={x}` where the value is a BOOLEAN renders `aria-checked=""` (empty-string presence) — because `runtime-dom`'s `applyStaticProp` (`packages/core/runtime-dom/src/props.ts`) checks `typeof value === 'boolean'` **BEFORE** the `aria-`/`data-` branch (`if (value) setAttribute(key, '') else removeAttribute(key)`). An empty `aria-checked=""` is an INVALID ARIA value — screen readers do NOT read it as "true"; they fall back to the default (effectively unchecked/unselected/collapsed). So a checked switch / selected tab / expanded tree node is announced as its OPPOSITE. **Fix**: ARIA STATE attributes must be the literal STRING `'true'`/`'false'` (or `'mixed'`), never a boolean — `aria-checked={checked() ? 'true' : 'false'}`. A STRING value skips the boolean branch and lands in the `aria-` `setAttribute(key, String(value))` branch → `aria-checked="true"`. For `aria-disabled` use `x ? 'true' : undefined` (absent when not disabled is fine; `'true'` when disabled). **This affects BOTH paths**: JSX attributes (`aria-checked={signal()}`) AND helper-object spreads (`{ 'aria-selected': isSelectedFn() }`) — both ultimately go through `applyStaticProp`. **Detection trap**: a browser test asserting `el.hasAttribute('aria-checked')` PASSES for both `""` and `"true"` — it MASKS the bug. Always assert the VALUE: `expect(el.getAttribute('aria-checked')).toBe('true')`. Fixed across all 6 `@pyreon/ui-primitives` interactive primitives (Switch/Checkbox/Radio/Tabs/Combobox/Tree) — they all shipped boolean aria-state from inception; the browser smokes only checked `hasAttribute`, so it stayed hidden. Bisect-verified (revert one to boolean → `expected '' to be 'true'`). Reference: `packages/core/runtime-dom/src/props.ts:applyStaticProp` (the boolean-before-aria ordering); `code-style.md` "Render-function primitives provide ARIA helpers".

## Context & Provider Mistakes

- **A framework COMPONENT resolving a shared resource from context BARE while the sibling HOOKS use `context ?? module-fallback` (asymmetric resolution)**: every `@pyreon/router` hook resolves `useContext(RouterContext) ?? _activeRouter`, but `RouterLinkImpl` read the context bare from package inception — after `setActiveRouter(router)` (no provider component), `useRouter()`/`useNavigate()` worked while `<RouterLink>` silently rendered a hash-fallback `#/path` href and swallowed clicks (`handleClick` called `preventDefault()` BEFORE the no-router bail → dead link). Invisible to provider-based tests (the dominant harness shape); surfaces only in fallback-only usage. **Rules**: (1) when a package exposes hooks AND components over the same context+singleton pair, route every consumer through the ONE resolver (`getActiveRouter()`) — never re-derive `useContext(...) ?? fallback` per call site (a bare read WILL slip in); (2) a component that can't resolve its dependency must degrade to the closest NATIVE behavior (here: plain-path `href` + un-intercepted click = full-load anchor, plus a dev warning), not a half-dead hybrid — which means guard-bails run BEFORE `preventDefault()`, never after. Reference: `packages/core/router/src/components.tsx:RouterLinkImpl`; regression `packages/core/router/src/tests/link-dx.test.ts` (bisect-verified).
- **Destructuring context values**: `const { mode } = useContext(ctx)` captures the value once at setup time. If the provider uses getters (`get mode()`), destructuring evaluates the getter immediately — the value becomes static. Instead, keep the context object reference and access properties lazily: `const ctx = useContext(Ctx)` then read `ctx.mode` inside reactive scopes.
- **Static provide for dynamic values**: `provide(ctx, "dark")` captures a static value. For dynamic values (mode switching), provide an object with getters or a signal getter: `provide(ModeCtx, () => modeSignal())`.
- **signal(newValue) to write** `[detector: signal-write-as-call]`: `signal(5)` does NOT set the value — it reads and ignores the argument. Use `signal.set(5)` or `signal.update(n => n + 1)`. Dev mode warns about this. The static detector flags any `X(value)` where `X` was declared as `const X = signal(...)` / `const X = computed(...)`.
- **onClick={undefined}** `[detector: on-click-undefined]`: `undefined`/`null` event handlers do NOT crash — the runtime explicitly treats a nullish handler as legitimate and silently bails (`applyEventProp` in `runtime-dom/src/props.ts` early-returns on `typeof value !== 'function'`, warning only for actually-wrong types like strings/objects). So `onClick={condition ? handler : undefined}` is the intended conditional-handler pattern. The real risk is a **silent dead control**: a handler that resolves to `undefined` when you meant to pass one (e.g. `onClick={maybeUndefined}` from props) leaves the button rendering fine but doing nothing — no error to trace it by. The detector fires only on an EXPLICIT literal `onClick={undefined}` (a code smell — just omit the attribute); a possibly-undefined value from props is the harder, un-detectable shape to guard by hand. (Corrected 2026-07 from an earlier "gets wrapped as an event listener and crashes" claim, which the runtime code disproves.)
- **Pyreon-flavored helper components in compat-mode apps without `nativeCompat()`**: Compat-mode JSX runtimes (`@pyreon/{react,preact,vue,solid}-compat`) wrap every user component in `wrapCompatComponent` to relocate the render context — that's how React/Preact/Vue/Solid-style state semantics work inside Pyreon's mount pipeline. **Pyreon-flavored components that use `provide()` / `onMount()` / `onUnmount()` / `effect()` at body scope break under that wrapping** because their setup runs inside the wrapper's accessor, not inside Pyreon's setup frame. `provide()` lands in a torn-down context stack, `effect()` re-runs lose live-signal access. **Fix**: call `nativeCompat(MyHelper)` from `@pyreon/core` after the function declaration. The marker tells compat jsx() runtimes to route the component through `h(type, props)` directly. The 24 framework components shipped marked already (RouterView, PyreonUI, FormProvider, …) — this rule is for USER-defined Pyreon-flavored helpers in compat-scaffolded apps. Reference: `packages/core/core/src/compat-marker.ts`. The bug is invisible in unit tests (synchronous mount preserves provide() context even with the wrapper) but surfaces under multi-render-cycle scenarios — signal change re-fires the wrapper's accessor → `provide()` in re-run lands in stale stack. PR #427's cpa-app-compat e2e gate is the regression catcher.

## Architecture Mistakes

- **[FIXED, 2026-07] Browser Back/Forward handlers doing a bare state sync instead of running the app's navigation pipeline**: `@pyreon/router`'s popstate/hashchange handlers were `() => currentPath.set(getCurrentLocation())` from inception — so browser traversals bypassed loaders (and `commitNavigation` PRUNES loader data for routes navigated away from → `useLoaderData()` returned `undefined`/rendered the errorComponent after pressing Back), guards, blockers (`useBlocker` documented "called before each navigation" but Back sailed through), middleware, `afterEach` (the a11y route announcer never announced Back/Forward), scroll save/restore, and `meta.title`. **Fix shape**: route browser-initiated traversals through the SAME `navigate()` pipeline with replace-commit semantics (the browser already moved the URL — never push a new entry); a traversal cancelled by a guard/blocker restores URL + position via `history.go(-delta)` using a per-entry `history.state.__pyreonIdx` stamp (falling back to `replaceState` for entries the router didn't stamp); a SUPERSEDED traversal restores nothing (the newer navigation owns the URL). **Two companion invariants**: (1) only the OWNING router (newest live client instance, `_navOwner`) may write the shared URL back on cancel — a stale instance that missed its `destroy()` (HMR remount, second router) must never fight the live one over the history stack; (2) scroll ownership splits: browser traversals run ScrollManager ONLY when an explicit `scrollBehavior` is configured (then `history.scrollRestoration = 'manual'`), else native restoration keeps Back/Forward scroll — a pipeline that unconditionally scrolls-to-top on Back regresses native UX. `push()`/`replace()` now resolve with `NavigationResult` (`'committed' | 'cancelled' | 'superseded'`, Vue's navigation-failure detection in value form). Bisect-verified: `packages/core/router/src/tests/popstate-pipeline.test.ts` (6 specs fail on revert). General rule: any event handler that mirrors EXTERNAL state (browser history, storage events, WebSocket reconnects) into app state must run the app's full transition pipeline, not a bare signal write — the bare write silently skips every contract (data, permission, announcement) the pipeline provides.
- **`innerRef` on a `@pyreon/styler` `styled()` component silently fails — only `@pyreon/elements` Element supports `innerRef`.** `@pyreon/elements` Element treats `innerRef` as a first-class prop (`Element/component.tsx`: `own.ref ?? own.innerRef`) because Element is a multi-layer component where a plain `ref` might attach to a wrapper rather than the inner DOM node. A `styled('div')` / `styled('input')` from `@pyreon/styler` renders a SINGLE DOM node and forwards plain `ref` straight to it (`styler/src/styled.tsx` wires `finalProps.ref`), so `ref` is correct there and `innerRef` is an UNRECOGNISED prop — it's forwarded to the DOM as an invalid `innerref="…"` attribute (a function value, ignored) and the ref callback NEVER fires. **Symptom (silent):** whatever the ref was supposed to capture stays null forever — no error, no warning. For a `@pyreon/virtual` scroll container this is catastrophic-but-invisible: `getScrollElement()` returns null → the virtualizer's `scrollElement` is never set → `scrollRect` stays `{0,0}` → the visible range is empty → `virtualItems()` returns `[]` → **the list renders ZERO rows** while `totalSize()` still computes correctly (it's `count × estimateSize`, independent of the element), so the sizer has the right height but is empty. **Real instances (all app-showcase):** chat `MessageList` (`<MessageScroll innerRef={…}>`) + dashboard `CustomersVirtualList` (`<VirtualScroll innerRef={…}>`) — both `styled('div')`, both shipped with completely-empty virtual lists from inception, invisible because happy-dom unit tests don't do real layout/measurement (the `scrollRect=0` path only manifests in a real browser) and there was no real-Chromium e2e. **Same bug class, non-virtual (NOT yet fixed — follow-up):** invoice `PreviewFrame` (iframe ref), kanban `NewCardInput`, todos `AddInput` / `SearchInput` (focus-on-mount) — all `styled()` + `innerRef`, all silently no-op. **Fix (example side):** use `ref`, not `innerRef`, on `styled()` components. **Fundamentally-correct fix (framework, recommended follow-up):** make `@pyreon/styler`'s `styled()` accept `innerRef` as a `ref` alias so the convention is uniform with Element and all current/future misuses just work — it's an additive change (`innerRef` was a silent no-op before). **Detection:** real-Chromium e2e asserting the virtualized list actually mounts rows (DOM row count > 0 AND ≪ total) — `e2e/app-showcase-virtual.spec.ts` (dashboard 1,024-row late-mount + chat seeded-list) is the regression lock; bisect-verified (revert `ref`→`innerRef` → 0 rows mount → spec fails). **General rule:** `ref` on a styled component goes to its DOM node — use it. `innerRef` is an Element-only convention; reaching for it on a `styled()` component is always a silent bug.
- **Interactive `<Portal>` content with DELEGATED event handlers (`onClick`, `onSubmit`, `onFocusIn`, …) that doesn't establish its own delegation root.** Pyreon does NOT `addEventListener` per element for common bubbling events — `setupDelegation(container)` installs ONE listener per delegated event on the MOUNT CONTAINER and per-element handlers are stored as `__ev_<event>` expandos; the root listener walks `e.target → container` invoking them. `@pyreon/core`'s `<Portal target={X}>` mounts its children into `X` (e.g. `document.body`) — OUTSIDE the mount container's DOM subtree. So a `click` on a Portal'd button bubbles `button → body → html → document`, never through the mount root, and the delegated handler **never fires** — the button renders fine, `el.click()` does nothing, `_toasts`/state is untouched. NON-delegated events (`mouseenter`/`mouseleave`/`focus`/`blur`/`scroll`) are unaffected (they use real `addEventListener` on the element). **This is invisible to mount-only / store-only tests** — the handler is "attached" (the expando is set), the DOM is correct; only a real click in a browser test surfaces it. **Defense:** the Portal content must establish a delegation root it CAN see — mount into a per-instance host element you create inside the target and call `setupDelegation(host)` (exported from `@pyreon/runtime-dom`). Scope to the host, NOT to `document.body`: a listener on body (an ancestor of the mount root) DOUBLE-FIRES the app's own delegated handlers (body's walk passes through every app handler from `e.target` up). `@pyreon/toast`'s Toaster does this (per-instance host + `setupDelegation`); `@pyreon/elements`' Portal creates the per-instance wrapper but is itself MISSING the `setupDelegation` call, so elements' overlay/modal/dropdown close buttons (delegated `onClick`) inside a Portal are subject to the SAME bug — never caught because no elements browser test clicks a Portal'd handler. **FIXED (structurally):** the runtime's Portal mount branch now calls `setupDelegation(target)` itself (`packages/core/runtime-dom/src/mount.ts`, PortalSymbol branch) — no consumer can forget. Delegating an ANCESTOR of the app root (`document.body`) is safe: the per-dispatch `DELEGATED_ELEMENTS` invoked-set in `setupDelegation`'s handler makes an outer root skip elements an inner root already handled (no double-fire; locked by `portal-delegation.browser.test.tsx`, bisect-verified). The scoped-host guidance remains best practice for cleanup isolation, but is no longer load-bearing for correctness. Reference: `packages/core/runtime-dom/src/delegate.ts:setupDelegation` + `DELEGATED_EVENTS`, `packages/fundamentals/toast/src/toaster.tsx` (the host + `setupDelegation` pattern), regression `packages/fundamentals/toast/src/tests/toaster.browser.test.tsx` (dismiss-click + action-button specs, bisect-verified dead-on-revert).
- **Overlay/popover positioning reachable ONLY through window resize/scroll listeners — nothing positions the content when it actually opens.** `useOverlay`'s `setContentPosition()` was invoked solely by a throttled handler wired to `window` `resize`/`scroll` in `setupListeners()`; `contentRef` registration only stored the node + flipped `isContentLoaded`, and `showContent()` only handled focus/active. Result: every dropdown/tooltip/popover portaled to `document.body` rendered WHEREVER BODY FLOW DROPPED IT (typically the bottom-left of the page) until the user scrolled or resized — and dispatching a synthetic `resize` downstream still measured unanchored because of the 200ms trailing throttle. **Fix shape:** position reactively on open — subscribe to `active` + `isContentLoaded` inside `setupListeners()` (plain `signal.subscribe`, disposers pushed onto the same cleanups; NOT `effect()` + rAF at hook body scope, which trips `no-imperative-effect-on-create` and leaks outside component setup), and defer the measurement one `requestAnimationFrame` with a re-check so layout settles after the Portal mount and a racing close is a no-op. Expose `setContentPosition` from the hook for content whose SIZE changes while open (async option lists). **Companion foot-gun fixed in the same pass:** `setupListeners()` returned its listeners un-attached and only the built-in Overlay component remembered to call it — raw `useOverlay` consumers shipped dead triggers. The hook now auto-attaches via `onMount` (idempotent: a second explicit `setupListeners()` call returns the FIRST call's cached cleanup — anti-patterns class D — and the cleanup resets the flag so KeepAlive re-mounts re-attach), plus a dev warning when `showContent()` fires with listeners never attached. **Why tests missed it for the package's lifetime:** the browser suites asserted visibility, roles, and focus — never COORDINATES; happy-dom has no layout at all, so only a real-Chromium `getBoundingClientRect` assertion (menu top ≈ trigger bottom, and NOT at the document origin) catches the unpositioned state — and the regression spec must render the content through a real `<Portal>`: inline conditional content lands next to the trigger by document flow and false-passes. Reference: `packages/ui-system/elements/src/Overlay/useOverlay.tsx` (`repositionOnOpen`), regression `Overlay-position-on-open.browser.test.tsx` (bisect-verified: subscriptions removed → anchoring spec fails).
- **Passing `layout` to `createApp` / `startClient` when fs-router already emits `_layout.tsx` as a parent route.** fs-router walks the routes directory and emits any `_layout.tsx` as a parent `RouteRecord` with the page routes nested as `children`. The matched route chain therefore wraps every page in the layout automatically — `<RouterView />` at depth 0 renders the layout component, and the inner `<RouterView />` inside the layout's body renders the next chain entry. Passing the SAME component as `options.layout` to `createApp` / `startClient` adds a SECOND wrapper around App's outer RouterView, mounting the layout twice. Real-app shape: 3× `nav.sidebar` + 3× `main.content` after hydration mismatch (SSR pipeline doesn't get the explicit `layout` arg, CSR does — they disagree, mismatch warning fires, Pyreon's recovery stacks both trees in the DOM). **Defense**: `createApp` now detects when `options.layout` references the same component as any top-level route's `component` and IGNORES the explicit option (warns in dev: `[Pyreon] createApp({ layout }) was passed a component that is ALSO a parent route in the matched chain ... ignored to prevent double-mount`). User-side rule: with fs-router, do NOT pass `layout` to `startClient` — `_layout.tsx` is the canonical layout registration. Reference: `packages/zero/zero/src/app.ts` `hasLayoutInRoutes` check; example pattern in `examples/{ssr-showcase,ui-showcase,app-showcase,fundamentals-playground}/src/entry-client.ts` (none pass `layout`).
- **Forgetting `makeReactiveProps` outside the CSR mount path.** The compiler emits `<Comp prop={signalRead}>` as `h(Comp, { prop: _rp(() => signalRead) })`. `mount.ts` (CSR) wraps the raw vnode props with `makeReactiveProps` before invoking the component, which converts each `_rp`-branded function into a property getter. Without that step, components read `props.x` and get the raw `_rp` function instead of the resolved value — `${props.x}` in a template literal stringifies the function source, embedding `() => props.path` directly into rendered HTML. Both `runtime-server` (SSR) and `hydrate.ts` previously skipped `makeReactiveProps`, so any `<Comp prop={signal()}>` shape produced the wrong attribute on cold-start SSR and during hydration. Fix: call `makeReactiveProps(rawProps)` in the same shape `mount.ts` uses (`mergeChildrenIntoProps` for SSR; the merged-children object in hydrate.ts) before `runWithHooks(vnode.type, mergedProps)`. Visible end-to-end through the fundamentals NavItem layout — `<RouterLink to={props.path}>` rendered `<a href="() => props.path">`. Companion compositional bug in the same fix: `RouterLink` hard-overrode the user's `class` prop with its internal `activeClass` accessor instead of merging — so `class={() => isActive() ? 'active' : ''}` was silently dropped. Always destructure `class` separately and merge via `cx([userClass, internalClass])` returned from a single accessor so `applyProp` can wrap it once in `renderEffect`.
- **Reactive children that read multiple sibling computeds — read order races on shared upstream changes.** A reactive accessor that reads `computed_A()` and `computed_B()` where BOTH transitively depend on the same upstream signal will see a stale pair on the next change tick. The accessor's effect subscribes to both A and B and to the upstream — when upstream notifies in iteration order, the EFFECT fires before B's recompute does (because B was registered AFTER the effect subscribed to the upstream during its first run). The effect reads A (now updated) but B (still cached at the old value), producing an inconsistent (A_new, B_old) pair. Fix: collapse A and B into a single atomic computed that returns the pair, with `equals` comparing both fields. The accessor then has exactly one upstream — so order doesn't matter and the next emission carries a consistent pair. Reference: `RouterView`'s `depthEntry: { rec, comp, errored }` (`packages/core/router/src/components.tsx`) — pre-fix this was `recordAtDepth` + `cachedComponent` and produced `rec=/button` paired with `comp=HomePage` (the outgoing page) for the duration of the navigation, leaving the wrong component rendered.
- **[SUPERSEDED by owner-based context]** ~~`restoreContextStack` truncation destroys provider frames pushed during `fn()`.~~ Under the owner-based context model there is **no client-side context stack to truncate**: `provide()` writes onto the component's `EffectScope` owner (`scope._contexts`), `useContext()` walks the owner chain (`scope._parent`), and context is disposed with the scope at unmount. Deferred boundaries (`<Show>`/`<For>`) capture the owner reference (`getContextOwner()`) at setup and restore it (`runWithContextOwner`) when they mount children — no snapshot, no dedup, no splice. (SSR keeps a request-scoped stack via `pushContext`/`popContext`; the `*-compat` layers run their own stack-based provide/inject.) `captureContextStack`/`restoreContextStack`/`ContextSnapshot` are deleted. Reference: `packages/core/core/src/context.ts` + `packages/core/reactivity/src/scope.ts`.
- **Effects that read context across re-runs capture+restore the active OWNER themselves.** `_bind` / `renderEffect` / `effect` re-run on signal change AFTER the synchronous mount frame has unwound, so the active context OWNER at re-run time may differ from setup time (`mountReactive` swaps owners via `runWithContextOwner` when it mounts deferred children). Each effect captures the active owner at SETUP via `setSnapshotCapture` (a DI hook `@pyreon/core` registers at module load — reactivity is below core in the dep order, backed by `getContextOwner` / `runWithContextOwner`), and restores it before invoking the user fn on every re-run other than the first (the synchronous mount owner is already correct on the first run). Capturing a single owner reference replaced the old deduped-stack-snapshot + identity-removal machinery. Reference: `packages/core/reactivity/src/effect.ts:_bind`/`renderEffect`/`effect` and `packages/core/core/src/context.ts:setSnapshotCapture` registration block.
- **Circular prop-derived const chains**: `const a = b + props.x; const b = a + 1` — the compiler detects the cycle and emits a `circular-prop-derived` warning but leaves the cyclic identifier as a static reference (not reactively inlined). The result is subtly non-reactive on the cyclic part. Fix: restructure the derivation chain so every const reads from `props.*` directly or from a non-cyclic predecessor.
- **Library components that iterate `props.children` at the VNode level (or `cloneVNode(props.children, …)`) without unwrapping function-wrapped children**: the Pyreon vite-plugin compiler rewrites `<Comp>{children}</Comp>` (where `children` is a local `const` derived from a getter — typically `splitProps` results) as `Comp({ ..., children: () => x.children })` (prop-inlining for reactivity). The receiving component sees `props.children` as a FUNCTION instead of the expected `VNode | VNode[]`. Most consumers don't notice because `mountChild` handles function children correctly via `mountReactive`; the trap fires when a library:
  - **iterates children directly**: `(Array.isArray(children) ? children : [children]).filter(…)` produces `[function].filter(…) === []` → the rendered subtree has ZERO children.
  - **calls `cloneVNode(props.children, …)`**: `{...function, props: {...}}` spreads zero own-enumerable properties (functions don't have them by default) → `{type: undefined, props: {…the override props…}}` → `mountElement(undefined)` → browser produces literal `<undefined></undefined>` tags.

  **Defense for any library that consumes `props.children` structurally** (not just by forwarding to `mountChild`): resolve eagerly at body entry. `const child = typeof props.children === 'function' ? (props.children as () => unknown)() : props.children`. Safe IF the library snapshots children at render time (no reactive-children semantics) — which is the common case for animation/layout wrappers. Reference: `packages/ui-system/kinetic/src/utils.ts:resolveChildren` + `StaggerRenderer` (iteration) + `TransitionItem` (cloneVNode). Real-app shape: `examples/bokisch.com`'s Intro section — `kinetic('div').stagger()` with component children rendered `<undefined>` tags + `<!--pyreon-->` markers in place of every child post-hydration; SSR HTML was correct. The compiler IS the root cause (auto-wrapping JSX children for component parents that don't want reactive accessors is over-eager), but a library-side defensive unwrap is the immediate, well-localized fix without compiler-wide audit.
- **Circular imports**: Keep dependency order (reactivity → core → runtime-dom → router → server)
- **Build before dev**: Workspace resolution via `"bun"` condition means no build step needed
- **`[key: string]: unknown` catch-all**: Use `data-*/aria-*` template literal index signatures
- **Detaching methods**: `_bindText(obj.method, node)` loses `this` → compiler only emits for simple identifiers
- **Duplicate module augmentation**: When a library package (e.g., `@pyreon/ui-theme`) already augments an interface (e.g., `StylesDefault extends ITheme`), consuming apps must NOT re-augment the same interface with a different type — this causes TS2320 "cannot simultaneously extend" errors. Remove app-level `pyreon.d.ts` augmentation when the library handles it.
- **Using non-existent dimension props in demos**: Always check the component definition before using `state`, `size`, or `variant` props — if the component doesn't define that dimension (e.g., Loader has no `.variants()`), the prop is invalid and causes type errors (`never[]`).
- **Assuming `<Portal>`'s target is `document.body` when reading the rendered DOM**: `@pyreon/elements`' Portal creates a per-instance wrapper element (default `<div>`, configurable via `tag` prop) inside `DOMLocation` (default `document.body`) and renders children INSIDE the wrapper. Multiple Portals sharing a DOMLocation each get their own wrapper, so children don't intermingle. Tests / asserts that previously read `document.body.firstChild === modalRoot` should now traverse one extra level: `document.body.querySelector('[data-modal-id]').parentElement`. Mirrors vitus-labs's Portal — provides cleanup isolation when several modals/dropdowns/tooltips share a portal root. Reference: `packages/ui-system/elements/src/Portal/component.tsx`.
- **One-shot measurement of dynamic-content slots in `<Element equalBeforeAfter>`**: Element's `equalBeforeAfter` mode equalizes the before/after slot widths once on mount AND keeps them equalized as the element resizes via `ResizeObserver` — async slot content (font swaps, lazy text, viewport resize) wouldn't fire the one-shot measurement and would leave the slots un-equal. Apps relying on `equalBeforeAfter` for icon-button rows, search inputs with adornments, or any layout where slot content arrives asynchronously now stay aligned. Falls back to the one-shot measurement when `ResizeObserver` is undefined (SSR or older runtimes). Mirrors vitus-labs's Element. Reference: `packages/ui-system/elements/src/Element/component.tsx`.
- **Re-emitting unchanged declarations in `@media` breakpoints**: a responsive theme that sets `{ color: { xs: 'red', sm: 'red' }, padding: { xs: 0, sm: '1rem' } }` should produce `@media(min-width: sm) { padding: 1rem; }` — NOT `@media(min-width: sm) { color: red; padding: 1rem; }`. Mobile-first `@media (min-width: …)` cascades inherit declarations from smaller breakpoints, so re-emitting `color: red` at `sm` is pure byte waste. `@pyreon/unistyle`'s `makeItResponsive` runs `optimizeBreakpointDeltas()` over the per-breakpoint render output and emits only the deltas. Stringify-fails (foreign engine result with `[object Foo]` toString) bail to the unoptimized fallback path so correctness wins over byte savings. Mirrors vitus-labs's pattern. Reference: `packages/ui-system/unistyle/src/responsive/optimizeBreakpointDeltas.ts` + `makeItResponsive.ts`.
- **No render-output cache for stable theme objects in `makeItResponsive`**: when the consuming component re-renders with the same internal theme AND the same outer theme reference (the common case under a stable provider), the responsive engine should return the previous render verbatim — not re-run `renderStyles` + `optimizeBreakpointDeltas` for every re-render. `makeItResponsive`'s `themeCache` entry carries a `rendered: WeakMap<theme, unknown[]>` keyed by the outer theme reference; identity match returns the cached array (CSSResults are immutable, safe to reuse). Mirrors vitus-labs's pattern. Reference: `packages/ui-system/unistyle/src/responsive/makeItResponsive.ts:ThemeCacheEntry`.
- **Conditionally-emitted CSS properties in responsive styles callbacks**: the inverse of the optimizer rule. When a styles callback writes `${t.block && 'align-self: stretch; width: 100%;'}`, a responsive theme like `block: [true, false, true]` runs the callback once per breakpoint with a single-value `t.block`. The truthy branch emits at xs, the falsy branch emits NOTHING at sm — and the optimizer is purely subtractive (drops re-emitted unchanged decls), so it can't synthesize a reset. Result: `align-self: stretch` from xs cascades through `@media (min-width: sm)` and the element stays stretched at the breakpoint where the user wrote `block: false`. **Fix**: always emit a value with an explicit reset for the falsy branch — `align-self: ${t.block ? 'stretch' : 'auto'}; width: ${t.block ? '100%' : 'auto'};`. The optimizer correctly drops both halves when nothing changes between breakpoints, so the always-emit pattern is free in the steady state. Applies to ANY responsive boolean/enum prop whose CSS effect is gated on truthiness — `block`, `equalCols`, `alignY === 'block'` (height: 100%), etc. Mirrors vitus-labs's PR #121 fix. Reference: `packages/ui-system/elements/src/helpers/Wrapper/styled.ts:styles`. Regression test: `packages/ui-system/elements/src/__tests__/wrapper-block-cascade.test.ts`.
- **Re-using a rocketstyle component's chain after `.config({ component: NewBase })`**: rocketstyle's `cloneAndEnhance` resets the `attrs` / `priorityAttrs` / `filterAttrs` / `compose` chains when `opts.component` is set AND differs from the current component. Those chains were tailored to the previous component's prop shape; applying them to a different element silently leaks invalid props through to the DOM (e.g. a `disabled` attr from a button-shaped attrs chain ending up on an `<a>`). `theme` / `styles` / dimension chains are preserved across the swap because they target rendered CSS, not prop forwarding. If you need to keep button-shaped attrs across a component swap, re-chain them explicitly: `Button.config({ component: 'a' }).attrs(sharedAttrs)`. Behaviour mirrors vitus-labs's rocketstyle. Reference: `packages/ui-system/rocketstyle/src/rocketstyle.ts:cloneAndEnhance`.
- **Index-builder skipping a discriminated-union branch's identifying field**: `@pyreon/unistyle`'s `keyToIndices` map is built once at module init by walking `propertyMap` and indexing each descriptor by `d.key` / `d.keys`. The `kind: 'special'` branch (`fullScreen`, `hideEmpty`, `clearFix`, `extendCss`, `backgroundImage`, `animation`) carries only `d.id` — no `key` / `keys` — so special descriptors were never indexed. **Single-special themes** (`{ fullScreen: true }`) silently worked because `fragments.length === 0` triggered a fallback full-scan that did hit `processSpecial`. The moment any non-special key was also present (`{ fullScreen: true, background: 'rgba(0,0,0,0.5)' }` — the real `<Overlay>` shape), the fast path produced one fragment from `background`, the fallback was skipped, and the special was dropped silently. Fix: index `d.id` alongside `d.key` / `d.keys` so the fast path resolves the special directly. **General rule for index builders**: when a property map contains a discriminated union, the index step must walk EVERY branch's identifying field. Missing one branch produces a silent fallback-only path that masks the bug under the "single key" scenario tests typically exercise. Bisect-verified: 7/7 paired-key specs in `unistyle/src/__tests__/special-keys.test.ts` failed without `if (d.id) addKey(d.id)`, all passed with it. Reference: `packages/ui-system/unistyle/src/styles/styles/index.ts:keyToIndices` builder.
- **Naive brace-counting CSS rule splitter + shape-enumerating @layer flattener silently drop rules**: `@pyreon/styler`'s `insertGlobal` splitter counted `{`/`}` blindly and its `@layer`-unsupported flatten matched ONE block shape — the 0.41.x fixes claimed completeness (sibling + nested blocks) but a fresh-DOM stress audit proved four remaining silent-drop shapes, all now closed: (1) `@layer` inside `@media`/`@supports`/`@container` — the flattener didn't descend into group rules, so insertRule succeeded with an EMPTY group body and the rules vanished with zero warn (fix: recurse into group bodies, preserving the wrapper: `@media X{@layer y{R}}` → `@media X{R}`); (2) the `@layer a, b;` ordering STATEMENT (and `@import …;`/`@namespace …;`) has no braces, so the splitter swallowed it on EVERY path including modern @layer-supporting browsers — the user's declared cascade order was silently wrong everywhere (fix: emit depth-0 semicolon-terminated at-statements as their own slices; when flattening, an ordering statement is meaningless and is dropped WITH a dev warn naming the loss); (3) `}` inside quoted strings / comments / unquoted `url(…)` tokens split rules mid-string and poisoned the depth counter negative, losing following SIBLING rules too (fix: a string/comment/url-aware state machine — `skipQuoted` honors backslash escapes); (4) anonymous `@layer { … }` blocks (valid CSS) weren't unwrapped, and unbalanced input dropped everything from the unclosed rule on with zero signal (fix: optional-name block regex + a dev warn naming the dropped tail). **Two general rules**: a hand-rolled parser over a language with escape contexts (strings, comments) MUST model those contexts before trusting its bracket arithmetic; and a flattener that enumerates shapes needs a stress matrix across the CONTAINER grammar (group rules, statement forms, anonymous forms), not just the shapes already seen in bug reports. **Honest caveat locked into the code**: flattening CHANGES cascade semantics in pre-@layer engines (unlayered-beats-layered inversion + ordering loss) — see the `insertGlobal` comment; it's a least-bad fallback, not an emulation. Bisect-verified: old-logic shims → 34/44 happy-dom specs + 2 real-Chromium cascade specs fail (`expected 'rgb(255, 0, 0)' to be 'rgb(0, 128, 0)'` — statement swallowed, source order won); restored → all pass. The scoped `insert()`/`prepare()` component path (`splitAtRules`) shared shape (3) — brace-in-string before/inside a nested `@media` mis-split, commented-out `@media` extracted LIVE — and got the same state-machine scan (shared `skipQuoted`/`isUrlOpen`; bisect: 6/6 specs fail on the naive scan). Reference: `packages/ui-system/styler/src/sheet.ts:splitTopLevelRules`/`unwrapLayers`/`splitAtRules` + `__tests__/global-layer-happydom.test.ts` + `sheet-split-atrules.test.ts` (stress matrices) + `styler.browser.test.tsx` (modern-path locks).
- **Building cache keys from raw props before resolution**: rocketstyle's `_rsMemo` previously built its dimension-prop key from `propsRec[dimName]` BEFORE `_calculateStylingAttrs` resolved the boolean shorthand. Under `useBooleans: true` the user writes `<Btn primary />` / `<Btn secondary />` — neither sets `props.state` directly, so `propsRec.state === undefined` for both, the keys collided, and EVERY boolean variant rendered with the FIRST cached variant's resolved styles. Real-app shape: a button group where primary, secondary, and danger all rendered in the FIRST color the user clicked. **General rule for cache keys**: any cache that's downstream of a normalization step must key on the NORMALIZED output, not the raw input — otherwise the cache collides every variant whose normalization happens to fan in from `undefined` / `null` / a default. Same pattern would bite any prop-resolver pipeline (compiler reactive-props inlining, attrs HOC chain accumulation, etc.). Bisect-verified at two layers: unit (`packages/ui-system/rocketstyle/src/__tests__/cache-key-boolean-collision.test.ts` — broken: `expected 'primary' to be 'secondary'`) AND real-Chromium (`rocketstyle.browser.test.tsx` Bug 5 spec — broken: `getComputedStyle().color` returned `rgb(255, 0, 0)` for the secondary button instead of `rgb(0, 255, 0)`, proving the bug surfaces at the actual CSS resolution layer not just at the API surface). The fix also adds an `Array.isArray` branch for multi-key dimensions (`variant={['primary', 'rounded']}`) which previously all collided onto `'~object'`. Reference: `packages/ui-system/rocketstyle/src/rocketstyle.ts:_resolveRsEntry`.
- **Loose `Iterator` / `List` props that allow mixed data shapes at the type level**: `<Iterator data={[1, {id:1}, null]}>` is structurally ambiguous — primitive arrays and object arrays are mutually exclusive iteration modes at runtime. `@pyreon/elements` ships FOUR overloads on `Iterator` and `List`: `SimpleProps<T extends SimpleValue>` (primitive arrays — `valueName` allowed), `ObjectProps<T extends ObjectValue>` (object arrays — `valueName` FORBIDDEN, `children` FORBIDDEN), `ChildrenProps` (no data/component, only children), and a `LooseProps` fallback for forwarding patterns. The generic `Props<T>` discriminator picks the right overload via `unknown extends T ? LooseProps : T extends SimpleValue ? SimpleProps<T> : T extends ObjectValue ? ObjectProps<T> : ChildrenProps`. **Strict per-mode constraints fire for direct callers whose shape matches Simple / Object / Children**; calls whose shape doesn't match any narrow overload fall through to the LooseProps fallback (legal, forwarding-pattern shape). The fallback exists because `@pyreon/rocketstyle`'s 4-overload-aware `ExtractProps` produces a WIDE union when extracting Iterator's props — without a binding home, forwarding patterns like `<Iterator {...wrapperProps} />` fail at every call site with "no overload matches this call". Trade-off (mirrors vitus-labs PR #229): forwarding-pattern support is more valuable than mixed-shape rejection at the type level; runtime still picks the right mode based on which props are populated. List inherits the same overloads through `IteratorChildrenProps & ListExtras` etc., plus blocks Element's `label` / `content` props (`ListOnly: { label?: never; content?: never }`). Reference: `packages/ui-system/elements/src/helpers/Iterator/types.ts` + `List/component.tsx`. Tests: `packages/ui-system/elements/src/__tests__/Iterator.types.test.ts` (per-overload happy-path assertions + LooseProps fallback binding tests). Bisect-verified at the type-level: removing the LooseProps overload from `IteratorComponent` reverts 5 forwarding specs to `error TS2769: No overload matches this call`.
- **Single-overload `ExtractProps<T>` shape silently dropping all but the last overload**: `T extends ComponentFn<infer P> ? P : T` is TS's documented behavior for overload-resolution-against-conditional-types — when `T` has N call signatures, only the LAST one matches `infer P`. For multi-overload primitives (Iterator / List / Element ship 3 overloads; rocketstyle wrappers can ship 2-4), this silently downgraded the public prop surface to the LOOSEST (last) overload the moment the component was wrapped through `rocketstyle()` / `attrs()` / any HOC reading `ExtractProps<typeof Comp>`. **Fix**: pattern-match up to 4 call signatures and union them — `T extends { (props: infer P1, ...args: any): any; (props: infer P2, ...args: any): any; (props: infer P3, ...args: any): any; (props: infer P4, ...args: any): any } ? P1 | P2 | P3 | P4 : ...` with fall-through to 3-arm, 2-arm, single `ComponentFn<infer P>`. Single-overload functions still work — TS fills missing slots by repeating the last overload, so the union of 4 copies of the same shape dedupes back to one. Mirrors vitus-labs PR #222. **Kept in sync across 4 copies**: `@pyreon/core/src/types.ts`, `@pyreon/elements/src/types.ts`, `@pyreon/attrs/src/types/utils.ts`, `@pyreon/rocketstyle/src/types/utils.ts`. Bisect-verified at the type-level via `packages/core/core/src/tests/extract-props-overloads.types.test.ts`: reverting just the core copy to the single-arm shape fails 6 of the 8 specs (the 2-overload, 3-overload, 4-overload union assertions + the Iterator-shaped 3-overload assignability) with `Type 'X' does not satisfy the constraint 'Y'`. Restored → 8/8 pass.
- **`as unknown as VNodeChild` on JSX returns** `[detector: as-unknown-as-vnodechild]`: This cast is unnecessary — `JSX.Element` (VNode) is already assignable to `VNodeChild`. Never add it; remove it where found.
- **Duplicating controlled/uncontrolled pattern**: Use `useControllableState` from `@pyreon/hooks` instead of manual `isControlled + signal + getter` pattern. Every primitive had this duplicated before the fix.
- **Static return null for conditional rendering** `[detector: static-return-null-conditional]`: `if (!isActive()) return null` runs once — components run once in Pyreon. Use reactive accessor: `return (() => { if (!isActive()) return null; return <div>...</div> })`. This applies to TabPanelBase, ModalBase, and any component that conditionally renders.
- **Static early-return with a signal condition** `[detector: static-early-return-conditional]`: `if (loading()) return <Skeleton/>` at the top of a component body is evaluated exactly ONCE at mount — the component is pinned to that branch forever (`loading.set(false)` never re-evaluates it). The compiler emits the shape unchanged with zero warnings and TS2774 does NOT cover it (the signal IS called), so this is a pure silent pin. Use `<Show when={() => loading()} fallback={<Skeleton/>}>` or return a reactive accessor: `return (() => loading() ? <Skeleton/> : <Content/>)`. The detector is signal-binding-gated (fires only when the condition reads a tracked `const x = signal(...)`/`computed(...)` binding — helper-call/props/env conditions are legitimate setup guards and stay unflagged); the `return null` shape stays with `static-return-null-conditional` (different fix-path message), so the two never double-fire.
- **Empty `.theme({})`** `[detector: empty-theme]`: Never chain `.theme({})` as a no-op. If a component needs no base theme, skip `.theme()` entirely.
- **User-controlled data in HTML comment content**: HTML comments terminate on `-->`, `--!>`, or, in some parsers, any `--` sequence. If you interpolate user-supplied data into a comment (`<!--key:${key}-->`), a malicious key can break out and inject arbitrary markup. Defense: URL-encode and replace every `-` with `%2D` before interpolation, so `-->` is structurally impossible. Reference: `runtime-server/src/index.ts:safeKeyForMarker`. Applies to any SSR-emitted marker the framework carries between server render and client hydration.
- **`Object.defineProperty` without `configurable: true` on props**: when authoring a getter on an object that may later be passed through `mergeProps` or `splitProps`, always set `configurable: true` explicitly. The default is `false`, which silently makes the descriptor non-redefinable — any later merge that overrides the same key throws `TypeError: Cannot redefine property`. `mergeProps` now forces `configurable: true` on copied descriptors as a defensive measure, but the root cause is still on the authoring side. Applies to reactive-prop wrappers (`_rp`), manually constructed rocketstyle attrs, and any bridge that exposes values via getters.
- **Bundler-coupled dev gates** `[detector: process-dev-gate]`: Pyreon publishes libraries to npm; consumers compile with whatever bundler they use — Vite, Webpack (Next.js), Rolldown, esbuild, Rollup, Parcel, Bun. Library code must NOT use bundler-specific dev-gate patterns. Two patterns this rule has seen ship and break:
  1. **`typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`**: dead code in real Vite browser bundles because Vite does not polyfill `process` — the typeof guard returns `false`, the whole expression is statically dead, warnings never fire. Caught originally in PR #200.
  2. **`import.meta.env.DEV` (and `(import.meta as ViteMeta).env?.DEV === true`)**: Vite/Rolldown-only. In a Pyreon library shipped to a Next.js (Webpack), esbuild, Rollup, Parcel, or Bun consumer, `import.meta.env` is `undefined` and dev warnings never fire — even in development. PR #200 introduced this as the "fix" to (1); the direction was wrong for library code.

  **The bundler-agnostic library standard** (used by React, Vue, Preact, Solid, MobX, Redux): bare `process.env.NODE_ENV !== 'production'` (no typeof guard). Every modern bundler auto-replaces `process.env.NODE_ENV` at consumer build time — no special config needed, no consumer setup. Reference implementation: `packages/fundamentals/flow/src/layout.ts:warnIgnoredOptions`. **Enforced by `pyreon/no-process-dev-gate`** (auto-fixable to the bundler-agnostic form). The companion rule `pyreon/dev-guard-warnings` recognises `if (process.env.NODE_ENV === 'production') return` as a valid early-return guard so `console.warn` calls in the function body don't fire spuriously. Server-only packages (`zero`, `runtime-server`, `server`, `vite-plugin`) are exempt — they always run in Node where `process` is real, and bundler-replacement is irrelevant.
- **Local `__DEV__` const alias prevents bundler tree-shake** (silent bundle bloat): `const __DEV__ = process.env.NODE_ENV !== 'production'` at module scope, then `if (__DEV__) console.warn(...)` at the use site. The pattern looks identical to the bundler-agnostic standard above 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 `process.env.NODE_ENV` is defined as `"production"`. The if-body retains the warning strings in the production bundle. The bare gate (`if (process.env.NODE_ENV !== 'production')`) folds reliably because the bundler's define replacement turns the expression into a literal `false` directly at the call site. **Real-world bug shape**: pre-fix, `@pyreon/runtime-dom` shipped 2,936 bytes (-23%) of un-stripped dev-warning strings; `@pyreon/reactivity` shipped 1,542 bytes (-20%) of the same; `@pyreon/core` shipped 781 bytes (-16%). Net Pyreon footprint was ~7 KB larger than necessary. Fixed by deleting the alias and using the bare gate at every use site (mechanical migration — same lint-rule-recognized pattern). The `dev-guard-warnings` lint rule also accepts conventional names like `__DEV__` / `isDev` / `IS_DEV` for *cross-module imports* (where the rule can't follow the import to verify the body), but a local file should use the bare gate. **Rule of thumb**: never assign `process.env.NODE_ENV !== 'production'` to a const inside a published library file; write the check inline at every site.
- **Early `if (prod) return` in a PUBLIC dev-only reader does NOT tree-shake the machinery it references** (the parse-time-vs-minify-time DCE distinction): a dev-only diagnostic reader that's a public export (retained when the package entry is bundled) with the shape `if (process.env.NODE_ENV === 'production') return empty; <body referencing module-level machinery>` still ships the machinery in production. The bundler's symbol-usage analysis (tree-shaking) runs on the AST BEFORE the minifier's unreachable-code elimination — so `_resolveLoc` / `_parseStackLine` / `preview`-style module symbols referenced in the statically-dead tail count as "used" and survive, even though the tail's own bytes get dropped. **Fix shape**: wrap the body in a DEV-BLOCK instead — `if (process.env.NODE_ENV !== 'production') { <body> } return empty` — because define-folding turns it into `if (false) {…}`, which esbuild/Bun drop at PARSE time, symbol references included. **When each shape is fine**: early prod-return is sufficient when the dead tail only references locals or already-retained exports (the bytes fold at minify); the dev-block is REQUIRED when the tail pins otherwise-unreferenced module-level machinery. Module-level `new FinalizationRegistry(cb)` / similar constructor side-effect consts also need `/* @__PURE__ */` to become DCE-eligible once their `register` call sites fold. **Real-world hit**: `@pyreon/reactivity`'s public readers (`getReactiveGraph` / `getFireSummaries` / LPIH stack-parse machinery + `getUpdateCause` / `describeReactiveGraph` walkers) shipped ~2.2 KB gz of provably-dead-in-prod code in every whole-entry bundle (9,254 → 7,014 gz after the fix, −24%). Locked by the public-API entry fixture in `reactive-devtools-treeshake.test.ts` (bisect-verified: un-foldable guard → machinery markers reappear in the prod bundle). Reference: `packages/core/reactivity/src/reactive-devtools.ts:getReactiveGraph` (the dev-block guard comment).
- **`check-bundle-budgets.ts` missing `define: NODE_ENV=production`**: prior to the bundle-shrink PR, the script's `Bun.build` invocation omitted the production define. The measurement therefore INCLUDED every `if (process.env.NODE_ENV !== 'production') console.warn(...)` string from `lib/`, overstating real consumer bundle by 5-20% per package. Real consumers ship with `define` set (Vite/Webpack/esbuild auto-replace), so the dev strings tree-shake out in real apps. The fix: pass `define: { 'process.env.NODE_ENV': '"production"' }` to `Bun.build` so the measurement reflects production reality. After landing, the budget file was regenerated with the smaller (true-production) sizes. **Rule for any future bundle-size measurement**: the script MUST match what consumers ship — set the production define explicitly; don't rely on bundler defaults that vary across `target` / `platform` settings.
- **Relying on `parent` in oxc visitor callbacks**: `VisitorCallback = (node) => void` — oxc's walker does NOT pass `parent`. A `parent?.type === '…'` check silently evaluates `undefined.type` → `undefined`, so the check is always false (or always true, depending on shape) — **silently inert**. When a rule needs parent context: (1) track via enter/exit depth counters on the parent node type, or (2) pre-mark child nodes via a `WeakSet` when visiting the parent, then look up the child on its own visit. Three rules were silently broken by this (`no-window-in-ssr`, `no-theme-outside-provider`, `no-props-destructure`, plus `component-context` util). The `VisitorCallback` type was narrowed to `(node: any) => void` to prevent recurrence.
- **Hooks defining event-listener callbacks outside `onMount`**: `const handler = () => { ... document.X ... }` declared at hook body scope, then `document.addEventListener('...', handler)` inside `onMount` — the handler closure is AST-untraceable (the rule can't prove the callback only runs in mounted browser context). Define the handler INSIDE `onMount`, return the cleanup function from `onMount` (not a separate `onUnmount` call). This co-locates browser-API access with the browser-only registration. Pyreon's `onMount(fn)` accepts a cleanup return value — don't duplicate cleanup via `onUnmount`.
- **Browser-only helpers called from event handlers without an explicit SSR guard**: When a module-level function reads `window.X` / `document.X` but is only called from `onMount`-registered listeners (via `setupListeners` / positioning helpers / click handlers), the rule flags each window/document access individually — it can't AST-trace the indirect call. Solutions in preference order: (1) inline the helper into `onMount` if small, (2) add an `if (typeof window === 'undefined') return <fallback>` early-return at the helper entry — the `no-window-in-ssr` rule recognises this form via its `early-return-on-typeof` heuristic and implicitly guards the whole body. Returning a fallback `{}` / `() => {}` from such guards is fine even if the path is unreachable at runtime; the guard exists primarily to document the SSR-safety contract at the callsite. Reference implementation: `packages/ui-system/elements/src/Overlay/useOverlay.tsx` — `calcDropdownVertical` / `setupListeners` / `getAncestorOffset`.
- **Tree-shake regression tests for dev gates**: when migrating a file's dev gate to `process.env.NODE_ENV !== 'production'`, also bisect-verify the bundle-level behaviour. Bundle the file via esbuild with `define: { 'process.env.NODE_ENV': '"production"' }` + `minify: true` + `treeShaking: true` and assert the dev-warning strings are absent from the prod output. Then bundle with `'"development"'` and assert they're present. This catches gate regressions where the source pattern is right but the minifier can't fold to a literal (e.g. the gate is wrapped in a way that obscures the constant from esbuild). Reference test: `packages/fundamentals/flow/src/tests/integration.test.ts` (`warnIgnoredOptions — gate pattern regression` describe block). Apply the same shape per-package when migrating critical dev-gate paths.
- **Framework APIs that require accessor props without value-form fallback**: `<Show when={signal}>` / `<Match when={signal}>` used to crash with `props.when is not a function` because the compiler's signal auto-call rewrites bare `when={mySignal}` to `when={mySignal()}` — passing a value (boolean) where the framework expected an accessor. The crash cascaded: the entire component tree under `<Show>` failed to render, often presenting as "missing styles" because hidden components have no class targets in the DOM. **Defensive normalization rule**: any framework API that accepts an accessor `T = () => X` MUST also accept the value form `X | (() => X)` and normalize via `typeof === 'function'`. The compiler can't know which props need accessor semantics, so the framework has to accept both. Apply this to any new control-flow component, any prop typed as `() => X`, and any signal-shaped prop in framework APIs. Reactive cases STILL need the accessor form to re-evaluate on signal change — the value form covers static booleans + the auto-call edge case. Reference fix: `packages/core/core/src/show.ts` `callWhen` helper.
- **Wrapper-style helper components leaking `{undefined}` JSX slots into void-element vnodes**: `@pyreon/elements`' Wrapper used to always render `<Styled>{own.children}</Styled>` regardless of whether `tag` was a void HTML element. When the parent passed no children (Element correctly skips them for void tags via `getShouldBeEmpty`), `own.children` was `undefined` — but `{undefined}` in JSX still serializes as a child slot, producing `vnode.children = [undefined]`. runtime-dom's void-element check (`vnode.children?.length > 0`) fired the warning. Fix: branch on `getShouldBeEmpty(own.tag)` inside the wrapper and use `<Styled />` (no slot) for void tags. **General lesson**: wrapper components that forward children must check the destination tag — JSX `{value}` slots are not free even when value is `undefined`. Audit any "passthrough" wrapper that accepts both layout-bearing and void HTML tags.
- **Listing a runtime-supported prop in a wrapper's `OWN_KEYS` without forwarding it back to the rendered vnode**: `@pyreon/elements`' Wrapper listed `'dangerouslySetInnerHTML'` in `OWN_KEYS`, so `splitProps` moved it into `own`. The downstream Styled JSX call only spread `...commonProps` (built from `rest`) and never re-attached `own.dangerouslySetInnerHTML` — the prop was silently dropped between Wrapper and the renderer, even though both `runtime-server` (`renderElement` reads `props.dangerouslySetInnerHTML?.__html`) and `runtime-dom` (`props.ts` `dangerouslySetInnerHTML` branch) support it. Real-app shape: `<Logo dangerouslySetInnerHTML={{ __html: '<svg>…</svg>' }} />` rendered an empty `<div></div>` instead of the inlined SVG. The `needsFix` and `isVoidTag` gates DID check `!own.dangerouslySetInnerHTML` (so the author was clearly aware the prop interacts with branching), they just forgot to actually forward it. **General rule for wrapper components**: every prop pulled into `own` via `OWN_KEYS` is the wrapper's responsibility to either consume OR re-forward. If a prop is structural (`tag`, `direction`, layout) the wrapper consumes it. If a prop is content / passthrough (`dangerouslySetInnerHTML`, `style`, `children`, `ref`) the wrapper MUST re-attach it on the rendered vnode. Audit any wrapper that uses `splitProps` against the destination runtime's prop pipeline. Fix: `if (own.dangerouslySetInnerHTML) return <Styled ... dangerouslySetInnerHTML={own.dangerouslySetInnerHTML} />` (children dropped — they're mutually exclusive with innerHTML; both runtimes treat both as inner-content sources). Bisect-verified at unit + real-Chromium browser layers — broken state: `expected undefined to be { __html: ... }` on the unit, `container.querySelector('svg')` returns null on the browser smoke. Reference: `packages/ui-system/elements/src/helpers/Wrapper/component.tsx`.
- **Mutable `let handler = null` followed by conditional assignment in `if (_isBrowser)` blocks**: The assignment-scope flow is untraceable by the SSR lint rule, which forces `if (_isBrowser && handler)` contortions at every use site. Prefer `const handler = _isBrowser && condition ? () => … : null` — ternary bindings derived from a typeof-bound const are recognised by `no-window-in-ssr` as typeof-derived, so `if (handler)` alone is sufficient at the use sites. Same pattern eliminates `destroy()` reassignment-to-null boilerplate: the handler binding is immutable for the router's lifetime. Reference: `packages/core/router/src/router.ts` — `_popstateHandler` / `_hashchangeHandler`.
- **`const handleClick = () => router.push(…); handleClick()` in render body**: Defining a nested fn that navigates and calling it synchronously in the component body is the same infinite-loop bug as a direct `router.push()` call — the lint rule catches both. Storing the nested fn for deferred execution (JSX handler, `setTimeout`, `onMount` callback) is safe. The rule tracks nested-function bodies for navigation calls and only fires when the resulting binding is invoked synchronously in the render body (not when stored / passed as callback).
- **Reaching for `document.createElement` / `cancelAnimationFrame` etc. globals from inside a CodeMirror plugin** (or any host-aware framework): use the host view's own document/window — `view.dom.ownerDocument.createElement(...)` and `view.dom.ownerDocument.defaultView?.cancelAnimationFrame(...)`. Both are SSR-safe (no global access at all) AND more correct (multi-document scenarios like iframes / shadow roots use their own document/window instance). Reference: `packages/fundamentals/code/src/editor.ts` (`CustomGutterMarker.toDOM`) and `packages/fundamentals/code/src/minimap.ts`. Avoid `null as unknown as HTMLElement` casts as a fallback for typeof guards — host-aware accessors give a real element with no escape hatch.
- **`import` of a `node:*` module at module scope in a client-safe published package** (enforced by `check-client-bundle-node-imports` CI gate): `@pyreon/zero`'s main entry is documented as client-safe — it's imported by browser code. A top-level `import { AsyncLocalStorage } from 'node:async_hooks'` (or any `node:fs` / `node:path` / `node:child_process` / etc.) anywhere in the transitive module graph reachable from the main entry pulls the Node-only module into the BROWSER bundle. The browser fails to resolve it → hydration breaks → cascading failures (counter clicks do nothing, theme toggle silent, the whole app brittle). **Real-world bug**: PR #1125 first cut added `import { AsyncLocalStorage } from 'node:async_hooks'` to `i18n-routing.ts` (exported from `@pyreon/zero`'s main entry via `useLocale`/`setLocale`). ssr-showcase e2e failed on `counter doesn't increment` + `theme toggle silent`. **Fix shapes**:
  1. **Split the Node-using code into a server-only module** loaded LAZILY from a Vite plugin hook. Setter-pattern bridge: the client-safe module exports a `_setX(reader)` API; the server-only module imports the client-safe module, calls `_setX(...)` to wire itself up, and the user-facing read API checks the registered reader. Reference: `packages/zero/zero/src/i18n-routing-als.ts` (server-only ALS) + `i18n-routing.ts` (`_setLocaleStoreReader` bridge). Same shape `match.ts` uses for `_setDefaultChromeLayout`.
  2. **Re-export the affected helpers from a `@pyreon/<pkg>/server` subpath** instead of the main entry. Server-only consumers import from the subpath; client consumers never see the module.

  **A LAZY `await import('./node-bearing-module')` does NOT escape this — it just changes the symptom from a build ERROR to a build WARNING + a dead chunk.** A dynamic import always produces a code-split chunk, so even though the import only RUNS server-side (e.g. inside a Vite `configureServer` hook), Vite/Rolldown still EMITS the chunk in the consumer's CLIENT build when the dynamic-import SITE sits in a module reachable from the client-safe entry. The chunk statically imports `node:*` → Vite prints `Module "node:async_hooks" has been externalized for browser compatibility, imported by "…/i18n-routing-als.ts"`. It's runtime-safe (the browser never loads the server-only chunk; `node:*` is externalized) but it's real noise + a dead chunk + an architectural smell. **Real-world bug**: bokisch.com's client build warned on `node:async_hooks` because `i18nRouting()` (a Vite plugin holding `await import('./i18n-routing-als')`) lived in `i18n-routing.ts`, which is client-reachable via the main entry's `useLocale`/`setLocale` exports. **Fix shape 3 (the one that actually removes the warning): move the dynamic-import SITE itself into a server-only module.** `i18nRouting()` + the als dynamic import moved to `i18n-routing-plugin.ts` (exported only from `@pyreon/zero/server`); `i18n-routing.ts` stays client-safe with no als reference. Now the client graph never reaches the dynamic-import site → no chunk → no warning. Same shape as the `island` → `@pyreon/server/client` split.

  **Don't** rely on dead-code elimination — Vite emits the static import statement even if the imported binding is never used at runtime, because static imports are eagerly evaluated for side effects. Type-only imports (`import type {...}`) are safe (TS-erased before bundling).

  **Gate gap (important):** `scripts/check-client-bundle-node-imports.ts` (CI required check) walks every client-safe package's main entry transitively for STATIC `node:*` imports — but it DELIBERATELY does NOT follow dynamic `import()` (it can't statically tell a server-hook-gated one from a real leak). So it PASSES on the dynamic-import-chunk shape above — that's the gap that let the bokisch.com warning ship. Broadly tightening it to follow relative dynamic imports would false-flag the `node:fs`/`node:path` builtins Vite externalizes SILENTLY (no warning) in any lib build. The regression guard for the dynamic shape is therefore a SCOPED reachability test, not the broad gate: `packages/zero/zero/src/tests/i18n-routing-client-safe.test.ts` walks the `@pyreon/zero` main entry following static + relative-dynamic imports and asserts `node:async_hooks` is unreachable (bisect-verified). NOTE a real `vite build` INSIDE vitest does NOT reproduce — vitest's own Vite context changes warning routing AND tree-shaking/chunking vs a standalone consumer build; the static reachability walk is the faithful, deterministic model of Rolldown's "reachable relative import → chunk" behavior.
- **Intentional `.peek()` inside `effect`/`computed`**: `.peek()` is the official signal-read API for cases where you specifically DON'T want subscription — loop-prevention (`if (next === editor.value.peek()) return` to skip writes during a write), imperative-ref access (a signal storing a non-reactive ref like a CodeMirror view), reading a signal once for a side-effect-only computation. The `pyreon/no-peek-in-tracked` rule fires on every such site by default — annotate the intentional ones with `// pyreon-lint-disable-next-line pyreon/no-peek-in-tracked` (or the alias `// pyreon-lint-ignore pyreon/no-peek-in-tracked`). Reference: `packages/fundamentals/code/src/bind-signal.ts:201` for the loop-prevention pattern.
- **`isBrowser()` / `isClient()` / `isServer()` / `isSSR()` as cross-module SSR guards**: any function with one of these conventional names is recognised by `pyreon/no-window-in-ssr` as a typeof guard at its call sites — `if (!isBrowser()) return …` followed by `window.X` is silent. The rule can't follow imports across files, so the **name is the contract**. Two implications: (1) implementing `isBrowser()` to actually return something OTHER than a typeof check (e.g. `() => true`) silently silences the rule wherever you call it — keep the body honest. (2) a non-conventional helper like `isReady()` or `inBrowser()` won't silence the rule; either rename it or use one of the four conventional names. Local typeof-bound consts (`const isBrowser = typeof window !== 'undefined'`) and locally-defined functions whose body returns a typeof check are also recognised regardless of name.
- **Duplicating root-level layouts under `prefix-except-default` i18n**: when `expandRoutesForLocales` (`@pyreon/zero`) fans the route tree into per-locale variants under `prefix-except-default`, a SOURCE root layout `_layout.tsx` (urlPath `/`) must NOT produce locale-prefixed duplicates. The route tree's hierarchical matching already wraps `/de/about` under the unprefixed `/_layout` (which sits at the root of the matched chain for every path). Producing a duplicate `/de/_layout` causes the matcher to nest BOTH layouts (`/_layout` → `/de/_layout` → page), mounting the layout component twice — two navbars, two PyreonUI providers, two `<main>`. **Non-root layouts** (`/dashboard/_layout` at urlPath `/dashboard`) MUST still be duplicated because the unprefixed pattern doesn't match the de-prefixed children. **Under `prefix` strategy** the skip does NOT apply: there is no unprefixed default to inherit from, so every locale needs its own root layout. **The bug class**: any cross-cutting transform that fans routes into variants must distinguish between layouts that wrap by hierarchical match (unprefixed root → all paths) vs by exact-prefix match (`/dashboard` → `/dashboard/*` only). Bisect-verified at the e2e layer (the unit + verify-modes layers can't catch it because they check route enumeration / dist filesystem; only rendered-DOM inspection sees the doubled layout structure). Reference: `packages/zero/zero/src/i18n-routing.ts:expandRoutesForLocales` (`route.isLayout && route.dirPath === ''` skip — see the next entry for why the discriminator is `dirPath`, NOT `urlPath === '/'`: a GROUP layout `(app)/_layout.tsx` also has urlPath `/` but is a real subtree boundary that MUST be duplicated per locale).
- **Keying the route TREE on a URL-derived path instead of the directory path (the group-layout clobber)**: fs-router builds its route tree from each file's `dirPath` and `placeRoute` assigns single-occupancy special slots (`node.layout = route`, same for `_error`/`_loading`/`_404`) last-wins. `(group)` segments are URL-invisible — `filePathToUrlPath` strips them — and `parseFilePath` ALSO stripped them from `dirPath`. That collapsed `(app)/` onto its parent directory, so `(app)/_layout.tsx` landed on the SAME tree node as the root `_layout.tsx` and one silently clobbered the other: the group layout rendered NOTHING (RouterView → RouterView → page, no layout DOM, no error). Same class for `_error`/`_loading`/`_404` inside groups, and SIBLING groups clobbered each other's specials. **The principle: URL-invisibility ≠ tree-invisibility.** A grouping segment must survive in the TREE key (`dirPath`) even while it's stripped from the URL key (`urlPath`) — the two keys serve different machines (matcher vs tree-builder) and conflating them collides every file whose stripped paths fan in to the same string (the same "cache keys must be built AFTER normalization… but on the RIGHT normalization" family as the rocketstyle `_rsMemo` entry). **Three compounding factors let it ship**: (a) a unit test CODIFIED the stripping (`expect(dirPath).toBe('')` for `(auth)/login.tsx`) — when a regression test encodes a behavior, verify the behavior is actually correct before trusting it; (b) every shipped example uses only LAYOUT-LESS groups (`(admin)/dashboard.tsx`, purely organizational), which work under both keyings; (c) the clobber was silent — `placeRoute` now warns loudly (`[Pyreon] fs-router: two _layout files resolved to the same route-tree node`) since a filesystem can't produce two same-slot files in one directory, so ANY overwrite is a route-expansion bug. Empirical lock: `packages/zero/zero/src/tests/fs-router-group-layouts.test.ts` — 8 scenarios proved at the RESOLVED-CHAIN level (real files → `generateRouteModule` → real `import()` → `createRouter` → `route.matched` markers), bisect-verified (restoring the group-strip fails root→group→page + sibling-group + specials specs). Reference: `packages/zero/zero/src/fs-router.ts:parseFilePath` (dirPath keeps group segments) + `placeRoute` (loud-clobber guard).
- **Iterating `props.children` at the VNode level without unwrapping a possible compiler-emitted accessor function** (enforced by `@pyreon/lint` rule `pyreon/no-iterate-children-without-resolve`, error-level, in `recommended`/`strict`/`app`/`lib`): The Pyreon vite-plugin's prop-inlining pass rewrites `<Comp>{children}</Comp>` (where `children` is a local `const` derived from a getter — `const children = childHolder.children` after `splitProps`) as `Comp({ ..., children: () => h.children })`. Receiving components see `props.children` as a FUNCTION instead of the expected `VNode | VNode[]`. **DOM-consuming code routes through `mountChild` which handles function children correctly via `mountReactive`, so the wrap is invisible there. Libraries that iterate children at the VNode level (kinetic's StaggerRenderer + top-level Stagger + GroupRenderer, elements' Iterator) or `cloneVNode` them directly (kinetic's TransitionItem + top-level Transition) are silently broken** — the function spread produces `{type: undefined}` and the DOM renders literal `<undefined>` tags. **History**: PR #731 (kinetic library-side fix for StaggerRenderer + TransitionItem), PR #732 (compiler-side carve-out for stable references at the JSX call site), PR #736 (parallel library fixes for top-level Stagger/Transition + Iterator + lint rule), PR #751 (lint rule scope-gap closures: variable-bound iteration + per-source-path mitigation regression spec), follow-up (react-compat `Children.*` + preact-compat `toChildArray` unwrap function children — the IfStatement-guarded iteration shape the lint rule deliberately can't catch). **Detected shapes**: `cloneVNode(EXPR, …)` / `(Array.isArray(EXPR) ? EXPR : [EXPR]).METHOD(…)` (inline OR variable-bound: `const xs = Array.isArray(X) ? X : [X]; xs.METHOD(…)`) / `EXPR.props` where EXPR ends with `.children`. **Acceptable mitigations** are tracked **per-source-path** in the same OR ancestor function scope: `resolveChildren(…)` call (canonical helper in `@pyreon/kinetic/utils`), `typeof X === 'function' ? X() : X` ternary, or `typeof X === 'function'` guard. Per-source-path means an outer `resolveChildren(props.children)` does NOT cover an inner inline-defined component's `innerProps.children` (different prop source) — each function's `props.children` is its own guarded surface. **Out of scope** (deliberate): pass-through patterns `...(Array.isArray(EXPR) ? EXPR : [EXPR])` (spread into h() rest args — mountChild handles function children); IfStatement-guarded iteration (`if (Array.isArray(x)) return x.map(…)` — framework primitives like `Dynamic` / `Show` / `Switch` use this with direct h() rest args that never reach the auto-wrap). Reference: `packages/tools/lint/src/rules/reactivity/no-iterate-children-without-resolve.ts`.

## Islands Mistakes

- **Importing `island` from the `@pyreon/server` main barrel into client/route code**: use `import { island } from '@pyreon/server/client'`, NOT `from '@pyreon/server'`, anywhere the file ships to the client (the dominant case being a route/component bundled client-side for hydration — and EVERY `@pyreon/zero` route ships to the client via `virtual:zero/routes`). `island.ts` is fully client-safe (only `@pyreon/core` `h` + `./island-codec`), but the `@pyreon/server` main barrel ALSO re-exports `createHandler` / `prerender`, which transitively pull `node:fs/promises` / `node:path` / `node:async_hooks` AND the package's `registerSingleton`. A barrel import drags that whole server module into the client/route bundle, producing TWO user-visible failures during the SSG build of that route: (1) the singleton sentinel throws — `[Pyreon] Duplicate @pyreon/server detected` — because the route chunk's bundled `@pyreon/server` (which runs `registerSingleton` at module-eval, OUTSIDE the SSG plugin's `withSilent`-wrapped handler import) is a second instance vs the build's node_modules copy; and (2) the dual `@pyreon/core` instance splits the context graph, so the hydrated island crashes with `Cannot read properties of undefined (reading 'ref')` (context lookup hits a different graph than the rocketstyle/`useContext` consumer expects). This is the exact failure that made islands unusable in zero routes. **Fix**: `island()` + its `IslandOptions`/`IslandMeta` types are exported from the CLIENT-safe `@pyreon/server/client` subentry; the `check-client-bundle-node-imports` gate now walks `/client` (bisect-verified) so it can never regress to transitively pulling a `node:` import. General rule for any package with a server barrel: a capability that is itself client-safe (renders a marker, encodes props) but lives in a barrel alongside `node:`-bearing siblings MUST be re-exported from a client-safe subentry, or a barrel import silently drags the server code into the client. Reference: `packages/core/server/src/client.ts` (`export { island } from './island'`) + `scripts/check-client-bundle-node-imports.ts` (`@pyreon/server/client` entry).
- **An `island()` whose CLIENT render produces its component inline / relies on a one-shot external hydration scan, when the host re-mounts route content** (`@pyreon/zero`): zero's route is a reactive child of RouterView, so on the client the SSR route DOM is DISCARDED + re-mounted (`hydrateReactiveChild` → `mountChild`), not hydrated in place. An island whose client render is the original async "load + render component inline" then (1) throws inside the host mount/`hydrateRoot` (async component, no Suspense boundary → the route error boundary swallows it: "An error occurred while rendering this page"), and (2) can't be covered by a one-shot `hydrateIslandsAuto` scan either — that runs once after the initial `hydrateRoot`, BEFORE the async lazy-route + its `<pyreon-island>` marker mount, so it finds nothing. **Fix shape: the island must OWN its hydration lifecycle.** `island()`'s client branch (`typeof document !== 'undefined'`) renders ONLY the marker, then `onMount` loads the chunk + mounts the component INTO the marker per the `data-hydrate` strategy (reusing `scheduleHydration`/`schedulePrefetch`, dynamically imported from `@pyreon/server/client` to stay client-only + avoid a `client`↔`island` static cycle). This is robust across BOTH a host that hydrates in place (static islands app — where `island()` runs only at SSR, so the client branch never fires) and one that re-mounts (zero). The server branch is unchanged (async → marker + content). **General rule:** a "partial-hydration marker" primitive (island, or anything that defers loading a subtree) must be self-contained on the client — render the marker + schedule its own hydration on mount — not depend on (a) an inline async render that a host hydrate/mount can't await, or (b) external one-shot scan timing that a reactive/lazy host defeats. Bisect-verified: `e2e/zero-islands.spec.ts` (real-Chromium island hydrate + signal click) fails with the inline-async island, passes with self-hydration; the 9 islands-showcase strategy specs stay green (static model unaffected). Reference: `packages/core/server/src/island.ts` (the `typeof document` self-hydration branch).
- **Deferred island hydration that mounts WITHOUT re-establishing the marker's context owner** (the 0.30.0 island-theme-context regression): under #1338's owner-based context (each mounted component's `EffectScope` is the context owner, linked to its parent by the renderer; `useContext()` walks the owner chain; there is NO global stack), an `island()` hydrates LATER (on idle/visible/interaction, after an async `import()`), in its OWN `hydrateRoot` mount. By then the marker component's owner is long gone, so the island's hydration root has NO parent owner → `useContext()` can't reach ancestor providers → a `rocketstyle` component reading the PyreonUI theme inside the island crashes (`Cannot read properties of undefined (reading 'base')`). Pre-#1338 the global stack still held the provider's frame, so a late mount found it; owner-based context removed that crutch. **Fix**: capture `getContextOwner()` SYNCHRONOUSLY in the island marker's render body (while its owner chain — up through `_layout`'s PyreonUI — is active), thread it through `scheduleHydration`, and re-establish it with `runWithContextOwner(owner, () => hydrateRoot(...))` (hydrate.ts sets `scope._parent = getContextOwner()`, so the island's root parents to the captured owner and `useContext` walks up to the real provider). Capture in the BODY, NOT inside `onMount`/the deferred callback — those run after the owner is restored away. Static-islands apps (`hydrateIslands` external scan, no client route tree → no owner) pass `null` and keep the prior detached-root behavior. **General rule**: any primitive that defers a mount past the synchronous render frame (islands, lazy boundaries, scheduled re-mounts) must capture the active owner at setup and restore it when it finally mounts — owner-based context only flows through links established at mount time, and a deferred mount has none unless you thread it. Bisect-verified: `island-client.test.tsx` (provider → island → context-reading component reads `'PROVIDED'`; drop the owner thread → reads `'DEFAULT'`). Reference: `packages/core/server/src/island.ts` (`getContextOwner()` capture) + `client.ts:hydrateIsland` (`runWithContextOwner` restore).
- **Registering a `hydrate: 'never'` island in `hydrateIslands({ ... })`** `[detector: island-never-with-registry-entry]`: the whole point of the `'never'` strategy is shipping zero client JS — registering a loader pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, so the loader never fires, but the bundler still includes the import). Drop the registry entry; the framework handles never-strategy islands at SSR with no client-side wiring. **Auto-registry omits never-islands by design**: under `@pyreon/vite-plugin` (`pyreon({ islands: true })`, default), `hydrateIslandsAuto(registry)` consumes a virtual module that's pre-filtered to skip never-strategy declarations. The detector exists to catch the bug shape in the manual-form `hydrateIslands({ ... })` call (non-Vite consumers, or Vite consumers who deliberately use the manual form). Reference: `packages/core/compiler/src/pyreon-intercept.ts:detectIslandNeverWithRegistry`. Caught syntactically when the `island()` declaration AND `hydrateIslands({ ... })` call appear in the same source — cross-file detection is `pyreon doctor --check-islands` territory (separate PR).
- **`hydrate: 'interaction'` for above-the-fold interactive components**: defeats the strategy. `'interaction'` is for components that are interactive BUT NOT VISIBLE on initial paint — modals, dropdowns, command palettes. Above-the-fold interactive content (header CTAs, hero buttons) needs `'load'`; deferring to first user interaction means the hero button shows but doesn't respond until the user clicks, then there's a click-replay round-trip before the actual handler fires. The replay path ALSO only covers click events — focus/pointerenter/touchstart trigger hydration but no replay (focus can't be reliably re-dispatched once the user has tabbed past; pointerenter is passive). Use `'load'` for any component the user is meant to interact with on first paint.
- **Pairing `prefetch: 'idle' | 'visible'` with `hydrate: 'load'` or `'never'`**: prefetch is a no-op for these strategies. `'load'` runs the loader synchronously already (the chunk is already being fetched on page load), `'never'` defeats the zero-JS strategy by pulling the component into the client bundle. The framework silently suppresses the `data-prefetch` attribute in both cases — nothing breaks at runtime but the user is signaling intent that doesn't apply. Drop the `prefetch` field. The canonical pairing is `hydrate: 'visible'` + `prefetch: 'idle'` (chunk warms during browser idle, hydration is instant by scroll-in).
- **Passing children to an island**: island props are JSON-serialized for the SSR → client transit (embedded in `<pyreon-island data-props="...">`). Children, functions, symbols, and `undefined` values are stripped during serialization — the island sees only what its own component returns from its own body, not what the parent passes as `children`. Refactor the island to render its own content from props (string IDs work fine; the island can fetch / restore the rich data on the client) or move the island wrapper inside the component itself.
- **Duplicate `name` across `island()` declarations**: the client-side hydration registry is keyed by `name`; only the FIRST loader fires. The second island silently fails to hydrate with no error flag — the user sees broken interactivity on the second component without any signal pointing at the cause. Always use distinct names. The `pyreon doctor --check-islands` audit catches this at the project level (finding code: `duplicate-name`).
- **Nested islands**: an `island()` whose loader-target file ALSO contains an `island()` declaration. Unsupported — the outer's `hydrateRoot` replaces the inner's subtree before its loader runs, so the inner never hydrates. Refactor to flatten (move the inner's content into the outer, OR remove the inner `island()` wrapper). The `pyreon doctor --check-islands` audit detects this statically (finding code: `nested-island`); reference: `packages/core/compiler/src/island-audit.ts:detectNestedIsland`.
- **Dead islands**: an `island()` declared in a file that no other source imports (statically OR dynamically). The component never reaches a rendered tree — effectively unreachable code. Either wire it up via a route OR remove the declaration. Auto-registry's `() => import('PATH')` counts as an import, so registered islands aren't flagged. The `pyreon doctor --check-islands` audit catches this (finding code: `dead-island`).
- **Cross-file registry drift**: a manual `hydrateIslands({ X })` entry whose key has no matching `island()` declaration anywhere in the project. Common cause: typo (the registry key must EXACTLY match the `name` field, including case), or the `island()` was renamed / removed but the registry entry wasn't. Switch to `hydrateIslandsAuto()` (with `@pyreon/vite-plugin` `islands: true`) to eliminate manual sync drift entirely. The `pyreon doctor --check-islands` audit catches this (finding code: `registry-mismatch`).

## SSR-rendering Mistakes

- **SSR↔hydration parity is a differential-fuzz target, and the whole class hides in the seam** (2026-07 campaign; permanent gate `packages/core/runtime-dom/src/tests/hydration-parity-fuzz.test.tsx`). Hydration walks the VNode tree in parallel with the parsed SSR DOM; the failure mode is a **cursor misalignment** where one child consumes the wrong number of DOM nodes and every following sibling mismatches. The fuzzer builds each seeded tree TWICE (SSR+hydrate vs fresh client mount) and asserts four oracles — zero `onHydrationMismatch`, identical comment-normalized DOM, identical DOM after identical signal flips, and root-identity reuse — plus an **O5 "ground truth"** oracle (a third instance mounted fresh with the flipped values) that catches "agreement on broken" where the hydrated AND client-mounted instances share the same wrong post-flip DOM. Six shipped bug classes it found, each a cursor/extent error: (1) `<For>` hydration mounted fresh rows but left the SSR rows → **duplicated list** + null cursor; (2) the HTML parser MERGES adjacent text-producing children (`{23}{'hello'}` → one `"23hello"` node) and hydration removed the whole node for the first child → must `splitText` each child's prefix; (3) a reactive accessor with a MULTI-ROOT initial (fragment/component/`<For>`) removed exactly ONE node before re-mounting → the rest duplicated; (4) empty-initial reactive text anchored its recovery at the PARENT anchor not the cursor → sibling-order corruption; (5) `mountChildren`'s sole-text-child `textContent =` fast path WIPED existing siblings when reached via a Fragment (client-mount bug, caught only by the cross-instance parity oracle); (6) static text mounted inside a reactive boundary returned `noop` cleanup → an accessor flipping away from a fragment-of-text ORPHANED the old text. **Durable rules**: (a) any SSR construct whose client DOM extent is ambiguous (0, 1, or many nodes) must emit a hydration RANGE marker the client can consume as a unit — the framework's `<!--k:-->`/`<!--pyreon-for-->` and the new `<!--$-->…<!--/$-->` accessor markers are all this pattern (Solid's `<!--$-->` is the analogue); (b) markers must be UNIFORM per construct — a marked range adjacent to an unmarked one reintroduces the exact cursor gaps the fuzzer flags (a conditional-on-value marker scheme regressed 83/5000 seeds); (c) a text node mounted into a LIVE parent through a reactive boundary MUST return a real remover, not `noop` — `noop` is valid ONLY when the node is a child of a freshly-built element removed as a unit (`_elementDepth > 0`); (d) a reactive text binding is POLYMORPHIC — the accessor can later yield a VNode, so `text.data = String(v)` renders `[object Object]`; upgrade to a subtree mount (`bindPolymorphicText`). **Detection lesson (reinforced): happy-dom is the discovery surface but the real-Chromium `ssr-node`/`ssr-showcase` e2e is the gate — and the SSR-string change (accessor markers) breaks every raw-HTML string assertion, so those must be made marker-tolerant, not the fix reverted.**


- **Hand-rolling `const isBrowser = typeof window !== 'undefined'` instead of importing the canonical env flag**: Pyreon ships `isServer` / `isClient` from `@pyreon/reactivity` (re-exported from `@pyreon/core`) — `isServer = typeof document === 'undefined'`, `isClient` its inverse. Use them; do NOT re-derive a local `typeof window`/`typeof document` const per package. Two reasons the hand-rolled form is worse: (1) **`typeof window` is the wrong discriminator** — it misreports environments where `window` is polyfilled (some Node setups) or absent-but-DOM-capable; `typeof document` ("is there a DOM") is the reliable test, and the primitive uses it. (2) **drift** — before this primitive, ~7 packages each rolled their own and they DISAGREED (`window` vs `document`), so SSR-detection behaviour varied by package. The flags are plain runtime constants (evaluated once at module load), NOT an export-condition fold — a fold would be a correctness footgun on bundlers that don't set the `browser` condition. Use them for SMALL env guards (module-level singletons, lazy globals, render output that differs server vs client); for DOM access inside a component prefer `onMount`/`effect` (never run during SSR), and for heavy server-only code prefer a `/server` subpath export. `@pyreon/lint`'s `no-window-in-ssr` recognises an `isClient`/`isServer` (or `isBrowser`/`isSSR`) guard ONLY when imported from `@pyreon/reactivity`/`@pyreon/core` — a same-named local const (`const isBrowser = true`) or a foreign-source import stays flagged. Reference: `packages/core/reactivity/src/environment.ts`.

- **Running loaders but NOT resolving lazy route components before the synchronous SSR render** (the 0.30.0 `mode: 'ssr'`/`'isr'` empty-page regression): `renderToString` is SYNCHRONOUS. `RouterView` renders the matched component at each depth by reading `router._componentCache.get(record)`; for a `lazy(() => import(...))` route that's NOT pre-cached, it falls back to `renderLazyRoute` which kicks off an ASYNC import that never completes during the sync render → the depth-N `<RouterView>` renders NOTHING. zero's fs-router emits every route as `lazy()`, so the SSR handler MUST resolve the matched chain's lazy components into the cache before rendering. The bug: `@pyreon/server`'s handler called `prefetchLoaderData(router, path)` — which runs LOADERS ONLY — so the layout (eager) rendered but the lazy PAGE rendered blank inside it (status 200, the unfilled template shell with `<!--pyreon-app-->` correctly replaced by an EMPTY router-view). `mode: 'ssg'` was unaffected because it already called `router.preload` (which resolves components). **Fix**: the handler calls `router.preload(path, req)` — it resolves lazy components into `_componentCache` AND runs loaders (forwarding the request + propagating loader-thrown redirects). `prefetchLoaderData` stays loaders-only on purpose (it's ALSO the RouterLink-prefetch path — warming loader data on hover should NOT eagerly download every route's component chunk). **General rule**: any code path that drives a SYNCHRONOUS `renderToString` of a router tree must pre-resolve the matched chain's lazy components first (`router.preload`), not just loaders. **Detection-gap lesson** (why it shipped): the `ssr-node` e2e asserted the LAYOUT's nav (`data-testid="nav-home"`) + the router-view div + `__PYREON_LOADER_DATA__` — ALL of which render even when the page leaf is empty (the nav is in the `_layout`, not the page). An SSR e2e MUST assert the route's OWN page content (a page-specific testid/heading), in the RAW HTTP response (`page.request.get`, no JS) — a hydrated-DOM assertion (Playwright with JS) ALSO masks an empty SSR page because client hydration fills it in. Reference: `packages/core/server/src/handler.ts` (`router.preload`), `packages/core/router/src/loader.ts:prefetchLoaderData` (loaders-only contract), regression tests `server.test.ts` (lazy-route handler render) + `loader.test.ts` (preload resolves components) + the strengthened `e2e/ssr-node.spec.ts` page-content assertions.

- **Streaming SSR shipped CSS-in-JS styles only via end-of-stream `<style>` tag → boundary content FOUCs until the final flush**: `@pyreon/runtime-server`'s `renderToStream` (used by `mode: 'stream'` / `examples/cpa-pw-app-solid`) pushes the shell `<head>` BEFORE the appStream starts, so any styles collected during render cannot land in the head. The string-mode handler (`renderToString`) calls `collectStyles()` AFTER render and injects the consolidated tag into `<head>` — in stream mode that pathway is bypassed entirely, AND any pre-existing `getStyleTag()` would only emit at end-of-stream (well after Suspense boundaries have already shipped their HTML). Boundary content reaches the browser unstyled until the trailing tag arrives = FOUC. **Fix shape**: any framework that collects CSS *during* a streaming render must expose a delta-flush API (returns rules-since-last-flush + advances a watermark) and the streamer must call it (a) once after the synchronous shell render → emit `<style>` inline at the top of the app body, (b) inside every Suspense boundary BEFORE the `<template>` element → so styles arrive before the swap script runs. Decouple via `globalThis.__PYREON_STYLER_FLUSH__` (mirrors `__pyreon_count__` perf-counter, SSG-plugin styler-tag lookup, cloudflare-adapter `__PYREON_SSR_TEMPLATE__`) — no hard `runtime-server → styler` dep, graceful no-op when styler isn't loaded. Idempotent watermark: second flush with no new rules returns `''`. `getStyleTag()` unchanged for SSG / non-streaming SSR. The watermark resets on per-request boundary (`reset()` / `clearAll()` / `resetSSRBuffer()`) so a re-rendered page starts fresh. Bundle cost: ~239 gz across both packages, well within budget. **General rule for any CSS-in-JS engine that supports streaming SSR**: delta-flush + caller-driven inline emission. Polling-style approaches (`getStyles()` re-emitting everything) ship duplicate rules in every boundary. Reference: `packages/ui-system/styler/src/sheet.ts:flushSSRPending`, `packages/core/runtime-server/src/index.ts` (post-shell flush + `streamSuspenseBoundary` per-boundary flush); bisect-verified by `__tests__/streaming-flush.test.ts` (13 specs) + `tests/styler-stream-flush.test.ts` (7 specs).

- **Animation wrappers that gate-out children on the server**: `<Transition show={() => false}>` (and any kinetic primitive that derives from it — `Stagger`, plus `kinetic('x')` with a falsy `show`) used to render `<Show when={false} fallback={null}>` on the server, which emitted EMPTY HTML for the wrapped subtree. The documented scroll-reveal pattern (`useIntersection` + sticky-signal) hit this every time because IO can't fire on the server, so `show` is false at SSR — any SSG site shipped with reveal-wrapped content **structurally absent** from prerendered HTML. Bad for SEO, social scrapers, accessibility tools, and no-JS users. **Ecosystem norm** (the framing the fix aligns to): Framer Motion, react-transition-group, react-spring, AutoAnimate all render children in SSR regardless of animation state — visual hiding is class/style only. "Content is structural, animation is visual." **Fix shape**: branch the render path at component setup on the initial `show()` value. Initially-visible → existing `<Show>`-gated mount (preserves runtime-unmount semantic for visible→hidden). Initially-hidden → always render children with hidden-state classes inlined (`leaveTo` if defined, else `enterFrom` — covers the scroll-reveal pattern that only configures the enter side); the existing `watch(stage)` effect drives the enter animation on `show` flip true on the SAME element. **Companion fix in `applyEnter`**: it must be symmetric to `applyLeave` and clear residual `leave`/`leaveFrom`/`leaveTo` classes at start, otherwise the SSR-baked hidden class competes with `enterTo` CSS during the enter cycle (latent issue, surfaces with the SSR fix). **Trade-off**: for initially-hidden Transitions, `unmount: true` no longer triggers true DOM removal after a later leave animation completes — element stays in DOM with leave-to class applied. **Why it shipped undetected**: zero existing kinetic tests exercised `show: () => false` initial state, and zero tests touched the runtime-server path — both real `renderToString` AND a hidden initial state were needed to surface the bug. Reference: `packages/ui-system/kinetic/src/Transition.tsx:wasInitiallyShown` branch + `Transition.ssr.test.tsx` (7 specs against real `renderToString`). **General rule for any animation/visibility wrapper**: animation state must never gate children OUT of SSR. If the component wraps visible content, the SSR output must contain that content. The visual state lives in classes/styles, not in conditional mounting. Apply this rule to any new control-flow wrapper added to the framework.

- **Applying `select.value` before the option children exist (PZ-09)**: four layers, including SSR serializing it as a dead content attribute. `HTMLSelectElement` has NO `value` CONTENT attribute — the parser ignores `value="…"` on `<select>` — and the `.value` PROPERTY setter selects the first matching `<option>`, so an assignment made BEFORE the options exist is silently dropped (first option wins; a later `sig.set(sameValue)` never notifies → no self-heal). Four places independently violated the invariant "apply select value AFTER children, as a property": (1) the compiler BAKED static `value="b"` into the `_tpl` HTML (dead attribute); (2) the compiler emitted the reactive `_bindDirect` line BEFORE the children `_mountSlot` line, so its eager initial update ran against an option-less select (only visible with DYNAMIC options — static options are in the clone before `bind()` runs, which is why that control cell always worked); (3) `mountElement` ran `applyProps` before `mountChildren` (both static + reactive h()-path initials dropped); (4) SSR serialized the same dead attribute, shipping first-option-selected HTML. **Fix shape (mirrors React's `postMountWrapper` / Solid's `Properties` handling)**: compiler — never bake select/value; emit a property-set bind line and defer EVERY select-value bind line past the element's children lines (`processAttrs` splices them into a per-element `deferredLines` list appended after `processChildren`; both backends byte-identical); runtime — `mountElement`/`hydrateElement` exclude `value` from the pre-children `applyProps` pass (`skipKey`) and apply it post-children via `applySelectValueProp` (descriptor-aware, so a reactive accessor's eager initial `renderEffect` run also sees the options); SSR — drop the dead attribute and mark the matching `<option selected>` instead (String()-coerced first-match, option value falling back to its stripped-collapsed text per `HTMLOptionElement.value` semantics; the select frame flows to option rendering via `AsyncLocalStorage`, so concurrent renders/streams can't cross-contaminate — no module-level stack, no cleanup contract). `value == null`/boolean emits nothing anywhere (an option's own `selected` attr stays authoritative). **Detection lesson**: happy-dom faithfully models all four select semantics (verified standalone before trusting it), so happy-dom unit tests ARE load-bearing for this class — locked by `runtime-dom/src/tests/select-value.test.tsx` (full matrix + hydrate child-mismatch re-mount) + `select-value.browser.test.tsx` (real-Chromium belt-and-braces), `compiler/src/tests/select-value-emit.test.ts` (bake-skip + order), the native-equivalence "select value binding (PZ-09)" block, and `runtime-server/src/tests/select-value-ssr.test.ts` (string + stream parity). All five layers bisect-verified. **Known residual gaps (deliberate)**: spread `value` (`<select {...props}>`) on the TEMPLATE path still applies pre-children (the `_applyProps` line isn't deferred — fine with static options, broken with dynamic ones; the h() path IS fixed for spreads since applyProps itself is split); array values on `multiple` selects are unsupported on BOTH sides (client `select.value = arr` String()-coerces — SSR matches single values the same way). **General rule**: any DOM property whose setter semantics depend on the element's CHILDREN (`select.value` today; the same class as media-element `currentTime`-before-src shapes) must be applied after the children mount, in every pipeline that touches it — compiler emission order, runtime mount order, hydration order, and the SSR serialization that stands in for it.

## SSG / e2e Test-Server Mistakes

- **`vite preview` for SSG e2e gates that read per-route prerendered HTML**: `vite preview` does SPA fallback — for ANY URL that doesn't map to a literal file in `dist/`, it serves `dist/index.html`. Correct for SPA builds; wrong for SSG. A test that `page.goto('/cs/posts')` then asserts on prerendered content (post-items rendered by the loader at SSG time, inline `window.__PYREON_LOADER_DATA__` script, per-route meta tags, anything emitted into the route-specific HTML) will silently read the HOME page HTML instead, because vite preview routes the request to `dist/index.html`. Symptoms look like genuine framework bugs: "useLoaderData returns undefined post-hydration", "meta tags missing", "post-items not rendering" — the data IS in the prerendered file, the framework hydration IS correct, but the test never sees that file. PR #516 documented this as a "loader-data hydration known gap" for two weeks before investigation revealed the actual bug was vite preview's SPA fallback. **Fix**: use `scripts/serve-ssg.ts` (directory-rewriting static server matching how real static hosts serve SSG output: `/cs/posts` → `dist/cs/posts/index.html`). Real static hosts (Netlify, Cloudflare Pages, GitHub Pages, S3+CloudFront, nginx + `try_files`) do this rewriting natively; `vite preview` does not. Apply this rule to any SSG e2e gate that exercises content under a non-root prefix — i18n route duplication, dynamic-route enumeration, subpath deploys (the existing `ssg-subpath` gate is exempt because its specs only check RouterLink-href + asset-URL prefixing, both of which vite preview serves correctly). Reference: `scripts/serve-ssg.ts` + `e2e-configs/ssg-i18n.config.ts`. The chain `bun run --filter=… build && bun scripts/serve-ssg.ts <dist> <port>` is the canonical Playwright `webServer` shape for these gates.

## Testing Mistakes

- **happy-dom fires `hashchange` for `history.pushState`/`replaceState` — deferred, so the echo can land in the NEXT test**: real browsers never fire `hashchange` for pushState/replaceState (WHATWG: only fragment navigations); happy-dom's `Location[setURL]` queues one on a `setTimeout` whenever the hash differs. Any code that treats `hashchange` as a genuine traversal (e.g. `@pyreon/router`'s browser-nav pipeline) then sees a STALE echo of a previous test's URL write — delivered mid-way through the next test, superseding its in-flight navigation with a path from the previous test. Passes in isolation, fails in the full file run; real Chromium never reproduces it. **Fix (spec-parity patch, not router code)**: wrap `history.pushState`/`replaceState` in the package's vitest `setupFiles` to count hash-changing calls and swallow that many synthetic `hashchange` events in a capture-phase listener; discriminate tests' MANUAL `new HashChangeEvent('hashchange')` dispatches by their empty `oldURL` (happy-dom populates it, manual events leave `''`). `location.hash = …` assignments are real fragment navigations and must NOT be swallowed. Reference: `packages/core/router/src/tests/setup.ts` (wired via `setupFiles` in vitest.config.ts).
- **Running `bun test`**: Use `bun run test` (runs vitest via package scripts)
- **Missing cleanup**: Always clean up mounted components, dispose effects
- **Fake timers**: Use real `setTimeout` with `await` — fake timers cause subtle issues
- **Testing internals**: Test public API behavior, not implementation details
- **DOM tests without happy-dom**: Packages with DOM need `environment: "happy-dom"` in vitest config
- **Stale DOM references after re-render in compat-layer tests**: `@pyreon/react-compat`, `@pyreon/preact-compat`, etc. do **full DOM subtree replacement on every state change** — there's no VDOM diffing in the compat layer (Pyreon's native pattern is fine-grained reactivity, not whole-component re-renders). A test that captures a button reference BEFORE click and asserts on `.textContent` AFTER click sees the OLD text because the captured node is now detached. **Always re-query the DOM after a state change**: `container.querySelector('#x')!.click(); await flush(); expect(container.querySelector('#x')!.textContent).toBe(...)`. Phase A2's first react-compat smoke held a stale reference and looked like a re-render bug; was actually a test-pattern bug. Reference: `packages/tools/react-compat/src/react-compat-rerender.browser.test.tsx`.

## Memory Leak Classes (catalog)

Seven classes seen in framework code. PR-by-PR history lives in commit messages + per-package CHANGELOGs — this catalog names the SHAPE + FIX so contributors recognize a pattern at code-review time before it ships.

| Class | Shape | Canonical fix | Static detection |
|---|---|---|---|
| **A** — Position-based cleanup of shared mutable state | Module-level array used as stack. `push()` at setup + `pop()` at cleanup. Out-of-LIFO removal (renderer-driven sibling unmount, reactive boundary mutation between push & pop) pops the WRONG entry. | Capture the pushed frame at push time; cleanup does identity-based removal: `() => stack.splice(stack.lastIndexOf(frame), 1)`. `@pyreon/core` exports `removeContextFrame` for compat-layer / advanced consumers. | None — audit-time only |
| **C** — Unbounded caches | Module-level `Map`/`Set`/`WeakMap` populated by hot path, with NO eviction OR eviction that doesn't fire on every relevant lifecycle event. | **LRU bound** (simplest, `Map` insertion-order eviction); **subscriber-aware sweep** (when eviction would silently break tracking); **lifecycle-event invalidation** (cache keyed by resource, hook the destruction event). | `scripts/check-bundle-budgets.ts` catches the related "main entry growing" pattern, not runtime growth |
| **D** — Event listener pile-up | Function registers a shared/global listener without refcount or idempotency guard. Callers expecting "call once" semantics get N listeners. | **Refcount idempotency**: module-level `let _refCount = 0` + `_disposeShared`; first mount runs setup, each unmount decrements, refcount→0 runs shared teardown. **Re-push cached cleanup** when a fast path returns a previously-registered cleanup. | `pyreon/init-fn-needs-idempotency` + `pyreon/no-raw-addeventlistener` + `pyreon/no-raw-setinterval` |
| **F** — Promise queue stale resolution | In-flight Promise replaced on refetch / retry without canceling the OLD handler's writes. SLOW old + FAST new → old's `setData` clobbers new. | **Version-tracking counter**: each invocation bumps `fetchVersion`; resolve/reject compare captured-vs-current and discard stale. `AbortSignal` works for `fetch()` callers; framework wrappers usually don't own the fetcher. | None — audit-time only |
| **H** — Closure-captured snapshots retained unboundedly | Effect / callback captures a snapshot (context stack, EffectScope, subscriber array) in its closure. Snapshot lives for effect's lifetime, retaining everything it points at. | Capture the MINIMAL subset needed (an ID, a key), not the full snapshot. Restore via identity-keyed splice (= Class A's fix). | None — deepest class, audit-time only |
| **I** — Orphaned timers from `Promise.race + setTimeout` | `Promise.race([work, new Promise(_, reject => setTimeout(reject, MS))])` without `clearTimeout` in `finally`. Success path leaves the rejection timer pinned for MS ms with closure + reject callback. | Capture the timer id outside the Promise constructor; `clearTimeout(timerId)` in `finally`. | `pyreon/promise-race-needs-cleartimeout` |
| **B / E** | Subscriber retention after dispose / disposed objects retained from collections. Documented for future-proofing; no real instances in shipped code (Pyreon's `signal.subscribe` returns a usable unsub). | — | — |

**Cross-class root cause**: module-level mutable state with imperfect cleanup contracts. Classes A/C/D are eviction failures; F is overwrite without invalidation; H is retention by held reference; I is timer that never gets cleared on the success path.

**The 3 questions to ask before introducing a new module-level cache / stack / registry**:

1. What's the eviction trigger?
2. What's the cleanup contract — strict LIFO, identity-based, refcount, or none?
3. Is the cleanup path actually exercised by any test?

If any answer is "the GC will handle it" or "the user will dispose it manually", treat the design as a leak source until proven otherwise.

## Lifecycle & Cleanup Mistakes

- **Content mounted into a LIVE parent (a Portal target) that returns no explicit remover leaks its DOM on dispose (the `<Portal>` instance, 2026-07)**: `mountChild(children, target, anchor)` returns a cleanup that, for element children, is a `noop` when `_elementDepth > 0` — valid ONLY because the child is part of a freshly-built element removed as a UNIT (its parent's `removeChild` drops all descendants). `<Portal>`'s mount (`mount.ts:PortalSymbol` branch) mounts children directly into `target` (e.g. `document.body`) — a LIVE parent that is NEVER removed as a unit — so the returned cleanup left the portaled DOM behind. A modal / toast / tooltip / dropdown stayed in the document FOREVER once its owner unmounted (route change, `<Show>` flip, conditional render, keyed-`<For>` removal) — a growing pile of stale overlays + a real memory leak, entirely uncovered by tests (no spec asserted portal REMOVAL on dispose, which is how it shipped). **Fix shape**: bracket the portaled content with comment markers (`<!--portal-->…<!--/portal-->`), mount BEFORE the end marker (so reactive content that grows/shrinks stays inside the bracket), and on dispose remove everything between the markers + the markers themselves. **The general rule (same family as the `mountChild` text-node `_elementDepth` gate + the SSR↔hydration parity "noop cleanup is valid only when the node is removed as part of a freshly-built element"): any framework code that mounts content into a parent it does NOT own/remove — a portal target, a document-level host, an existing app node — MUST return a real remover, never rely on the `_elementDepth > 0` noop.** Bisect-verified: `packages/core/runtime-dom/src/tests/portal-dispose.test.tsx` (static + reactive-grown content both fail with `expected <div> to be null` when the marker-removal is reverted to `mountChild(children, target, null)`). Discovered via the `@pyreon/testing` audit — `render()` + `cleanup()` left portaled modals in `document.body` across tests, which traced to this framework-level leak. Reference: `packages/core/runtime-dom/src/mount.ts` (PortalSymbol branch).
- **Module-level `WeakSet`/`WeakMap` membership registries fed by high-churn objects retain their GROWN BACKING TABLE forever (Class C variant — the For/keyed anchor-registry instance, 2026-07)**: a `WeakSet` never pins its KEYS, so it reads as leak-proof — but V8 never SHRINKS an ephemeron hash table after growth, so a module-level registry that every list row passes through (`_forAnchors`/`_keyedAnchors` got one `add()` per row; a 10k-row bench suite pushed the table to 32768 slots) retains the 256KB table for the page's lifetime even after every key is GC'd. Heap-snapshot signature: one huge `array:` node retained via `internal "table"` from a `WeakSet` in a module scope — it was the ENTIRE retained-heap delta vs Solid on the krausest-style bench (3.16MB → 2.90MB when fixed). **Fix shape**: don't register high-churn objects in a module-level weak collection to answer a membership question — carry the answer on the OWNING record instead. Here, entries record their DOM extent (`ForEntry.end`/`KeyedEntry.end`, null = single node) at mount, so `moveEntryBefore` moves the exact `[anchor..end]` range with no registry at all (also removes a per-row `WeakSet.add` from the create path and makes multi-node moves exact instead of neighbor-sniffing). Bisect-verified by `tests/for-entry-range-move.test.tsx` (5 specs fail when the range walk is broken). **Detection lesson**: invisible to the heap-slope leak-sweep (the table stops growing once it reaches workload high-water — flat slope) AND to GC-observable WeakRef tests (keys DO die); only heap-snapshot attribution by constructor/retainer names it. **Rule: before adding a module-level Weak collection on a per-item hot path, ask "what is this table's high-water capacity, and who ever releases it?" — weak keys don't make the TABLE weak.**
- **Long-lived SCRATCH arrays holding object references after the pass that filled them (Class H — the mountFor LIS instance, 2026-07)**: a per-instance scratch buffer that outlives its pass (`LisState.entries` lives as long as the `<For>`) and is only ever overwritten `[0..newN)` retains the stale tail `[newN..oldN)` FOREVER when the list shrinks — here, every removed row's `ForEntry` → its `anchor` DOM subtree + `cleanup` closure → disposers → signal subscriber links. **Fix shape**: `scratch.fill(undefined, 0, n)` at the end of the pass that filled it (O(n) writes on an already-O(n log n) path — free). Typed-array scratch (`Int32Array` etc.) holds numbers and can stay as capacity; ONLY reference-typed scratch needs the release. **Detection lesson — the heap-slope leak-sweep is structurally blind to this class**: its journeys run at CONSTANT size, so the scratch is fully overwritten every pass and the slope stays flat; the failure mode needs reorder-at-large-N → SHRINK → stay-mounted. The deterministic lock is a GC-observable unit test (WeakRefs on removed rows' DOM + `--expose-gc` via the package vitest config's `overrides: { test: { execArgv } }` — vitest 4 removed `poolOptions`) — bisect-verified: 60/62 removed rows stayed pinned with the release line reverted. Reference: `packages/core/runtime-dom/src/nodes.ts:forLisReorder` + `tests/for-lis-scratch-release.test.tsx`. **Rule: any reusable scratch buffer that stores OBJECT references must be nulled at the end of each pass; ask "what pins this slot when the workload shrinks?"**

- **Introspection registries holding STRONG refs to DOM captured once at setup (Class H — the devtools `_components` instance, 2026-07)**: the dev-mode devtools component registry stored `el: parent.firstElementChild` (captured ONCE at mount) as a strong property in a module-level Map, cleaned up only by `unregisterComponent` at UNMOUNT. But a component's DOM can be REPLACED by a reactive re-render while the component stays mounted — the entry then pins the detached ORIGINAL subtree for the component's entire lifetime, with no cleanup event ever firing. Found via a real downstream heap snapshot (retainer chain `_components → entry → el → detached <div class="metric-card">` — a dashboard whose cards re-render on data refresh). **Fix shape**: back the field with a `WeakRef` + getter (`get el() { return elRef?.deref() ?? null }`) — reads are unchanged for live elements, replaced DOM becomes GC-eligible immediately, and the public `entry.el` API shape survives. **Rule: an introspection/diagnostic registry (devtools, perf counters, tracing) must never be a GC root for DOM or app objects — its lifecycle-event cleanup only covers the object's END of life, not mid-life REPLACEMENT of what it points at. Hold via WeakRef; the "cleanup exists" answer is insufficient when the pointed-at value can be swapped without a lifecycle event.** Detection: heap-snapshot retainer analysis, not the leak-sweep (the count is small and constant-slope per re-render burst). GC-observable lock: `packages/core/runtime-dom/src/tests/devtools-el-weakref.test.tsx` (bisect-verified: strong ref → `expected <div class="metric-card"> to be undefined`). Reference: `packages/core/runtime-dom/src/devtools.ts:registerComponent`.

- **Position-based pop for stack frames unmounted out-of-order (ERROR-BOUNDARY STACK)**: same bug class as the context-stack one below, different shared array. `ErrorBoundary` pushed its error handler onto a module-level `_errorBoundaryStack` and registered `onUnmount(() => popErrorBoundary())` where `popErrorBoundary` did `stack.pop()`. Sibling boundaries unmount in renderer-driven order — keyed `<For>` removing a non-last item, `<Show>` flipping the FIRST of several, route nav unmounting an outer of nested routes — so a non-last sibling's `onUnmount` popped the LAST sibling's handler instead of its own. Survivor's handler gone from the stack; orphan's handler at the top. Subsequent throws in the survivor's children dispatched to the orphan (whose owning boundary is disposed → `error.set(err)` is a no-op) → error silently swallowed AND survivor's fallback never rendered. **Fix**: `popErrorBoundary(handler)` accepts the handler reference and removes by IDENTITY via `lastIndexOf + splice`. Each `ErrorBoundary`'s `onUnmount` passes its OWN handler. Reference: `packages/core/core/src/{component.ts:popErrorBoundary, error-boundary.ts:71}`. **The class generalizes**: any time framework code does `push(X)` at setup + `pop()` at cleanup on a module-level array, ask "can X be removed in non-LIFO order?" If yes, switch to `lastIndexOf + splice` with the pushed reference.
- **[CLIENT-SUPERSEDED — applies to genuine module-level stacks] Position-based pop for stack frames that may be pushed by reactive boundaries**: `popContext()` does `stack.pop()` — pops the LAST frame. **Pyreon client context no longer uses a global stack** (owner-based: `provide()` writes onto the component's `EffectScope` owner, released when the scope is disposed — no orphan-frame growth, no position-pop hazard), so this no longer applies to client `provide()`/`useContext()`. It **still applies** to any genuine shared module-level stack: the **SSR request-scoped stack** (consumed via `pushContext`/`popContext`/`removeContextFrame`, isolated per request) and the **`*-compat` layers' own stack-based provide/inject**. For those, if you push onto the stack and a reactive boundary's effect can run during your lifetime, never use a position-based pop for cleanup — capture the pushed frame reference at push time and register cleanup as `() => stack.splice(stack.lastIndexOf(frame), 1)` (identity-based removal via `removeContextFrame`). The historical client-side amplification (hundreds of thousands of orphan frames under nested reactive boundaries × toggles) is the bug the owner model structurally eliminated. Reference: `packages/core/core/src/context.ts:removeContextFrame`; the `ctx-stack-growth-repro.test.tsx` regression now asserts the owner model keeps the (compat/SSR) stack bounded.
- **A per-view `dispose()` destroying a SHARED, lazily-cached resource**: when a reactive primitive is a *view* over a resource that is keyed/cached and shared by other consumers (a `WeakMap<Y.Doc, Awareness>` shared by N transports + N views; a module-level connection pool; a ref-counted singleton), its `dispose()` must tear down ONLY what that view added (its own listener/subscription), NOT the shared resource. Tearing down the shared resource on one view's dispose strands every other holder — and because Pyreon auto-calls `dispose` via `onCleanup` on component unmount, a SINGLE component unmounting silently kills the resource for the whole app. **Fix shape**: ownership lives with the thing that CREATED/keys the resource (the doc owns its awareness → `YjsCrdtDoc.destroy()` → `destroyDocAwareness`), not with any view; the view's dispose is listener-detach only; "announce departure"-style side effects belong on the transport/connection layer, not the view. **Real-world hit**: `@pyreon/sync`'s `syncedAwareness().dispose()` originally did `aw.destroy()` + `removeAwarenessStates` + `docAwareness.delete` — so disposing one presence view (or one component unmounting) destroyed the doc-shared `Awareness`, killing the transport + every sibling view. Fixed to listener-only; the doc owns teardown. Bisect-locked by the multi-view + `doc.destroy()` specs in `packages/fundamentals/sync/src/tests/awareness.test.ts`. **The smell to watch for**: a `dispose()` that calls a `.destroy()` / `.delete(key)` on something it `get`-or-`peek`'d from a shared cache rather than something it allocated itself.
- **Nulling WebSocket handlers before `close()`**: `ws.onmessage = null; ws.close()` — a queued message arriving between null-assignment and close fires a null handler and crashes. Always `close()` FIRST, then null the handlers.
- **`intentionalClose` reset on reactive dependency change**: If a user explicitly calls `close()` on a WebSocket subscription and a reactive dependency (URL, enabled) changes, don't silently override `intentionalClose` and reconnect. Respect the user's explicit close unless `enabled` was explicitly provided and transitions to `true`.
- **Silent plugin/init error swallowing**: `catch (_err) { /* silent */ }` in plugin runners or async initialization hides bugs. Always log in `__DEV__` mode and call user-provided `onError` callbacks. Reference: `store/src/index.ts` (plugins), `storage/src/indexed-db.ts` (IndexedDB init).
- **An `async` `_mount` that lazy-loads a heavy engine, with `dispose()` gated only on `view.peek()`** (the editor-adapter async-mount-lifecycle class — `@pyreon/rich-text` + `@pyreon/code` both shipped instances). A WYSIWYG / code-editor adapter creates its underlying engine (`new Editor()` / `new EditorView()`) only AFTER an `await import('@tiptap/*')` / `await loadLanguage(...)` — so there's a window where the editor is "mounting" but `view` is still `null`. Three bugs hide in that window, all confirmed in a real browser and all invisible to happy-dom / mount-only tests: (1) **dispose-during-pending-mount leaks the engine** — `dispose()` reads `view.peek()` (null mid-load), no-ops; the `await` then resolves and `view.set(editor)` creates a live ProseMirror/CodeMirror view + contenteditable DOM that nothing will ever tear down (a fast navigate-away while the lazy chunk loads is the real trigger). (2) **mount failure is an unhandled promise rejection** — the mount component does `void instance._mount(el)` with no `.catch`, so a broken extension set, a throwing extension, or a failed chunk import surfaces only as a cryptic uncaught rejection with no framework context, and the editor silently never mounts (all computeds stuck at their fallbacks). (3) **re-mount reverts to the config-time content** — if `_mount` seeds the engine from a captured `pendingContent` (the initial config), disposing + re-mounting the SAME instance (the documented user-owned lifecycle) loses every edit, because the live document lives in a separate signal. **Fix shape (all three, in `_mount`/`dispose`)**: a `mountToken` generation counter — `dispose()` (and any newer `_mount`) bumps it; `_mount` captures `const token = ++mountToken` BEFORE its awaits and bails (`if (token !== mountToken) return`, destroying the engine if already constructed) after each await, closing the leak + concurrent-mount race; wrap the body in `try/catch` routing to a config `onError` (and a `[Pyreon]`-prefixed dev `console.error` when absent) so failures never escape as unhandled rejections; track `hasMountedOnce` and seed a re-mount from the LIVE document signal (`baseJson.peek()` / `value.peek()`), not the stale config content. **Both packages are now fixed (`@pyreon/rich-text` #1914, `@pyreon/code` #1916):** rich-text had all three bugs; `@pyreon/code` had (1) + (2) only — it already seeds from `value.peek()` so it never had (3) — and its `<DiffEditor>` had the SAME leak by a different route (an unmount during the async grammar load left `onUnmount` to run with `mergeView` still null, so the resolving `await` then built a leaked `MergeView`), guarded with an `unmounted` flag set in `onUnmount` and checked after the await. Note the component-managed case (DiffEditor) uses an `unmounted` boolean rather than a `mountToken`, since it's a per-render ref, not a re-mountable instance. Reference: `packages/fundamentals/rich-text/src/editor.ts:_mount` + `dispose`; `packages/fundamentals/code/src/editor.ts:mount` + `dispose` + `components/diff-editor.tsx`. Regression-locked by bisect-verified async-mount-lifecycle specs in BOTH packages: `rich-text.browser.test.tsx` (re-mount preserves edits / dispose-during-mount no leak / mount failure → onError) and `code.browser.test.tsx` (createEditor dispose-during-mount no leak + mount failure → onError, DiffEditor unmount-during-load no leak).
- **Untracked `requestAnimationFrame` loops**: `requestAnimationFrame(animateFrame)` inside animation functions without storing the frame ID leaks frames when the function is called again or the instance is disposed mid-animation. Always store the ID (`_frameId = requestAnimationFrame(fn)`), cancel previous (`cancelAnimationFrame(_frameId)`) before starting new, and cancel in `dispose()`. Reference: `flow/src/flow.ts` — `_layoutFrameId` / `_viewportFrameId`.
- **Bare `requestAnimationFrame` / `cancelAnimationFrame` in animation paths**: even with frame-ID tracking, if an async function (`async layout()` awaiting `await computeLayout(...)`) reaches its rAF call site AFTER vitest tears down the test environment, the now-undefined global throws `ReferenceError: requestAnimationFrame is not defined`. All 324 tests pass, vitest reports unhandled errors, the job exits 1 — confusing. Wrap rAF/cAF in defensive helpers that no-op when the global isn't `function`-typed: `const _raf = (cb) => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : 0`. Reference: `flow/src/flow.ts` — `_raf` / `_caf`. Same pattern applies to any animation path that completes asynchronously and could land in stripped-down envs (SSR, post-teardown, web workers).
- **`Date.now()` + `Math.random()` for unique IDs** `[detector: date-math-random-id]`: Under rapid operations (paste, clone), `Date.now()` returns the same value within a millisecond and `Math.random().toString(36).slice(2, 6)` has only ~1.67M combinations — collision probability is non-trivial. Use a monotonic counter instead. Reference: `flow/src/flow.ts` — `_pasteCounter`.
- **Raw `addEventListener` / `removeEventListener` in component or hook bodies** `[detector: raw-add-event-listener / raw-remove-event-listener]`: Bypasses Pyreon's lifecycle cleanup — listeners leak on unmount. Use `useEventListener` from `@pyreon/hooks` which registers the cleanup automatically. The detector only flags bare `window` / `document` / common DOM identifiers (`el`, `element`, `node`, `target`) so framework-host chains like `view.dom.ownerDocument.addEventListener(...)` are left alone.
- **`effect()` doing imperative work at component setup** [`pyreon/no-imperative-effect-on-create`]: `effect(() => fetch(...))`, `effect(() => document.addEventListener(...))`, `effect(() => setTimeout(...))` allocate per-instance and run synchronously during component setup — that's how PR #268's bokisch.com 20s render bug accumulated under 150 component instances. `effect()` is for pure reactive subscriptions (signal reads + signal writes); imperative work (DOM, IO, scheduling) belongs in `onMount(() => { ... })`. The lint rule narrowly flags `fetch` / `setTimeout` / `setInterval` / `requestAnimationFrame` / `requestIdleCallback` / `queueMicrotask` global calls and `document.X` / `window.X` / `localStorage.X` / `sessionStorage.X` member access inside effect bodies. Pure `effect(() => sum.set(a() + b()))` and `effect(() => console.log(count()))` are NOT flagged. Foundation hooks (`@pyreon/hooks`, `@pyreon/rx`) are exempted via `exemptPaths` because they're the layer that wraps timers/listeners FOR users — flagging them would defeat the abstraction.
- **Statically importing a heavy module used ONLY in an event handler / lifecycle callback** [`pyreon/no-heavy-import-only-in-handler`]: `import { renderChart } from '@pyreon/charts'` at module top, with `renderChart` referenced exclusively inside `onClick={() => renderChart(el)}` (or an `onMount`/`onUnmount`/`onCleanup` callback) forces the heavy `@pyreon/charts` chunk into the INITIAL bundle even though nothing touches it until the user interacts. The fix is a dynamic `await import('@pyreon/charts')` inside the handler — the chunk then stays out of the eager graph and loads on demand. Distinct from `pyreon/no-eager-import` (info, fires on EVERY heavy static import including ones genuinely needed at render): this rule is the PRECISE, actionable counterpart — it fires only when EVERY reference to the binding is provably inside a deferred scope, so the recommended fix is unambiguous and there is no "but I need it at render" false positive. **Conservative by construction**: a single eager reference (JSX element `<Chart/>`, a module-eval `const x = renderChart`, a plain helper called at render) suppresses the report entirely — a false negative is acceptable, a false positive (telling someone to defer an import they need eagerly) is not. Heavy set defaults to the 4 documented lazy-loaded Pyreon packages (`@pyreon/charts|code|flow|document`) and is extensible via the `heavyModules: string[]` rule option. Origin: distilled from the Tier-2 resumability spike's L3 heavy-import classification — the one part of that (otherwise shelved) analysis with zero false-positive risk. **`effect` / `renderEffect` are deliberately NOT in the deferred-scope set**: their callbacks run synchronously during component setup (the `no-imperative-effect-on-create` shape), so a heavy module used in an effect body is a render-time dependency, not a deferrable one — recommending a dynamic import there is wrong. This exclusion was driven by the real-corpus e2e, not foreseen in the synthetic specs: `examples/app-showcase/src/sections/invoice/LivePreview.tsx` calls `@pyreon/document`'s `render` inside an `effect` (a legit reactive render); the initial deferred-set including `effect` false-positived there. **General lesson (reinforces the M3.B rule below)**: a new lint rule MUST be run against the real example corpus before merge — synthetic FIRES/DOES-NOT-FIRE specs only cover the shapes the author thought of; the real tree surfaces the scope semantics the author got subtly wrong (here: "deferred lexical scope" ≠ "deferred execution"). Reference: `packages/tools/lint/src/rules/performance/no-heavy-import-only-in-handler.ts`; bisect-verified — disabling the eager-guard collapses it to `no-eager-import` (the 4 conservative DOES-NOT-FIRE specs fail); validated zero-false-positive across all 577 example `.ts(x)` files via the workspace programmatic API (NOT `bunx pyreon-lint`, which resolves to a non-existent npm package — use `lintFile` from the workspace src or `bun run --filter='@pyreon/lint'`).
- **Closure-captured `parent` in a reactive mount loop becomes stale after a sibling reconciler moves the markers**: any framework primitive that (a) accepts `parent` as a setup arg, (b) inserts a marker into that `parent`, and (c) calls `parent.insertBefore(...)` from inside an effect re-run is structurally unsafe under Pyreon's `mountFor` frag-then-move pattern. `mountFor` builds its children into a `DocumentFragment` and then commits via `liveParent.insertBefore(frag, tailMarker)` — the move carries the marker and all sibling DOM along, but the inner mount's CLOSURE still holds the original (now-empty) fragment as `parent`. The next signal-driven re-run calls `insertBefore(node, marker)` against the stale fragment → throws `NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node`. The throw lands in Pyreon's unhandled-effect-error path → `console.error` + complete loss of the subtree's children from the DOM. **Two real instances fixed in this bug class**: PR #776 `mountReactive` (For-of-Show batched-toggle — captured `parent` stale after `mountFor` frag-then-move), PR #783 `mountKeyedList` (For-of-direct-keyed-array — same shape, three call sites: `mountNewEntries`, `mountVNode`, `keyedListReorder → applyKeyedMoves → moveEntryBefore`). **Fix shape (both PRs)**: read `marker.parentNode` (or `tailMarker.parentNode` for keyed-list shapes) at each effect run, threading the resulting `liveParent` through every helper that does `insertBefore`. Falls back to closure-captured `parent` only when marker is detached (cleanup edge case) — the cleanup path was already consistent (`marker.parentNode?.removeChild(marker)`), only the mount/reorder paths used the stale captured `parent`. **General rule for framework code**: any mount loop running inside an effect that accepts `parent` as a setup arg should compute the live parent from a marker at each re-run — `parent` is captured at setup and the DOM moves; markers are part of the moved DOM and their `parentNode` reflects the current live parent. **Sibling primitives audited safe (2026-Q2)**: `mountFor` itself uses `startMarker.parentNode` at the top of its effect; `KeepAlive` / `TransitionGroup` / `Transition` use `containerRef.current` (live DOM-element refs that move with the surrounding tree, never become stale); `template.ts`'s `_bindText`/`_bindDirect` write to text-node `.data` only (no parent.insertBefore). **Reproducer**: `bun run perf:leak-sweep --app perf-dashboard --journeys domConditionalToggle-1000` (the For-of-Show shape; #783's keyed-list shape is reproduced by the regression test at `packages/core/runtime-dom/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts` — requires For children to return a function directly so `mountKeyedList` lands with frag-as-parent, not the more common `<div>`-wrapped shape which routes through the `<div>` and isolates from the frag-move). Discovery chain: #770 leak-audit harness → #772 leak-sweep multi-journey driver → #774 it.fails CONTRACT lock → #776 mountReactive fix → #783 mountKeyedList sibling fix → #779 nightly leak-sweep CI gate.
- **Reactive-render entry points missing `runUntracked` around child mounts**: any framework primitive whose effect body mounts children (`mountFor`, `mountKeyedList`, `KeepAlive`, `TransitionGroup`, custom equivalents) MUST wrap the child mount work in `runUntracked(() => ...)` — same shape `mountReactive` already does. Without this, signal reads during a child's setup (`useQuery`'s `new QueryObserver(client, options())` reading the queryKey signal at construction time, `useTheme` reads, any `signal()` invocation in a component body) leak their subscription up to the parent effect's `activeEffect`. When the leaked signal flips, the PARENT effect re-runs → `runCleanup()` disposes ALL inner effects (the children's per-component effects), and the parent's keyed-update path skips re-mount on unchanged keys → the children's reactivity is gone forever. Real-world shape: PR #490's `queryReactiveKey-1000` journey saw 0 setOptions runs across 10 flips of a shared reactive query key — `signalWrite: 1015` confirmed the writes happened, `query.setOptions: 100` confirmed only the initial-mount runs fired, the 1000 expected re-runs never came. Fix lives at `packages/core/runtime-dom/src/nodes.ts` (mountFor, mountKeyedList), `keep-alive.ts`, and `transition-group.ts`. Bisect-verified: reverted the `runUntracked` wrap → `<For>`-shaped regression test failed with `expected 100 to be 0` (all 100 inner effects didn't fire after first flip); restored → all 11 expected runs per effect. Reference: `packages/core/runtime-dom/src/tests/fanout-repro.test.tsx`.
- **Re-entrant signal write inside the same effect's batch flush**: when an effect's run body writes to a signal it's currently subscribed to (or any signal whose ONLY subscriber is the same run), historically the batch system silently dropped the re-fire — `signal.set` wrapped the notify chain in `batch()`, the flush iterated subscribers from a Set, and JS Set iteration + Set.add idempotency meant re-enqueuing an already-visited entry was a no-op. **Fixed** by the two-tier flush in `packages/core/reactivity/src/batch.ts`: tier 1 drains `computed.recompute` callbacks (cascading-iteration with within-pass Set dedup), tier 2 drains `effect.run` callbacks in multi-pass mode (within-pass dedup preserved by Set.add idempotency on entries not yet visited; cross-pass re-fire enabled by routing already-visited entries to `_nextEffectPass`). The two-tier split is what `computed.ts:_markRecompute(recompute)` registers — the WeakSet-based brand routes computed callbacks to tier 1 so all derived values settle before any effect runs (prevents the deep-cascade stale-read shape this code path used to hide). MAX_PASSES caps tier-2 at 32 to prevent pathological infinite re-enqueues; converging patterns terminate after 1-2 passes. Reference: `packages/core/core/src/error-boundary.ts:handler` calls `error.set(err)` synchronously and the boundary's effect re-fires on the next pass (mounting the fallback). No queueMicrotask defer, no synchronous `handling` flag — both were workarounds before the structural fix and have since been removed.

## Build Pipeline Mistakes

- **TWO owners for one build post-step — a CLI wrapper re-implementing work a plugin's `closeBundle` already does** (the 0.43.x `zero build` defect). `@pyreon/zero`'s ssr-plugin owns the SSR post-step (server bundle → `dist/server/entry-server.js` + `template.html` staging + adapter dispatch — the path verify-modes + the ssr-node/isr-node e2e exercise). The `zero build` CLI ALSO ran its own `vite build --ssr` pass, prerender pass, and `adapter.build() → dist/output` — each wrapped in a bare `catch { /* optional */ }`. Result: the SSR bundle built TWICE into divergent trees (with a user entry: FOUR copies), the CLI's deployed `dist/output` server had NO `template.html` (→ DEFAULT_TEMPLATE + `/src/entry-client.ts` — the "Production SSR shipping the DEV client entry" class: server-renders, never hydrates), zero-config apps got NO server bundle at the documented location at all, and every failure was swallowed into a green "Build completed" (the "silent-filter" shape, in the very commit meant to kill silent failures). **Fix**: ONE owner — the CLI is now exactly one `vite build`; the plugin chain owns client + server + template + prerender + adapter, and an EXPLICITLY-configured adapter failure rethrows (fails the build) while auto-selected adapter failures stay console-error-only. **Rules**: (a) a CLI wrapping a plugin-driven build must DELEGATE, never duplicate, the plugin's post-step — two owners produce divergent artifact trees and which one deploys is platform-config luck; (b) never `catch {}` a deploy-artifact step — surface + fail when the user explicitly asked for that artifact; (c) recursion-gate env-flag literals shared across plugins live in ONE module (`build-flags.ts`), not kept in sync by comment; a flag LEAKED from a parent process (vs set by an in-process sub-build — discriminated by an in-process marker) prints a one-line notice instead of silently disabling the whole post-step. Reference: `packages/zero/cli/src/commands/build.ts`, `packages/zero/zero/src/{build-flags.ts,ssr-plugin.ts,ssg-plugin.ts}`; regression locks (all bisect-verified): `cli/src/commands/build.test.ts` (end invariants: ONE server bundle, template next to THE entry, no `dist/output`) + `zero/src/tests/integration/build-post-step.test.ts` (real builds; explicit-adapter rejects, auto continues).
- **An uncapped `>=X` dependency range on a library that uses a version-specific API silently opts into the next breaking MAJOR the day it becomes `latest`** (the TS7 `ESNext` crash, 2026-07). `@pyreon/{compiler,mcp,cli}` declared `typescript: ">=5.0.0"` (deliberately wide, per a stale "don't narrow it" note from when TS7 was only on the `rc` tag). When **TypeScript 7.0.2 became `latest` on npm**, every fresh `bunx @pyreon/mcp` / clean install resolved `>=5.0.0` → TS7 — which REMOVED the classic Compiler API (`createSourceFile`/`ScriptTarget`), so `ts.createSourceFile(f, code, ts.ScriptTarget.ESNext, …)` threw the cryptic `Cannot read properties of undefined (reading 'ESNext')` in every parse-backed tool (`validate`/migrate/audit). A project pinned to 5.x/6.x kept working, which MASKED it until a fresh install hit `latest`. **Three durable rules:** (a) a library that depends on a version-SPECIFIC API surface must EXCLUDE by range every major that removed it — `">=5.0.0 <7.0.0"`, never a bare `>=X` that trusts the ecosystem not to ship a breaking `latest`; (b) a dependency the shipped `lib` UNCONDITIONALLY imports + calls is a real **dependency**, not a **peer** — a peer the code hard-requires only "works" because some consumer happens to re-declare it (compiler's `typescript` was a peer while its lib always `import ts from 'typescript'`); (c) add a self-diagnosing GUARD at the API-use boundary (a plain function `assertClassicTs()` before each `ts.createSourceFile`, NOT an import-time throw — the server + non-parsing tools stay up) so a force-pinned bad major fails with `[Pyreon] needs TypeScript 5.x/6.x … pin ">=5.0.0 <7.0.0"` instead of a mystifying `undefined.ESNext`. Fix: `packages/core/compiler/src/ts.ts:assertClassicTs` + the 4 capped `package.json` ranges + a `diagnose.ts` ERROR_PATTERNS entry for `reading 'ESNext'`; guard unit-testable against a synthetic TS7-shaped module (bisect-verified). Note: `bun install --frozen-lockfile` tolerates the pre-existing stale `bun.lock` (range metadata lags package.json), so the cap needs NO lockfile change — bun re-serializes churn on every install; don't stage it.
- **Divergent traversal REACHABILITY between the dual compiler backends — and "agreement-on-broken" hiding in the untested overlap** (the 2026-07 auto-call fuzz campaign; fixed in both backends + locked by `fuzz-equivalence.test.ts`). The signal auto-call pass had DIFFERENT reachability per backend: the JS gate (`referencesSignalVar`) skipped nested Arrow/Function children; the Rust collector skipped function bodies AND nested JSX entirely. Result: three user-facing bug shapes, all invisible to the 386-test hand-curated equivalence corpus because BOTH backends agreed (on broken output) in the overlap — (1) `onClick={() => count.set(count + 1)}` (the canonical counter) emitted `count.set(count + 1)` — adds the signal FUNCTION — on the SHIPPED native backend; (2) `title={sig ? "a" : "b"}` inside `.map` re-emits was stuck forever in BOTH backends (bare signal fn is always truthy); (3) `{cond() ? <span id={`v${sig}`}> : null}` stringified the signal's SOURCE into the DOM on native. A related class in `static_attr_to_html`: a `_ => Some(String::new())` catch-all treated "static but unrecognized" as handled-empty → the native backend silently DROPPED `tabIndex={-1}` / `title={1+2}` / `id={("x")}` attributes; JS dropped no-subst template attrs. **Three durable rules**: (a) any pass that REWRITES source in both backends must have its traversal reachability defined ONCE and mirrored exactly — a reachability difference is a byte-divergence factory; (b) never write a catch-all arm that returns "handled, emit nothing" — unrecognized shapes must fall through to the dynamic/runtime path (`None`/`null`), else attributes/content silently vanish; (c) a hand-curated equivalence corpus locks KNOWN shapes only — the combinatoric space between them needs a seeded grammar fuzzer (`packages/core/compiler/src/tests/fuzz-equivalence.test.ts`, 300 seeds × client/SSR in CI; the discovery campaign ran 10k seeds × 2 modes → 0 divergence). The unified auto-call rule the fix locked: reach nested function bodies (shadow-aware) + nested JSX; EXACTLY-BARE signal (parens/TS-layer transparent) as a DOM-element attr/child in a RE-EMITTED region stays bare (both runtimes treat callables as reactive accessors → fine-grained binding, no branch remount — bisect-proven by element-identity assertions); template-path bindings still call (the emit assigns the VALUE). Duplicate plain JSX attrs dedupe LAST-wins in the template path (baking both hands the decision to the HTML parser = FIRST-wins, the opposite of JSX object semantics) + emit `duplicate-jsx-attr`.

- **Compiler template fast-path value emit diverging from the runtime `applyProp` normalization** — the JSX compiler has TWO ways a prop reaches the DOM: the optimized `_tpl` template path (the `attrSetter` in `compiler/src/jsx.ts` + the Rust mirror in `native/src/lib.rs`) and the runtime `applyProp` path (`runtime-dom/src/props.ts`, used by `h()`/component props/spreads). When the two DISAGREE on value normalization, a binding works through one path and is silently broken through the other. **The instance:** `applyProp` normalized class via `typeof v === 'string' ? v : cx(v)` and applied object styles per-property, but the template `attrSetter` assigned the RAW value — so `class={[a(), 'b']}` rendered `"a,b"` (no `cx`), `class={{active:a()}}` rendered `"[object Object]"`, `style={() => ({...})}` set `cssText` to an object → `"[object Object]"` → **no styles**, and `style={{color:theme()}}` was a one-shot `Object.assign` (never reactive). All worked through `h()`/`applyProp` (so component-prop spreads were fine) but broke in compiled element JSX — and were untested (no class-array/object-style mount test existed). **Fix:** the template setters now produce the SAME normalization the runtime does — class via `typeof v === 'string' ? v : _cx(v)`, and style by DELEGATING to a shared exported runtime helper `_setStyle` (= `applyStyleProp`) rather than re-implementing it inline (so number→px, kebab-casing, and stale-key removal come for free and can't drift). **General rule:** any value normalization the runtime `applyProp` performs (cx, style-object handling incl. number→px + key-removal, URL-guards, kebab-casing) MUST be mirrored in the compiler's template path — and the cleanest way to guarantee that is to EXPORT the runtime's normalizer and have the compiler emit a call to it, not duplicate the logic. The two paths are a divergence-prone pair; when you touch one, check the other. Lock parity with a runtime DOM mount test (not just a compiled-string assertion — the string can look plausible while the DOM is wrong, e.g. `className = [array]`). **Sub-lesson — alias injected PUBLIC-name imports.** When the compiler injects an import for a PUBLIC export the user may already import (here `cx` from `@pyreon/core`), inject it under an internal alias (`import { cx as _cx }`) — a bare injected `import { cx }` collides with a hand-written component's own `cx` import ("Identifier `cx` has already been declared", which broke the docs build). `_`-prefixed internal names (`_rp`, `_tpl`, `_setStyle`) never collide; public names must be aliased. Reference: `packages/core/compiler/src/jsx.ts:attrSetter` + `native/src/lib.rs:attr_setter` ↔ `packages/core/runtime-dom/src/props.ts:applyStyleProp` (exported as `_setStyle`); regression `runtime-dom/src/tests/compiler-integration.test.tsx` (class/style binding fidelity, incl. the cx-collision spec) + `compiler/src/tests/native-equivalence.test.ts`.
- **`_tpl()` was SVG-namespace-blind, AND the compiler's template `class` binding used the HTML-only `el.className =` — TWO bugs that only combine to break in a real browser (the `@pyreon/flow` edges-don't-render bug).** The compiler lowers any DOM subtree with ≥1 element to `_tpl("<html-string>")`; the runtime's `_tpl` did `template.innerHTML = html` and cloned the root. **Bug 1 (runtime):** `template.innerHTML` runs the HTML parser, which only enters SVG foreign-content mode on a literal `<svg>`. A template rooted at a BARE SVG child — `<g>`, `<path>`, `<rect>` (what a flow EDGE lowers to: `_tpl("<g><path…")`, because the `<svg>` container has reactive `<For>` children so it is NOT fully templatized and each edge `<g>` is its own `_tpl`) — was parsed in the HTML namespace, so the cloned nodes were inert `HTMLUnknownElement`s that render nothing (nodes visible, connecting lines gone; the MiniMap's node-dot `<rect>`s the same). Fix: `_tpl` detects an SVG-rooted string (leading tag ∈ `SVG_TAGS`, excluding `svg`/`title`) and parses it inside a `<svg>` wrapper, then MOVES the children into the cache template (moving + cloning preserve `namespaceURI`). **Bug 2 (compiler), which Bug 1's fix EXPOSED:** the template `attrSetter` emitted `el.className = …` for `class={…}`. On an HTMLElement `className` is a writable string; on a real **SVGElement it is a read-only `SVGAnimatedString`, so the assignment THROWS** — the reactive `_bind` effect throws, `mountFor` skips the edge item, and you get ZERO edges (worse than the 3 invisible HTML ones). Before Bug 1's fix the elements were HTML (writable `className`), so Bug 2 was latent. Fix: finish what `_setStyle` started — export `applyClassProp` as `_setClass` and have BOTH compiler backends emit `_setClass(el, v)` (which uses `setAttribute("class", …)`, valid on HTML AND SVG) instead of the inline `.className =`. This removes the last `.className =` / injected `_cx` from the template path (see the `_setStyle` divergence entry directly above — class was the one attr still inlined). **THE PARITY TRAP that hid both:** (a) **happy-dom does not implement `SVGAnimatedString`** — `svgEl.className = 'x'` is a writable no-throw there, so a happy-dom mount test of the edge PASSES while Chromium throws; (b) **happy-dom parses `<g>`-rooted `innerHTML` in the HTML namespace too**, so happy-dom can't even see Bug 1; (c) the flow **e2e masked it with `page.locator('svg path').count() >= 3`** — a CSS type selector matches by `localName`, so it counted the broken HTML-namespaced `<path>` too and stayed green. **The load-bearing tests MUST assert the real-SVG discriminators in a REAL browser**: `path instanceof SVGPathElement`, `namespaceURI === 'http://www.w3.org/2000/svg'`, `getTotalLength() > 0` (methods that exist only on genuine SVG geometry elements) — a `querySelector('path')` count proves nothing. **General rule:** any runtime code that builds DOM from an HTML STRING (`innerHTML`, `<template>`) is SVG/MathML-blind unless the string is rooted at `<svg>`/`<math>`; and any per-element property assignment the compiler inlines (`className`, and by extension any read-only SVG DOM property) must go through a runtime helper that uses `setAttribute` so it's namespace-safe — mirror it in BOTH backends + rebuild the native binary, and verify in real Chromium, not happy-dom. Reference: `packages/core/runtime-dom/src/template.ts:_tpl` (`isSvgRooted` wrap) + `props.ts:applyClassProp` (exported `_setClass`) ↔ `compiler/src/jsx.ts:attrSetter` + `native/src/lib.rs:attr_setter`; regressions `runtime-dom/src/tests/tpl-svg-namespace.{test.ts,browser.test.tsx}` + `compiler-integration.test.tsx` (bare-`<g>` real-compiler mount, namespace-asserted, bisect-verified) + the strengthened `e2e/app-showcase-flow.spec.ts` (asserts `isSvgPath`/`getTotalLength`, bisect-verified: revert `_tpl` fix → `isSvgPath:false`; revert `_setClass` → 0 edges).
- **PMTC Kotlin emit producing an androidx symbol the kotlinc validate stubs MASK but the real `gradle assembleDebug` can't resolve** (the stub-masked-symbol class — FOUR instances now: the fetch-arc's `withContext`/`Dispatchers`/`Json`, the phantom `pyreonIcon`, `Color`/`RoundedCornerShape`, and the `<Heading>` Material-3-vs-2 typography mismatch). **A second sub-variant beyond missing-imports — a WRONG SYMBOL NAME on an already-imported object:** the `<Heading>` emit produced `MaterialTheme.typography.headlineLarge` (a Material 3 name), but the emit's whole base is Material 2 (`import androidx.compose.material.*`; `Button`/`Text`/`Icon` resolve from there), and Material 2's `Typography` has no `headlineLarge` — so a `<Heading>` app failed `gradle assembleDebug` with `Unresolved reference 'headlineLarge'`. The stub's `object MaterialTheme.typography` FAKED the M3 names, so it typechecked green in the validate loop while uncompilable against real M2; no example used `<Heading>`, so the device gate never exercised it (found by a real local Android build of a richer scaffolded app during ship-readiness revalidation). **Fix (this variant): correct the emitted name to the real library's surface (M2 `h4`/`h5`/`h6`/`subtitle1`/`body1`/`body2`) AND tighten the stub to list EXACTLY the real members** (M2 `Typography`: `h1`–`h6`/`subtitle1/2`/`body1/2`/`button`/`caption`/`overline`) — a stub that's a SUPERSET of the real surface MASKS; a stub that mirrors it CATCHES (the tightened stub now fails the validate-kotlin gate on an M3 regression, double-protecting alongside the device build). Reference: `packages/native/compiler/src/emit-kotlin.ts:HEADING_TYPOGRAPHY` + `kotlin-stubs.ts:MaterialTheme.typography`. **General rule for stub design: a kotlinc/swiftc validation stub must mirror the real library's EXACT public surface, never a convenient superset — a superset stub is itself a masking source.** The native compiler's `validate-kotlin` test loop CONCATENATES `kotlin-stubs.ts` into the same file before `kotlinc`, so any symbol with a stub resolves regardless of whether the emitted code carries a real `import`. The REAL Android build (`packages/native/cli` → host `gradle assembleDebug`) has NO stubs — it needs the actual `import androidx.compose.…`. So an emit that produces e.g. `Color(0xFF…)` (any `color=` prop → `tint`/`background`/text color) compiles green in the validate loop AND ships to the device gate RED with `Unresolved reference 'Color'`. The masking is doubly silent because `androidx.compose.ui.*` is a SINGLE-package star import — it does NOT pull `androidx.compose.ui.graphics.Color`, `androidx.compose.foundation.shape.RoundedCornerShape`, etc., which live in sub-packages. **The trap surfaces only when an example/fixture first USES the prop on a REAL build** — `Color()` had a stub from inception but no example used a `color=` prop reaching gradle until the icons-arc header `<Icon color="primary">`. **Fix**: add the symbol to the CLI's content-keyed conditional imports (`packages/native/cli/src/build.ts:conditionalKotlinImports`) — `if (emitted.includes('Color(')) imports.push('import androidx.compose.ui.graphics.Color')`. **General rule for the PMTC Kotlin emit**: every androidx symbol the emit can produce that does NOT live in one of the unconditional star-imported packages MUST have a conditional import keyed on its emitted text — a kotlinc-stub is for the VALIDATE loop's convenience, never a substitute for the real import the device build needs. When adding a new emit that references an androidx type, ask "is this in a star-imported package?" — if not, add the conditional import in the SAME change, or the device gate (not the validate loop) catches it one round later. The device gate is the only thing that exercises the real-import path; treat a validate-green/device-red Kotlin delta as this class by default. Reference: `packages/native/cli/src/build.ts:conditionalKotlinImports` (Color/RoundedCornerShape/Icons.Filled/coroutines/serialization-json arms) + `tests/build.test.ts` (per-symbol conditional-import specs).
- **PMTC Swift `.task` attached to a transparent `Group` wrapping a conditional → cancelled+restarted on every state flip (the fetch never settles)** — the device-found-runtime class that compile-only validation (`swiftc -typecheck`) structurally cannot catch. A fetch-bearing component appends a mount-time `.task { begin → resolve|reject }`. SwiftUI ties a `.task`'s LIFETIME to its host view's IDENTITY. When the host is a transparent `Group { if isPending { fallback } else { content } }` (what `<Suspense>` / `<ErrorBoundary>` emit), SwiftUI redistributes the modifier onto the if/else BRANCH — so each loading/error flip changes the branch's structural identity and SwiftUI CANCELS + RESTARTS the task. begin() sets isPending=true → flip → task cancelled mid-flight → restart → begin() again → infinite thrash; the URLSession fetch never completes, so the boundary renders NOTHING (not even its fallback — the else-branch shows an empty `ForEach`). The bug is invisible to the `validate-swift` loop (the emit TYPECHECKS fine) and to happy-path unit tests; it only surfaces on a real Simulator where the `.task` actually runs. **Fix**: `emitSwiftComponent` wraps any fetch-bearing component's body in a concrete `ZStack` (`if (_hasFetchDecl) { ZStack { <body> } }`) so the appended `.task` attaches to a STABLE-identity host that fires ONCE on appear; the inner conditional's flips no longer touch the ZStack's identity. **Kotlin needs no equivalent** — its fetch harness is a `LaunchedEffect(Unit)` SIBLING node keyed by the stable `Unit`, which runs once and is not cancelled by recomposition (a fundamentally different mechanism from a SwiftUI view modifier). **General rule for any SwiftUI `.task`/`.onAppear`-style modifier the emit appends**: it MUST attach to a view whose identity does NOT depend on the async state it drives. If the component body can be a transparent `Group`/conditional, wrap it in a concrete container first. Compile-only validation can never catch this — it's a runtime view-lifecycle property; the device gate is the only thing that exercises it. Reference: `packages/native/compiler/src/emit-swift.ts:emitSwiftComponent` (`_hasFetchDecl` ZStack branch) + `tests/fetch-computed-shapes.test.ts` (`Swift `.task` stable-host wrap (device-found)`, bisect-verified) + the `examples/native-tasks-ios` `lifecycle-page` XCUITest (good-fetch `lc-quote` + failed-fetch `lc-error` both render).

- **Hardcoding a package's OWN version in a self-registration / diagnostic call**: every `@pyreon/*` package called `registerSingleton('@pyreon/X', '0.24.6', import.meta.url)` with a HARDCODED version literal that nothing in the release process bumped — so `changeset version` advanced `package.json` to `0.28.x` while all 24 literals stayed frozen at `0.24.6`. The version is only diagnostic (the duplicate-instance sentinel keys on module `location`, not version), but it has real diagnostic VALUE: the error reports each instance's version, surfacing a genuine version skew between two installed copies — which a stale literal silently defeats (every instance reports the same frozen version). **Fix**: derive name + version from the package's own `package.json` — `import { name as __pkgName, version as __pkgVersion } from '../package.json' with { type: 'json' }` then `registerSingleton(__pkgName, __pkgVersion, import.meta.url)`. Single source of truth; the build inlines the literals (rolldown tree-shakes the JSON import to just the `name`/`version` strings — verified zero `devDependencies`/`scripts` bloat in `lib/`), dev (bun → src) reads the live `package.json`, and drift is **structurally impossible** — no sync script, no CI gate needed. `@pyreon/mcp` already used this pattern (`import packageJson from '../package.json' with { type: 'json' }`); it's the proven repo precedent. **General rule**: a module that needs its OWN package's version (diagnostics, telemetry, a `version` export) must DERIVE it from `package.json`, never hardcode a literal that a human has to remember to bump on release. The bump-the-literal approach (sync script + drift gate) was considered and rejected — it adds tooling to paper over a problem the JSON import eliminates outright.
- **Dereferencing `import.meta.url` (or any runtime-injected URL) at module-init without guarding for `undefined`**: Cloudflare workerd passes `undefined` for `import.meta.url`. `@pyreon/reactivity`'s `registerSingleton(pkg, version, import.meta.url)` → `normalizeLocation(url)` did a bare `url.indexOf('?')` → `Cannot read properties of undefined (reading 'indexOf')` at MODULE-EVAL, before any handler ran → every `@pyreon`-based Cloudflare Worker crashed at startup. A `node:fs`-only test can never catch this — Node always supplies a real `import.meta.url`, so the `undefined` branch is unreachable in vitest/node-invoke; only a real-workerd run (`wrangler pages dev`) surfaces it. **Fix**: guard at the top — `if (typeof url !== 'string' || url.length === 0) return '<unknown>'`. **General rule**: any module-init deref of a runtime-injected value (`import.meta.url`, `import.meta.env.X`, `globalThis.process`) must tolerate the value being absent on SOME target runtime — workerd hides `import.meta.url` + `process`, Deno/edge hide others. The crash is unrecoverable (module-eval, before any try/catch around a handler), so the guard is the only defense. Lock it with a unit test that passes `undefined as unknown as string` and asserts no-throw, bisect-verified to throw the exact `indexOf` TypeError when the guard is removed (`packages/core/reactivity/src/tests/singleton-sentinel.test.ts` "workerd / undefined import.meta.url" block). Reference: `packages/core/reactivity/src/singleton-sentinel.ts:normalizeLocation`.
- **A filesystem read as the ONLY delivery path for a build artifact breaks no-filesystem runtimes (workerd)**: `@pyreon/zero`'s `readBuiltTemplate()` read the production SSR template via `readFileSync(new URL('./template.html', import.meta.url))` — correct for Node-based adapters (node/bun/vercel/netlify all run on Node), but Cloudflare workerd has NO filesystem, so the read threw → caught → `undefined` → the SSR page silently shipped the dev `entry-client.ts` (no hashed `<script>`) and never hydrated in production. The happy-path `/posts` 200 looked fine; only inspecting the shipped HTML (dev-entry vs hashed-entry) revealed it. **Fix**: provide a runtime-agnostic delivery path — the cloudflare adapter reads the artifact at BUILD time (in Node) and inlines it into `globalThis.__PYREON_SSR_TEMPLATE__` in `_worker.js`, then **dynamic-imports** the handler so the global is set BEFORE the consuming module evaluates (`createServer → readBuiltTemplate`); `readBuiltTemplate` checks the global FIRST, falling back to `readFileSync` for Node runtimes. A static `import handler from …` would be hoisted ABOVE the global assignment → the consuming module evaluates first → the inline is defeated; the dynamic `await import(...)` after the assignment is load-bearing. `JSON.stringify(artifact)` is safe to embed in a `.js` module (no HTML tokenizer sees `</script>`; the marker comments survive byte-for-byte). **General rule**: an artifact a SSR/edge handler needs at runtime must NOT be delivered solely by `fs` — provide a `globalThis`-inlined (or bundler-`define`'d) fallback set before the consuming module evaluates, so it works on workerd / Deno / edge runtimes that have no filesystem. The node-invoke smoke is a FALSE-POSITIVE gate for this class (it has fs); lock the build-time contract with a structural assertion on the emitted bundle (global set before the dynamic import + the artifact carries the prod, not dev, marker), bisect-verified (`packages/zero/zero/src/tests/adapters.test.ts` "inlines the built SSR template into a global BEFORE dynamic-importing the handler"). Reference: `packages/zero/zero/src/adapters/cloudflare.ts` + `entry-server.ts:readBuiltTemplate`.
- **Static VALUE-import of a heavy package at module-eval in a module reachable from a cheap entry point**: the import-graph cousin of `pyreon/no-heavy-import-only-in-handler` (bundle weight) — here the cost is **module-eval / cold-transform time**, not bundle bytes. `@pyreon/lint`'s LSP module (`src/lsp/index.ts`) added a top-level `import { analyzeReactivity } from '@pyreon/compiler'`. `cli.ts` statically imports the LSP module (to expose `--lsp`), and `src/index.ts` re-exports it — so **any** importer of the lint CLI / package root transitively cold-loaded the ENTIRE `@pyreon/compiler` graph (full JSX transform + oxc-parser + the TS compiler API via `pyreon-intercept`) at eval. A `runner.test.ts` `beforeAll` doing `await import('../cli')` (it only needed the CLI flag parser) **timed out at 10s** in CI under cold vitest transform. **Local `bun run test` did NOT reproduce it** — warm filesystem + already-transformed modules; only CI's cold cache crossed the hook timeout. **Fix**: lazy-load + memoize only the runtime VALUE (`let _v; async () => (_v ??= (await import('@pyreon/compiler')).analyzeReactivity)`); keep the TYPE as `import type` (fully erased — zero eager-load). The consuming fn (and the LSP message handler / transport) become async; the editor re-requests on the next keystroke so first-call latency is invisible. **General rule**: a heavy package must not be statically value-imported in any module on the static import path of a *cheap* entry point (a CLI arg parser, a package-index re-export, a lint runner). `import type` is free; defer the value behind the flag/path that actually uses it. **Test-environment lesson**: a 10s hook timeout from a transitive heavy import is invisible to warm-cache local runs — the CI cold transform is the only place it surfaces; treat "passes locally, hook-times-out in CI on an unrelated test's `import('../X')`" as a heavy-eager-import smell, not flake. Reference: `packages/tools/lint/src/lsp/index.ts:loadAnalyze`.
- **Bare `import.meta.hot.accept()` (no callback) in a render-framework's Vite plugin**: a self-accepting module with NO callback tells Vite "I handled this update" — Vite re-evaluates the module but the mounted DOM still references the OLD component closure, so NOTHING re-renders. Worse: the self-accept ALSO suppresses Vite's full-reload fallback, so the user gets neither an in-place update NOR an automatic reload — a **silently-stale UI until a manual refresh**. This shipped in `@pyreon/vite-plugin`'s `injectHmr` from inception; every component/JSX edit in a `@pyreon/zero` dev app required a hand refresh. The reported symptom ("on changes I need to make refresh manually") had no error, no console output — the bare accept actively hid it. **Fix**: the accept callback must either drive a framework re-render with the FRESH module Vite hands it, OR call `import.meta.hot.invalidate()` to fall back to an automatic reload. Never emit a bare `accept()` for a module whose exports drive rendered DOM. Reference: `packages/tools/vite-plugin/src/index.ts:injectHmr` → `globalThis.__pyreon_hmr_swap__(<id>, freshModule)` (registered by `@pyreon/router._hmrSwap`, matched to the active lazy route via `_hmrId` from `@pyreon/zero` fs-router's `lazy(() => import(…), { hmrId })`). **Use the namespace Vite passes the accept callback, NOT a re-run of the dynamic-import thunk**: the thunk lives in the (non-invalidated) virtual routes module, so re-importing it returns the OLD module — the stale-`?t=` trap. Bisect-verified at the e2e layer (`e2e/zero-hmr.spec.ts`): reverting to the bare `accept()` times out with the route marker stuck at its pre-edit value; only a real dev server + real Chromium + a real file edit exercises this — unit tests can't (synchronous transform output looks identical at the string level until you assert the exact callback shape).

- **Catch-all dev middlewares registered in `configureServer` shadowing Vite's `server.proxy` (the PZ-11 zero-dev proxy swallow)**: middlewares registered directly via `server.middlewares.use()` inside `configureServer` land BEFORE Vite's internal middlewares — proxy included (`configureServer` runs before Vite installs its internal stack; only the RETURN-a-function form registers after). A framework catch-all that terminates responses (zero's dev SSR middleware + 404 handler — both accept any request whose `Accept` includes `text/html` OR `*/*`, which is fetch's DEFAULT) therefore swallows every URL a `server.proxy` context owns: with a reachable `_404.tsx`, a proxied `GET /api/backend/x` from client code returned zero's 404 HTML and the backend never saw the request — SILENTLY (the proxy config just "didn't work"). **Fix shape**: a catch-all dev middleware must `next()` every URL owned by other middleware, and "owned" must be computed with the OWNER's exact semantics — capture `Object.keys(resolvedConfig.server?.proxy ?? {})` in `configResolved` and mirror Vite's `doesProxyContextMatchUrl` (`^`-prefixed context = RegExp, else prefix match, tested against the FULL `req.url` including query — a stripped pathname can disagree with the downstream proxy). Announce the honoring once at boot (`[Pyreon] zero dev: honoring vite server.proxy for: …`) so the behavior is discoverable. Companion lesson: a path-class skip added to ONE catch-all (the W24 `/api/*` skip on the 404 handler) silently misses its SIBLING catch-all — in `mode:'ssr'` the SSR middleware ran FIRST and still swallowed `/api/*`; when two middlewares share a bug class, fix both and keep their guards consistent. Dev precedence: fs api routes > `server.proxy` > SSR/404 (fs-wins matches production, where `server.proxy` doesn't exist). One carve-out: `isApiRoute` only claims `.ts`/`.js` files, so an `api/*.tsx` file IS a page route — the `/api/*` skip is gated on a page-route match so such a page keeps dev SSR. Reference: `packages/zero/zero/src/vite-plugin.ts:matchesProxyContext` + the PZ-11 guards; bisect-verified by `tests/integration/dev-proxy.test.ts` (real Vite dev server + real HTTP backend: guards reverted → 5 specs fail with 404-instead-of-backend; restored → 17/17).

- **A build-time resolver / heavy precompute in a Vite `transform` hook gated only by `!isSsr` (no `&& isBuild`)**: the `transform` hook runs in BOTH `command: 'serve'` (dev) and `'build'`. A precompute that bakes a value at first transform and CACHES it (rocketstyle-collapse's resolver SSR-renders the real component once, captures the styler class, freezes it into a `_tpl` template) is correct in build but a **double bug in dev**: (1) it spins the heavy resolver per dev process — for rocketstyle-collapse a SECOND nested Vite SSR server bound to the same root, leaked because `closeBundle` is a build-only Rollup hook that never fires in `vite dev`; (2) the frozen value is computed from the resolver's OWN module graph and will NOT react to the user's HMR edits to the source it depends on (theme files, `.theme()` callbacks) — the dev UI silently shows the pre-edit styling until a hard restart, **strictly worse than the un-collapsed mount, which IS HMR-reactive**. The "it only ran in build because nobody tested dev" framing is the trap: nothing *gated* it to build; it ran (badly) in dev and the contract was implicit + untested. **Fix**: gate the block `if (enabled && isBuild && !isSsr)` (the plugin's `config(_, env)` hook sets `isBuild = env.command === 'build'`, which always runs before `transform`), and surface the dev no-op ONCE per process via `this.info('[Pyreon] … is build-only — `vite dev` keeps the normal mount so source edits stay HMR-reactive …')` (module-scoped `let warned = false` guard — `transform` is per-file) so an opted-in `vite dev` consumer isn't left wondering why nothing happened. **General rule**: any `transform`-hook precompute that (a) is expensive to construct, (b) caches a value across the process, or (c) freezes a value derived from user source that the user can HMR-edit, MUST be `isBuild`-gated and announce its dev no-op. Build-only is the *correct* behavior for cache-and-freeze precomputes, not a limitation — but it has to be explicit, tested, and visible. **Bisect-verify locally with a stubbed resolver** (no nested Vite, no workspace `lib/` — otherwise removing the gate false-negatives because the real resolver can't boot in a non-clean tree and silently bails to null): `vi.mock` the resolver loader to return a canned result, then `command:'serve'` + gate-removed → `__rsCollapse(` emitted → fail; gate present → skipped → pass; companion `command:'build'` spec (same source, same stub) still collapses, proving the gate is the only difference. Reference: `packages/tools/vite-plugin/src/index.ts` (`if (collapseEnabled && isBuild && !isSsr)` + `warnedDevCollapse`) + `packages/tools/vite-plugin/src/tests/rocketstyle-collapse-dev.test.ts`.

- **Diagnosing example build failures by editing source instead of rebuilding lib/**: If an example build (or `bun run verify-modes`) fails with `MISSING_EXPORT`, missing files, or "the source clearly handles this case but the build doesn't" symptoms — the most likely cause is **stale `lib/` directories** for the workspace package being imported, NOT a bug in the package source. Vite resolves vite.config.ts imports via the `node` condition (= `lib/`), not the `bun` condition (= `src/`). After `git pull`, branch switching, or hand-editing source mid-session, `lib/` may not reflect current source. Run `bun scripts/bootstrap.ts` (~30ms when clean, ~45s when rebuild needed). Bootstrap detects mtime drift across all packages and rebuilds all that need it. **Do NOT edit source thinking you're fixing a logic bug when the actual bug is "lib was built from older source."** Concrete trap: a route filter in `@pyreon/zero`'s `scanRouteFilesWithExports` correctly excluded API routes from the page-routes virtual module, but the published `lib/` was built before that filter was added — `verify-modes` failed with `MISSING_EXPORT "default" is not exported by "src/routes/api/posts.ts"`, which read as a route-handler bug. The actual fix was `bun scripts/bootstrap.ts`. The bootstrap script now detects this case via mtime drift, but only on `bun install` — manual edits between installs still need a manual bootstrap run.

- **Flipping opt-in `_active`-gated instrumentation to always-on without deferring expensive work to read time**: when a framework instrumentation layer (devtools registry, perf counters, tracing hooks) is structurally invisible for the "open panel AFTER mount" user workflow because `if (!_active) return` blocks recording at capture time, the natural fix is to make capture always-on in `__DEV__` (gated only by `NODE_ENV` at the caller — tree-shaken in prod). But ANY expensive work in the capture path — `.stack` formatting (`new Error().stack`), source-map resolution, JSON stringify of complex args, anything that touches I/O — must move to READ time, not eager-fire per capture. **Why**: local cost lies. Bun + plain Node measure `new Error()` + `.stack` access in ~0.5µs per call; under happy-dom + 60 concurrent vitest workers (CI parallel-load) the same code is 20-60× slower due to GC pressure, source-map resolution contention, and JIT thrash. Pre-fix PR #913 made `_rdRegister` always-on but kept eager `.stack` parsing → 10k-signal local cost 360ms, CI worst 5947ms (blew past the 800ms threshold). **Fix shape**: capture only the cheapest possible primitive at create time (`new Error()` allocation without `.stack` access is ~0.14µs; the stack is captured lazily). Store the raw primitive on the record. Resolve in the snapshot/read API (`getReactiveGraph()` etc.) and memoize the result on the record so subsequent reads are O(1). Drop the captured primitive after first resolve so it becomes GC-eligible. Most consumers of always-on instrumentation never read most events (LPIH inlay-hints only consume loc for hot lines the user has on screen; devtools panel only inspects the subset of nodes the user opens) — the typical cost per event is just the cheap allocation regardless of count. **General rule for any framework instrumentation that goes always-on**: (1) at capture, store the smallest primitive that lets you reconstruct the data later; (2) defer expensive resolution to a read-time path; (3) memoize on the record; (4) bench the worst-case capture cost against a 10k-event microbench under CI conditions, NOT just local — primitives like `.stack`, JSON.stringify, and PerformanceObserver entries can be 20-60× slower under parallel-load contention; (5) keep an opt-out test helper (`__resetXForTesting()`) so cross-test pollution from the always-on registry growing across `it()` blocks doesn't compound GC pressure into threshold flakes. Reference: `packages/core/reactivity/src/reactive-devtools.ts:_captureCallerLocation` returns `DeferredLocation { __deferred, err, skipFrames }` instead of resolved `SourceLocation`; `_resolveLoc(rec)` is called only by `getReactiveGraph` / `getFireSummaries` and memoizes onto `rec.loc`. Post-deferred-parse: 10k signals at 40ms local / <1s CI worst case (9× faster than eager-parse; parity with the OLD `_active`-gated zero-cost baseline on the vite-injected fast path). Bisect-verified end-to-end against `examples/perf-dashboard`: 477/477 (100%) pre-existing signals get `loc` populated when devtools attaches AFTER mount (LPIH coverage uniform across capture timing).

- **`existsSync(out) && readFileSync(out) === content` then `writeFileSync(out, content)` as a write-if-changed guard (CodeQL `js/file-system-race` TOCTOU)**: the common "only write a generated file when its content changed" pattern, written as exists-check → read → compare → write, trips CodeQL's high-severity `js/file-system-race` (time-of-check-to-time-of-use) because the `existsSync` "check" is paired with the later `writeFileSync` "use". The `existsSync` is REDUNDANT — a missing file is just a read miss the read's own `try/catch` already handles. **Fix (fundamentally-correct, not suppression): drop `existsSync` and read directly in a `try/catch` (ENOENT → treat as absent), compare, write.** One fewer syscall, no check-before-use, alert cleared. Don't reach for the `codeql-config.yml` paths-ignore here (that's for genuine build-time *code-construction* false positives like `compiled-verdicts.ts` / `client-directives.ts`) — this one has a clean structural fix. Real instance: `@pyreon/zero`'s `route-types-gen.ts:writeRouteTypes` (typed-routes codegen). General rule for any "write a generated file only on change" helper: read-then-compare, never `existsSync`-then-write.

## CI / Build Gate Mistakes

- **A published CLI bin that relies on `import.meta.main` (or a bundler-strippable top-level guard) to invoke — a SILENT no-op for consumers, invisible to the monorepo**: a `bin` that is a no-op still builds, ships, and exits 0 — so nothing flags it except a real `npx <tool>` run. Two shipped instances of the class (both in the 0.43.x published tarballs): (1) `@pyreon/lint`'s `bin/pyreon-lint.js` did a bare `import('../lib/cli.js')` and relied on `if (import.meta.main) main()` inside `cli.ts` — but the bundler treats `lib/cli.js` as a LIBRARY entry (it has exports) and TREE-SHOOK the guarded top-level call away, so the shipped `lib/cli.js` is a pure re-export and `npx pyreon-lint` ran NOTHING (dead on every runtime); (2) `@pyreon/mcp`'s stdio server was gated on `import.meta.main` alone, which **Node only defines from v24.2** (`undefined` on 20/22 LTS) → `npx pyreon-mcp` under Node LTS started nothing (it worked under `bunx`, which masked it). **Why the monorepo never caught it**: dev runs `bun src/cli.ts` (where `import.meta.main` IS true), and the internal gate calls the exported entry (`runCli()`) PROGRAMMATICALLY — neither path exercises the published bin. **Fix shape**: a hand-written `bin/<name>.js` (shipped as-is, NEVER bundled) that imports the entry function and calls it EXPLICITLY (`import { runCli } from '../lib/cli.js'; const code = runCli(process.argv.slice(2)); if (code !== null) process.exit(code)`) — runtime-agnostic, bundler-proof, mirrors `@pyreon/cli`'s unconditional `main().catch()`. When a bin IS the bundled lib entry (mcp) and must NOT self-run on import (tests import it), use a CROSS-RUNTIME entry check, not bare `import.meta.main`: a pure `matchesProcessEntry(meta, moduleUrl, resolvedEntryUrl)` that trusts the boolean when present, else compares the resolved process-entry URL (`pathToFileURL(realpathSync(process.argv[1]))`, symlink-resolved for `.bin`) to `import.meta.url` — the pure matcher makes the LTS `undefined`-meta path unit-testable without an old Node. **Prevention gate** (`scripts/check-bin-liveness.ts`, CI job "Check Bin Liveness", needs bootstrap): spawns every published bin under REAL Node (NOT Bun — the failure is Node-runtime-specific; running under Bun masks exactly the bug) and fails naming any bin that does nothing — `--version`/`--help` must produce non-empty stdout; a stdio server (mcp) must answer a JSON-RPC `initialize` handshake. Bisect-verified: reverting the lint bin → the gate reports `✗ exit 0 but EMPTY stdout`, exit 1. **General rule**: `import.meta.main` is a DEV convenience (Bun / Node ≥24.2), never the invocation trigger for a PUBLISHED bin — and any bin-liveness claim must be verified against the published tarball under Node, not `bun src/…`.
- **A `changes`/decide job gating required checks with `if: needs.changes.outputs.code == 'true'` is FAIL-OPEN**: when an affected-detection ("changes") job feeds a `code` output that gates the heavy required checks (Build / Verify Modes / Test (browser) / Audit Types / …), the naive `if: needs.changes.outputs.code == 'true'` is fail-open two ways. (1) If the detect STEP misbehaves (affected.ts non-zero exit / empty stdout), `code` is empty → `== 'true'` is false → every heavy job SKIPS → a skipped required check reports **success** to branch protection → a real regression PR is mergeable with all heavy checks green-via-skip. (2) If the `changes` JOB itself fails (infra flake / `timeout-minutes` trip), the output is empty AND the dependents auto-skip → same fail-open. **Fixes (BOTH needed):** (a) STEP-level — default the output fail-closed: `CODE=$(affected.ts …) || CODE=true; if [ "$CODE" != "false" ]; then CODE=true; fi` (only a clean `false` narrows to docs-only; anything else runs the full set). (b) JOB-level — make the gate run when the decide job didn't succeed, WITHOUT losing the skip-on-real-dep-failure optimization, via status functions: `if: ${{ (success() && needs.changes.outputs.code == 'true') || (failure() && needs.changes.result != 'success') }}`. This runs on a `changes` crash (fail-closed) but still skips when lint/typecheck/bootstrap failed (those already block via their own required checks — don't waste compute). A plain `!cancelled() && (code != 'false')` is WRONG: it force-runs every heavy job on every lint-failing PR. Reference: `.github/workflows/ci.yml` (the `changes` job + the 12 gated heavy jobs); the proven precedent is the `E2E` aggregator's `if: always()` + `exit 1` when `e2e-decide != success`.
- **A path-scoped workflow promoted to a required check — two deadlock/laundering traps**: (1) a TRIGGER-level `paths:` filter is deadlock-UNSAFE for required checks — a filtered-out PR creates NO check run, so branch protection waits on "Expected" forever; path scoping must move to a JOB-level skip via an always-running `changes` decide job (a skipped job satisfies a required check). (2) once `labeled` is in the trigger `types`, every label addition creates a NEW workflow run on the same head SHA, and the LATEST check run per name is what branch protection reads — so if the job gate skips builds on unrelated-label events, a RED required run can be LAUNDERED into a skipped-green latest check by adding any label. The path-detect arm must therefore fire on EVERY PR action including `labeled` (re-running the matrix on a rare label addition is the safe side; `cancel-in-progress` dedups). Both traps ship together in any "promote an opt-in device/e2e workflow to required" change. Reference: `.github/workflows/native-device.yml` (the `changes` job + the laundering comment on `ios-build`); the fail-closed dependent-gate idiom is the "changes/decide job gating required checks is FAIL-OPEN" entry above.
- **Classifying a doc-INPUT file as "docs-only" hides it from the package whose tests PARSE it**: an affected-detection that treats `.claude/**` / `docs/**` / `*.md` as docs-only (so `build`/`verify-modes` skip — correct, they don't read them) ALSO makes those paths seed no workspace in the test-cell matrix, so the package whose tests ASSERT their structure never runs. E.g. `@pyreon/mcp`'s `anti-patterns.test.ts` / `patterns.test.ts` parse `.claude/rules/anti-patterns.md` + `docs/patterns/*.md`; a reorg that breaks the parser merges unnoticed because the `test (tools)` cell skipped mcp. **Fix:** map doc-INPUT files to their consuming package as a LEAF seed in the test-affected computation (like `scripts/**` → `@pyreon/test-utils`), so the consuming cell runs — WITHOUT reclassifying them as `code` for the heavy-job gate (build still correctly skips). **The seed MUST be ADDITIVE, not an `else`-only fallback** (`if findOwningWorkspace is null`): `docs/patterns/**` is already OWNED by the `@pyreon/docs` workspace, so a fallback branch never reaches `@pyreon/mcp` and its `patterns.test.ts` silently stops running on a pattern-doc reorg. Run the consumer check for EVERY path regardless of whether it also owns a workspace (both seeds land). **Test-fixture trap:** a synthetic workspace fixture that OMITS the owning package (`@pyreon/docs`) makes the fallback form pass — the shadowing only appears when the owner is present, so the regression test must include it. **Two signals, not one, gate the two job classes:** `code` (`!isDocsOnly`) gates the read-nothing heavy jobs (they skip on a doc-input change — nothing to build); a separate `affected` (`--has-affected`, this computation non-empty) gates bootstrap + the test/typecheck cells (they run on a doc-input change so the parser test fires). They diverge exactly on a doc-input change (`code=false`, `affected=true`). Reference: `scripts/affected.ts:DOC_INPUT_CONSUMERS` / `docInputConsumer` (additive leaf seed) + `--has-affected`.
- **A safety-net gate red-on-arrival is a DEAD gate**: `Coverage (Full)` (push:main + merge_group) ran red on every completed main run for weeks — 12 packages sat below their configured vitest thresholds, so the gate could not distinguish "new regression" from "arrived red"; its stated purpose ("main never regresses") was structurally unmet. **Rule: a threshold-style gate's thresholds must sit AT/BELOW measured reality at ALL times** — ratchet UP as tests land (the `lint-baseline.json` discipline), never leave aspiration baked into the gate. When coverage drifts because a feature wave's tests live in another tier, the fix is per-package and honest: browser-covered files get `coverageExclude` (whole file) or targeted `/* v8 ignore */` (mixed file) with a rationale naming the covering browser test — the `@pyreon/a11y` skip-link instance: `skip-link.tsx` was covered ONLY by `skip-link.browser.test.tsx`, dragging the node gate to 69% (its sibling `visually-hidden.tsx` had the documented exclusion; the new file didn't) — never exclude a file with NO coverage anywhere; cheap real gaps get genuine tests; the rest is an explicit threshold re-baseline documented as debt (BELOW_FLOOR_EXEMPTIONS entry when below floor). 2026-07 restoration: 15 packages triaged (a11y/store/primitives/server/lint/styler/unistyle/testing via exclusion+tests, cli/compiler/router/runtime-dom/validate/form re-baselined; testing ALSO had NO explicit thresholds — the gate assumed 95 while vitest enforced the 80/75 category default, a silent divergence to check when adding a package). Reference: `scripts/check-coverage.ts` + the `coverage-full` comment block in `.github/workflows/ci.yml`.
- **Silent-filter on aggregate CI gates**: a gate that runs `op(item)` per package / file and aggregates with `results.filter(r => !r.failed)` silently hides the failure mode it was supposed to catch. The `failed` items disappear from the measured set without ever surfacing in the gate's output. Pattern surfaced TWICE in close succession: PR #434 (bundle-budgets reported "All 49 within budget" while 5 packages silently failed Bun.build); PR #435 (bootstrap swallowed postinstall build failures, leaving partial `lib/` state). Both gates were doing the right per-item work; both filters quietly threw away the diagnostic value. **Defensive design rule** for any aggregate gate: (1) include a `failures: [{name, error}]` (or equivalent) field in machine-readable output, (2) print failures to stderr in human-readable mode, (3) exit non-zero on any failure regardless of the postinstall / "transient flake" rationale. Aborting the operation loudly is always better than continuing with hidden partial state — silent partial state misleads users into thinking work succeeded, and the symptom surfaces hours later far from the cause. **Bisect-verify shape**: revert the failure-surfacing change → assert the measured count drops AND no `failures` field appears → restore → assert the count returns AND `failures: []`. If both assertions don't fail in the bisected state, the regression test isn't load-bearing.
- **`core.hooksPath` install treating "set to git default location" as a user override**: any non-empty `core.hooksPath` value used to be treated as a real user override (husky / lefthook / custom path) — but if the value happens to resolve to git's default `<git-dir>/hooks` location (e.g. set explicitly by an older bootstrap run, or by a user who didn't realise it matched the default), the installer bailed and Pyreon's hook at `.githooks/pre-push` was orphaned. Result: every `git push` SHOULD have run lint + typecheck + tests on affected packages, but git was looking at the empty `.git/hooks/` instead. Fix: distinguish "genuine custom path" from "set to default location" by comparing `current` against the candidate default paths (`<git-dir>/hooks` AND `<git-common-dir>/hooks` for worktrees). Reference: `scripts/install-git-hooks.ts:getDefaultHooksPaths`. **Worktree gotcha**: `core.hooksPath` is repo-shared (not per-worktree), but `git rev-parse --git-dir` returns the per-worktree git dir. Accept both `<git-dir>/hooks` and `<git-common-dir>/hooks` as default locations.
- **Subprocess testing as a default for shell scripts**: when a Pyreon `scripts/*.ts` file ships with significant policy logic, tests should call the policy as a pure function (`exported`) rather than `spawnSync`-fork the script and assert on captured output. **Two failure modes** this rule prevents:
  1. **Subprocess output capture under parallel load** is non-deterministic. Tests fail with `expected '' to contain 'X'` — `console.log` / `console.warn` ran correctly but the output was lost between fork and parent capture due to pipe-buffering races under heavy concurrent I/O (`bun run --filter='*' test` against 60+ packages, what the pre-push hook computes for any cross-package PR).
  2. **GIT\_\* env-var leak inside git hooks**. When the script (or its test fixture) runs `execSync('git ...', { cwd: tempDir })` from inside a git pre-push / pre-commit hook, git sets `GIT_DIR` / `GIT_WORK_TREE` / `GIT_INDEX_FILE` etc. in the env to point at the OUTER hook's repo. Those env vars OVERRIDE both the `cwd` option and `git -C <dir>` — git uses GIT_DIR if set, regardless of where it was invoked. **The original `install-git-hooks.test.ts` literally corrupted the running worktree's git config** because `execSync('git config user.email test@test.local', { cwd: tempDir })` saw GIT_DIR and wrote to the worktree's `.git/config` instead of the temp dir's. Symptoms: tests fail with `expected { kind: 'no-hooks-dir' } to deeply equal { kind: 'not-a-git-repo' }` (test temp dir's `.git` was never created — `git init` no-op'd against GIT_DIR), or `expected { kind: 'already-configured' } to deeply equal { kind: 'installed' }` (script reads OUTER repo's `core.hooksPath`).

  **Fix shape**:
  - Export `function doThing(cwd: string): StructuredResult` from the script (cwd injected as parameter, no `process.cwd()` calls inside policy); `main()` translates the result to console output for the bin path.
  - **Inside the script's git invocation helper, EXPLICITLY clear every `GIT_*` env var** before `execSync` (`Object.keys(env).filter(k => k.startsWith('GIT_'))`). Don't rely on `cwd` or `-C` alone — the env vars override both.
  - **Test fixtures must do the same** for THEIR git invocations (`git init`, `git config user.email`, `git config --get`) since those bypass the script's helper. A `cleanGitEnv()` helper in the test that returns `{ ...process.env, [GIT_*]: removed }` and gets passed as `env` to every `execSync` is the canonical shape.
  - Use `git -C <dir>` (git's native working-dir flag) AS WELL AS the env-clearing — defense in depth.
  - Tests call the policy function directly and assert on the discriminated-union return shape. NO subprocess fork for the assertion path. Keep ONE thin smoke test that spawns the actual binary and asserts only on `result.status === 0` (NOT on captured output).

  Reference: `scripts/install-git-hooks.ts:runGit` (env-clearing + `-C` + cwd, all three) + `packages/internals/test-utils/src/tests/install-git-hooks.test.ts:cleanGitEnv` (post-fix). The shipped fix took TWO iterations: first replacing subprocess with direct call (closed failure mode 1 — load-dependent), then env-clearing (closed failure mode 2 — only surfaced in the real pre-push run, not in `bun run --filter='*' test` standalone, because standalone doesn't set GIT_*).

## SSG / ISR Correctness

- **Keying an `immutable` cache on file EXTENSION instead of a hashed-asset PATH**: `Cache-Control: public, max-age=31536000, immutable` is ONLY safe for content-hashed files (Vite renames them on every change, so a URL is permanently pinned to one byte-content). Vite content-hashes only files it emits under `/assets/`. Keying immutability on the EXTENSION (`ext === '.js' || ext === '.css'`) instead of the path 1-year-immutable-caches a NON-hashed root file — `public/sw.js` (service worker), `public/config.js`, any unhashed `.css` — making a stale copy **unevictable** (a poisoned service worker is the classic deploy-breaker). This bit `nodeAdapter` / `bunAdapter`'s emitted handlers from inception; the platform adapters (vercel/netlify) were already correct because they scope to `/assets/(.*)`. **Defense**: immutable ONLY for `pathname.startsWith('/assets/')` (or the configured `build.assetsDir`); `*.html` → `max-age=0, must-revalidate` (prerendered pages change every deploy); everything else → a short revalidatable default. **General rule: never infer "this file is content-hashed and safe to cache forever" from its extension — only from the fact that it lives in the build's hashed-output directory.** The existing test CODIFIED the bug (asserted a root `/app.js` was immutable) — when a regression test encodes a behavior, double-check the behavior is actually correct before trusting it. Reference: `packages/zero/zero/src/adapters/{node,bun}.ts`; bisect-locked by the node + bun spawn-and-curl runtime-contract tests in `adapters.test.ts` (`/assets/*.js` immutable, root `/sw.js` NOT).
- **Caching the SSR response by `url.pathname` only when the page reads cookies / headers**: `createISRHandler` (`@pyreon/zero/server`) defaults to keying its in-memory LRU by `url.pathname` — fine for fully-public pages but a privacy footgun the moment any rendered HTML depends on `Cookie` / `Authorization` / query params. Cached HTML rendered for User A is served to User B (including anonymous visitors), leaking session state. **Defense**: pass `cacheKey: (req) => …` opt-in for any auth-touching page (vary by `Cookie` header, by session token, etc.), AND make the cache key constant for anonymous users so they share one bucket. `revalidate()` reads the SAME `cacheKey` against the ORIGINAL request — pre-fix the background re-render issued a no-cookie request and stomped the user's cached entry with anonymous content. Reference: `packages/zero/zero/src/isr.ts:deriveKey` + `ISRConfig` JSDoc. Documented prominently in `docs/src/content/docs/zero.md` ISR section. Caught in M1.1.
- **`cp(src, dest)` where `dest` is the same as, or a subdirectory of, `src`**: Node's `fs.cp` throws `ERR_FS_CP_EINVAL` ("cannot copy … into itself" / "src and dest cannot be the same"). This bit ALL SIX zero deploy adapters (node/bun/static/vercel/netlify/cloudflare): the SSR plugin passes `clientOutDir === outDir === distDir` with the server bundle already at `distDir/server`, and every adapter did `cp(clientOutDir, outDir/<subdir>)` (a copy into its own subtree) + the node/bun `cp(distDir/server, distDir/server)` (same dir). Because the SSR plugin CATCHES adapter throws and does NOT rethrow (so a buggy adapter can't hide a successful bundle from CI), the failure was SILENT — the deploy artifact was never staged and `node dist/index.js` never existed, making `mode: 'ssr' | 'isr'` unrunnable end-to-end. **Why it shipped: the adapter tests used a mock client dir DISTINCT from outDir, so they only ever hit the disjoint-`cp` branch — never the `clientOutDir === outDir` shape the real plugin produces.** **Defense**: a `materialize(src, dest, { preserve })` helper that branches on same-dir (no-op), dest-inside-src (copy top-level entries INDIVIDUALLY — each entry's source/dest are disjoint subtrees so no copy-into-self — preserving the originals so the flat `outDir` still serves under `vite preview`), disjoint (whole-dir copy); + a same-dir regression test for every adapter. **General rule**: any `cp` whose `dest` could be inside `src` (or equal) MUST resolve both paths and branch — a directory-tree copy where the destination is reachable from the source is never valid. And: a test that constructs adapter inputs with DISTINCT dirs when production passes the SAME dir tests the wrong shape. Reference: `packages/zero/zero/src/adapters/stage.ts:materialize` + `tests/{stage,adapters}.test.ts` (same-dir block). Caught when the artifact was finally run end-to-end.
- **Production SSR shipping the DEV client entry (`/src/entry-client.ts`)**: `createHandler` defaults `clientEntry` to `/src/entry-client.ts` (the dev path Vite serves) + `DEFAULT_TEMPLATE` (no hashed `<script>`, no CSS `<link>`). A production server entry that calls `createServer({ routes })` with no `template`/`clientEntry` therefore server-renders correctly but ships HTML referencing `/src/entry-client.ts` — which 404s in production (no Vite) → the page NEVER hydrates and has no styles. This was latent behind the cp-EINVAL bug (nobody could run the artifact). **Defense**: the SSR build copies the built client `index.html` (which carries the hashed `<script>` + CSS `<link>` + injection placeholders) → `dist/server/template.html`; `createServer` reads that sibling at runtime (`new URL('./template.html', import.meta.url)`) as the production template, and suppresses the dev client-entry injection via a new `clientEntry: false` handler option (the built template already references the hashed entry). Adapters copy the whole server dir, so the template travels everywhere. **General rule**: a production SSR handler's default client-entry / template are DEV values — the production build must supply the built template (with hashed asset refs) or the page server-renders but can't hydrate. "Server-renders" ≠ "works" — always verify hydration end-to-end (load the EMITTED server in a real browser + assert a JS-driven interaction), not just that the SSR HTML looks right. Reference: `packages/zero/zero/src/entry-server.ts:readBuiltTemplate` + `packages/core/server/src/handler.ts` (`clientEntry: string | false`); the `ssr-node` e2e gate runs `node dist/index.js` in real Chromium.
- **Silent SSG path collisions**: `ssgPlugin` (`@pyreon/zero/server`) used to dedupe path collisions silently via the `writtenPaths` Set — two routes producing the same URL (static route overlapping `getStaticPaths` enumeration, two enumerators producing the same slug, etc.) appeared as "missing pages" in `dist/` with no error and no signal pointing at the cause. The user's mental model said "I have a route for X" while the build silently dropped one of the duplicates. **Defense**: `detectPathCollisions(paths)` runs after `resolvePaths`; the build throws `[Pyreon] SSG path collision — N URL(s) resolved by multiple routes: ...` listing every collision sorted. **General rule**: any aggregation step that can produce duplicates (file enumeration, slug generation, URL resolution) MUST either (a) treat duplicates as a structural error and bail loudly, or (b) document the deduplication semantics + emit a manifest of dropped entries. Silent dedup is the worst-of-both — hides bugs AND looks like correct behavior. Reference: `packages/zero/zero/src/ssg-plugin.ts:detectPathCollisions` + `formatPathCollisionError`. Caught in M1.4.
- **Vite-plugin source changes invisible to running dev server**: Vite's own config bundler hardcodes `conditions: ["node"]` and resolves Pyreon plugin packages (`@pyreon/zero`, `@pyreon/vite-plugin`) via the `node` condition → `lib/`. So `src/vite-plugin.ts` edits are INVISIBLE to the running dev server until `lib/` is rebuilt — even though user runtime code (`@pyreon/zero/server` etc.) IS read from `src/` via the `bun` condition through `ssrLoadModule`. This trap surfaced TWICE: (1) PR with route-filter fix where `verify-modes` failed with `MISSING_EXPORT` from a stale `lib/` (closed by mtime-detection in `scripts/bootstrap.ts`), (2) M1.2 bisect verification where a stash-then-run produced "test still passes" results 3× before realizing the dev server was still serving the pre-stash `lib/`. **Defense for bisect cycles** (see `.claude/rules/testing.md` "Dev-server bisect" recipe): after reverting source for any plugin package, run `bun run --filter='@pyreon/<package>' build` + kill the dev server before re-running playwright. The recipe IS documented but it's easy to forget under the "plain `git stash` works for source files" intuition — plugin code is the exception.
- **Sequential manifest writes without atomicity in build pipelines**: when a build step writes N adapter-consumed manifests sequentially (`_redirects` → `_redirects.json` → `_pyreon-revalidate.json` → ...), a `SIGINT` mid-flush leaves partial state — half the files point at the new build, half the old. Adapters polling the dist at deploy time see an inconsistent snapshot. Bare `await writeFile(path, content)` exposes the partial-write window (a reader that opens the file during the write sees a truncated body). **Fix**: write to a sibling `<target>.tmp.<pid>.<seq>` first, then `rename` — POSIX rename is atomic, readers see either the OLD file or the FULL new file, never a half-written body. Best-effort tmp cleanup on rename failure (`try { unlink(tmp) } catch {}`). **Don't** apply this to per-page HTML writes — they're individually-readable (no cross-file invariants) and the rename-per-page cost on large sites is significant. **General rule**: any build step that writes N files an external consumer reads as a coherent set MUST use atomic-rename per file. Reference: `packages/zero/zero/src/ssg-plugin.ts:writeFileAtomic`. Caught in M2.1.
- **Bare `JSON.stringify(loaderData)` for SSR data embedding**: pre-M2.2 the four loader-data serialization sites (`html.ts` `buildScripts*`, ssg-plugin SSR entry, vite-plugin dev SSR) used `JSON.stringify(data).replace(/<\//g, '<\\/')`. Two failure modes hidden in plain sight: (1) a loader returning a Mongo/Prisma model with back-references intact throws `Converting circular structure to JSON` — opaque, doesn't name which route's loader is broken. (2) a loader returning `{ data, fn: () => {} }` works by accident (JSON.stringify drops function VALUES), but the silence trains users to think loaders accept any shape. **Fix**: `stringifyLoaderData(data)` from `@pyreon/router` with a WeakSet cycle detector + key-path tracker. Strips functions / symbols silently (explicit drop covers array entries where `JSON.stringify` would otherwise produce `null` slots), throws `[Pyreon] Loader returned circular reference at "<route-path>". Loaders must return JSON-serializable data...` naming the offending route. **General rule**: any framework boundary that takes user data and serializes it to a string MUST own the serialization with a clear-error path. Bare `JSON.stringify` shifts the diagnostic burden to the user. Reference: `packages/core/router/src/loader.ts:stringifyLoaderData`. Caught in M2.2.
- **Dev-only console warnings on platform-adapter env-var failures**: adapters like `vercelAdapter.revalidate(path)` read env vars (`VERCEL_DEPLOYMENT_URL`, `VERCEL_REVALIDATE_TOKEN`) at call time. Pre-M2.4 the missing-env warning was gated `if (process.env.NODE_ENV !== 'production')`. But adapters' `revalidate()` is invoked from PRODUCTION webhook handlers — a CMS triggers `revalidate('/posts/1')`, the env var was forgotten, `regenerated: false` is returned silently, no console output, no failure mode reported back, the user notices stale content hours later. **General rule for production-only code paths**: warnings about missing required config MUST fire regardless of NODE_ENV. Dedupe per-process via a module-level Set so a busy handler doesn't spam logs, but the FIRST call always surfaces the misconfiguration. Reference: `packages/zero/zero/src/adapters/warn-missing-env.ts`. Caught in M2.4.
- **Per-route convention as a CONVENTION rather than a scaffolded helper**: `vercelAdapter.revalidate(path)` (PR I) introduced the `/api/_pyreon-revalidate` route convention but left it as documentation — users had to write the request-parsing + secret-validation + path-validation + revalidate-dispatch logic themselves. Two recurring foot-guns surfaced from real consumer attempts: (a) revalidation handlers that DIDN'T validate the path against the manifest, so a leaked `VERCEL_REVALIDATE_TOKEN` let an attacker revalidate arbitrary URLs (forcing the platform to re-render pages of their choice — minor cost-amplification + cache-pollution attack). (b) handlers that conflated env-var validation with secret validation, returning 200 for missing-env-var requests and confusing the CMS into thinking revalidation worked. **General rule**: when a convention has a CORRECT shape AND a SUBTLE security-relevant shape, ship a drop-in scaffold. Documentation + examples are not enough; the boilerplate IS the bug surface. Reference: `packages/zero/zero/src/vercel-revalidate-handler.ts:vercelRevalidateHandler` — validates POST + path + secret against env-var-or-misconfig-500 + manifest-or-404 explicitly. Caught + closed in M3.1.
- **Dynamic routes (`[id].tsx`, `[...slug].tsx`) silently skipped under `mode: 'ssg'` without `getStaticPaths`**: fs-router's auto-detect step intentionally DOESN'T emit URLs for routes whose params can't be enumerated at build time. Without `export const getStaticPaths`, the route's `dist/<concrete>/index.html` is never created — user thinks their `/posts/1` route is prerendered but production serves 404. The silence is correct framework behavior (no way to know concrete IDs without an enumerator) but the author confusion is severe. **Defenses (3 layers ship in M3.A)**: (a) `pyreon/missing-get-static-paths` lint rule (warn) catches this at edit time, scoped to `src/routes/` to avoid false positives on unrelated `[bracket]` filenames. (b) `pyreon doctor --check-ssg` audit catches it at CI time across the whole project tree. (c) Documentation in `.claude/rules/anti-patterns.md` (this entry). The rule is `warn` not `error` because dynamic routes in `mode: 'ssr'` / `'isr'` legitimately don't need the enumerator — the user must consciously decide which mode the route uses.
- **`export const revalidate = TTL` (non-literal) silently dropped from build-time ISR manifest**: PR I's `extractLiteralExport` only captures `NumericLiteral` (`60`, `3600`) and `false` keyword. Identifier references (`const TTL = 60; export const revalidate = TTL`), arithmetic (`30 * 60`), function calls, template literals — all silently dropped. The route stays absent from `dist/_pyreon-revalidate.json` and adapter `revalidate()` calls return `regenerated: false` with no signal. **Defense (3 layers ship in M3.A)**: (a) `pyreon/revalidate-not-pure-literal` lint rule (error) catches at edit time. (b) `pyreon doctor --check-ssg` audit catches at CI time. (c) Documentation. The rule is `error` because there's no scenario where non-literal revalidate exports work — the extractor's contract is "numeric literal OR `false`, anything else is dropped." Fix: inline the value (`export const revalidate = 60`).
- **`export const loader = <non-callable>` crashes SSR with `TypeError: loader is not a function`**: fs-router treats `loader` as a callable invoked with `LoaderContext`. Users learning the API occasionally export `loader = { data: 1 }` (mistaking it for static data) or `loader = await fetch(...)` (resolving the value at module-eval time instead of per-request). The runtime crashes deep inside `prefetchLoaderData` with a stack trace that doesn't name the route. **Defense (M3.5)**: `pyreon/invalid-loader-export` lint rule (error) catches non-callable shapes at edit time. Accepts arrow functions, function expressions, identifier references (can't resolve cross-module without type-check, defer to TS), call expressions (`makeLoader(...)` factory pattern), member expressions, TS cast expressions. Rejects object literals, string/numeric literals, array literals, etc. — anything that's structurally non-callable.
- **Convention-driven lint rules that scope by filename alone can false-positive on adjacent conventions**: M3.5's `pyreon/missing-get-static-paths` originally fired on ANY dynamic-named file under `src/routes/` (e.g. `[id].tsx`, `[...slug].tsx`). It correctly caught missing-enumerator on page routes — but surfaced as a false positive on `examples/cpa-pw-blog/src/routes/api/echo/[...path].ts` the moment the showcase landed. **API routes are runtime-only by definition**; fs-router invokes them per-request and never prerenders them, so `getStaticPaths` doesn't apply. The fix combines two skips for defense-in-depth: (a) **path-based** — any file under `src/routes/api/` (fs-router's runtime-handler convention) is skipped; (b) **export-shape** — any file without `export default` is skipped (page routes structurally require a default-exported component; method-handler-only files like `export function GET()` are API routes by structure wherever they sit). The same fix is mirrored in `auditSsg` (`@pyreon/compiler:detectDynamicRouteMissingGetStaticPaths`) so `pyreon doctor --check-ssg` doesn't false-positive either. **General rule for convention-driven detectors**: when a convention's filename overlaps with an adjacent convention's filename, scope by BOTH path location AND structural signal (export shape, marker comments, etc.). Filename alone is insufficient — adjacent conventions WILL collide. Caught in M3.B when the showcase exercised the rule against a real-world repo for the first time; the rule had 22 specs at landing but none covered the API-route shape because no test fixture had matched it. **General lesson**: lint rules NEED a real-world consumer (an example app, a published project) exercising every convention they target — synthetic specs catch the happy paths the author thought of, real-world repos surface the structural collisions the author didn't.

- **Per-path-open-coded dispatch lets a typed enum branch go unimplemented**: `imagePlugin`'s `PlaceholderStrategy` typed `'dominant-color'` from inception, but each of the three code paths (CDN / dev / build) open-coded `generateBlurPlaceholder` directly instead of dispatching on the configured strategy. Result: `placeholder: 'dominant-color'` silently produced a blur, and `placeholder: 'none'` was honored ONLY in the CDN path — build mode ignored it. Same typed-but-unimplemented bug class the `audit-types` gate exists to catch, but invisible to it because the field WAS referenced (the type was used at the config boundary; only the runtime branch was missing). **Fix**: a single `generatePlaceholder(input, strategy, size)` dispatcher that EVERY path calls — `'none'` early-returns `''`, `'color'` → `generateColorPlaceholder`, else blur. `normalizePlaceholder` collapses the deprecated `'dominant-color'` alias → `'color'` at the config boundary so the dispatcher only sees the resolved set. **General rule**: when a config enum has N branches, route every call site through ONE dispatcher keyed on the resolved enum — never open-code one branch at each site. A per-site default silently swallows every other branch, and the audit can't see it because the type IS referenced. Bisect-locked by the `'none' produces no placeholder regardless of input` spec, which fails against the pre-dispatcher build path. Reference: `packages/zero/zero/src/image-plugin.ts:generatePlaceholder` + `normalizePlaceholder`.

## Best-Practice Mistakes (opt-in `@pyreon/lint` rules)

These are caught by **opt-in** lint rules — off in `recommended`/`strict`/`app`/`lib`, enabled via the `best-practices` preset or per-rule `.pyreonlintrc.json` config. Library-scoped ones auto-activate only when the project's `package.json` declares the relevant `@pyreon/*` dep (`utils/project-deps:isProjectDependency`), so they never add noise to projects that don't use the library. Each rule's lint message is prescriptive — an AI assistant reading `pyreon doctor` / `pyreon-lint` output can apply the fix directly.

- **`<img>` without `alt`** [`pyreon/require-img-alt`]: every `<img>` needs `alt` for screen readers. Add a descriptive `alt`, or `alt=""` if the image is purely decorative.
- **`<img>` without `width`+`height`** [`pyreon/img-requires-dimensions`]: missing intrinsic dimensions cause Cumulative Layout Shift (CLS) as the image loads. Set both `width` and `height` (or a CSS `aspect-ratio`).
- **`content-visibility: auto` without `contain-intrinsic-size`** [`pyreon/content-visibility-needs-intrinsic-size`]: `content-visibility: auto` makes the browser skip rendering an off-screen element and ESTIMATE its size; with no `contain-intrinsic-size` the estimate is wrong, so the box corrects on render and shoves content below it down — a Cumulative Layout Shift that's mobile-biased (narrow viewport ⇒ more off-screen content ⇒ more estimate→correct corrections) and invisible on fast desktop loads. This was the exact bug that capped bokisch.com/resume's mobile Lighthouse at 91 (single shift = the whole 0.145 CLS). Fix: `contain-intrinsic-size: auto <height>` next to it (camelCase `containIntrinsicSize` in style/theme objects); the `auto` keyword makes the browser remember the real size after first render. Detects the object-literal form (JSX `style={{}}` + styler/rocketstyle `.theme(()=>({}))`), `css`/`styled` tagged-template CSS, and string `style="…"`. Known limitation (opt-in + warn, so the bar is low): can't see a `contain-intrinsic-size` set on a different selector/object that this one inherits from — possible false positive; exempt via `exemptPaths` or an inline `// pyreon-lint-ignore`.
- **Positive `tabIndex`** [`pyreon/no-positive-tabindex`, auto-fixable]: `tabIndex={n}` where n > 0 hijacks the natural tab order and breaks keyboard navigation. Use `0` (focusable, natural order) or `-1` (programmatic focus only).
- **Raw `<img>` in a `@pyreon/zero` app** [`pyreon/prefer-zero-image`]: prefer `@pyreon/zero`'s `<Image>` — it adds lazy-loading, `srcset`, and a blur placeholder for free.
- **`useQuery` options as an object literal** `[detector: query-options-as-function]`: `useQuery({ queryKey, queryFn })` captures the options ONCE. `@pyreon/query` hooks take options as a FUNCTION so `queryKey` can read signals and refetch reactively — wrap it: `useQuery(() => ({ queryKey: [id()], queryFn }))`. `useMutation` is the exception (its options are a plain object — imperative, no tracking).
- **Nested `@pyreon/rx` transforms** [`pyreon/rx-prefer-pipe`]: `map(filter(src, p), f)` creates N intermediate computeds. Compose with `pipe(src, filter(p), map(f))` — one computed, one subscription.
- **Signal read in `useForm({ initialValues })`** [`pyreon/no-signal-in-form-initial-values`]: `initialValues` is captured once at form setup, so `initialValues: { name: user() }` snapshots the signal and never updates. Pass the plain value, or use `form.setFieldValue` / a reactive field for dynamic defaults.
- **`{t('…')}` interleaved with JSX** [`pyreon/i18n-prefer-trans-for-rich-jsx`]: when a translated string sits next to JSX element siblings (`<p>{t('cta')} <a>…</a></p>`), string interpolation can't safely carry the markup. Use `@pyreon/i18n`'s `<Trans>` component for rich/JSX interpolation. Plain text (`<h1>{t('title')}</h1>`) is fine — the rule only fires when element siblings make it "rich".
- **Manual `new URLSearchParams(...)` in a router app** [`pyreon/prefer-typed-search-params`]: hand-parsing the query string loses type-coercion + SSR-safety. Use `@pyreon/router`'s `useTypedSearchParams({ page: 'number', q: 'string' })` — typed, auto-coerced, NaN-guarded, SSR-safe.

## Library API-Shape Mistakes

- **An issue-count window across an `await` on a SHARED accumulator misattributes concurrent siblings' entries** (fuzz-found, `@pyreon/validate` `.catch`, 2026-07). `.catch` decided "did MY schema fail?" by snapshotting `ctx.issues.length` at entry and comparing after settle — under `parseAsync`, a SIBLING field's issue landed inside that await window, so the catch both misfired its fallback AND truncated the sibling's legitimate issue → an invalid object parsed `ok: true`. **Rule: on a shared mutable accumulator, a begin/end length-window is only valid across SYNCHRONOUS code; any variant that spans an await must give the questioner a PRIVATE child accumulator (merged up on success) — position/count-based attribution cannot survive interleaving** (same family as the position-based-pop Class A entry). Fix: `compiledWithCatch` runs the whole pipeline against a child ctx sharing only `path`/`context`. Reference: `packages/fundamentals/validate/src/core/schema.ts:finishCatch`; caught ONLY by the async differential fuzzer (`jit-async-differential.test.ts`) — the sync fuzzers structurally cannot interleave, so async-capable grammar in differential fuzzers is load-bearing.

- **A rest-args factory that diverges from the array form the REST of the library + the market use → cryptic runtime crash** (fuzz-found, `@pyreon/validate` `s.union`, 2026-07). `s.tuple([...])`, `s.enum([...])`, and Zod / Valibot / ArkType's `union([...])` all take an ARRAY, but `s.union` was rest-args-only (`s.union(a, b)`). A user writing the natural `s.union([a, b])` by analogy hit a *type* error at build — but if the array reached the constructor at runtime (dynamic construction, `as never` cast, plain-JS caller), it was stored as a single "member" and crashed at PARSE time with a cryptic `member['~standard'] is undefined` deep in `_compileType`, not a clear message. **Rule: a composition factory should accept the SAME shape its siblings + the market use; when a signature is unavoidably rest-args, still (a) accept the array form too — cheap: `args.length === 1 && Array.isArray(args[0]) ? args[0] : args` is unambiguous because a schema is never an array — and (b) guard non-schema / too-few members at CONSTRUCTION with a `[Pyreon]`-prefixed message so a bad call never surfaces as a cryptic deep-in-parse crash.** Found by a differential fuzzer (JIT↔interpreter) whose schema grammar was extended to the fallback field types the in-repo differential test never generated; the fuzzer used the natural array call form and crashed instantly. Reference: `packages/fundamentals/validate/src/composition/union.ts` (dual-form factory + constructor guard) + `tests/union-call-forms.test.ts` (bisect-verified). **Detection lesson: a differential fuzzer's SCHEMA generator is as important as its input generator — the existing JIT↔interp test only built string/number/boolean/object/array fields (all fully inlined), so the partial-inline fallback seam AND the union call-form footgun were both invisible until the grammar was widened.**

## Documentation Mistakes

- **Forgetting to update all surfaces**: CLAUDE.md, docs/, README, llms.txt, llms-full.txt, MCP api-reference must all stay in sync
- **Outdated examples**: Examples must compile and run — no pseudocode in docs
- **Literal backslashes in manifest `summary` / `mistakes` / `example` string VALUES** (caught in the styler manifest migration, PR after #624; **renderer FIXED in PR #1442**): `@pyreon/manifest`'s `renderStringLiteral` historically escaped `` ` `` → `` \` `` and `${` → `\${` but NOT literal backslashes. A manifest string whose resolved value contained a literal `\` (e.g. ` ``` `bash in a CodeGroup example body) serialized to `\\\`` in the generated file = (escaped backslash)(RAW backtick) → premature template-literal close in `api-reference.ts` → tsc parse failure. **The renderer now escapes `\` FIRST** (before backticks + `${`), so the bug class is structurally closed. Reference: `packages/internals/manifest/src/render.ts:renderStringLiteral` (post-fix). Symptom guide for the historical shape (still relevant if someone reverts): after `bun run gen-docs`, `bunx oxlint packages/tools/mcp/src/api-reference.ts` — a parse error in the freshly-generated region (not the hand-written ones) is this bug.
- **`<Playground code={` … `}>` in docs (deprecated)**: iframe-sandboxed string-blob code with nested template-literal escape passes — the exact shape behind PR #1434's `'\n'` double-unescape `SyntaxError`. **Migrated wholesale in PR #1448** (36 instances across 30 docs-zero pages → `<Example file="./examples/<topic>/<slug>" />`). For new docs always use `<Example>`. The legacy VitePress `docs/` site retains `<Playground>` until it's fully cut over to docs-zero. **Reusable migration tooling**: `scripts/migrate-playground-to-example.ts` (parses + extracts + rewrites) + `scripts/batch-fix-example-types.ts` (iterative TS strict-mode fixup). **Don't author new `<Playground>` calls** — the value prop ("type-checked, refactor-safe, cross-mount signal-share") is structurally absent. Enforced by `pyreon/no-playground-in-docs` lint rule.
- **`<Example>` example components that hard-require `props.shared`**: every example component MUST accept `{ shared?: Signal<T> }` and fall back to a local signal — `const count = props.shared ?? signal(0)`. Without that fallback, the example breaks when used WITHOUT `share` (i.e., as a standalone single demo). The contract is "bridgeable, not require-bridged." Reference: `docs/src/examples/reactivity/signals-read-write-react.tsx` for the canonical shape.
- **`as never` casts on accessor-form JSX attribute values** (fixed at the root in PR #1442): the canonical Pyreon pattern for reactive attributes is `attr={() => sigCall() === target ? 'value' : undefined}`. Several attrs in `@pyreon/core`'s JSX types declared the static-value union but missed the function-accessor variant — `aria-current`, etc. Consumer code had to write `(() => …) as never` to silence the type error. **Fix the root cause** in `packages/core/core/src/jsx-runtime.ts` by adding `| (() => UnionOfStaticTypes | undefined)` to the attr's type, matching the shape used by `aria-selected`, `aria-disabled`, `aria-hidden`. Audit every accessor-supported attr to ensure the function variant is present.
