Architecture Mistakes
Generated from
.claude/rules/anti-patterns.md(the same source as MCPget_anti_patterns). Each entry is a real mistake + its fix; where a detector code is listed, the linter /pyreon doctor/ MCPvalidatecatches it automatically.
Package dependency cycle broken by a registration seam (the ui-core ↔ unistyle instance, 2026-07)
two UI-system packages formed a cycle — <PyreonUI> (ui-core) imported enrichTheme/themeToCssVars/cpseRewrite from unistyle, while unistyle imported ui-core's config/isEmpty. Fix: the LOWER package (ui-core) owns the canonical TYPE (PyreonTheme) + a registration slot (theme-engine.ts: setThemeEngine/getThemeEngine + a ThemeEngine interface), and the HIGHER package (unistyle) registers its engine at module load (setThemeEngine({ enrichTheme, themeToCssVars, cpseRewrite }) in its index.ts); ui-core reads it via getThemeEngine(). Dep direction is now one-way (unistyle → ui-core), the graph acyclic. Same shape as setStyleExtraction/setSnapshotCapture/_setDefaultChromeLayout. Three load-bearing gotchas: (1) read the engine LAZILY at runtime use-sites, never eagerly at component setup — the ROOT <PyreonUI> provider runs its setup BEFORE the child components that pull unistyle in have mounted, so an eager getThemeEngine() at setup can't see a not-yet-registered engine; defer every engine read into the enrichedTheme computed + the style-extraction opt-in (both post-mount). A vitest setupFiles: import '@pyreon/unistyle' HIDES ordering bugs (the test imports unistyle first) — only a real dev-server/e2e boot catches them. (2) the registering package MUST declare sideEffects: ["./src/index.ts","./lib/index.js"] (not false) — a bare module-scope setThemeEngine(...) call is dead code to a consumer's tree-shaker, so sideEffects: false drops the registration from the production bundle (precedent: @pyreon/validate/@pyreon/testing). (3) getThemeEngine() must DEGRADE, not THROW, when nothing registered — the first cut threw a "needs the theme engine" error, which broke every context that renders <PyreonUI> WITHOUT unistyle in its graph: a real @pyreon/rocketstyle-only app (rocketstyle depends on ui-core + styler but NOT unistyle, so removing ui-core→unistyle stops the transitive load), plus every isolated test that renders PyreonUI (@pyreon/testing's renderWithTheme, rocketstyle's browser tests). The consumer that reads a lower package's seam can't assume the higher package is loaded. Fix: getThemeEngine() returns a minimal FALLBACK engine (identity enrichTheme, empty themeToCssVars, identity cpseRewrite) + dev-warns ONCE — PyreonUI stays functional (theme passes through un-enriched; no CSS vars / CPSE / default breakpoints) instead of crashing; the real engine wins when unistyle loads. Tests that assert REAL unistyle theming (CSS-variables mode, responsive enrichment) must then import '@pyreon/unistyle' explicitly (+ add it as a devDep) — the fallback keeps them from crashing but can't synthesize the behavior they assert. General rule for breaking a package cycle via a seam: put the TYPE + the slot in the package that must NOT depend upward; register from the package that already depends downward; read LAZILY (post-mount) so registration order can't race the reader; mark the registering package's entry as a side-effect so tree-shaking can't delete the registration; and make the seam's reader DEGRADE GRACEFULLY when unregistered (a hard throw assumes the upper package is always loaded — which siblings that skip it disprove). Reference: packages/ui-system/ui-core/src/theme-engine.ts (FALLBACK_ENGINE) + unistyle/src/index.ts; verified by the ui-showcase-regression e2e (26/26, app boots), @pyreon/testing + rocketstyle browser suites, and ui-core/src/tests/theme-engine.test.ts.
[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).
A reactive MOUNT accessor that reads position/layout-derived signals as VALUES remounts its whole subtree when they change (the Overlay content-flip-remount + hover-content-unreachable classes, 2026-07).
Two sibling bugs in the same primitive, both a "lazily-mounted content interacting with signals that update AFTER it mounts" shape. (1) Overlay's content-mount accessor ({() => active() ? <Portal>{render(children, { align: align(), alignX: alignX(), alignY: alignY() })}</Portal> : null}) read the resolved-align SIGNALS as VALUES — so it SUBSCRIBED to them, and a viewport-edge flip (useOverlay writes innerAlignY 'bottom'→'top' during the position pass) re-ran the accessor → the entire Portal/content subtree REMOUNTED. Double-fired the content's onMount + dropped any internal state (an input the user was typing in a popover). Fix: pass the position-derived values as _rp()-branded accessors (the exact shape the compiler emits for prop={signal()}, and the same shape the trigger's aria-expanded already used) — makeReactiveProps converts them to live getters, so the content re-styles IN PLACE with no remount and the mount accessor never subscribes to them. General rule: a reactive mount/conditional accessor must only read the signals that decide the STRUCTURE (mount vs unmount); any value the mounted subtree merely DISPLAYS must reach it as a reactive prop, never a value read inside the accessor — else every change to that value remounts the subtree. (2) setupListeners() attached the CONTENT's hover listeners once at mount via attachHoverListeners(), but a hover overlay's content renders only while OPEN — so contentEl was null at that point and the mouseenter/mouseleave listeners NEVER attached. Moving the pointer trigger→content never fired onContentEnter → the pending hide timer wasn't cancelled → the tooltip/dropdown closed out from under the pointer (its content unreachable). Fix: (re)bind the content listeners to the live contentEl every time isContentLoaded flips (the same signal the position-on-open subscription rides), tracking the currently-attached node so detach targets the right one. General rule: attach-once listeners for a node that mounts LAZILY (a portaled overlay body, a <Show>-gated element) miss it — sync the listener binding to the mount signal, not to setupListeners-time. Both real-Chromium-locked + bisect-verified (Overlay-content-reactive-align → expected 2 to be 1 on the mount count; Overlay-hover-content → tooltip expected null not to be null). Reference: packages/ui-system/elements/src/Overlay/{component.tsx,useOverlay.tsx}.
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]
/* zero-content: unhandled mdast node "delete" */ 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.
[FOOTGUN — reactive-props inlining re-invokes a STATEFUL prop-derived const at every JSX use site → a fresh, disconnected instance per use].
The compiler's reactive-props inlining (consts derived from props.* are inlined at JSX use sites so signal reads stay live) assumes the derivation is a CHEAP PURE expression (const full = props.first + ' ' + props.last). When the initializer is instead a stateful factory call — const m = createModel(props.catalog), const store = createStore(props.id), anything that allocates signals/state — the inliner substitutes the initializer expression at each use site, so <A model={m}/><B model={m}/> compiles to <A model={createModel(props.catalog)}/><B model={createModel(props.catalog)}/> → two separate model instances. The reactive graph is silently SEVERED: writes land in one instance, reads subscribe to another, and per-component islands each work internally (a view whose onClick + reads share ITS one instance updates fine) while anything spanning two views (one view writes, another reads; a <Show> in the parent reads the original binding) is dead — the maddening "some interactions react, structurally-identical ones don't" signature. No error fires (every instance is valid), so it presents as a pure reactivity mystery. Fix: declare the binding let (or var) — the inliner explicitly does NOT track let/var, so the single instance is shared (// oxlint-disable-next-line prefer-const since it's never reassigned; comment WHY it's load-bearing). Detection: inspect the compiled output for the binding name — if model={m} became model={_rp(() => createModel(…))} at multiple sites, you've hit it. Compiler follow-up (the fundamentally-correct fix): the inliner should NOT inline a prop-derived const whose initializer is a CallExpression to a non-pure/unknown callee — inlining is only sound for pure expressions; a stateful factory must be referenced, not re-invoked (mirror the pure-call allowlist the static-hoist pass already maintains). Real instance: @pyreon/atlas's <Workbench> built its createModel(props.catalog) as const and passed model={m} to 6 region views + theme=/mode= on <PyreonUI> — every view + PyreonUI prop minted its own model, so zoom worked (Canvas's onClick + label share Canvas's instance) but the variant control (AddonPanel's instance) never repainted the preview (Canvas's instance) and <Show when={() => m.view()}> never switched (read the parent binding while TopBar's onClick wrote TopBar's instance). Bisect-verified via the atlas-workshop e2e (const → 5/7 fail: preview/view-switch/search/actions dead; let → 7/7).
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).
Assigning the CSS transition SHORTHAND clobbers a pre-existing transition-delay — and happy-dom masks it
el.style.transition = 'opacity 300ms ease-out' resets EVERY transition longhand it omits, including transition-delay → 0s, in spec-compliant engines (Chromium / Firefox). @pyreon/kinetic bakes each staggered child's delay onto transition-delay, so applyEnter/applyLeave doing a bare el.style.transition = enterTransition ERASED the stagger delay the instant the enter/leave animation started — every child in a STYLE/preset stagger (.preset(slideUp).stagger(), i.e. every built-in preset, since presets set enterTransition) animated AT ONCE, silently defeating .stagger(). happy-dom does NOT model the shorthand→longhand reset (verified: assigning the shorthand leaves transitionDelay untouched), so the existing StaggerRenderer specs — which assert the delay on the render-time VNODE, never on the DOM element AFTER applyEnter mutated it — all passed; only real Chromium catches it. Fix: assign the shorthand through a helper (setTransition) that re-applies the delay AFTER, sourced from a STABLE --kinetic-delay custom property (a plain inline transition-delay is itself wiped by the transition = '' reset kinetic performs at the entered stage, so it can't survive multi-cycle staggers — a custom prop survives both). Two general rules: (1) never assign a CSS SHORTHAND (transition, animation, background, font, border) when you need to preserve a longhand it subsumes — re-apply the longhand after, or set longhands directly; (2) any CSSOM-shorthand-reset behavior is a test-environment-parity blind spot — happy-dom under-models it, so the regression test MUST be real-Chromium. Bisect-verified: revert setTransition to a bare el.style.transition = value → stagger-delay-preserved.browser.test.tsx items[1]/[2] read transitionDelay: '0s' (expected '0s' to be '80ms'); restore → 80ms/160ms. Reference: packages/ui-system/kinetic/src/utils.ts:setTransition + Stagger.tsx/StaggerRenderer.tsx (--kinetic-delay emit). Companion: kinetic's nextFrame BATCHES all same-burst callbacks into ONE shared double-rAF (2 rAF registrations for a 1000-child stagger, not 2000 — the measured dominant per-child overhead vs Motion One at N=1000, flipping stagger-1000 from a 1.27× loss to a CI-tie; a callback registered after the batch's outer frame opens a NEW batch so its "from" state still paints, and the batch is identity-keyed to the scheduling requestAnimationFrame so a swapped stub/polyfill can't strand callbacks on a dead batch). Its cancel REMOVES the callback from its batch — works in every phase (the historical bug shape was a bare cancelAnimationFrame(outerId) that missed the inner frame after the outer fired → a rapid enter→leave inside one frame could still commit the stale enter-to state; Set-removal is immune by construction) without touching batch siblings, and is SSR/post-teardown safe (no browser API in the cancel at all).
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.
A framework helper consuming a compiler-emitted reactive prop as a RAW value (the rocketstyle inline-dimension-prop bug)
the Pyreon compiler emits an INLINE reactive dimension prop — state={sig() ? 'a' : 'b'} written directly in a component's JSX return — as a bare accessor state: () => sig() ? 'a' : 'b' (NOT _rp-branded, so makeReactiveProps leaves it a raw function; a .map()- or helper-scoped prop stays a plain re-evaluated VALUE, which is why those worked). rocketstyle's calculateStylingAttrs read props[dimName] and only accepted string/number — a function fell to the else → undefined, so the dimension was SILENTLY dropped (active-tab highlight, signal-driven variant/size/state never applied for inline reactive props; the dimension prop is still reserved/stripped from the DOM, so it LOOKS wired). Fix: resolve a function-valued dimension prop (typeof x === 'function' ? x() : x) — and because this runs inside rocketstyle's reactive resolution computed, the accessor call TRACKS the signal, so a flip re-resolves the class with no remount (reactivity comes for free). Static / plain-value / _rp-getter (already-resolved-to-string) props are unchanged. General rule: any framework helper that consumes a component prop the compiler may emit reactively (a dimension resolver, a prop-classifier, an HOC that reads a specific prop) must handle the ACCESSOR form (() => value), not just the raw value — else inline reactive usage silently no-ops while .map()/helper-scoped usage works, a maddening "it works there but not here" split. The workshop side has a matching gotcha: rocketstyle .states()/.variants()/.sizes() take the CALLBACK form .states((t) => ({ active: { …structured keys… } })) — an object-of-functions .states({ active: (t) => … }) produces EMPTY dimension themes, and dimension VALUES must use structured unistyle keys (backgroundColor), not an extendCss blob (a single extendCss key collides on merge → REPLACES the base's, losing base styling; structured keys merge cleanly + win over a base extendCss shorthand). Bisect-verified: packages/ui-system/rocketstyle/src/__tests__/attrs.test.ts ("resolves a FUNCTION-valued (accessor) dimension prop" — reverted: expected { state: undefined } to deeply equal { state: 'active' }) + real-Chromium e2e/atlas-workshop.spec.ts (active-tab highlight / variant flip / zoom scale). Reference: packages/ui-system/rocketstyle/src/utils/attrs.ts:calculateStylingAttrs.
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
This cast is unnecessary — JSX.Element (VNode) is already assignable to VNodeChild. Never add it; remove it where found.
Detected by: as-unknown-as-vnodechild — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
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
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.
Detected by: static-return-null-conditional — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
Static early-return with a signal condition
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.
Detected by: static-early-return-conditional — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
Empty .theme({})
Never chain .theme({}) as a no-op. If a component needs no base theme, skip .theme() entirely.
Detected by: empty-theme — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
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.
An allowlist HTML sanitizer whose tag set enumerates ONLY HTML silently strips every foreign-content (SVG/MathML) element to a text node
@pyreon/runtime-dom's innerHTML fallback sanitizer allowed 69 HTML tags and zero SVG tags, and sanitizeNode replaces any non-allowlisted element with a text node — so <span innerHTML="<svg>…</svg>"> rendered <span></span> with NO error/warning (an entire icon set ships blank; the only escape, setSanitizer, is global — weakening XSS protection app-wide to draw an icon). Fix: add a curated SAFE-SVG profile (shape/gradient/clip/mask/text/filter-primitive elements, mirroring DOMPurify's default SVG profile), NOT "allow all SVG minus a few" — an allowlist is safer than a denylist. EXCLUDE the XSS-capable SVG elements: <script>, <foreignObject> (embeds arbitrary HTML → reopens every HTML XSS vector), <style>, and SMIL <animate>/<set>/animate* (the attributeName="href" values="javascript:…" animation-XSS vector). Two companion traps: (1) the attribute URL-guard keyed on URL_ATTRS.has(attr.name) MISSES SVG's xlink:href (whose qualified name isn't in the set) — guard on attr.localName === 'href' too, or an <a xlink:href="javascript:…"> slips through. (2) compare tagName.toLowerCase() and store the allowlist LOWERCASE (lineargradient, fegaussianblur) — the HTML parser re-cases SVG foreign content on serialize so the DOM round-trips, but the lookup must be case-normalized. Detection: happy-dom parses SVG well enough for a unit test, but the load-bearing lock is a real-Chromium test asserting the sanitized string becomes genuine SVGElement nodes (path instanceof SVGPathElement, getTotalLength() > 0, namespaceURI === 'http://www.w3.org/2000/svg') — a string .includes('<path') doesn't prove the browser materialized real SVG. Reference: packages/core/runtime-dom/src/props.ts:SAFE_SVG_TAGS + tests/props.test.ts (bisect-verified) + tests/innerhtml-svg.browser.test.tsx. General rule: any allowlist over a language with multiple namespaces (HTML + SVG + MathML) must enumerate every namespace it intends to permit — a single-namespace allowlist silently drops the others with no diagnostic.
A bug report can MIS-DIAGNOSE a type-augmentation mechanism ("no supported way to add X downstream") — verify the actual augmentation FORM before accepting "impossible"
a downstream report claimed SvgAttributes (a module-scope export interface used inside a later declare global { namespace JSX }, re-exported type-only from the barrel) could not be augmented, measuring a 15→4953 error explosion. Empirically (self-contained fixture mirroring the exact structure): declare module '@pyreon/core' { interface SvgAttributes { … } } DOES merge and reach <path> (a bogus attr still errors — strictness intact). The explosion came from the intuitive-but-WRONG form declare global { namespace JSX { interface SvgAttributes { … } } }, which declares a SEPARATE JSX.SvgAttributes that does not merge with the module interface IntrinsicElements actually references. The correct downstream seam for a module-scope exported interface is declare module '<pkg>', never the JSX-namespace form — document it in the interface's JSDoc so it's discoverable (the report author was competent; the seam was just undiscoverable). Reduce the NEED to augment by shipping the common attributes/elements (here mask/filter/<image> + filter primitives were genuinely missing). Same "verify the mechanism, not the stated cause" family as the TS7-ESNext and [zero:ssg] Skipping entries. Reference: packages/core/core/src/jsx-runtime.ts:SvgAttributes JSDoc + tests/svg-attributes.types.test.ts.
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
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:
typeof process !== 'undefined' && process.env.NODE_ENV !== 'production': dead code in real Vite browser bundles because Vite does not polyfillprocess— the typeof guard returnsfalse, the whole expression is statically dead, warnings never fire. Caught originally in PR #200.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.envisundefinedand 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.
Detected by: process-dev-gate — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
Bare top-level component-brand assignments pin EVERY component of a package into every consumer bundle
[guard: no-bare-component-brand.test.ts]: Component.displayName = name / .pkgName / .PYREON__COMPONENT / .isText at module top level are mutations a bundler must run once ANY binding of the module is used — sideEffects: false only helps when NOTHING is imported. In a package where every component brands itself this way the components pin each other: measured on @pyreon/elements, importing just <Portal> paid the whole 7.5KB gz; the fix took Portal-only to 2.39KB (−68%) and <Element> to 4.02KB. Fix: brand on the export expression — export default /* @__PURE__ */ Object.assign(Component, { displayName: name, … }) (same object identity; droppable exactly when unused; compose with nativeCompat(...) by nesting). One offending file taxes the SIBLING imports, not itself — so the aggregate is ALSO locked in scripts/import-budgets.json (@pyreon/elements::portal/::element). Same mechanism as PURE-form nativeCompat (#2368), wider property set; swept elements + coolgrid.
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.
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.
A VNode tree-walker that dispatches ONLY on typeof type (string vs function) silently drops Fragment subtrees
(the @pyreon/connector-document extraction instance, 2026-07 — same silent-drop CLASS as PR #197's metadata drop, in the same package): extractDocumentTree's extractNode classified each vnode by getDocumentType(type) → typeof type === 'function' → typeof type === 'string' → else return null. But a <>…</> fragment compiles to h(Fragment, …) whose type is a SYMBOL (Symbol.for('Pyreon.Fragment')), matching NONE of those branches — so a fragment vnode fell through to return null and its ENTIRE subtree vanished from the exported document with NO error. Two idiomatic shapes hit it: a bare <> grouping doc primitives as a child, and any wrapper component that returns multiple siblings by wrapping them in a Fragment (the standard reason to reach for one). A component returning a bare VNodeChild[] array (() => items.map(…)) hit the same class in the function-invocation branch (isVNode(result) is false for an array → dropped). Both produced an empty list/section — invisible to every existing test because the connector's suite (like the PR #197 era) only ever fed hand-rolled marked-function fixtures WITHOUT a <>, and no test grouped siblings with a Fragment. Fix: treat a Fragment vnode transparently (flatten its children into the parent, exactly like the DOM-element/string branch) and flatten a bare-array component return. Compare via Symbol.for('Pyreon.Fragment') from the GLOBAL registry (instance-safe across a dual-@pyreon/core split — no runtime import needed). General rule: any code that walks a Pyreon VNode tree by branching on type MUST handle the Fragment symbol as a transparent container — a typeof-only dispatch (string/function) structurally forgets it, and forgetting it is a SILENT drop, not a crash. The same applies to a bare-array child/return (a valid VNodeChild). Bisect-verified: reverting the Fragment branch fails 6 real-h(Fragment) specs + 1 real-rocketstyle-primitive <> spec with expected [] to have a length of N. Reference: packages/ui-system/connector-document/src/extractDocumentTree.ts (FRAGMENT_TYPE branch + array-return arm) + src/__tests__/{extractDocumentTree,real-primitive-extraction}.test.ts. Detection lesson (reinforces PR #197): the connector's own suite tested extraction ONLY with hand-rolled marked-function fixtures — the real rocketstyle-primitive path + the real Fragment symbol were both absent; a real-h() + real-primitive test is the only thing that surfaces this class.