pyreon

JSX Mistakes

Generated from .claude/rules/anti-patterns.md (the same source as MCP get_anti_patterns). Each entry is a real mistake + its fix; where a detector code is listed, the linter / pyreon doctor / MCP validate catches 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>() =&gt; name()</div>): compiler/src/tests/template-child-classification.test.ts + runtime-dom/src/tests/template-child-classification.test.tsx.


[FIXED — compiler ref-hoist, 2026-07] Static-element reactive-attr/text refs computed AFTER a sibling _mountSlot (dynamic array / conditional child) → broken element-ref walk.

Historical bug shape (kept for the lesson + old-version diagnosis): when a DOM/SVG element had a DYNAMIC child ({arr.map(...)}, {cond && <x/>}, {cond() ? <A/> : <B/>}) positioned BETWEEN or BEFORE STATIC siblings carrying reactive attrs or interpolated text, the compiler emitted those siblings' refs as .nextSibling/.nextElementSibling walks computed in source order — but _mountSlot for the dynamic child REMOVES its <!> placeholder + inserts content + a <!--pyreon--> marker BEFORE the trailing ref was computed (net sibling-count delta ≠ 0), so the walk landed on the wrong node (Cannot read properties of undefined (reading 'setProperty') when _setStyle hit the marker comment; null (reading 'setAttribute') / null (reading 'data') when it walked past the end). The class was WORSE than misbind/crash — delayed sibling-slot anchor destruction: with TWO adjacent slots, the second slot's own inline placeholder walk resolved to the FIRST slot's reactive marker, which _mountSlot then REMOVED; slot 0's next falsy→truthy re-flip threw insertBefore … is not a child of this node (unhandled effect error) and SILENTLY LOST the subtree. Failure was initial-state-dependent (single flips accidentally correct — only the double flip fired the subtree loss, which is why the e2e gates never caught it; fires on both plain client mount AND post-hydration). The fix (both backends, byte-identical): two-phase template-bind emission — phase 1 (refLines) captures EVERY pristine-clone node reference (element walks const __eN = …, sole-text captures const __tN = X.firstChild, hoisted placeholder consts const __pN = <walk> for _mountSlot args + replaceChild targets) BEFORE phase 2 (bindLines) runs any mutation — phase-2 ops are identity-based, hence order-independent w.r.t. sibling structure. Reference: packages/core/compiler/src/jsx.ts:buildTemplateCall (refLines/bindLines + hoistPlaceholderRef) + native/src/lib.rs:TemplateBuilder.ref_lines; regression locks (bisect-verified): compiler/src/tests/template-ref-hoist.test.ts (emit ordering) + runtime-dom/src/tests/slot-before-sibling-refs.test.tsx (mount/flip/hydrate behavior incl. the double-flip subtree-loss shape, compiled through the REAL transform). The old static-wrapper workaround (<g>{nodes.map(...)}</g> / <div style="display:contents">{conditionals}</div>) is no longer required on current versions — still valid guidance for apps pinned to older compilers. Real historical instances: @pyreon/flow MiniMap ({nodes.map} between two <rect>s) + Controls (conditional buttons before the reactive zoom-% <div>). General lesson (still live): the flow package's vitest-browser tests use a JSX transform that does NOT match the real @pyreon/vite-plugin compiler, so they MASK this class of real-compiler template bugs (markers, MiniMap, Controls all shipped broken + green-in-vitest). A real-compiler e2e (or transformJSX from @pyreon/compiler directly) is the only reliable gate for template-codegen correctness — the new regression suites compile through the REAL transform for exactly this reason.


[root cause FIXED by the compiler ref-hoist above] Conditional WRAPPER child ({cond && <div>…</div>}) before a static sibling that gets ref-walked → HierarchyRequestError on a FRESH client mount (same class as the entry above; CodeBlock instance).

