JSX 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.
key on <For>
Use by not key — JSX reserves key for VNode reconciliation
Detected by: for-with-key — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
Missing by on <For>
<For each={...}> without a by prop defeats keyed reconciliation — every update remounts the full list. Always supply by={item => item.id}.
Detected by: for-missing-by — surfaced by @pyreon/lint / pyreon doctor / MCP validate.
.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>() => 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) [FIXED, 2026-07] a dynamic boolean attribute on a templated element WAS buggy via the compiled binding — the template path emitted a raw el.setAttribute("hidden", value) with NO boolean-attr presence/absence guard, so hidden={false} set hidden="false" (attribute PRESENT → still hidden). The template attrSetter now routes generic dynamic attrs through the runtime _setAttr (= applyAttrProp), which mirrors applyStaticProp: boolean non-aria → presence/absence (hidden={false} → ABSENT), boolean aria → "true"/"false", null/undefined → removeAttribute. See the "Compiler template fast-path value emit diverging…" entry's aria/boolean/null sibling-fix note. The static-CLASS workaround is no longer required (it stays valid + harmless for apps pinned to older compilers). (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). Second sibling — a container-level PAN pointerdown handler must ALSO bail when the pointer lands on the framework's own UI chrome (Controls / MiniMap / Panel) or any interactive control, not just on nodes/handles. @pyreon/flow's handlePointerDown bailed for .pyreon-flow-node + .pyreon-flow-handle but NOT the panels, so a pointerdown on a zoom button fell through to setPointerCapture(container) → the button never received pointerup → its click never fired → the zoom / fit-view buttons "did nothing" (the exact user-reported symptom; wheel-zoom + node-select still worked because they don't go through a swallowed button click). Fix: target.closest('.pyreon-flow-controls, .pyreon-flow-minimap, .pyreon-flow-panel, .nodrag, button, input, textarea, select, a') → return at the top of the pan handler. Detection trap: a synthetic el.click() bypasses the pointer sequence and PASSES — only a REAL coordinate click (Playwright) reproduces the capture-swallow, because setPointerCapture redirects real pointerup routing, not a manually-dispatched click. Reference: flow-component.tsx:handlePointerDown; gated by e2e/app-showcase-flow.spec.ts (real coordinate click on the zoom button, bisect-verified) + flow/src/tests/edge-render.browser.test.tsx (pointerdown-on-control must not pan).
A headless toggle/radio primitive rendering <label onClick={toggle}> around a hidden <input onChange={toggle}> fires onChange 2–3× per click (the double-toggle bug).
A <label> FORWARDS its click to the wrapped control as a default action, so one user click on the label fires: (1) the label's own onClick → toggle, (2) the forwarded <input> click → the input's onChange → toggle, and (3) that forwarded input click BUBBLES back up to the label → onClick → toggle again. Net for a checkbox: three toggles (odd → visually "works" but onChange fires 3×); for a radio the value is idempotent so state is right but onChange still fires 2–3×. Either way any consumer counting changes / running side effects in onChange misfires. Fix: onClick={(e) => { e.preventDefault(); toggle() }} on the label — preventDefault cancels the label's forward-to-input default action, so no input click, no input onChange, no bubble = a single toggle. The hidden input stays in sync via its reactive checked={checked()} binding (not the native forward), and its onChange still covers a native form reset. Detection trap (test-environment parity): happy-dom models label→input forwarding DIFFERENTLY — it forwards even past preventDefault, so it double-fires REGARDLESS of the fix, while a real browser single-fires only WITH it. So the exact single-fire count is a REAL-Chromium contract — put the strict onChange-count assertions in describe.runIf(isBrowser) (gate on __vitest_browser__); the state-machine CODE (toggle/select/keydown/disabled-guard) is still fully covered in happy-dom by the keyboard + disabled + initial-ARIA tests, so coverage is unaffected. A synthetic dispatchEvent(new MouseEvent('click')) on the label DOES trigger the forwarding in Chromium, so no real-coordinate click is needed to reproduce. Reference: @pyreon/ui-primitives CheckboxBase/RadioGroupBase onClick; regression toggle-primitives-interaction.browser.test.tsx (bisect-verified: revert the preventDefault → the "fires exactly once" spec fails with [true, false, true] in Chromium).
A <svg width:100% height:100%> overlay whose containing block collapses to 0×0 paints NOTHING
— invisible even with correct geometry + a visible stroke. An absolutely-positioned, shrink-to-fit wrapper (all children position:absolute → zero content size) resolves a child svg's width/height:100% to 0, and a ZERO-AREA svg viewport disables rendering of the entire svg: the <path>/<g> elements EXIST in the DOM (a querySelector count > 0, getTotalLength() > 0, valid d, computed stroke visible) but nothing paints. Real instance: @pyreon/flow's edge <svg class="pyreon-flow-edges"> inside the transformed .pyreon-flow-viewport (which had transform but no width/height) → 0×0 → flow edges NEVER visually rendered for the package's lifetime (the className/namespace fix #2172 made the paths get CREATED but they still didn't paint). Fix: give the containing block a definite size — .pyreon-flow-viewport { width: 100%; height: 100% } (React Flow's model) so the svg's 100% resolves to the container; keep overflow: visible so content extends past it. Pairs with a pointer-events swap: a now-full-size transformed viewport would swallow clicks meant for sibling panels + the container's pan handler, so set the viewport pointer-events: none and re-enable interactive descendants (node wrapper pointer-events: auto, edge path pointer-events: stroke). Detection trap: a svg path COUNT or a getTotalLength() assertion PASSES on a 0×0 svg (geometry is independent of paint) — only a rendered-box assertion (getBoundingClientRect().width > 0) catches it. That is exactly why the app-showcase e2e (path count + real-SVG-namespace + length) MASKED the bug. Companion footgun in the same investigation: edge geometry used a node.width ?? 150 fallback for content-sized nodes (no explicit width), starting the edge ~70px off the node — fixed by auto-measuring the rendered node (ResizeObserver per node → instance.measurements) and preferring node.width ?? measured ?? 150. Reference: packages/fundamentals/flow/src/components/flow-component.tsx (viewport div sizing + measureRef); gated by e2e/app-showcase-flow.spec.ts (edge svg box > 0) + flow/src/tests/edge-render.browser.test.tsx, both bisect-verified (expected 0 to be greater than 0).
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'). Framework mitigation (2026-07): applyStaticProp now runs an aria-first coercion (props.ts:503, SSR-mirrored) BEFORE the generic boolean-presence branch, so a boolean aria reaching the applyProps/spread path is corrected to "true"/"false" — but the SOURCE must still emit strings (the compiler's template reactive-attr path renders an unset x || undefined as present-empty aria-*="" rather than absent, and defense-in-depth shouldn't lean on the net). Fixed across ALL @pyreon/ui-primitives primitives: the aria-checked/aria-selected sites (Switch/Checkbox/Radio/Tabs/Combobox/Tree) converted first; the 2026-07 audit then closed the residual x || undefined booleans — aria-invalid (Switch/Select/Checkbox/Slider/RadioGroup), Slider's aria-disabled, Tree's aria-multiselectable — so every aria-state emission is now a string. Locked by value-asserting aria-state.browser.test.tsx (assert the VALUE + ABSENCE, never hasAttribute). Bisect-verified by REMOVING the runtime safety net (props.ts:503): with it gone, the boolean source fails expected '' to be 'true' while the string source still passes — proving the string fix is load-bearing independent of the net. Reference: packages/core/runtime-dom/src/props.ts:applyStaticProp + packages/ui/primitives/src/aria-state.browser.test.tsx; code-style.md "Render-function primitives provide ARIA helpers". Compiled-template-path caveat — the "BOTH paths" claim above holds ONLY for the runtime h()/spread path (a real-compiler gap the @pyreon/ui-components a11y pass surfaced, NOT fixed here): the COMPILED template fast path (_tpl + attrSetter, packages/core/compiler/src/jsx.ts:attrSetter) emits a RAW el.setAttribute("name", expr) with NO null guard, so a DIRECT JSX aria/data attr whose value has an undefined/null branch — the recommended aria-disabled={x ? 'true' : undefined} shape ITSELF — renders the LITERAL string aria-disabled="undefined" (present, an INVALID ARIA value → announced as its opposite / ignored) whenever that branch is taken, because setAttribute ToString-coerces undefined→"undefined". Verified: transformJSX('<button aria-disabled={x ? "true" : undefined}>') emits __root.setAttribute("aria-disabled", x ? 'true' : undefined). So every @pyreon/ui-primitives interactive base (Switch/Select/Slider/RadioGroup/Checkbox/Tabs — whose aria-invalid/aria-disabled/data-* carry an undefined branch) renders ="undefined" by default in real (vite-plugin-compiled) apps. MASKED because those primitives' browser tests use the oxc automatic JSX runtime (importSource: '@pyreon/core' in their vitest.browser.config.ts), which routes through h()→applyProps→applyStaticProp (null→removeAttribute) — NOT the real compiler, the exact "wrong-transform masks template bugs" trap. Note the BOOLEAN case is fine via the template path (setAttribute coerces true→"true", false→"false"); the undefined/null branch is the hazard, so the string-form fix above does NOT close it. Root-cause fix = the template attrSetter must mirror applyStaticProp (null→removeAttribute, boolean→presence/aria-string) in BOTH backends (JS jsx.ts + Rust native/src/lib.rs) + SSR parity — a separate @pyreon/compiler follow-up, the same class as the documented template boolean-attr gap (hidden={false}→hidden="false"). Reference: packages/core/runtime-dom/src/props.ts:applyStaticProp (the boolean-before-aria ordering); packages/core/compiler/src/jsx.ts:attrSetter (the un-guarded raw setAttribute); code-style.md "Render-function primitives provide ARIA helpers".