pyreon

SSR-rendering 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.

A quantified regex over an AMBIGUOUS character class is polynomial — and a hardening fix is the likeliest place to introduce one (normaliseTarget, 2026-09)

replacing a trim() with /^[\u0000-\u0020\s]+|[\u0000-\u0020\s]+$/g looks like a strictly-safer normalisation and is a quadratic ReDoS. The class is AMBIGUOUS — \u0000-\u0020 already contains every character \s adds below U+0080 — so on a long run of matching characters the engine retries the $-anchored alternative from every position. Measured on the shipped implementation: 5k chars 13.5ms, 20k 204ms, 80k 3,563ms, clean O(n²). What makes it a real defect rather than a curiosity is REACHABILITY, and a hardening function has the worst possible reachability by construction: it exists precisely because its input is attacker-supplied. Here redirect() targets come straight from a ?next= parameter and the guard runs on every SSR redirect, so the fix for an open redirect shipped a denial of service into the same function. General rule: any regex quantified over a character class must have an UNAMBIGUOUS class (no member reachable two ways) and, where the job is a leading/trailing scan, prefer an INDEX WALK — while (i < end && s.charCodeAt(i) <= 0x20) i++ cannot backtrack at all, and is usually closer to the spec you are implementing. It was here: <= 0x20 is exactly the "C0 control or space" set the URL parser strips, so dropping \s made the guard MORE faithful (a leading U+00A0 is not stripped by a browser either, so it correctly stays part of the path) AND removed the no-control-regex suppression the regex had needed. Detection: CodeQL's js/polynomial-redos caught it on the PR that introduced it, and the first triage was wrong — the check was dismissed as a pre-existing dependency alert because an unrelated Scorecard alert was also open. A CodeQL check whose summary says "New alerts in code changed by this pull request" is never the pre-existing one; read the annotation before attributing it. Reference: packages/core/router/src/redirect.ts:normaliseTarget; bisect-verified in router/src/tests/redirect-normalisation.test.ts (restoring the regex fails with normalisation took 3730ms — quadratic backtracking is back; the timing assertion is safe from flake only because the margin is four orders of magnitude — 0.01ms linear against a 500ms budget).


A URL guard that inspects the string it was HANDED, when the browser inspects a PREPROCESSED one — the classify-the-wrong-string class (safeRedirectLocation, 2026-09)

classifyRedirectTarget normalised with String.prototype.trim() and then tested for //, http(s):// and a leading scheme:. A browser does neither of those first. The WHATWG URL parser preprocesses its input in two steps — strip leading/trailing C0 controls AND space, then remove ALL ASCII tab and newline from anywhere in the input — and trim() covers the first only partially (Unicode whitespace plus five C0 controls, NOT \u0000\u0008/\u000e\u001f) and the second not at all, because that character sits in the MIDDLE where no trim can reach. Measured against the platform's own URL parser: "\u0000//evil" and "/<TAB>/evil" both classified internal and resolve to https://evil/ (open redirect); "\u0001javascript:alert(1)" and "java<TAB>script:alert(1)" both classified internal and resolve to a live javascript: URL (XSS, since the client router assigns the result to location). The plain forms (//evil, javascript:…) were correctly blocked, which is what made the guard look right. Second half of the same bug: the internal branch returned the ORIGINAL target, not the normalised t it had just judged — so even a correct verdict handed the caller back the bytes that produce a different one. General rule: a guard that decides what a string MEANS must first normalise it exactly as the consumer that will act on it does, and must then return the normalised value — inspecting one string and emitting another is a bypass by construction. Reach for the consumer's own parser as the oracle rather than a table of expected outputs: the claim is "this cannot resolve off-origin", and only a parser can answer it (a hand-written table encodes the same assumptions that were wrong). Same family as the </-only script-escape entry — both are "the sink normalises, the check did not". Reference: packages/core/router/src/redirect.ts:normaliseTarget; bisect-verified in router/src/tests/redirect-normalisation.test.ts (restoring trim() + the original-target return fails 11 specs; the intentional-external and interior-NUL controls stay green).


</-only escaping of script-context JSON is insufficient — <!--<script> bypasses it (SSR data-embed XSS-adjacent)