A component that conditionally renders a leading wrapper — <root>{props.x && <header/>}<body>…</body></root> — lowers the conditional to a _mountSlot, and body's ref was root.firstElementChild.nextElementSibling (or .nextSibling) emitted AFTER that slot. On the FIRST client mount (the SPA-navigation path — NOT the in-place-hydrate path, which is why it hid until you clicked into the page) the absent conditional made the slot remove its <!> placeholder, the sibling walk over-shot, and a later _mountSlot.insertBefore ran against a Comment-node parent → HierarchyRequestError: insertBefore … node type does not support this method (or Cannot read properties of null (reading 'nextSibling'), depending on slot timing). Real instance: @pyreon/zero-content's <CodeBlock> (conditional {filename && <header>} + {showLineNumbers && <gutter>}) — every docs code sample rendered broken on landing→docs nav. The compiler ref-hoist (entry above) removes the bug class at the root — sibling refs now resolve against the pristine clone before any slot mutates it. CodeBlock's local fix predates it and stays as-shipped (always-RENDERED static wrappers with a --empty modifier class — harmless, still valid, no churn needed). Two gotchas the fix surfaced, both worth their own rule: (a) [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.


[FIXED, 2026-09] The h()-path parity fuzz is BLIND to the compiled path — a fuzz whose SSR arm and client arm are both COMPILED from one source found three shipped defects on its first 300 seeds.

hydration-parity-fuzz.test.tsx builds every tree with h(), so it exercises hydrateElement/hydrateSoleAccessorChild and never _tpl/_mountSlot/_textSlot; #3302/#3307/#3310 were locked only by hand-written compiled specs. Its compiled sibling (hydration-parity-fuzz-compiled.test.tsx, shared grammar in _hydration-fuzz-grammar.ts + a toSource renderer) renders the SAME seeded spec through transformJSX for the SSR emit AND the client emit and hydrates one over the other. The server arm MUST be the compiled SSR emit, not the h() form: the compiler wraps a component's {props.children} in an accessor, so the SSR emit brackets that slot with its own $ range and the h() form does not — hydrating the compiled client over h()-SSR duplicated every <Show>-in-<Comp> (a harness artifact that read exactly like a runtime bug until the two server forms were diffed). Root swaps and adoption bails are a RETENTION ratchet (74.6% at 300 seeds), not a hard oracle — a template whose adoption bails takes the documented swap fallback. Three real defects, each with its own entry below: the children-slot accessor-level mismatch, the fragment-hidden placeholder parent, and the null-placeholder guard regression.


[FIXED, 2026-09] SSR and client emits must carry the SAME number of reactive levels for a slot — {props.children} was wrapped on the server (shouldWrap) and passed BARE to _mountSlot on the client.

A function-valued children (<Comp>{() => sig()}</Comp>) then reached _mountSlot as the slot's OWN accessor: one level on the client, two in the markup (<!--$--><!--$-->x<!--/$--><!--/$-->). Hydration adopted the outer range, mis-walked the inner one (text@slot > reactive > text), and mounted the child's text a second time beside its server node (propalphaalpha); a reactive children prop was frozen at bind time too. Fix: wrap a children expression in the template emit with the SAME shouldWrap predicate the h() path uses — symmetry by construction, both backends. Deliberately NOT applied to an element-valued const ({el}): a VNode is never an accessor and hydration's literal branch already walks its range (the r15 specs lock that emit). Rule: when two emits of one source must agree on a marker count, derive both from ONE predicate; a second predicate is a divergence waiting for the shape nobody tested. Locked by compiler/src/tests/template-slot-emit-parity.test.ts + runtime-dom/src/tests/compiled-slot-parity.test.tsx.


[FIXED, 2026-09] elementHasDynamic read an element's DIRECT children while the emit reads the FLATTENED list — a fragment hid a placeholder's parent and its walk was inlined into phase 2.

<b><i/><>{x}</></b>: the fragment-wrapped expression flattens to a placeholder child of <b> at emit time, but <b> counted as static, got no phase-1 const, and its phase-2 line was _setChildAt(__p0.nextSibling, __p1, …) — evaluated AFTER _setChildAt(…, __p0, …) had replaced __p0: null.replaceChild, a client-mount crash (fuzz seed 10, two fragment-wrapped texts in one template). Same PZ-08 class (a phase-2 walk from a mutated node), one predicate short: the absorbed-component case had been added there, the fragment case had not. Fix: the predicate looks through fragments, mirrored in Rust. Rule: a classification predicate must consume the same (flattened) input the emit consumes — "direct children" and "emitted children" are different lists whenever fragments exist.


