@pyreon/runtime-dom — API Reference
Generated from
runtime-dom'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see runtime-dom.
Surgical signal-to-DOM renderer with zero virtual DOM overhead. The compiler emits _tpl() (cloneNode-based template instantiation) + _bind() (per-node reactive bindings) calls that mount directly to the DOM without VNode diffing. Reactive text uses TextNode.data assignment (not .textContent) for minimal DOM mutation. Supports SVG/MathML namespace auto-detection (67 tags), custom elements (props as properties), CSS transitions via <Transition> / <TransitionGroup>, and component caching via <KeepAlive>. Dev-mode warnings use the bundler-agnostic bare process.env.NODE_ENV production gate (auto-replaced by every modern bundler) so they tree-shake to zero bytes in production Vite builds.
Features
mount() — mount VNode tree into container, returns unmount function
hydrateRoot() — hydrate SSR-rendered HTML, preserving existing DOM
Transition — CSS-based enter/leave animations driven by a
showaccessorTransitionGroup — animate list item additions and removals
KeepAlive — cache and restore component state across mount/unmount cycles
_tpl() + _bind() — compiler-driven template instantiation with zero VNode overhead
SVG/MathML — 67 tags auto-detected, correct namespace URI, setAttribute-only
Custom elements — props set as properties on hyphenated tag names
Event delegation — synthetic event system for performance
Dev-mode warnings — container validation, output validation, duplicate keys, text-binding coercion ("[object Object]" / function-source), reactive-prop-call setup diagnosis
Complete example
A full, end-to-end usage of the package:
import { mount, hydrateRoot, Transition, TransitionGroup, KeepAlive } from "@pyreon/runtime-dom"
import { signal } from "@pyreon/reactivity"
import { Show } from "@pyreon/core"
// Mount — clears container, returns unmount function
const unmount = mount(<App />, document.getElementById("app")!)
// Hydrate SSR-rendered HTML (preserves existing DOM) — container FIRST
hydrateRoot(document.getElementById("app")!, <App />)
// Transition — CSS-based enter/leave, visibility driven by the required show accessor
const visible = signal(true)
const FadeExample = () => (
<Transition name="fade" show={() => visible()}>
<div>Content</div>
</Transition>
)
// CSS: .fade-enter-active, .fade-leave-active { transition: opacity 0.3s }
// .fade-enter-from, .fade-leave-to { opacity: 0 }
// TransitionGroup — drives the list itself via items / keyFn / render (not <For> children)
const items = signal([1, 2, 3])
const ListExample = () => (
<TransitionGroup
name="list"
items={() => items()}
keyFn={(i) => i}
render={(i) => <div>{i}</div>}
/>
)
// KeepAlive — cache component state across mount/unmount cycles
const tab = signal<"a" | "b">("a")
const TabExample = () => (
<KeepAlive>
<Show when={tab() === "a"}><ExpensiveA /></Show>
<Show when={tab() === "b"}><ExpensiveB /></Show>
</KeepAlive>
)Exports
| Symbol | Kind | Summary |
|---|---|---|
mount | function | Mount a VNode tree into a container element. |
render | function | Alias for mount. |
hydrateRoot | function | Hydrate server-rendered HTML. |
Transition | component | CSS-based enter/leave animation wrapper. |
TransitionGroup | component | Animate list item additions and removals with CSS transitions. |
KeepAlive | component | Mount children ONCE and keep them alive when hidden — when active() returns false the children are CSS-hidden (`displa |
_tpl | function | Compiler-internal: instantiate a cached template and run its bindings. |
_bindText | function | Compiler-internal: bind a SIGNAL (anything carrying ._v + .direct) to a text node via TextNode.data assignment, re |
sanitizeHtml | function | Sanitize an HTML string. |
__PYREON_DEVTOOLS__ | constant | Browser devtools hook, installed automatically on the first mount() (no-op on the server). |
API
mount function
mount(root: VNodeChild, container: Element): () => voidMount a VNode tree into a container element. Clears the container first, sets up event delegation, then mounts the given child. Returns an unmount function that removes everything and disposes all effects. In dev mode, throws if container is null/undefined with an actionable error message.
Example
import { mount } from "@pyreon/runtime-dom"
const dispose = mount(<App />, document.getElementById("app")!)
// To unmount:
dispose()Common mistakes
createRoot(container).render(<App />)— Pyreon uses a single function call:mount(<App />, container)mount(<App />, document.getElementById("app"))without!— getElementById returnsElement | null. The runtime throws in dev if null, but TypeScript needs the assertionmount(<App />, document.body)— mounting directly to body is discouraged; use a dedicated container elementForgetting to call the returned unmount function — leaks event listeners and effects. Store and call it on cleanup
See also: hydrateRoot · render
render function
render(root: VNodeChild, container: Element): () => voidAlias for mount. Provided for API familiarity — both names point to the same function.
Example
import { render } from "@pyreon/runtime-dom"
render(<App />, document.getElementById("app")!)Common mistakes
renderis an EXACT alias formount(same function reference) — its foot-guns aremount's (null container throws; props are reactive-vs-static per the compiler; call the returned function to unmount + dispose effects). Do NOT expect anyrender-specific behavior
See also: mount
hydrateRoot function
hydrateRoot(container: Element, root: VNodeChild): () => voidHydrate server-rendered HTML. Walks the existing DOM and attaches reactive bindings without recreating elements. Expects the DOM to match the VNode tree structure — mismatches emit dev-mode warnings. Returns an unmount function. NOTE the argument order is (container, root) — the CONTAINER comes first, which is the REVERSE of mount(root, container).
Example
import { hydrateRoot } from "@pyreon/runtime-dom"
// Hydrate SSR-rendered HTML — container FIRST, then the app:
hydrateRoot(document.getElementById("app")!, <App />)Common mistakes
Passing arguments in
mountorder —hydrateRoot(container, root)takes the container FIRST (opposite ofmount(root, container))
See also: mount · @pyreon/runtime-server
Transition component
<Transition name={name} show={() => boolean} appear={boolean} onAfterEnter={fn} onAfterLeave={fn}>{children}</Transition>CSS-based enter/leave animation wrapper. Visibility is driven by the REQUIRED show: () => boolean accessor — the child animates in when it flips true and out when it flips false (do NOT wrap the child in a <Show>; show is the toggle). Applies {name}-enter-from/-enter-active/-enter-to classes on enter and the corresponding -leave-* classes on leave. appear runs the enter transition on initial mount. Has a 5-second safety timeout — if transitionend/animationend never fires, the transition completes automatically. onAfterEnter/onAfterLeave fire when each phase settles.
Example
const visible = signal(true)
<Transition name="fade" show={() => visible()}>
<div>Content</div>
</Transition>
/* CSS:
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s }
.fade-enter-from, .fade-leave-to { opacity: 0 }
*/Common mistakes
Omitting
show— it is REQUIRED (() => boolean); Transition drives visibility itself, so a plain child with noshowwill not animateWrapping the child in a
<Show>—showalready toggles visibility; a nested<Show>double-gates itMissing CSS classes —
<Transition name="fade">does nothing without.fade-enter-active/.fade-leave-activeCSSPassing a
modeprop — Transition has nomode; for sequenced list moves use TransitionGroup
See also: TransitionGroup · @pyreon/kinetic
TransitionGroup component
<TransitionGroup items={() => T[]} keyFn={(item, i) => key} render={(item, i) => VNode} name={name} tag={tag} />Animate list item additions and removals with CSS transitions. Unlike <Transition>, it does NOT take <For> children — it drives the list itself via three required props: items (a reactive accessor), keyFn (a stable key extractor), and render (returns ONE DOM-element VNode per item, whose type must be a string tag like "li" so a ref can be injected). Each item gets enter/leave classes on mount/unmount; -move classes FLIP-animate reordering. tag sets the wrapper element (default "div").
Example
const items = signal([{ id: 1, name: "a" }, { id: 2, name: "b" }])
<TransitionGroup
name="list"
tag="ul"
items={() => items()}
keyFn={(item) => item.id}
render={(item) => <li>{item.name}</li>}
/>
/* CSS:
.list-enter-active, .list-leave-active { transition: all 0.3s }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateY(10px) }
.list-move { transition: transform 0.3s }
*/Common mistakes
Passing a
<For>as children — TransitionGroup owns iteration viaitems/keyFn/render, it is not a<For>wrapperA
renderthat returns a component or fragment — it must return a single DOM-element VNode (stringtype) so the group can inject a ref
See also: Transition · For
KeepAlive component
<KeepAlive active={() => boolean}>{children}</KeepAlive>Mount children ONCE and keep them alive when hidden — when active() returns false the children are CSS-hidden (display: none) but stay mounted, so their signals, effects, scroll position, and form inputs are PRESERVED. This is the opposite of conditional rendering (<Show>/ternary), which destroys and recreates component state on every toggle. active defaults to true (always visible). Use one KeepAlive per slot you want cached (e.g. one per route or tab).
Example
// One KeepAlive per route — each keeps its own subtree mounted + hidden.
<KeepAlive active={() => route() === "/a"}><RouteA /></KeepAlive>
<KeepAlive active={() => route() === "/b"}><RouteB /></KeepAlive>Common mistakes
activeMUST be a thunk — writeactive={() => cond()}, notactive={cond}; the runtime callsprops.active?.(), so any non-function value (a boolean, or a bare signal the compiler auto-calls to a value) THROWSTypeError: props.active is not a functionat mount — there is no<Show when>-style value-form normalization; only omittingactiveentirely defaults to visibleKeepAlive CSS-HIDES when inactive, it does NOT unmount — the hidden component's effects, timers, subscriptions, and signals keep RUNNING (memory + side-effect cost); use it ONLY for expensive-to-recreate state, not as a default wrapper
It is the OPPOSITE of
<Show>/ternary — those DESTROY + recreate state on toggle; reach for KeepAlive precisely when you need state PRESERVED across hide/show (form drafts, scroll, heavy trees)Each KeepAlive slot keeps its OWN children mounted — wrapping N routes in N KeepAlives keeps ALL N subtrees mounted + their effects live simultaneously, not just the active one
There is NO
include/exclude/max/name-based cache or LRU eviction (that is Vue's KeepAlive) — visibility is driven solely by theactiveaccessor
See also: Transition · Show
_tpl function
_tpl(html: string, bind: (root: Element) => (() => void) | undefined): NativeItemCompiler-internal: instantiate a cached template and run its bindings. The html string is parsed into a <template> ONCE per distinct string (module-level cache); every call cloneNode(true)s the content and invokes bind(root) — which wires reactive bindings and returns the cleanup. Returns a NativeItem ({ __isNative, el, cleanup }) that mountChild/hydrateRoot consume directly. Sole-dynamic-text children arrive with a BAKED " " placeholder text node in the html (grabbed via .firstChild — no createTextNode/appendChild per instantiation). Not intended for direct use — the JSX compiler emits _tpl() calls automatically.
Example
// Compiler output for <div class="box">{text()}</div>:
_tpl("<div class=\"box\"> </div>", (__root) => {
const __t0 = __root.firstChild as Text
const __d0 = _bindText(text, __t0)
return () => { __d0() }
})Common mistakes
COMPILER-EMITTED — never hand-write
_tpl(); the html string + the bind walks (.firstChild/.nextSiblingcaptures) are generated to match the JSX exactly, and a hand-written mismatch corrupts the ref walksThe html is parsed + cached per DISTINCT string (module-level) then
cloneNode(true)d — a dynamically-built html string defeats the cache (a<template>parse per unique string)Bindings run against the CLONE after ALL node references are captured (the two-phase ref-hoist) — this is what keeps a dynamic slot before static siblings from corrupting their walks; the capture-before-mutate ordering is load-bearing, not cosmetic
See also: _bindText · _bindDirect
_bindText function
_bindText(source: Signal-like, node: Text, caller?: () => unknown): () => voidCompiler-internal: bind a SIGNAL (anything carrying ._v + .direct) to a text node via TextNode.data assignment, returning a dispose function. The fast path BYPASSES the effect system entirely — it subscribes via the signal's .direct() single-subscriber slot (no Set, no deps array, no tracking-stack push); renderEffect is only the fallback for bare callables. Writes the initial value synchronously at bind time (which is why the baked " " template placeholder never renders). Each text node gets its own independent binding for fine-grained reactivity.
Example
// Compiler output for <div>{count()}</div>:
_tpl("<div> </div>", (__root) => {
const __t0 = __root.firstChild as Text
const __d0 = _bindText(count, __t0) // the SIGNAL, not a thunk
return () => { __d0() }
})Common mistakes
COMPILER-EMITTED — don't hand-write
_bindText; write JSX{signal()}or{row.label()}and let the compiler emit it (bare identifiers AND non-computed member chains qualify; computed access likerow[k]()stays on the general path)The
sourceMUST expose._v(read DIRECTLY for the initial value, not via a call) — a custom signal-wrapper that forwards.direct/.peekbut NOT_vbinds''and never updates (thestorage-signal-v-forwardingbug class); build wrappers withwrapSignal(base, { set }), which forwards_vby constructionHand-writing a member-chain bind without the
callerarg losesthis— for{row.label()}the compiler emits_bindText(row.label, node, () => row.label()); the 3rd arg is what preservesthison the slow path (a detachedobj.methodalone would lose it)A signal whose VALUE later becomes a VNode / VNode[] UPGRADES the binding to a subtree mount at the text node's position (the polymorphic upgrade); plain string/number values stay on the
.datafast path
See also: _tpl · _bindDirect
sanitizeHtml function
sanitizeHtml(html: string): stringSanitize an HTML string. If a custom sanitizer was registered via setSanitizer() (e.g. DOMPurify) it is used; OTHERWISE a built-in DOMParser-based tag-allowlist runs (strips unsafe elements + attributes) — it is NOT an identity passthrough, and it never uses the browser Sanitizer API (that lives only in the runtime's innerHTML PROP sink, which prefers native el.setHTML() on Chrome 105+ and falls back to sanitizeHtml). DOM-only: the runtime invokes it only on the client innerHTML prop path — dangerouslySetInnerHTML is intentionally RAW (never sanitized; React parity), and SSR never calls it.
Example
import { setSanitizer, sanitizeHtml } from "@pyreon/runtime-dom"
setSanitizer(DOMPurify.sanitize)
const clean = sanitizeHtml(userInput)Common mistakes
Assuming
dangerouslySetInnerHTMLis sanitized — it is NOT: the runtime assigns__htmlRAW (React parity — the developer owns sanitization), and nosetSanitizerpolicy applies to it; sanitize untrusted HTML yourself, e.g.dangerouslySetInnerHTML={{ __html: sanitizeHtml(userHtml) }}WITHOUT
setSanitizerit is NOT a passthrough — a built-in tag-allowlist sanitizer strips unsafe elements/attributes; but that allowlist is CONSERVATIVE, so legitimate-but-uncommon markup may be stripped — register a policy viasetSanitizer(DOMPurify.sanitize)if you need specific tagssetSanitizer(fn)is GLOBAL but does NOT cover everyinnerHTMLsink — on browsers with the native Sanitizer API theinnerHTMLPROP path prefersel.setHTML()(bypassing your custom policy); only directsanitizeHtml()calls and the no-setHTMLfallback use it, anddangerouslySetInnerHTMLnever doesIt is DOM-only (uses DOMParser) — never call it during SSR; the runtime only invokes it on the client innerHTML prop path
setSanitizer(null)RESTORES the built-in allowlist fallback — it does NOT disable sanitization
See also: setSanitizer
PYREON_DEVTOOLS constant
window.__PYREON_DEVTOOLS__: { version; getComponentTree(); getAllComponents(); highlight(id); onComponentMount(cb); onComponentUnmount(cb); enableOverlay(); disableOverlay(); reactive: PyreonReactiveDevtools }Browser devtools hook, installed automatically on the first mount() (no-op on the server). Exposes the component tree + an element-picker overlay (also Ctrl+Shift+P) for the @pyreon/devtools Chrome extension, plus a $p console helper. The reactive namespace bridges @pyreon/reactivity’s opt-in graph: reactive.activate() / deactivate() start/stop tracking, reactive.getGraph() returns the live signal/computed/effect nodes + dependency edges, reactive.getFires() the bounded fire timeline — powering the extension’s Signals / Graph / Effects / Profiler / Console tabs. Dev-only and tree-shaken from production builds; reactive is zero-cost until activate() is called by an attached panel.
Example
// In the browser console (after the app has mounted):
$p.tree() // root component entries
window.__PYREON_DEVTOOLS__.reactive.activate()
window.__PYREON_DEVTOOLS__.reactive.getGraph() // { nodes, edges }Common mistakes
Reading it before the first
mount()— it is installed by mount; it isundefineduntil then (and alwaysundefinedon the server / in production builds)Expecting
reactive.getGraph()to return data without callingreactive.activate()first — tracking is opt-in (zero-cost until a panel attaches)Depending on it in app code — it is a dev-tooling hook, tree-shaken in production; never branch runtime behavior on its presence
See also: mount
Package-level notes
SVG/MathML uses setAttribute only: SVG and MathML elements ALWAYS use
setAttribute()for prop forwarding, never property assignment. Many SVG properties (markerWidth,refX, etc.) are read-onlySVGAnimatedLengthgetters —el[key] = valuecrashes. Detected byel.namespaceURI !== "http://www.w3.org/1999/xhtml".
Custom elements use property assignment: Elements with a hyphen in their tag name (custom elements) get props set as JS properties, not HTML attributes. This matches the web components spec — attributes are strings, properties can be any type.
Transition 5s safety timeout: If
transitionendoranimationendnever fires (missing CSS, display:none, zero-duration), the transition completes automatically after 5 seconds to prevent stuck UI.
Dev warnings use bare process.env.NODE_ENV: All dev-mode diagnostics — the
mount()null-container error, invalid component-output warning, duplicate<For>keys, the text-binding coercion warnings (a VNode or raw function String()-coerced by_bindText→ the "[object Object]" / function-source silent-render shapes), and the setup-throw diagnosis for a compiler-wrapped reactive prop called as a function — are gated on the bundler-agnostic bareprocess.env.NODE_ENV !== "production"(NOTtypeof process, NOTimport.meta.env.DEV). Every modern bundler literal-replaces it at consumer build time; production bundles contain zero warning bytes.
Event delegation:
setupDelegation(container)is called bymount()— common events are delegated to the container root for performance. Direct event binding (non-delegated) is used for events that do not bubble (focus, blur, scroll, etc.).