escaping just </ (JSON.stringify(data).replace(/<\//g, '<\\/')) does NOT make a JSON blob safe to drop into an inline <script> body. The HTML tokenizer enters the script-data-double-escaped state on <!-- followed by <script — NEITHER token contains a slash — so a value like <!--<script>alert(1)// survives verbatim, corrupts the script boundary, and the page either mis-parses (hydration DoS: SyntaxError → blank page) or, with a crafted payload, executes injected script. Also: raw U+2028 / U+2029 are legal in JSON strings but are literal line terminators inside a <script>SyntaxError; JSON.stringify does not escape them. Fix: neutralise the whole < class + the two JS line separators.replace(/</g, '\\u003C').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'). \\u003C makes </script, <!--, and <script all unformable and SUPERSETS the old </ escape; every escaped form parses back to the original character under JSON.parse, so the hydrated DATA is byte-identical — only the SERIALIZED representation is neutralised. > and & need no handling in this context: character references are not decoded in script-data, and --> cannot close a comment that can no longer be opened. General rule: any code embedding data into an inline <script> body must escape the <-class (so no </script/<!--/<script can form) plus U+2028/U+2029 — a </-only escape is a well-known bypass. One helper, N call sites (folklore not a fix): find every copy. In Pyreon the loader-data serializer AND the store-state twin (__PYREON_LOADER_DATA__ at html.ts + render-page.ts; __PYREON_STORE_STATE__ at render-page.ts) both route through the SINGLE stringifyLoaderData in @pyreon/router — one fix covers all three embed sites. @pyreon/atlas's bakedRpcScript (build/bake.ts) is the correct in-repo PRECEDENT (it already escapes </, <!--, U+2028/9). CORRECTION (2026-08-27): the scriptSafe helper in packages/fundamentals/{code,charts,rich-text,flow}/src/webview.ts is NOT the same class, and the <-class escape MUST NOT be applied to it. Those four embed RAW JAVASCRIPT SOURCE into an inline <script>, not a JSON string. \u003C is only a character escape inside a string literal; in raw JS it corrupts every < operator, so if (i < n) becomes a syntax error. Verified by emitting buildFlowHostHtml() and parsing the <script> body with oxc: 0 parse errors before, Invalid Character '<' after. For raw-JS embedding the </ escape is the correct and sufficient mitigation — it breaks the only token that can close the tag, and a valid JS source file cannot contain a bare U+2028/U+2029 outside a string literal (where ES2019 makes them legal anyway). The general rule therefore needs its payload type stated: escape the < class when embedding DATA (JSON); escape </ when embedding CODE. A lint rule that flags </-only escaping generically would flag those four correct sites. Parse side is clean by construction: hydrateLoaderData stores into a Map keyed by route-record OBJECTS via Object.hasOwn with framework route-path keys — no plain-object write, no __proto__ vector. Reference: packages/core/router/src/loader.ts:stringifyLoaderData; regression-locked (bisect-verified: revert to </-only → the <!--<script> exploit spec fails with expected '…"<!--<script>alert(1…' not to contain '<!--') in router/src/tests/loader.test.ts (inline-<script> context escaping (security)) + server/src/tests/server.test.ts (buildScripts neutralises the <!--<script> script-data bypass).


[FIXED, 2026-08] An OPT-IN per-page hook with N consumers, wired by exactly one of them — SSR shipped styler CLASS NAMES with no CSS.

renderPage's collectStyles was opt-in, and of its three consumers (production createHandler, zero's dev SSR middleware, zero's SSG prerender entry) only the SSG entry ever passed one. So both SSR paths emitted HTML carrying pyr-1abc23 classes with no <style> tag at all — measured on examples/ui-showcase: 23/23 classes on /button with zero matching rules, on every route. The DOM after hydration is CORRECT, so the only symptom is a wrong FIRST PAINT. Worse, the SSG path had already been fixed for this exact bug (its comment reads "prerendered HTML carried styler-generated class names … but had ZERO <style> tags in the head") and the siblings were left behind — the "a fix applied to ONE call site is folklore, not a fix" class, on a hook whose default ('') is silently indistinguishable from "this app has no CSS-in-JS". Fix at the choke point, not the call sites: renderPage defaults collectStyles to globalThis.__PYREON_STYLER_COLLECT__, registered by @pyreon/styler's singleton on SSR init — the string-mode twin of the __PYREON_STYLER_FLUSH__ seam streaming already used, so still no server → styler dependency, and every current AND future consumer is covered by construction. The two seams are independent (getStyleTag() never moves flushSSRPending()'s watermark), so a streaming app with a string-mode isr route is correct on both. Two general rules. (1) An opt-in hook that N call sites must remember to wire is a silent-hole generator — give it a safe DEFAULT at the shared choke point and let an explicit value override, rather than auditing the call sites. (2) A defect masked by a SECOND defect has no symptom until the mask is removed — hydration was discarding the server DOM and rebuilding it, so users saw nothing for ~300ms instead of seeing it unstyled; the FOUC only became observable once hydration adopted the server DOM (fix/map-composed-hydration-adoption, which found this while its own ui-showcase-regression dropped to 22/26). When fixing bug A drops an unrelated gate, suspect that A was hiding B rather than that A is wrong. Detection: assert the invariant "every styler class in the SSR HTML has a matching rule" on the RAW HTTP response (request.get, no JS) — a hydrated-DOM assertion re-emits the CSS client-side and false-passes, and a hasAttribute-style "is there a <style>" check passes on the app's own unrelated reset tag. Reference: packages/core/server/src/render-page.ts + packages/ui-system/styler/src/sheet.ts; locks render-page.test.ts ("defaults to the globalThis seam") + styler/__tests__/ssr-flush-global.test.ts + e2e/ui-showcase-regression.spec.ts ("SSR styler CSS emission"), all bisect-verified.


[FIXED, 2026-08] A replay plan compiled from SAMPLE row 0 applied to N rows without per-row VNODE-shape verification silently drops a divergent row's bindings.

The hydration row-plan (replayRowPlan) verified per-STEP DOM shape (tags, markers) but nothing verified each row's VNODE against row 0's — and per-step checks only cover what the plan TOUCHES. A renderItem that diverges per item (a conditional onClick on some rows, an extra child element, a per-item ref) was adopted with row 0's bindings only: dead click handlers, unhydrated extra children, refs that never fire — zero warnings, and every DOM-side check PASSES because the SSR DOM came from the divergent row's own vnode. Row-root ref was additionally never wired at all (refs were step-element-only). Fix: a row-shape SIGNATURE recorded at plan build (tags, ordered for-in prop-key sequences, child kinds/counts, plus a walk-completeness check so a truncated row can't underfill step targets) verified per row BEFORE any binding; divergence bails that row to the interpretive walk. Rule: a fast path that compiles a plan from one INSTANCE and replays it over N must verify each instance against the plan's FULL shape, not just the positions the plan binds — the untouched remainder is exactly where a divergent instance's extra bindings die silently. Bisect-verified per guard in runtime-dom/src/tests/hydration-plan-specialization.test.tsx (key-compare off → dead-click specs fail; childCount off → nesting-rebalance spec fails; root-ref off → ref specs fail). Reference: packages/core/runtime-dom/src/hydration-plan.ts:verifyRowShape.


a client-side route prefetch that injects <link rel="modulepreload" href={routePath}> to "warm the route's JS chunk" is systematically broken under SSR. rel="modulepreload" is specified to fetch AND PARSE a module script; the href is the navigation PATH (/threats/detections), which an SSR server (dev or prod) returns as text/html — so the browser logs Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html" on every hover, plus a wasted HTML round-trip. It's cosmetic (navigation still works — the real chunk loads via the router's lazy loader on nav) but fires for EVERY route, in dev and any SSR deploy. The per-route chunk URL is not knowable from the client path — it lives inside the matched route record's loader() closure (Vite rewrites import('./routes/x.tsx') to import('/assets/x-hash.js'), and the specifier is not exposed as a property). So you cannot construct a correct modulepreload href at runtime from the path. Fix: warm the chunk through the router's own lazy loaderrouter.preload(path, undefined, { skipLoaders: true }) runs record.loader() (the real Vite-resolved import()) into the component cache, always the correct chunk URL, code-only (no loader/data side effects on hover). Keep the sibling rel="prefetch" as="document" hint (valid — prefetching the next-nav HTML is exactly what as="document" is for). Two sub-lessons: (a) a bare .catch on an optional-chained call throws — router?.preload(...).catch(...) is a Cannot read '.catch' of undefined when router is null (no active router); it must be router?.preload(...)?.catch(...). (b) The existing unit test ASSERTED the bug ("injects a <link rel="modulepreload"> as well") — a test that encodes the broken behavior can never catch it; rewrite the assertion to the corrected truth (no modulepreload), keep the invariant (prefetch warms the route). happy-dom can't emit the real strict-MIME console error — the real-Chromium ssr-node e2e (route paths return HTML) is the only gate that observes the actual symptom. Reference: packages/zero/zero/src/link.tsx:doPrefetch; regression link-prefetch.test.ts (unit) + e2e/ssr-node.spec.ts "zero <Link> prefetch" (bisect-verified).


SSR↔hydration parity is a differential-fuzz target, and the whole class hides in the seam

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


[FIXED, 2026-09] A hydration-mismatch recovery that mounts the client's render fresh but leaves the UNCLAIMED server range until the accessor's next run — dead DOM that is visible, countable and handler-less past the hydration barrier.

adoptReactiveRange handed the accessor's first render to hydrateChild, which on a tag divergence mounts the client's render before the anchor and returns the cursor it stopped at; the server nodes it did not consume were only cleared by mountReactive's per-run cleanup, i.e. on the FIRST FLIP. The shape that hit it is ordinary: a query-backed list whose SERVER cache was warm (a module-level QueryClient shared across SSR requests — a cross-request-state smell in its own right) and whose CLIENT cache was cold, so the server rendered <ul> with rows and the client rendered <p>Loading…</p>, and the page showed BOTH for the whole fetch — three rows whose delete buttons carried no __ev_click. data-pyreon-hydrated is set when hydrateRoot returns, before the fetch lands, so a Playwright spec that waited for hydration, counted 3 rows and clicked one was clicking dead DOM: new-demos.spec.ts › useDelete removes a task failed 3/5 locally and more under CI load, and read as a query/mutation bug for weeks. Fix: snapshot the server range BEFORE the walk, treat everything between the starting cursor and the returned cursor as claimed, and remove every snapshotted node outside that span in the same first render. Freshly mounted nodes are never in the snapshot; adopted nodes are claimed by construction. The one trap: mountReactive has already inserted its own anchor immediately before end, INSIDE the range — stop the snapshot at it, or the sweep detaches the boundary and the accessor never renders again (the first cut did exactly that: sweep correct, list dead forever). General rule: server DOM the client's first render did not claim must be gone when hydrateRoot returns — the hydration barrier is a promise that everything on screen is live, and "cleared on the next flip" breaks it for exactly as long as the data takes to arrive. The parity fuzz is structurally blind here (its oracle is zero mismatches). Locked by runtime-dom/src/tests/hydrate-mismatch-sweeps-server-range.test.tsx (both accessor paths + an adopt control, bisect-verified: revert → expected 3 to be +0). The demo's client is now created per mount (examples/fundamentals-playground/src/demos/FeatureDemo.tsx).


A hydration range marker may be elided ONLY where the DOM already states the extent — and the marker's per-row STRUCTURAL guard must be re-stated, not dropped

(2026-08). SSR wraps every reactive accessor in <!--$-->…<!--/$--> because its extent is runtime-unknowable (0, 1, or many nodes). Exactly one construct escapes that: an accessor that is its element's SOLE child, where the tag boundary already delimits the slot — everything between <a> and </a> IS the extent, for every value. Eliding there is sound where the value-conditional scheme that regressed 83/5000 parity-fuzz seeds was not, and the difference is the whole rule: elide by CONSTRUCT (a static vnode shape both sides derive identically), never by VALUE. children.length === 1 && typeof children[0] === 'function' is such a shape; "this value happened to render one text node" is not — it puts a marked range next to an unmarked one and reopens the cursor gaps. The trap is the second half. The triplet was doing a job nobody had written down: on both hydration fast paths (replayRowPlan for interpretive <For> rows, replayAdoptPlan for compiled _tpl rows) it also proved, per row, that the slot still held a TEXT node — so a row whose accessor rendered EMPTY (no node) or a VNode (an element) bailed to the interpretive walk instead of binding .data onto null or an element. Delete the markers and those replays return true having verified nothing, which is silent corruption on exactly the rows that diverge. Both replays now state the invariant directly (nodeType === 3 && nextSibling === null at the slot). General rule: before removing a redundant-looking artifact, enumerate every consumer that reads it — one of them is usually using it as a cheap proof of something else, and a per-row fast path that verifies NOTHING is worse than the cost you removed. A third of the change is the four surfaces agreeing byte-for-byte (renderElement + streamElementNode; hydrateElement + both replays; the _escSole emit in BOTH compiler backends) — and the compiled emit needs a RUNTIME typeof v === 'function' check, not a static one, because {() => sig()} reaches the hole as a function while {sig()} is compiler-wrapped and arrives as a value; a mapitem hole holding a function is the shape no fuzz covers (their map values are strings). Reference: packages/core/runtime-server/src/index.ts:soleAccessorChild/_escSole + runtime-dom/src/{hydrate.ts,hydration-plan.ts}; locked by sole-accessor-marker-elision.test.tsx + the parity fuzz at 20,000 seeds (all three fuzz gates take PYREON_FUZZ_SEEDS).


A compiled-template NativeItem reached by hydration is CLONED and replaceChild-ed, discarding the server DOM — hydration that does not hydrate

(fixed 2026-08). _tpl() builds a fresh subtree and hydrateChild's __isNative branch swapped it in for the SSR nodes, on the reasoning that the final DOM is identical. It is not: identity is the whole product of hydration. Measured node retention on current main before the fix — a leaf <div class="leaf">hello</div> 0/2, a 3-level tree 0/4, a component-wrapped subtree 2/4 (only the skeleton survived, and only because the template emitter BAILS on component children so those elements lower to h()hydrateElement); <For> rows were 10/10 because they alone armed the one-shot _tpl adopt target. So every pure-DOM subtree in every compiled SSR app was rebuilt. This is a correctness bug, not a cost: text typed into an uncontrolled input before the bundle boots is wiped, focus is lost, scroll position resets, and listeners attached by non-Pyreon code are dropped — all verified, all restored by the fix. And the exposure is maximal rather than marginal: forms with handlers, forms with reactive attrs, and the ordinary card/leaf shape all templatize. Fix: hydrateComponent arms the SAME one-shot target the <For> path uses with the component's SSR cursor, so a root _tpl binds against the existing nodes; hydrateChild skips the swap when native.el === domNode (replacing a node with itself detaches and reattaches it, destroying focus for nothing). The safety half is the load-bearing part. The slot is consumed by whichever _tpl runs FIRST inside the armed window — and for an h()-rooted component that is an INNER template, not the root, because arguments evaluate before the call. A local template can therefore STEAL the root's SSR node: verified, a <div class="other"> came back as <div class="root">OTHERVAL<!--/$--></div>, wrong class plus a stray marker. So adoption is gated on the template's static skeleton being byte-equal to the target — tags, static attributes, and static text (a baked dynamic slot is a single space and is skipped; dynamic props are absent from the template, so attributes are a SUBSET check). Under that gate a wrong consumer can only ever adopt a node byte-identical to the one it would have cloned, making mis-consumption cost an adoption and never correctness. General rule: when a one-shot slot is claimed by position rather than by identity, the claimant must be verified structurally — and the verification must be strong enough that claiming the WRONG slot is indistinguishable from claiming the right one. Same family as the position-based-pop Class A entries. Reference: packages/core/runtime-dom/src/hydrate.ts:hydrateComponent + hydration-plan.ts:matchDomAgainstTemplate; locked by hydrate-component-tpl-adoption.test.tsx (bisect-verified both directions — dropping the arming fails the retention/typed-input specs with expected <input> to be <input> // Object.is equality; dropping the skeleton gate fails the theft spec with expected 'root' to be 'other').


A hole-free compiled template is not a hydration lever — staticness and repetition are anti-correlated

(2026-08; probes examples/benchmark/probe-holefree-census.ts + probe-ssr-retention.ts). The recurring idea is that since the compiler knows at emit time which _tpl calls have NO dynamic holes, hydration should claim those subtrees in O(1) instead of verifying them node by node. Two facts kill it. (1) The bind is ALREADY free: a fully-static subtree lowers to _tpl(html, () => null) — a no-op binder — so the only per-node work left for such a subtree is the adoption VERIFY (hydration-plan.ts:matchDomAgainstTemplate), which is not overhead but the thing that makes adopting a server node SAFE; without it an inner template can consume the slot armed for the root and adopt a node it does not match (reproduced in #2918). The proposal therefore trades correctness for exactly the cost that buys it. (2) The share is ~0 where hydration actually walks. Instance-weighted, via a temporary _tpl census hook: the app-page hydration bench (the committed in-place-hydration instrument) is 0.0% — 8,988 template instances covering 43,764 elements, every one bound; a real docs content page is 0.6% (5 hole-free of 909 template-covered elements — 0.04% of that page's 11,581 live elements). The reason is structural, not incidental: a template is hole-free exactly when it carries no data, and a subtree carrying no data has no reason to REPEAT. Measured instances per distinct template — hole-free 1.0–1.1 (max 2) vs bound 4.6 / 17.8 / 2996 (max 8,400). So the elements that dominate a page's node count are always the bound ones. The generalizable rule is a measurement one: instance-weight a code shape before optimizing it — a DECLARATION-weighted census over-reports by 30–50×. The same docs site reads 45.5% hole-free counting each _tpl call site once and 0.6% counting instances on a real page from it; the one page that looks favourable (landing, ~34% — two runs read 34.9% and 33.9%, so treat it as a band) is 76% a single decorative hero SVG rendered ONCE, and adopts nothing at all. No wall-clock timing was taken and none is needed: the kill criterion is a COUNT, and the ceiling is bounded without one — 96 hole-free elements on the most favourable page, which at the ~25ns/DOM-crossing DERIVED from the marker entry above is single-digit µs against a milliseconds-scale hydration. That is an ESTIMATE from a published per-crossing figure, not a measurement of an implementation; on the two pages that matter the share is 0.0% and 0.6%, where no coefficient can rescue it. Scope, so this is not over-read: the measurement kills the WHOLE-TEMPLATE notion (skip a _tpl that has no holes anywhere). It says nothing about the finer-grained variant — most ELEMENTS inside a BOUND template carry no binding either — which is UNMEASURED here and would have to clear a harder bar, since skipping those is precisely a weakening of the skeleton gate rather than the removal of dead work.


A @pyreon/zero page discards ~100% of its SSR <body> at hydration, so compiled-template adoption never engages

(measured 2026-08, NOT fixed — recorded for whoever picks it up; probe examples/benchmark/probe-ssr-retention.ts). On the docs site's own production SSG build, /docs/router ships 11,567 server-rendered elements and 63 survive hydration with their identity intact (0.5%) — <body> retention 10/11,514 = 0.1%, and the survivors are exactly <html> + <head> and its children. hydrateRoot IS called (zero/src/client.ts branches on hasSSRContent), but zero's route content is a reactive child of RouterView, so the SSR DOM is re-mounted rather than hydrated in place — the same property the islands entry documents, here quantified. Independently confirmed by a _tpl census: ZERO templates were armed for adoption on either docs page, against 8,988 armed on the app-page bench, so the hook is proven to fire. Consequence: #2918's adoption fix — which took that bench from 1/2206 to 2201/2206 retained nodes — does not reach zero apps at all, so every zero page load destroys typed input, focus, scroll position and any non-Pyreon listener, and rebuilds DOM the server already produced. Detection gotcha that makes this easy to measure WRONG: the stamp must be a SYNCHRONOUS end-of-body <script>, never a DOMContentLoaded listener — module scripts are deferred, i.e. they execute after parse but BEFORE DOMContentLoaded, so a DCL-registered stamp measures the post-boot DOM and reports a meaningless ~100% (observed: 628/628 on a page whose real retention is 0.5%). Always run a page you expect to RETAIN as a positive control — islands-showcase reads 9/9 — because a probe that reports 0% everywhere is indistinguishable from a broken one.


The $-marker normalization is Pyreon's hydration tax, and the REMOVAL is not the cost — the walk + verify are

(2026-08 measurement campaign; harness examples/benchmark/probe-marker-cost.ts). A compiled row template bakes a text placeholder (<a> </a>) while SSR emits <a><!--$-->text<!--/$--></a>, so replayAdoptPlan must strip the open marker before the compiled bind reads __e1.firstChild. On the 1,000-row hydration bench that is ~0.29ms of Pyreon's ~1.47ms WALK (20%) — larger than the entire 0.20ms gap to Vue's 1.27ms, which is what makes it the obvious target and has now drawn two attempts. Measured decomposition (real Chromium, crossOriginIsolated 5.0µs clock, n=60, fresh DOM per sample): walk 90ns + verify 160ns + remove 55ns per row. The intuitive culprit — the remove() — is only 19%; at ~25ns per DOM property crossing the cost is simply the ELEVEN crossings that locating and verifying the triplet makes, and elByPath re-walks to the very element the compiled bind then walks to itself (~90ns/row of duplicated work that only a COMPILER change could share). Refuted alternatives, all measured, all losing: a TreeWalker(SHOW_COMMENT) sweep +17% (it visits 3,000 comments to find 1,000 — the C++ filter does not pay for the extra crossings); a querySelectorAll batch walk +3% (the batch walk itself IS cheaper, 55ns vs 90ns/row, but static-NodeList indexing gives all of it back); a flattened FIRST/NEXT hop list +3% (loop shape is not the cost, DOM crossings are); a childNodes.length verify −6%. The only sizeable runtime-only win is dropping the two .data compares (−16%, ~0.057ms) and it is REJECTED — it trades the structural guard that catches a row whose accessor rendered a different KIND (VNode vs string) for a saving that still does not win the op. The residual is architectural, not slack. An accessor's DOM extent is runtime-unknowable (0, 1, or many nodes), so SSR must delimit it; Vue's compiler FUSES adjacent interpolations into one TEXT child ({{a}}{{b}}_toDisplayString(a) + _toDisplayString(b)), which makes a dynamic text statically the SOLE TEXT_CHILDREN of its element — so Vue emits no marker, performs ZERO DOM mutations hydrating a well-formed compiled tree, and for a sole-child interpolation does not even locate the text node (one el.textContent compare, @vue/runtime-core runtime-core.cjs.js:2171-2190; its <!--[-->/<!--]--> fragment anchors are walked past and RETAINED as vnode.el/vnode.anchor, never removed). The only route that closes the gap is eliminating the marker for a SOLE-CHILD accessor, where the element's own tag boundary already supplies the extent (nothing to left-merge with, empty = no children, many = all children) — a decision derivable from the SAME static vnode shape on both sides, so it is NOT the banned value-conditional scheme. It is declined here anyway: it must land BYTE-IDENTICALLY across renderNode + streamNode (runtime-server), hydrateReactiveChild/hydrateElement (runtime-dom) and the _ssr emit in BOTH compiler backends, and it is a near sibling of the conditional-marker scheme that regressed 83/5000 parity-fuzz seeds. A narrowing for whoever attempts it: only a SOLE-CHILD accessor slot is adoptable at all — every other dynamic-text shape ({a}{b}, text {a}, {a}<b/>) compiles to a <!> placeholder and templateSignature refuses any template whose HTML contains <!, so the marker-bearing adopt path has exactly ONE shape to serve. SEQUEL (2026-09) — the sole-child route was taken one step further at the COMPILER: TEXT FUSION. <p>Hello {name}!</p> / <td>{a}{b}</td> / <li>{n} items</li> now lower to ONE accessor child, () => _fuse("Hello ", name(), "!"), on the _tpl, _ssr and h() paths alike (both backends, fuseTextChildren), so every mixed text run IS the sole-child shape and emits no marker at all — Vue's fusion, in Pyreon's polymorphic form: _fuse (core) joins text-ish parts with a lone-{x}'s coercion and returns the PARTS ARRAY the moment any part is a VNode/array/function, which bindPolymorphicText and renderNode already mount. MEASURED HONESTLY, because the first number was a trap: the docs site's own SSG pages move by ONE marker each (/docs/router 246 → 245 open <!--$-->; a stale July dist had read 629, which is why the entry above overstated the lever) — the docs' prose is markdown rendered through innerHTML, not JSX interpolations, and the hydration bench's rows are sole-child <a>{() => r.label()}</a> which already elide. Where JSX text runs actually live — a static census compiling every examples/, docs/src, packages/ui* TSX (682 files, 1,187 _tpl roots) — 84 elements fuse, client <!> placeholders drop 287 → 189 (−34%) and _ssr-baked open markers 72 → 34 (−53%). No walk TIMING is claimed: the box was at load 8 and the bench fixture has no fusable run; the per-crossing estimate from the entry above (~0.3µs per marker triplet) bounds the docs-page effect at nothing and an app page at tens of µs. Three things had to hold, each locked: the fusion BOUNDARY is identical in both backends (a backend fusing one shape more diverges on the wire, not just in the template — native-equivalence runs the boundary corpus on all three paths); the _ssr fuzz ORACLE had to learn the same fusion (a hand-built h() oracle that still emits per-part accessors reports 25/250 seeds diverged against a correct emit — the oracle, not the code); and the old hand-built-SSR adoption suites (hydrate-mid-text-slot-adoption, hydrate-text-slot-adoption) had been written against the pre-fusion h() emit, so they kept their mid/trailing-slot purpose only by gaining an element sibling. Reference: compiler/src/jsx.ts:fuseTextChildren + native/src/lib.rs:fuse_text_children, core/src/props.ts:_fuse; locks runtime-dom/src/tests/text-fusion.test.tsx (SSR through BOTH compiled SSR emits, hydrate through the compiled client emit; bisect-verified: neutering fusion fails 9/16 with Hello <!--$-->Ada<!--/$-->!) + compiler/src/tests/text-fusion-emit.test.ts (6/14 fail on revert).


Hand-rolling const isBrowser = typeof window !== 'undefined' instead of importing the canonical env flag

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


Running loaders but NOT resolving lazy route components before the synchronous SSR render

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


Streaming SSR shipped CSS-in-JS styles only via end-of-stream <style> tag → boundary content FOUCs until the final flush

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


Animation wrappers that gate-out children on the server

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


A marker-elision decision made from the STATIC vnode shape holds only where every consumer of that shape agrees — and the COMPILED TEMPLATE path is a separate consumer that must be proven, not assumed.

runtime-server omits the <!--$-->…<!--/$--> hydration range markers when an accessor is its element's SOLE child, because the tag boundary already delimits the extent (soleAccessorChild). The elision is decided from children.length === 1 && typeof children[0] === 'function' — identical on the server and in hydrateSoleAccessorChild — and its docblock states that its call sites "must agree byte-for-byte or hydration misaligns". They do agree while both sides go through h(). But <div class="list">{ITEMS.map(…)}</div> lowers ASYMMETRICALLY: the SSR emit leaves it as h() with an accessor child, while the CLIENT emit templatizes it to _tpl("<div class=\"list\"><!></div>") + _mountSlot. That compiled path never routes through hydrateSoleAccessorChild, so it is a further consumer of the same shape that never joined the agreement — it goes looking for a range the server deliberately never emitted. Measured: the sole-.map() child and a nested only-child slot both serialize with hasOpen=false, while the same list behind a static sibling (<h2>T</h2>{…}) keeps its markers and works. General rule: when you elide a boundary marker because "both sides agree by construction", enumerate every CONSUMER of that construct — h() render, h() hydration, the compiled SSR emit, AND the compiled CLIENT template path are four different readers, and a compiler that lowers one side to h() and the other to a template makes "both sides" a false description of the system. Prove the agreement against the compiled path explicitly (a spec that renders through the SSR emit and hydrates through the CLIENT emit, which is the only shape a real app produces), or the elision is a latent trap that costs nothing until something tries to ADOPT the region — at which point it silently falls back to a full rebuild. Note the blast radius is asymmetric and easy to mis-scope: with nothing adopting, absent markers cost nothing at all, so this is invisible on main and surfaces only for the change that starts adopting. The fix direction is forced: the server cannot know which client consumer will run, so emitting markers conditionally would break the h() pair — the compiled path must instead learn the SAME marker-less convention, substituting [parent.firstChild, <synthesized close>] for the marker pair. The missing half is a REMOVAL CONTRACT, and it is what makes a marker-less region dangerous rather than merely lossy. hydrateChild's cleanups DISPOSE bindings; they do not remove nodes, because hydrateRoot tears the whole container down. mountReactive needs the opposite — its per-run cleanup is what clears the previous render before the next one mounts. Walking a marker-less region with a bare hydrateChild call therefore produces a binding that is live but owns nothing: the first flip AWAY strands every server node (measured: 8 server + 8 fresh = 15 <article> where 8 were expected — worse than the full rebuild it replaced, because a rebuild is at least correct). The marked path had already solved this with an explicit bridge (adopt through mountReactive, whose first mount hydrates and whose returned cleanup clears the LIVE range from a stable start marker to mountReactive's own anchor), so the correct move is to SYNTHESIZE the close the server elided and run that same core — not to write a second adoption mechanism beside it. Corollary, and the reason to enumerate consumers rather than trust a docblock: hydrateSoleAccessorChild — the h()-side reader that DID join the agreement — only adopted the single-text-node case and mounted fresh for everything else, retaining 1/N for a multi-root region. "Both sides agree" was true about marker PLACEMENT and false about what either side then DID with the region, so a shape can be simultaneously in-agreement and lossy. Reference: packages/core/runtime-server/src/index.ts:soleAccessorChild + packages/core/runtime-dom/src/hydrate.ts:adoptReactiveRange (the one adoption core, reached by the marked range, the compiled marker-less slot, and the h() sole-accessor child alike) + the <!>-slot gate in hydration-plan.ts:matchDomAgainstTemplate. Sequel — when a SECOND relaxation lands on the same walk, the removed gate may have been someone else's load-bearing premise. #2939 taught the same verifier to skip a declared mount HOLE's range, and wrote its soundness note as "skipping to the end is only sound because a hole is always TRAILING … and templateSignature refuses every template containing a <!>". That last clause was this PR's gate — the blanket html.includes('<!') bail — so removing it silently retired half of a NEIGHBOURING feature's argument. Both relaxations say the same thing ("this element's whole server range belongs to a later claimer, stop verifying"), one handing the range to _mountChild and the other to _mountSlot; firing both on one element would hand the same nodes to two claimers, which is the duplicate-DOM failure again. They are in fact disjoint — a hole is an element emitted EMPTY, and an empty element has no <!> — but that was an assumption about the compiler, not a property of the code, so the hole re-check now also requires !slotAtEnd and the stale clause is corrected in place. General rule: when you delete a broad gate, grep for every comment and invariant that CITES it. A gate is not only a behaviour, it is a premise other code reasons from, and the citation is the only trace of that dependency. Corollary on honesty: the added conjunct is DEFENSIVE — the compiler cannot currently emit the overlapping shape, so removing it leaves every spec green, and it is kept for the same reason its sibling emptiness re-checks are (turn a compiler assumption into a structural property) rather than because a test proves it. Sequel 2 (2026-09) — the runtime cannot decide soleness AT ALL, so the compiler must say it. With the compiled path adopting, _mountSlot read a <!--$--> at its placeholder as the slot's OWN range. But a sole slot's VALUE can begin with a nested range — a <Show>'s root accessor, a fragment starting with an accessor — and SSR marks THAT range while eliding the slot's; the nested consumer found its markers consumed, fell to the legacy remove-one-node path, and everything after it DUPLICATED (<b>t<input><input></b>, a <For> under <Show> with every row twice; compiled parity fuzz seeds 1237/2447 at 3000 seeds — main passed at 300, which is why the CI seed count is not a proof). The obvious repair, "a placeholder at firstChild is sole", fails the MIRROR shapes (seeds 150/273/291): <main>{null}{acc}</main> and <span><>{acc}</></span> are NOT sole to SSR — the {null} and the fragment count as children, so the slot is MARKED — while the client template renders no node for them and the ref lands on firstChild all the same. Position cannot decide; the marker cannot decide; only the compiler sees the JSX-level construct SSR keys on. Fix: the compiler emits the verdict — _mountSlot(…, true) on exactly the sole shape, from the SAME ssrSoleChild predicate the SSR emit uses for _escSole, both backends — and _mountSlot trusts it (parked mid range first, then sole, then marked). The same verdict gates the lone-reactive-text firstChild fast form, whose disagreement with SSR ({null}{n()}, <>{n()}</>) merely made the verifier refuse the template. General rule: when a runtime discriminator has to reproduce a decision the COMPILER made from the source (soleness, a construct-level marker elision), pass the decision down instead of re-deriving it from the DOM — every DOM-derived proxy (position, marker) has a shape it misreads, and a fuzz sweep wider than CI's is the only thing that finds them. Reference: compiler/src/jsx.ts:processChildren (ssrSole) + native/src/lib.rs mirror, runtime-dom/src/template.ts:_mountSlot; lock runtime-dom/src/tests/sole-slot-verdict.test.tsx (bisect-verified two ways).


A non-reflecting DOM property set on the client where SSR could only serialize an ATTRIBUTE — the reset-default class

<input value> / <textarea value> were applied as a PROPERTY on client mount, and a property assignment never creates the content attribute. But the attribute IS the reset target (input.defaultValue reflects it; a textarea's default is its text content), and SSR can only ever emit the attribute — so form.reset() CLEARED a field on a client-mounted page and RESTORED it on a hydrated one. Same markup, same form, behaviour decided by whether the user landed on the page or navigated to it. Measured in Chromium: .value='hello' then form.reset()""; with the default established → "hello". Fix: a shared runtime normalizer (applyValueProp, exported to the compiler as _setValue — the same applyClassProp_setClass / applyStyleProp_setStyle / applyAttrProp_setAttr extraction that exists precisely to stop the compiled and h() paths diverging) assigns the property AND establishes defaultValue. The load-bearing half is that the default is established on the FIRST application only. A controlled input writes its signal from onInput, so its binding re-runs on every keystroke; assigning both in the same updater drags the reset target along with the typing and silently turns form.reset() into a no-op — bisect-verified, removing the first-write guard fails exactly the three specs that assert the default stays put (expected 'typed by the user' to be 'initial'). React draws the same line (initInput seeds the default, updateInput follows only an explicit defaultValue prop). Marker is a Symbol-keyed own property on the element, not a module-level Map — a registry keyed by DOM node is leak class C. General rule: when SSR can only express a value as an ATTRIBUTE and the client sets a non-reflecting PROPERTY, the two paths agree on the visible state and disagree on everything the attribute governs (reset, serialization, [attr] selectors) — decide deliberately whether the client should also write the attribute, and if the value can change reactively, establish it ONCE rather than on every pass. Scope the reflection by TAG: select (default lives in <option selected>) and media muted are deliberately left diverging, because React, Preact and Solid all diverge identically there — that is industry-normal, not an outstanding defect. Reference: packages/core/runtime-dom/src/props.ts:applyValueProp + both compiler backends' attrSetter/attr_setter; locked by input-default-value.browser.test.tsx (real Chromium — the dirty-value flag and form.reset() are not worth trusting to a partial DOM), the compiled-path specs in compiler-integration.test.tsx, and the now-ARMED input.value/textarea.value entries in the hydration parity fuzzer.


Applying select.value before the option children exist (PZ-09)

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


A LEAN fast-path helper selected by the attribute NAME, carrying only the oracle branches that name rules out

the compile-to-string SSR path (ssrTemplate, ON by default) routes each dynamic attr to _ssrAttr (= renderProp verbatim) or to a lean _ssrAttrGen/_ssrAttrUrl, and the compiler picks purely from the (statically-known) NAME. Both lean helpers documented themselves "BYTE-IDENTICAL to renderProp" but dropped two of its branches the name cannot decide. (1) The FUNCTION branch depends on the VALUE'S TYPE, so no name-based selection can rule it out — a bare identifier holding an accessor (d={geometry} from a prop/const; the compiler wraps only syntactically-visible functions) reached the hole as a raw function and String(fn) wrote the closure SOURCE into the attribute: d="() =&gt; geometry()?.path ?? &quot;&quot;". Visible in the SSR HTML AND a guaranteed hydration mismatch, since the client's applyAttrProp resolves. Only the lean subset broke (d/id/title/role/data-*/href/src) while class/style/aria-*/camelCase were fine — which is why it hid. Resolve BEFORE the url-guard (it only inspects strings, so an accessor returning javascript: would sail past it). (2) The TAG branch is unreachable when the helper is not GIVEN the tag_ssrAttrGen(name, value) has no tag, so renderProp's <textarea value> skip could not fire; the fast path emitted a dead value="…" attribute AND an EMPTY textarea, so every server-rendered prefilled textarea came back blank (blank with JS off, plus a mismatch). Fixed by BAILING <textarea value> to the h() path (joining the select/option PZ-09 bail) at the ATTRIBUTE seam — which also covers the compile-time bake arm, where no runtime helper is involved at all. Rules: (a) a fast path chosen by one dimension of a value must still carry every branch of its oracle that the dimension cannot decide — enumerate the oracle's branches and ask which of them the selector can actually exclude; (b) a lean variant that cannot even receive the input a branch needs must not be selected for that input; (c) prefer a BAIL to the proven path over re-deriving the branch (byte-identity by construction). Detection lesson: the byte-identity test had the RIGHT oracle and the RIGHT shape (for (const v of […]) expect(_ssrAttrGen(…)).toBe(_ssrAttr(…))) and still missed both — its value matrix had no function and its name matrix no textarea value. A differential test is only as strong as its INPUT matrix; enumerate the oracle's branches and cover one input per branch. Reference: packages/core/runtime-server/src/index.ts:_ssrAttrGen/_ssrAttrUrl + compiler/src/jsx.ts:ssrSerializeAttr + the native/src/lib.rs mirror; bisect-verified in runtime-dom/src/tests/ssr-template-differential.test.tsx + runtime-server/src/tests/ssr-template.test.ts.


A conditional DOM-element child ({cond && <el>} / {cond ? <el> : <el|null>}) left its branch as a VNode in the compile-to-string SSR path — lower it to _ssr, byte-identically

(2026-08). The ssrTemplate fast path lowers a top-level element and .map items (via _ssrChildren) to _ssr(...) string builds, but a conditional's branch element fell through ssrSerializeExprChild to _escSole(cond && <el>) / _esc(...) with the element left as raw JSX — so the TAKEN branch allocated a VNode and walked renderNode on every request. ssrLowerNestedElements now lowers the eligible DOM-element operand of &&/?: to a nested _ssr(...) (built by the SAME buildSsrBuf(el, 'recursed') + ssrCallText a top-level element uses, so _ssr(el) ≡ renderNode(<el>)), leaving the &&/?: structure and the caller's sole/shouldWrap marker decision untouched (it reads the ORIGINAL expr). Byte-identity for EVERY value: _esc/_escSole route a RawHtml through renderNode exactly as a VNode, and a falsy && left operand (false/null/0/''/NaN) is returned verbatim by both — so the emitted string is identical to the h() path whether the branch is taken or not (sole → _escSole, no markers; non-sole + dynamic → the same <!--$-->…<!--/$--> bake the pre-change path already produced). Scope, EXACT (mirroring ssrTryMap): only DOM elements with NO component (preserved) children lower — a component-child branch needs the _ssrDeferred range-bracketing an in-hole element has no source range for; a non-element operand keeps its slice; and the lowering runs in RECURSED mode ONLY — a conditional inside a .map/<For> ROW (mapitem/foritem) keeps the VNode path, because _ssrItem can return a RAW STRING that would DOUBLE-ESCAPE through _escSole (a scoped follow-up needing its own return-type analysis + fuzz). Both backends emit byte-identically (native-equivalence); SSR↔h() parity is fuzz-locked at 20,000 seeds after the grammar was widened to generate conditional-element children — the load-bearing subtlety being that the fuzz ORACLE must wrap a child in an accessor iff isDynamic (a non-pure CALL — sN()/.map — NOT a data.f member), exactly mirroring shouldWrap, or it manufactures false marker divergences. Measured ~1.65× faster renderToString on a 40-conditional-element page (5.7µs vs 9.4µs, byte-identical output). Reference: packages/core/compiler/src/jsx.ts:ssrLowerNestedElements/ssrLowerElementText + the native/src/lib.rs mirror; locked by runtime-dom/src/tests/ssr-template-differential.test.tsx (both branches, bisect-verified — reverting the lowering fails the _ssr-count "optimization fired" specs) + ssr-template-fuzz.test.tsx (conditional grammar).


The sanitized innerHTML prop's SSR twin emitted the value RAW — a client-guard-without-its-SSR-twin (server-side stored/reflected XSS)

Pyreon ships TWO innerHTML props — dangerouslySetInnerHTML (raw, developer owns sanitization; React semantics; CORRECT) and innerHTML (the SANITIZED path — the client's applyStaticProp runs an allowlist sanitizer via DOMParser, auto-injected by @pyreon/vite-plugin). But the SSR/SSG/stream renderers (renderElementNode/streamElementNode in @pyreon/runtime-server) emitted the innerHTML value RAW next to the intentionally-raw dangerouslySetInnerHTML branch — so attacker-controlled markup landed in the initial HTML response and executed at PARSE time, before hydration could re-sanitize it (<img src=x onerror=…> fires during parse). A client guard shipped WITHOUT its server twin. The constraint that makes it non-trivial: the sanitizer is DOM-based (DOMParser + node walk) and CANNOT run in Node — "call sanitizeHtml on the server" is not a one-liner, and a hand-rolled string HTML sanitizer is mXSS-prone precisely on the SVG foreign-content surface the allowlist supports (the <svg><style>…</style><img onerror> family) — parse5-scale to do safely, not "a few hundred lines". Fix (fail-loud): both SSR paths THROW a clear [Pyreon] error naming the prop + the remedy (client-only island / SPA route, or dangerouslySetInnerHTML with a server-safe sanitizer) — a loud error beats a silent XSS. A real-parser server sanitizer (parse5/DOM-in-Node) is the documented follow-up; escaping-as-text is WRONG (silently corrupts legitimate HTML). General rule: a client-side security guard (sanitize, escape, URL-block) MUST have a matching SSR twin — the initial HTML response is parsed by the browser before any JS runs, so a guard that only exists in the client applyProp/hydrate path is defeated by the pre-hydration parse; when the guard cannot run server-side, fail loud, never emit raw. Note this is NOT adoption-skipped on hydration (_markAdoptedHtmlEl marks only dangerouslySetInnerHTML), so the client still re-sanitizes innerHTML after hydration — but the pre-hydration window is the whole XSS. Reference: packages/core/runtime-server/src/index.ts:throwSsrInnerHtmlUnsupported; bisect-verified in runtime-server/src/tests/ssr-innerhtml-xss.test.ts (revert the two throws → raw <div><img … onerror …></div> emitted; restore → both paths throw) + client parity in runtime-dom/src/tests/ssr-innerhtml-xss-parity.test.ts.


SSR-rendering Mistakes