[FIXED, 2026-09] A guard added to a branch DESIGNED for null must be null-safe — and a throw inside an adopt bind is swallowed into dead bindings, not a crash.

#3307's !isMidSlotText(placeholder) conjunct on _mountSlot's marker-less branch read .nodeType unguarded; that branch's own comment names the empty sole slot as the case where "both sides are null" (an accessor that rendered null, markers elided, an EMPTY element). The bind threw, hydrateComponent's catch logged and kept the server nodes, and the element's class AND slot bindings were orphaned — identity kept, no mismatch, no error box, visible only on a later flip. Shipped for a day; found by the compiled fuzz (seeds 76 + 112) and hotfixed in #3312. Rule: read the branch's contract before tightening its guard — the value it admits is the value your guard must accept; and treat a console.error from hydrateComponent's catch as a failed adoption, never as noise. Locked by hydrate-empty-sole-slot.test.tsx.


[FIXED, 2026-08] A compiled template in COMPONENT-CHILD position runs before the component's own body — so a provider's own element child read the WRONG context.

<Provider><div>{useCtx()}</div></Provider> lowers to jsx(Provider, { children: _tpl(html, bind) }), and _tpl(…) is an ARGUMENT: it runs before Provider's provide(). renderEffect snapshots the active context OWNER when it is CONSTRUCTED, and construction is that eager call — so every binding in the child resolved context against the pre-provide owner and rendered the DEFAULT value. Measured on main: client DEFAULT, plain SSR PROVIDED, SSR+ssrTemplate DEFAULT — i.e. wrong on the client AND disagreeing with itself across an ssrTemplate flag the vite-plugin sets automatically. The accessor form {() => useCtx()} was equally broken (deferring the READ does not move the CONSTRUCTION), which is why the usual "wrap it in an accessor" advice did not rescue it, and a component that DROPPED its children still paid for the eagerly-built subtree plus an orphaned, undisposable binding (leak class H). Fix (Solid's model): a component's SOLE child is emitted {_lc(() => _tpl(…))} — a memoized, untracked thunk branded REACTIVE_PROP, so the EXISTING makeReactiveProps step in mount/hydrate/SSR turns it into a property getter with no changes of its own. The getter is the load-bearing choice, not an implementation detail: it returns the VALUE, so Switch's branch scan, kinetic's resolveChildren, @pyreon/elements' Iterator, the *-compat Children.* helpers and every render-prop primitive see exactly what they saw before — a bare accessor would have handed all ~40 of them a function, which is the documented "compiler wraps children in an accessor" breakage class. General rule: a compiled template may only CONSTRUCT DOM; the moment its emit performs work ordered against a component's setup — a binding, a context read, a nested mount — that call must be deferred to the point the component READS props.children, not left as an eager argument. Scope is the SOLE meaningful child (JSX whitespace elided): with one child props.children is one value and the getter is transparent; with several it is an ARRAY, and deferring that means either an array of thunks every structural consumer must unwrap or a new runtime branch resolving such arrays — a contract change, so multi-child parents stay eager and the residual is pinned by its own spec rather than half-fixed. A member-expression tag (<Ctx.Provider>) is not classified as a component anywhere in the compiler, so it is eager too. Detection lesson (the reason PR #2914 could not ship): the compiler's 2,047 tests, its byte-identical native-equivalence oracle, the 300-seed differential fuzz, runtime-dom's 1,249 tests and the ssr-node e2e were ALL green against the broken build — only ui-showcase-regression (26/26 → 4/26) caught it, because the bug needs a provider ABOVE a templatized element and no synthetic fixture had one. A template-emission change is a real-app-e2e change. Reference: packages/core/core/src/props.ts:_lc + compiler/src/jsx.ts:isSoleComponentChild/braceTemplateChild + native/src/lib.rs:brace_template_child; regression runtime-dom/src/tests/lazy-component-children.test.tsx (17 specs, both JSX runtimes, bisect-verified — reverting both backends fails 8 with expected '<div class="k">DEFAULT</div>' to be '…PROVIDED…') + the _lc parity block in compiler/src/tests/native-equivalence.test.ts (bisect-verified: reverting the JS backend alone fails 11 parity specs).


Absorbing COMPONENT children into a template is worth 41% of the deep-tree gap, and its remaining blocker is HYDRATION, not ordering

(templatizeComponentChildren, opt-in, 2026-08). The template emitter bails on a component child, so one <Node/> makes templateElementCount return −1 for that element AND every ancestor — an app's whole composition skeleton lowers to h() + mountElement. Baking the skeleton and mounting each component child into the clone (_mountChild appended when nothing static follows it, _mountSlot + <!> otherwise) measured 4.53ms → 3.94ms (−13.0%) on the 2,047-component deep-tree mount, closing the gap to SolidJS 1.31ms → 0.77ms (standing 1.41× → 1.24×), with Vanilla/Solid/React/Vue/Svelte as in-run controls moving ≤2% (two noisier arms ≤5.5%) and the arm verified by grepping the built bundle for the baked <div class="branch"></div> BEFORE any number was read. Three things have to be right, and two of them only a real app proves. (1) Ordering. A _tpl bind runs when the CALL EXPRESSION evaluates, so a bind that MOUNTS COMPONENTS is ordered against the enclosing component's setup — the entry above is the general form. The sole-child case is _lc-deferred; EVERY other eager-argument position (multi-child component parent, MEMBER/namespaced tag parent — jsxTagName reports '' there so an uppercase test would misread it as a DOM tag — fragment, expression container) must BAIL to h(). Neuter that gate and a provider's grandchild renders DEFAULT. (2) The absorbed child must be a PRESERVED HOLE, never a slice. Slicing its source emits the text as it was BEFORE the walk transformed it, silently dropping _rp on its props (so a signal-driven prop freezes), _lc on its own sole child, and any nested _tpl — it compiles, renders, and passes every structural check. Bracket the template's replacement around the child's own range (the _ssrDeferred preserved-hole shape) and walk it. Nothing in the repo's unit suites caught this; 6 of 26 ui-showcase-regression specs did. (3) PZ-08. An absorbed component child emits a phase-2 mount line, so its element needs a phase-1 ref const — otherwise the inlined walk runs after a preceding _mountSlot removed its <!>, and the component either throws Cannot read properties of null (reading 'nextSibling') or lands in the marker and vanishes SILENTLY (<section></section>). It is now DEFAULT ON (@pyreon/vite-plugin; the @pyreon/compiler primitive stays opt-in, the same split ssrTemplate uses), but it spent three PRs default-OFF for hydration reasons, and neither of the first two recorded reasons was right. The original diagnosis ("a _tpl result is SWAPPED at hydration", retention 3/4 → 0/4) was measured before component-root template adoption landed; a component-root _tpl then ADOPTED, which took the OFF arm of that 3-level layout from 3/4 to 4/4 and made the ON arm's 0/4 a strictly larger loss than first priced. The live mechanism was the MOUNT HOLE — see the entry directly below, which closed the all-component shape (0/4 → 4/4) and then the mixed one (3/4 OFF → 4/4 ON). What made the flip safe was not covering every shape but making the uncovered ones cost NOTHING: the emitter now absorbs exactly [element*][component+] — components in one TRAILING run, preceded only by baked elements — and BAILS every other arrangement to h(), which is byte-identically what the option emits when off. So the option changes an emit exactly when it absorbs (locked over 5,000 fuzz seeds as differs === appendForm), and there is no shape it can make worse. The <!> + _mountSlot variant for components is GONE: it rendered correctly but produced a template containing a comment, which templateSignature refuses, so it cost more retention than the absorb bought. Reference: compiler/src/jsx.ts (templateMountIsEagerlyOrdered / registerHole / the bracketed emit in tryTemplateEmit); regression runtime-dom/src/tests/templatize-component-children.test.tsx (24 specs; all three mechanisms bisect-verified, incl. the hydration cost pinned as an executable measurement).


[MOSTLY FIXED, 2026-08] Compiled-template hydration adoption stops at a MOUNT HOLE — and the fix needs three halves, not one.

A component-root _tpl adopts its SSR nodes because hydrateComponent arms a one-shot target with the component's cursor, but only while the template's element tree is COMPLETE. A template with a HOLE — a position left empty for the bind to fill — has an element tree that is a strict SUBSET of the server's, and matchDomAgainstTemplate walks every DOM element child against a flat template tag list, so the hole's server content reads as EXTRA ELEMENTS and the match is rejected. (Disproof of the earlier "eager argument, no cursor to arm" diagnosis: an EMPTY hole adopts — same shape, absorbed component rendering null, retention 2/2, runtime.tpl.adopt fires. The arming was never the problem; the hole's CONTENT was.) Closing it took three things, and doing only the first is a correctness BUG rather than a partial win: (1) the verifier must SKIP the hole's DOM range; (2) the compiled bind must HYDRATE that range, not mount into it — relaxing (1) alone leaves _mountChild appending a second copy beside the server's (<section>SSR</section><section>CLIENT</section>); (3) the range must be DELIMITED. The third is where the cheap instinct is wrong: the answer was NOT to emit an SSR range marker around every component's output. A hole is always TRAILING (the compiler routes any component child with static content after it through a <!> placeholder instead, and templateSignature refuses every template containing one), so the parent element's own tag boundary supplies the extent — the same argument that makes a sole-child accessor's markers elidable. SSR gained ZERO bytes, and a per-component marker would have taxed every hydrated page on a path where marker normalization already measures ~29% of the walk. The relaxation must be DECLARED, never inferred. _setChild and a spread innerHTML also fill an empty template element and do NOT hydrate, so "any empty element may have extra children" would duplicate or discard their content; the compiler bakes data-pyreon-hole onto the element instead — carried ON the element it describes, not as an index or a path, which is this repo's most-repeated bug class — and _tpl strips it at parse time so it never reaches user DOM. Whatever the bind does not claim is SWEPT, which is exactly the empty element a clone would have produced, so a mis-declared hole costs an adoption and never correctness. Result on a 3-level layout: 0/4 → 4/4, three adoptions. RESIDUAL — CLOSED, and the fix was smaller than the residual made it sound. The MIXED shape (a component with a static sibling BEFORE it) measured 3/4 OFF → 0/4 ON and read as "we need a verifier that can adopt a comment-placeholder-bearing template". It needed nothing of the kind. The hole mechanism above never actually required the element to be EMPTY — only for the hole to be TRAILING, which is what lets the closing tag supply the extent. So the declaration became "this element ends short" rather than "this element is empty": the compiler bakes the static prefix as usual and still marks the element, the verifier matches the template's own k children first and starts the hole cursor after them, and k is read off the TEMPLATE (el.children.length) so no count crosses the compiler/runtime boundary to drift. k === 0 is the all-components case and reduces to the original code path exactly. Result: 3/4 OFF → 4/4 ON, still zero SSR bytes. The shapes that genuinely would need a marker — anything static AFTER a component — are not absorbed at all; they bail to h(). The lesson is the framing: a "residual" stated as a missing CAPABILITY ("the verifier must learn placeholders") was really a too-narrow PRECONDITION ("the element must be empty"), and the honest question is not "how do we build that capability" but "what does the mechanism actually require?" General rule: a template's static skeleton is adoptable only while it is a COMPLETE description of the DOM it claims. When the emit leaves a position for a runtime mount, that position needs (a) an extent — from a server marker, or from a structural boundary that both sides derive identically — (b) a hydrate-mode counterpart to the mount call, and (c) a sweep for whatever the render did not claim. A verifier relaxation on its own duplicates the page. Reference: compiler/src/jsx.ts:TPL_HOLE_ATTR + processChildren (both backends), runtime-dom/src/template.ts (MOUNT HOLES: strip, cursor frame, _mountChild, sweep) + hydration-plan.ts:matchDomAgainstTemplate; pinned in runtime-dom/src/tests/hydrate-template-hole-limit.test.tsx + mount-hole-adoption.browser.test.tsx, every mechanism bisect-verified — including per BACKEND, because reverting only the JS side leaves all 39 specs GREEN (transformJSX prefers the native binary).


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 onClicktoggle, (2) the forwarded <input> click → the input's onChangetoggle, and (3) that forwarded input click BUBBLES back up to the label → onClicktoggle 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 labelpreventDefault 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()applyPropsapplyStaticProp (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".


JSX Mistakes