@pyreon/runtime-dom is Pyreon's browser renderer. It mounts VNode trees directly to the DOM using surgical signal-driven updates -- no virtual DOM diffing. It supports hydration, CSS transitions, template cloning, and keep-alive caching.
Installation
npm install @pyreon/runtime-dombun add @pyreon/runtime-dompnpm add @pyreon/runtime-domyarn add @pyreon/runtime-domEntry points
Everything is exported from the main entry. Three subpaths exist alongside it:
| Import | Exports |
|---|---|
@pyreon/runtime-dom | Everything below, plus mount, hydrateRoot and the rest of the renderer. |
@pyreon/runtime-dom/transition | Transition, TransitionGroup. |
@pyreon/runtime-dom/keep-alive | KeepAlive. |
@pyreon/runtime-dom/sanitizer | defaultSanitize — the allowlist sanitizer the innerHTML prop uses. |
The main entry already tree-shakes
The obvious reading — "import from the subpath to get the small bundle" — is wrong, and worth stating plainly because it costs people time. Measured with Bun.build, minified, gzipped, @pyreon/* externalized, NODE_ENV=production:
| What you import | Size |
|---|---|
mount | 7,710 B gz |
mount + Transition + TransitionGroup + KeepAlive | 9,421 B gz |
mount + hydrateRoot | 14,200 B gz |
The whole namespace (import * as) | 17,992 B gz |
And the two spellings are byte-identical:
| Size | |
|---|---|
import { mount, Transition } from '@pyreon/runtime-dom' | 8,344 B gz |
mount from the main entry + Transition from /transition | 8,344 B gz |
So the subpaths are not a size lever. Import from the main entry and let your bundler do its job; reach for a subpath when you want the boundary stated in the import rather than inferred from a bundler's behaviour.
/sanitizer is the one that stands alone — 1,177 B gz with nothing else pulled in — because sanitizing a string of HTML has no reason to drag in a renderer.
Core Concepts
Pyreon's runtime-dom takes a fundamentally different approach from virtual-DOM frameworks like React or Vue. Instead of diffing a virtual tree on every update, Pyreon creates DOM nodes once and uses fine-grained reactive effects to update only the specific text nodes, attributes, or properties that depend on changed signals. This means:
No re-renders: Components run their setup function exactly once. There is no "render cycle."
Surgical updates: When a signal changes, only the specific DOM operation tied to that signal executes.
Zero overhead for static content: Static elements have no ongoing runtime cost after initial mount.
Mounting
mount
Mount a VNode tree into a container element. Clears the container first, then mounts the given child. Returns an unmount function that removes everything and disposes all effects.
import { mount } from '@pyreon/runtime-dom'
const unmount = mount(<App />, document.getElementById('app')!)
// Later: unmount and clean up everything
unmount()The mount function performs three steps internally:
Installs DevTools integration (if available).
Clears the container via
innerHTML = "".Calls
mountChildto recursively mount the VNode tree.
The returned unmount function is safe to call multiple times -- subsequent calls are no-ops after the first.
render
An alias for mount. Use whichever name you prefer:
import { render } from '@pyreon/runtime-dom'
const unmount = render(<App />, document.getElementById('app')!)mount with JSX
If you are using the Pyreon JSX transform, mounting looks like this:
import { mount } from '@pyreon/runtime-dom'
function App() {
return <h1>Hello Pyreon</h1>
}
const unmount = mount(<App />, document.getElementById('app')!)Components run once and return JSX directly — there is no render-thunk wrapper. Reactivity is handled per-binding by the compiler (signal reads inside JSX text positions become reactive accessors automatically); the component body never re-runs.
Mounting Lifecycle
When mount is called, the following lifecycle occurs:
Container cleared -- All existing DOM children are removed.
VNode tree walked --
mountChildrecursively processes each node.Components initialized -- Each component function is called once. An
EffectScopeis created for the component so all signals and effects are tracked. If a component throws during setup or render, the error is logged viaconsole.errorinstead of being silently swallowed.DOM elements created --
document.createElementfor element VNodes (orcloneNodefor template-optimized elements).Props applied -- Event listeners registered, reactive effects created for dynamic attributes.
Children mounted -- Recursive depth-first mount of child VNodes.
Refs populated --
ref.currentis set after the element is inserted into the DOM.onMount hooks fired -- Cleanup functions returned from
onMountcallbacks are tracked.DevTools registration -- Component entries registered for the Pyreon DevTools panel.
How mountChild Works
The mountChild function is the core dispatcher. It handles all VNode types:
| Input | Behavior |
|---|---|
() => VNodeChild | Reactive: re-mounts whenever the accessor changes |
null, undefined, false | Nothing rendered |
string, number | Static text node |
| VNode with string type | DOM element created with createElement |
| VNode with function type | Component: function called once, output mounted |
| VNode with Fragment symbol | Transparent wrapper: children mounted directly |
| VNode with ForSymbol | Efficient keyed list reconciliation |
| VNode with PortalSymbol | Children mounted into the portal target |
| NativeItem | Pre-built template clone inserted directly |
Reactive Text Fast Path
When a reactive accessor returns a primitive (string, number, boolean), Pyreon uses a text-node fast path: it creates a single Text node and updates .data in-place via a renderEffect. This saves a comment marker node and reduces DOM operations from 3 to 1 per update compared to the generic mountReactive path.
function Counter() {
const count = signal(0)
return <p>Count: {() => count()}</p>
// The reactive binding {() => count()} uses the text fast path.
// Only text.data is updated -- no DOM node replacement.
}Keyed Array Detection
When a reactive accessor returns an array where every VNode carries a key, Pyreon automatically uses its keyed list reconciler. This reconciler uses a Longest Increasing Subsequence (LIS) algorithm to minimize DOM moves:
function KeyedList() {
const items = signal([
{ id: 1, text: 'Alpha' },
{ id: 2, text: 'Beta' },
{ id: 3, text: 'Gamma' },
])
return <ul>{() => items().map((item) => <li key={item.id}>{item.text}</li>)}</ul>
}Element Depth Optimization
Pyreon tracks element nesting depth during mount. When a child is nested inside a parent element, its cleanup function can skip DOM removal (since removing the parent handles that). This avoids allocating a removeChild closure for every nested element, significantly reducing memory overhead in large trees.
Unmounting
The cleanup function returned by mount performs:
Effect disposal -- All
renderEffectandeffectinstances created during mount are stopped.onUnmount hooks -- Component
onUnmountcallbacks are fired in reverse mount order.onMount cleanup -- Return values from
onMountcallbacks are called.DOM removal -- All mounted DOM nodes are removed from the container.
Ref cleanup --
ref.currentis set back tonull.DevTools unregistration -- Component entries removed from the DevTools panel.
const unmount = mount(<App />, document.getElementById("app")!)
// Perform a full teardown
unmount()
// The container is now empty, all effects are stopped,
// all event listeners are removed.Hydration
hydrateRoot
Hydrate a server-rendered container with a Pyreon VNode tree. Reuses existing DOM elements for static structure, attaches event listeners and reactive effects without re-rendering. Falls back to fresh mount for dynamic content.
import { hydrateRoot } from '@pyreon/runtime-dom'
const unmount = hydrateRoot(document.getElementById('app')!, <App />)How Hydration Works
The hydration strategy is "walk-and-claim" -- it walks the VNode tree in parallel with the live DOM and:
Static elements are matched by tag position, with props (events + reactive effects) attached.
Static text is reused as-is.
Reactive text is reused with a reactive effect attached to
.data.Components are called, and their output is matched against the DOM subtree.
For lists use SSR hydration markers (
<!--pyreon-for-->) for boundary detection.Fragments are transparent -- children matched directly against DOM nodes.
Portals always remount into their target container.
Reactive children ADOPT the server DOM, they don't rebuild it
A reactive child — an accessor ({() => cond() ? <A/> : <B/>}), a component's sole child, a <Show>/<Match> branch, anything that can re-render a variable range of DOM — has a runtime-unknowable extent (zero nodes, one, or many), so SSR wraps its range in comment markers (<!--$-->…<!--/$-->). Older Pyreon versions used those markers only to find the range and discard it: hydration always mounted a fresh client-side tree and threw the server's DOM away.
Both hydration paths for a reactive accessor — the marker-delimited range path and the sole-child path used when an accessor is the ONLY child of its element (whose tag boundary already delimits the extent, so SSR elides the markers entirely) — now adopt the server's existing nodes instead, exactly when the client's first render agrees with what the server produced. Concretely: if you reload a page whose reactive region rendered <b>hello</b> on the server and the client's first render also produces <b>hello</b>, hydration reuses that very DOM node rather than removing it and mounting a new one. The practical difference is real and observable:
Text a user typed into an uncontrolled input before the JS bundle finished loading is not wiped by hydration.
Focus and scroll position inside the adopted region are preserved.
Any DOM listener a non-Pyreon script attached to that region before hydration survives.
The client's first render is still the source of truth. Adoption is an optimization that fires only when server and client agree — if the client's first render produces something DIFFERENT (or renders nothing, e.g. an accessor guarded by client-only state), the server's range is discarded and rebuilt exactly as before. You don't configure this; it's automatic and falls back safely. _tpl()-compiled component-root templates get the same treatment — a component whose entire output is one compiled template can adopt its SSR nodes rather than clone a fresh template instance.
DOM Cursor Helpers
During hydration, the walker skips comment nodes and whitespace-only text nodes to find "real" DOM nodes. This ensures that formatting whitespace in server-rendered HTML does not cause mismatches.
Hydration with JSX
import { hydrateRoot } from '@pyreon/runtime-dom'
// Server-rendered HTML is already in #app
const unmount = hydrateRoot(document.getElementById('app')!, <App />)SSR + Hydration Full Example
// --- server.ts ---
import { renderToString } from '@pyreon/runtime-server'
const html = await renderToString(<App />)
const page = `
<!DOCTYPE html>
<html>
<body>
<div id="app">${html}</div>
<script src="/client.js"></script>
</body>
</html>
`
// --- client.ts ---
import { hydrateRoot } from '@pyreon/runtime-dom'
hydrateRoot(document.getElementById('app')!, <App />)Hydration Mismatch Handling
When the server-rendered DOM does not match the client VNode tree, Pyreon falls back to a fresh mountChild call for the mismatched subtree. This means hydration is self-healing -- it always produces a correct result, though with a performance cost for the mismatched portion.
Common causes of hydration mismatches:
Date/time rendering that differs between server and client.
Browser-specific APIs (e.g.,
window.innerWidth) used during render.Random values or UUIDs generated during render.
Conditional rendering based on client-only state.
Hydration Debug
Enable or disable hydration mismatch warnings:
import { enableHydrationWarnings, disableHydrationWarnings } from '@pyreon/runtime-dom'
enableHydrationWarnings() // log warnings when DOM doesn't match VNode
disableHydrationWarnings() // silence warnings (production)Warnings are enabled automatically in development (NODE_ENV !== "production"). Each warning includes:
Mismatch type:
tag,text, ormissingExpected value: What the VNode tree expected
Actual value: What the DOM contained
Path: Human-readable location in the tree, e.g.
root > div > span
Example warning output:
[pyreon] Hydration mismatch (tag) at <root > div > span>: expected "h1", got "h2"Debugging Hydration Issues
import { enableHydrationWarnings } from '@pyreon/runtime-dom'
// Enable during development
if (import.meta.env.DEV) {
enableHydrationWarnings()
}
// Now hydrate -- mismatches will be logged to console
hydrateRoot(document.getElementById('app')!, <App />)onHydrationMismatch — telemetry hook
For wiring hydration-mismatch reports into Sentry / Datadog / observability tooling without depending on console.warn parsing, register a structured callback:
import { onHydrationMismatch } from '@pyreon/runtime-dom'
onHydrationMismatch((mismatch) => {
// mismatch: { type: 'tag' | 'text' | 'missing', expected, actual, path }
reportError({
component: 'hydration',
phase: 'mismatch',
error: new Error(`Hydration ${mismatch.type} mismatch at ${mismatch.path}`),
metadata: mismatch,
})
})The hook fires on every hydration mismatch (in addition to the dev-mode console warning when enableHydrationWarnings() is on), so a single call surfaces the full mismatch flow to your telemetry pipeline. Returns a disposer that unregisters the listener.
Template Cloning
Template cloning is Pyreon's optimization for repetitive DOM structures. Instead of creating elements one by one with createElement + setAttribute, it parses HTML once into a <template> element and clones it via cloneNode(true) -- approximately 5-10x faster.
createTemplate
Creates a row/item factory backed by HTML template cloning. The HTML string is parsed exactly once via <template>.innerHTML. Each call to the returned factory clones the root element via cloneNode(true).
import { createTemplate } from '@pyreon/runtime-dom'
interface Row {
id: number
label: Cell<string>
}
const rowFactory = createTemplate<Row>('<tr><td></td><td></td></tr>', (el, row) => {
const td1 = el.firstChild as HTMLElement
const td2 = td1.nextSibling as HTMLElement
td1.textContent = String(row.id)
const text = document.createTextNode(row.label.peek())
td2.appendChild(text)
const unsub = row.label.subscribe(() => {
text.data = row.label.peek()
})
return unsub // cleanup function
})
// Usage: rowFactory(row) returns a NativeItem for direct DOM insertioncreateTemplate API
function createTemplate<T>(
html: string,
bind: (el: HTMLElement, item: T) => (() => void) | null,
): (item: T) => NativeItemParameters:
| Parameter | Type | Description |
|---|---|---|
html | string | HTML string for the template. Parsed once. |
bind | (el, item) => cleanup | null | Wiring function. Receives the cloned element and the item data. Should set up text content, event listeners, and reactive effects. Return a cleanup function or null. |
Returns: A factory function (item: T) => NativeItem that clones the template and wires it up.
NativeItem
The NativeItem type is a lightweight container for pre-built DOM elements:
interface NativeItem {
__isNative: true
el: HTMLElement
cleanup: (() => void) | null
}When mountChild encounters a NativeItem, it inserts the element directly without VNode processing -- zero allocation overhead.
Real-World Example: High-Performance Table
import { createTemplate } from '@pyreon/runtime-dom'
import { signal } from '@pyreon/reactivity'
interface Product {
id: number
name: signal<string>
price: signal<number>
inStock: signal<boolean>
}
const productRow = createTemplate<Product>(
`<tr class="product-row">
<td class="id"></td>
<td class="name"></td>
<td class="price"></td>
<td class="stock"><span></span></td>
</tr>`,
(el, product) => {
const cells = el.children
const idCell = cells[0] as HTMLElement
const nameCell = cells[1] as HTMLElement
const priceCell = cells[2] as HTMLElement
const stockSpan = cells[3]?.querySelector('span') as HTMLElement
// Static binding
idCell.textContent = String(product.id)
// Reactive bindings
const nameText = document.createTextNode(product.name.peek())
nameCell.appendChild(nameText)
const priceText = document.createTextNode('')
priceCell.appendChild(priceText)
const cleanups = [
product.name.subscribe(() => {
nameText.data = product.name.peek()
}),
product.price.subscribe(() => {
priceText.data = `$${product.price.peek().toFixed(2)}`
}),
product.inStock.subscribe(() => {
stockSpan.textContent = product.inStock.peek() ? 'In Stock' : 'Out of Stock'
stockSpan.className = product.inStock.peek() ? 'badge-green' : 'badge-red'
}),
]
return () => cleanups.forEach((fn) => fn())
},
)_tpl (Compiler Internal)
The compiler-emitted template instantiation function. Parses the HTML once (cached globally), clones for each call, and runs the bind function to wire up dynamic parts. You do not call this directly -- the compiler generates _tpl calls automatically.
// Compiler output example, for <div class="box"><span>{text()}</span></div>:
_tpl('<div class="box"><span> </span></div>', (__root) => {
const __e0 = __root.firstElementChild
const __t1 = __e0.firstChild
const __d0 = _bindText(text, __t1)
return __d0
})Note the template bakes a single space (<span> </span>) rather than an empty tag — that's what gives the sole dynamic text node an existing firstChild to bind to in the clone, instead of having to insert one. _bindText subscribes directly to the source (a signal or a plain function) and writes to the text node's .data — no intermediate renderEffect allocation. See @pyreon/compiler's Reactive Text Nodes for the full set of binding shapes (sole dynamic text, fused mixed content, and mixed element/expression children each compile differently).
How the Compiler Uses _tpl
When the Pyreon compiler detects a static JSX element tree, it emits _tpl(html, bindFn) instead of nested h() calls. Benefits:
cloneNode(true)is approximately 5-10x faster than sequentialcreateElement+setAttribute.Zero VNode / props-object / children-array allocations per instance.
Static attributes are baked into the HTML string (no runtime prop application).
Before optimization (h calls):
<div class="box">
<span>{text()}</span>
</div>After optimization (_tpl):
_tpl('<div class="box"><span> </span></div>', (__root) => {
const __e0 = __root.firstElementChild as HTMLElement
const __t1 = __e0.firstChild as Text
const __d0 = _bindText(text, __t1)
return __d0
})Template Cache
_tpl uses a global Map<string, HTMLTemplateElement> cache. The same HTML string always reuses the same parsed template, so even components that appear thousands of times (e.g., list items) only parse the HTML once.
Props
applyProp / applyProps
Apply props to a DOM element. Handles event listeners, reactive values, static attributes, classes, styles, and innerHTML sanitization.
import { applyProp, applyProps } from '@pyreon/runtime-dom'
// Apply all props at once
const cleanup = applyProps(el, {
class: () => (active() ? 'on' : 'off'),
onClick: handleClick,
disabled: () => loading(),
})
// Apply a single prop
const cleanup = applyProp(el, 'class', () => theme())applyProps API
function applyProps(el: Element, props: Props): (() => void) | nullIterates all props (except key and ref) and calls applyProp for each. Returns a single chained cleanup function, or null if no props need teardown. Uses for-in instead of Object.keys() to avoid allocating a keys array.
applyProp API
function applyProp(el: Element, key: string, value: unknown): (() => void) | nullReturns a cleanup function if the prop creates a subscription (event listener or reactive effect), or null for static props.
Prop Handling Rules
Event Listeners
Props matching onXxx (e.g., onClick, onInput, onMouseEnter) are registered via addEventListener. The handler is automatically wrapped in batch() so multiple signal writes from one handler coalesce into a single DOM update.
function Counter() {
const count = signal(0)
const name = signal('')
return () => (
<div>
{/* batch() wraps the handler -- both signal writes
produce only one DOM update */}
<button
onClick={() => {
count.update((c) => c + 1)
name.set(`Count ${count.peek()}`)
}}
>
Increment
</button>
<p>{name()}</p>
</div>
)
}The event name is derived by lowercasing the third character and taking the rest: onClick becomes click, onMouseEnter becomes mouseenter.
Typed Event Targets
Event handlers receive a TargetedEvent<E> where currentTarget is typed to the element. No more manual casts:
// currentTarget is typed as HTMLInputElement -- no cast needed
<input onInput={(e) => name.set(e.currentTarget.value)} />
// currentTarget is typed as HTMLSelectElement
<select onChange={(e) => choice.set(e.currentTarget.value)}>
<option value="a">A</option>
<option value="b">B</option>
</select>The TargetedEvent type is exported from @pyreon/core for use in explicit type annotations.
Supported Events
In addition to standard DOM events (onClick, onInput, onChange, onKeyDown, etc.), Pyreon supports:
onBeforeInput-- fires before the input value changes, with access toinputTypeanddataonInvalid-- fires when a form element fails constraint validationonResize-- fires when the element is resized (useful withResizeObserver-backed elements)onToggle-- fires when a<details>element is opened or closed
Reactive Props
Any non-event function prop is treated as reactive. A renderEffect re-evaluates it whenever its signal dependencies change:
<div
class={isActive() ? 'active' : 'inactive'}
style={() => ({ color: theme() === 'dark' ? 'white' : 'black' })}
aria-label={() => `Item ${index()}`}
data-count={() => String(count())}
disabled={() => isLoading()}
/>Each reactive prop creates one lightweight renderEffect. When the signal changes, only that specific DOM operation runs -- no diffing, no reconciliation.
Classes
The class prop accepts strings, arrays, objects, or nested combinations. Under the hood, values are resolved using cx() (exported from @pyreon/core). Both static and reactive values are supported:
// Static string
<div class="card shadow" />
// Reactive string
<div class={`card ${selected() ? "selected" : ""}`} />
// Array of classes (falsy values are filtered out)
<div class={["card", isActive() && "active", size() && "large"]} />
// Object syntax (keys with truthy values are included)
<div class={{ card: true, active: isActive(), disabled: isDisabled() }} />
// Nested mix of strings, arrays, and objects
<div class={["base", { active: isActive() }, size() && "lg"]} />
// Using className (alias)
<div className="container" />You can also use cx() directly for composing class names outside of JSX:
import { cx } from '@pyreon/core'
const className = cx('btn', props.variant && `btn-${props.variant}`, { disabled: props.disabled })Styles
The style prop accepts a string or an object:
// String style
<div style="color: red; font-size: 14px" />
// Object style
<div style={{ color: "red", fontSize: "14px" }} />
// Reactive object style
<div style={() => ({
color: theme() === "dark" ? "white" : "black",
backgroundColor: theme() === "dark" ? "#1a1a1a" : "#fff",
transform: `translateX(${offset()}px)`,
})} />Object styles are applied per-property via el.style.setProperty(kebabKey, cssValue) — not Object.assign(el.style, value). This matters for two behaviors Object.assign can't give you:
Stale-key removal. The runtime tracks which property keys it wrote on the last pass (a
WeakMap<Element, Set<string>>, so it dies with the element). If a key present in the previous style object is absent from the new one, it's explicitlyremoveProperty'd — the property doesn't just linger with its old value.Object.assignwould leave it stale, since assignment only ever adds/overwrites.null/undefinedper-property unset.style={{ background: active() ? 'red' : null }}removes thebackgrounddeclaration entirely whenactive()is false.String(null)is the string"null", an invalid CSS value the browser would otherwise silently ignore (leaving the previous value in place) — the runtime special-cases a nullish per-property value intoremovePropertyinstead.
Object keys are converted from camelCase to kebab-case (fontSize → font-size) before being set, except custom properties (--my-var), which pass through unchanged.
Auto-px for numeric values: When a style property expects a length and you pass a number, Pyreon automatically appends px. This applies to properties like width, height, padding, margin, fontSize, borderRadius, top, left, etc. Properties that are unitless (like opacity, zIndex, flex, lineHeight) are left as-is.
// Numbers are auto-converted to px for length properties
<div style={{ width: 200, height: 100, padding: 16, opacity: 0.5 }} />
// Equivalent to: style="width: 200px; height: 100px; padding: 16px; opacity: 0.5"Data Attributes
Data attributes work like any other attribute:
<div
data-testid="user-card"
data-user-id={String(userId())}
data-active={() => String(isActive())}
/>ARIA Attributes
ARIA attributes are set as standard attributes. A boolean value is automatically rendered as the literal string "true"/"false" (see Boolean Attributes above), so you can pass a boolean directly rather than manually String()-converting it:
<button
aria-label={isOpen() ? 'Close menu' : 'Open menu'}
aria-expanded={() => isOpen()}
aria-controls="nav-menu"
role="button"
/>Boolean Attributes
For ordinary HTML boolean attributes, a boolean value toggles attribute presence: true adds the attribute (empty string), false removes it:
<input
disabled={isSubmitting()}
readonly={() => !canEdit()}
required
/>DOM Properties
When a key exists as a property on the element (e.g., value, checked, selected), Pyreon generally sets the DOM property directly instead of using setAttribute. This ensures correct behavior for form elements:
<input
value={inputValue()}
type="text"
/>
<select>
<option selected={() => choice() === "a"} value="a">A</option>
<option selected={() => choice() === "b"} value="b">B</option>
</select>Some important qualifications to "sets the property directly":
data-*andaria-*are ALWAYS attribute semantics, even when the tag happens to have a same-named JS property (custom elements in particular). Setting them as attributes keepsgetAttribute/dataset/CSS attribute selectors/SSR-rendered HTML all in agreement.valueon<input>/<textarea>also reflectsdefaultValueon the first write. A property assignment alone (el.value = x) never updates the element's content attribute, butform.reset()restores from that attribute — so without this, a client-mounted page'sform.reset()would clear a field that a hydrated page'sform.reset()correctly restored, purely because SSR could only ever have emitted an attribute. Pyreon establishesdefaultValueonce (not on every keystroke of a controlled input, which would drag the reset target along with typing) soform.reset()behaves identically whether the page was hydrated or client-mounted.<select value>is a special case (works correctly, but the mechanics are worth knowing).<select>has novalueCONTENT attribute at all — the HTML parser ignores one if you write it — and the.valuePROPERTY setter selects the matching<option>, so it can only work correctly once the<option>children exist. The compiler defers a select'svaluebinding until AFTER its children are mounted (both statically and reactively), and SSR marks the matching<option selected>instead of emitting a deadvalue=attribute. You don't need to do anything differently —<select value={choice()}>...</select>just works — but if you're debugging a select that shows the wrong initial option, this ordering is usually why.A property that turns out to be read-only never crashes the mount. A handful of DOM properties that look assignable are actually getter-only IDL accessors (
input.list,input.form,select.options,table.rows,video.buffered, …). Pyreon tries the property assignment and falls back tosetAttributeif it throws — so<input list="my-datalist">(an ordinary, documented prop) works correctly instead of crashing strict-mode code with "Cannot set property list which has only a getter".SVG and MathML elements always use
setAttribute, never property assignment — many of their properties (SVGRectElement.x,SVGMarkerElement.refX, …) are read-onlySVGAnimated*accessors, so property assignment would throw for those elements categorically. This matches React, Vue, and Solid.
URL Attribute Security
Pyreon blocks javascript: URIs (and data:text/html, script-bearing data:image/svg+xml, and similar executable data: payloads) in URL-bearing attributes — href, src, action, formaction, poster, cite, data, and SVG's xlink:href (checked under either the xlink:href or plain href spelling, and both formaction/formAction casings). This logic is shared verbatim between the client h() path, the compiled-template path, and SSR — one guard, so a URL that's blocked client-side can never sneak through as raw SSR HTML. In development mode, a warning is logged:
// This will be blocked:
<a href="javascript:alert('xss')">Click me</a>
// [pyreon] Blocked unsafe href value: "javascript:alert('xss')"
// These are safe:
<a href="https://example.com">Safe link</a>
<a href="/relative/path">Relative link</a>
<img src="https://example.com/image.png" />data:image/... is a narrower, deliberate exception: on src/poster of an image-context tag (<img>, <picture>'s <source>, <video poster>, …) a raster data: URI, or an image/svg+xml one decoded and scanned for embedded <script>/event-handler content, is allowed through — this is what lets @pyreon/zero's own <Image> blur/color placeholders (data:image/webp;base64,…) render at all. data:text/html and any other non-image data: scheme stay blocked everywhere.
innerHTML and dangerouslySetInnerHTML
innerHTML (Sanitized)
The innerHTML prop is automatically sanitized in the browser:
// Automatically sanitized -- safe (client-side)
<div innerHTML={userContent} />Server-side (SSR / SSG / streaming): innerHTML FAILS LOUD. Its sanitizer is DOM-based (DOMParser) and cannot run in Node, so the server renderers throw a clear [Pyreon] error rather than emit the value raw — emitting attacker-controlled markup would execute at initial HTML parse, before hydration could sanitize it (a stored/reflected XSS). If you need innerHTML on a server-rendered page:
Untrusted content — render the element in a client-only island / SPA routeso sanitization happens in the browser.
Trusted content, or your own server-safe sanitizer — use
dangerouslySetInnerHTML(below), optionally pre-sanitized with DOMPurify+jsdom orsanitize-htmlon the server.
dangerouslySetInnerHTML (Raw)
For trusted HTML content, use dangerouslySetInnerHTML with an __html property. In development mode, a warning is logged:
// Raw HTML -- you own sanitization
<div dangerouslySetInnerHTML={{ __html: trustedHtml }} />HTML Sanitization
sanitizeHtml
The sanitizeHtml function sanitizes HTML strings using a three-tier strategy:
Custom sanitizer (if set via
setSanitizer) -- highest priority.Browser Sanitizer API (Chrome 105+) -- native, fast.
Built-in fallback -- DOM-based allowlist sanitizer.
sanitizeHtml is DOM-based, so it runs in the browser only. In a no-DOM environment (Node/SSR) with no custom sanitizer registered it throws with an actionable message rather than returning unsanitized HTML — the server-renderedinnerHTML prop relies on this and fails loud (see above).
import { sanitizeHtml } from '@pyreon/runtime-dom'
const safe = sanitizeHtml("<script>alert('xss')</script><p>Safe</p>")
// "<p>Safe</p>"
const safe2 = sanitizeHtml('<img onerror="hack()" src="x">')
// '<img src="x">'
const safe3 = sanitizeHtml('<a href="javascript:alert(1)">Link</a>')
// '<a>Link</a>'Built-in Fallback Sanitizer
The built-in fallback sanitizer:
Parses HTML via
DOMParserinto a temporary document.Walks the node tree recursively.
Allows safe HTML tags (block + inline elements, no scripts/embeds/forms) and a curated safe-SVG profile — shape, gradient, pattern, clip/mask, text, and filter-primitive elements — so inline icons (
innerHTML="<svg>…</svg>") render instead of being stripped to blank.Strips event handler attributes (
onclick,onerror, etc.).Blocks
javascript:anddata:URLs inhref,src,action, and similar attributes — including SVG'sxlink:href.Excludes the XSS-capable SVG elements:
<script>,<foreignObject>(embeds arbitrary HTML),<style>, and SMIL animation elements (<animate>/<set>— theattributeName="href"animation-XSS vector).Replaces any other unsafe element with its text content.
For richer SVG (or a stricter policy), set a custom sanitizer via setSanitizer (e.g. DOMPurify).
Safe tags include: a, abbr, address, article, aside, b, blockquote, br, code, dd, del, details, div, dl, dt, em, figcaption, figure, footer, h1-h6, header, hr, i, ins, kbd, li, main, mark, nav, ol, p, pre, q, s, section, small, span, strong, sub, summary, sup, table, tbody, td, tfoot, th, thead, time, tr, u, ul, var, wbr, and more.
setSanitizer
Override the built-in sanitizer with a custom one:
import { setSanitizer } from '@pyreon/runtime-dom'
// With DOMPurify:
import DOMPurify from 'dompurify'
setSanitizer((html) => DOMPurify.sanitize(html))
// With sanitize-html:
import sanitize from 'sanitize-html'
setSanitizer((html) => sanitize(html))
// Reset to built-in:
setSanitizer(null)Security Best Practices
// SAFE: innerHTML is auto-sanitized
<div innerHTML={userComment} />
// UNSAFE: you must sanitize yourself
<div dangerouslySetInnerHTML={{ __html: trustedHtml }} />
// RECOMMENDED: use a dedicated sanitizer for user content
import DOMPurify from "dompurify"
setSanitizer((html) => DOMPurify.sanitize(html))
// Now all innerHTML props go through DOMPurify
<div innerHTML={untrustedHtml} />Transition
The Transition component adds CSS enter/leave animation classes to a single child element, controlled by a reactive show prop.
import { Transition } from '@pyreon/runtime-dom'
import { signal } from '@pyreon/reactivity'
const visible = signal(false)
function App() {
return () => (
<div>
<button onClick={() => visible.update((v) => !v)}>Toggle</button>
<Transition name="fade" show={() => visible()}>
<div class="modal">Content</div>
</Transition>
</div>
)
}CSS Class Lifecycle
The class lifecycle follows a standard enter/leave pattern:
Enter:
{name}-enter-fromand{name}-enter-activeare added.Next animation frame:
{name}-enter-fromis removed,{name}-enter-tois added.On
transitionendoranimationend:{name}-enter-activeand{name}-enter-toare removed.
Leave:
{name}-leave-fromand{name}-leave-activeare added.Next animation frame:
{name}-leave-fromis removed,{name}-leave-tois added.On
transitionendoranimationend: element is unmounted.
CSS Transition Example
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 300ms ease;
}CSS Animation Example
/* Using CSS @keyframes animations */
.bounce-enter-active {
animation: bounce-in 500ms ease;
}
.bounce-leave-active {
animation: bounce-in 300ms ease reverse;
}
@keyframes bounce-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.15);
}
100% {
transform: scale(1);
opacity: 1;
}
}<Transition name="bounce" show={() => showModal()}>
<div class="modal">Modal content</div>
</Transition>Slide and Fade Combined
.slide-fade-enter-from {
opacity: 0;
transform: translateY(-20px);
}
.slide-fade-enter-active {
transition: all 300ms ease-out;
}
.slide-fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
.slide-fade-leave-active {
transition: all 200ms ease-in;
}<Transition name="slide-fade" show={() => isOpen()}>
<div class="dropdown-content">
<p>Dropdown items here</p>
</div>
</Transition>Appear on Mount
Set appear to animate the element when it first renders:
<Transition name="fade" show={() => true} appear>
<div class="hero-banner">Welcome!</div>
</Transition>Custom Class Names
Override individual class names for integration with CSS utility frameworks:
<Transition
show={() => visible()}
enterFrom="opacity-0 -translate-y-4"
enterActive="transition-all duration-300 ease-out"
enterTo="opacity-100 translate-y-0"
leaveFrom="opacity-100 translate-y-0"
leaveActive="transition-all duration-200 ease-in"
leaveTo="opacity-0 translate-y-4"
>
<div class="card">Tailwind-animated card</div>
</Transition>Lifecycle Callbacks
<Transition
name="modal"
show={() => showModal()}
onBeforeEnter={(el) => {
// Runs before enter animation starts
el.style.willChange = 'opacity, transform'
}}
onAfterEnter={(el) => {
// Runs after enter animation completes
el.style.willChange = ''
el.querySelector('input')?.focus()
}}
onBeforeLeave={(el) => {
// Runs before leave animation starts
console.log('Modal closing...')
}}
onAfterLeave={(el) => {
// Runs after leave animation completes and element is removed
console.log('Modal closed')
}}
>
<div class="modal-overlay">
<div class="modal-content">
<input type="text" placeholder="Auto-focused on enter" />
</div>
</div>
</Transition>Cancellation Handling
If show flips back to true during a leave animation, Transition cancels the leave and immediately starts an enter animation. This prevents visual glitches when users rapidly toggle visibility.
TransitionProps
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | "pyreon" | CSS class name prefix |
show | () => boolean | required | Reactive boolean controlling visibility |
appear | boolean | false | Run enter animation on initial mount |
enterFrom | string | {name}-enter-from | Override enter-from class |
enterActive | string | {name}-enter-active | Override enter-active class |
enterTo | string | {name}-enter-to | Override enter-to class |
leaveFrom | string | {name}-leave-from | Override leave-from class |
leaveActive | string | {name}-leave-active | Override leave-active class |
leaveTo | string | {name}-leave-to | Override leave-to class |
onBeforeEnter | (el: HTMLElement) => void | -- | Called before enter animation |
onAfterEnter | (el: HTMLElement) => void | -- | Called after enter animation |
onBeforeLeave | (el: HTMLElement) => void | -- | Called before leave animation |
onAfterLeave | (el: HTMLElement) => void | -- | Called after leave animation |
Important Notes
Child must be a DOM element, not a component. If you pass a component as a child, a console warning is emitted. Wrap it in a
<div>for animations to work.Transition uses a
refinjection to access the underlying DOM element. This is transparent -- you do not need to forward refs.When
showisfalse, the child is unmounted from the DOM (not just hidden). UseKeepAliveif you need to preserve state while hidden.
TransitionGroup
Animates a keyed reactive list with CSS enter/leave and FLIP move animations.
import { TransitionGroup } from '@pyreon/runtime-dom'
import { signal } from '@pyreon/reactivity'
const items = signal([{ id: 1 }, { id: 2 }, { id: 3 }])
function AnimatedList() {
return () => (
<TransitionGroup
tag="ul"
name="list"
items={() => items()}
keyFn={(item) => item.id}
render={(item) => <li class="item">{item.id}</li>}
/>
)
}CSS for TransitionGroup
/* Enter and leave animations */
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.list-enter-active,
.list-leave-active {
transition: all 300ms ease;
}
/* FLIP move animation -- critical for smooth reordering */
.list-move {
transition: transform 300ms ease;
}How FLIP Move Animation Works
TransitionGroup uses the FLIP (First, Last, Invert, Play) animation technique:
First: Record the bounding rect of every existing item before DOM mutations.
Last: Perform DOM mutations (add, remove, reorder items).
Invert: Calculate the delta between old and new positions. Apply an inverse
transform: translate(dx, dy)so items appear in their old positions.Play: Remove the transform with a CSS transition so items smoothly animate to their new positions.
Complete Animated List Example
import { TransitionGroup } from '@pyreon/runtime-dom'
import { signal } from '@pyreon/reactivity'
const nextId = signal(4)
const items = signal([
{ id: 1, text: 'Apple' },
{ id: 2, text: 'Banana' },
{ id: 3, text: 'Cherry' },
])
function AnimatedTodoList() {
const addItem = () => {
const id = nextId.peek()
nextId.set(id + 1)
items.update((list) => [...list, { id, text: `Item ${id}` }])
}
const removeItem = (id: number) => {
items.update((list) => list.filter((item) => item.id !== id))
}
const shuffle = () => {
items.update((list) => {
const copy = [...list]
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[copy[i], copy[j]] = [copy[j]!, copy[i]!]
}
return copy
})
}
return () => (
<div>
<button onClick={addItem}>Add</button>
<button onClick={shuffle}>Shuffle</button>
<TransitionGroup
tag="ul"
name="todo"
items={() => items()}
keyFn={(item) => item.id}
render={(item) => (
<li class="todo-item">
{item.text}
<button onClick={() => removeItem(item.id)}>x</button>
</li>
)}
/>
</div>
)
}.todo-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.todo-enter-active {
transition: all 400ms ease-out;
}
.todo-leave-to {
opacity: 0;
transform: translateX(30px);
}
.todo-leave-active {
transition: all 300ms ease-in;
}
.todo-move {
transition: transform 400ms ease;
}TransitionGroup with Lifecycle Callbacks
<TransitionGroup
tag="div"
name="card"
items={() => cards()}
keyFn={(card) => card.id}
render={(card) => <div class="card">{card.title}</div>}
onBeforeEnter={(el) => {
el.style.willChange = 'opacity, transform'
}}
onAfterEnter={(el) => {
el.style.willChange = ''
}}
onBeforeLeave={(el) => {
// Lock dimensions to prevent layout shift during leave
const rect = el.getBoundingClientRect()
el.style.width = `${rect.width}px`
el.style.height = `${rect.height}px`
el.style.position = 'absolute'
}}
onAfterLeave={() => {
console.log('Card removed from DOM')
}}
/>TransitionGroupProps
| Prop | Type | Default | Description |
|---|---|---|---|
tag | string | "div" | Wrapper element tag |
name | string | "pyreon" | CSS class prefix |
appear | boolean | false | Animate items on initial mount |
items | () => T[] | required | Reactive list source |
keyFn | (item: T, index: number) => string | number | required | Stable key extractor |
render | (item: T, index: number) => VNode | required | Render function for each item |
moveClass | string | {name}-move | Class applied during move animation |
enterFrom | string | {name}-enter-from | Override enter-from class |
enterActive | string | {name}-enter-active | Override enter-active class |
enterTo | string | {name}-enter-to | Override enter-to class |
leaveFrom | string | {name}-leave-from | Override leave-from class |
leaveActive | string | {name}-leave-active | Override leave-active class |
leaveTo | string | {name}-leave-to | Override leave-to class |
onBeforeEnter | (el: HTMLElement) => void | -- | Called before enter animation |
onAfterEnter | (el: HTMLElement) => void | -- | Called after enter animation |
onBeforeLeave | (el: HTMLElement) => void | -- | Called before leave animation |
onAfterLeave | (el: HTMLElement) => void | -- | Called after leave animation |
KeepAlive
Mounts children once and keeps them alive even when hidden. Unlike conditional rendering (which destroys and recreates component state), KeepAlive CSS-hides children while preserving all reactive state, scroll position, form values, and in-flight async operations.
import { KeepAlive } from '@pyreon/runtime-dom'
import { signal } from '@pyreon/reactivity'
function App() {
const route = signal('/a')
return () => (
<>
<nav>
<button onClick={() => route.set('/a')}>Route A</button>
<button onClick={() => route.set('/b')}>Route B</button>
</nav>
<KeepAlive active={() => route() === '/a'}>
<RouteA />
</KeepAlive>
<KeepAlive active={() => route() === '/b'}>
<RouteB />
</KeepAlive>
</>
)
}How KeepAlive Works
First activation: Children are mounted into a wrapper
<div>withdisplay: contents(transparent to CSS layout).Deactivation: The wrapper's
displayis set tonone. Children remain in the DOM with all effects running.Reactivation: The wrapper's
displayis set back to""(which becomescontents). No re-mounting occurs.
The display: contents wrapper means KeepAlive is invisible to CSS layout -- children behave as if they were direct children of the parent element.
KeepAlive with Tab Switching
function TabPanel() {
const activeTab = signal('settings')
return () => (
<div class="tab-container">
<div class="tab-bar">
{['settings', 'profile', 'notifications'].map((tab) => (
<button
class={activeTab() === tab ? 'tab active' : 'tab'}
onClick={() => activeTab.set(tab)}
>
{tab}
</button>
))}
</div>
{/* Each tab preserves its state independently */}
<KeepAlive active={() => activeTab() === 'settings'}>
<SettingsForm />
</KeepAlive>
<KeepAlive active={() => activeTab() === 'profile'}>
<ProfileEditor />
</KeepAlive>
<KeepAlive active={() => activeTab() === 'notifications'}>
<NotificationPreferences />
</KeepAlive>
</div>
)
}KeepAlive Preserving Form State
function ExpensiveForm() {
const name = signal('')
const email = signal('')
const bio = signal('')
const scrollPos = signal(0)
onMount(() => {
// This scroll position is preserved across tab switches
const el = document.querySelector('.form-container')
if (el) el.scrollTop = scrollPos.peek()
return undefined
})
return () => (
<div class="form-container" onScroll={(e) => scrollPos.set(e.currentTarget.scrollTop)}>
<input value={name()} onInput={(e) => name.set(e.currentTarget.value)} />
<input value={email()} onInput={(e) => email.set(e.currentTarget.value)} />
<textarea value={bio()} onInput={(e) => bio.set(e.currentTarget.value)} />
</div>
)
}
// When wrapped in KeepAlive, all form values, scroll position,
// and signal state survive when the tab is switched away and back.KeepAliveProps
| Prop | Type | Default | Description |
|---|---|---|---|
active | () => boolean | () => true | Reactive boolean controlling visibility. When false, children are CSS-hidden but remain mounted. |
children | VNodeChild | -- | The content to keep alive |
KeepAlive vs Conditional Rendering
| Feature | KeepAlive | Conditional Rendering |
|---|---|---|
| State preserved | Yes | No -- destroyed and recreated |
| Effects running | Yes (always) | Only when mounted |
| Scroll position | Preserved | Lost |
| Form values | Preserved | Lost |
| DOM nodes | Always in DOM (hidden) | Added/removed |
| Memory usage | Higher (all tabs mounted) | Lower (only active tab) |
Reactive-render contract — child mounts run untracked
<For>, internal mountKeyedList, KeepAlive, and TransitionGroup all wrap their child mount work in runUntracked(() => ...) — same pattern mountReactive (the renderer for {() => ...} accessor children) already had.
What this means in practice: signal reads during a child component's setup do not subscribe the OUTER reactive primitive's effect. Concretely, this code shape works as expected:
const userId = signal(1)
<For each={items} by={r => r.id}>
{(item) => {
// ✓ This signal read does NOT track the For effect.
const query = useQuery(() => ({
queryKey: ['user', userId(), item.id], // ← reads userId
queryFn: () => fetch(`/users/${userId()}`).then(r => r.json()),
}))
return <li>{query.data()?.name}</li>
}}
</For>When userId.set(2) fires, only the useQuery's internal setOptions effect re-runs — the <For> effect itself does NOT re-run. Without this contract, <For> would re-run, dispose every inner setOptions effect, and silently lose reactivity on every item (the keyed-update path skips re-mount on unchanged keys).
This is the documented contract; if you find a case where it doesn't hold, treat it as a framework bug. Reference fix: PR #505. Anti-pattern catalog entry: .agents/rules/anti-patterns.md → "Reactive-render entry points missing runUntracked around child mounts".
Real-World Examples
Animated Route Transitions
import { Transition } from '@pyreon/runtime-dom'
import { signal } from '@pyreon/reactivity'
const route = signal<string>('/home')
function Router() {
return () => (
<div class="router">
<Transition name="page" show={() => route() === '/home'}>
<div class="page">
<HomePage />
</div>
</Transition>
<Transition name="page" show={() => route() === '/about'}>
<div class="page">
<AboutPage />
</div>
</Transition>
<Transition name="page" show={() => route() === '/contact'}>
<div class="page">
<ContactPage />
</div>
</Transition>
</div>
)
}.page-enter-from {
opacity: 0;
transform: translateX(30px);
}
.page-enter-active {
transition: all 300ms ease-out;
}
.page-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.page-leave-active {
transition: all 200ms ease-in;
position: absolute;
width: 100%;
}Staggered List Animation
function StaggeredList() {
const items = signal<string[]>([])
const loadItems = async () => {
const data = await fetch('/api/items').then((r) => r.json())
// Add items one at a time for staggered effect
for (let i = 0; i < data.length; i++) {
setTimeout(() => {
items.update((list) => [...list, data[i]])
}, i * 100)
}
}
return () => (
<TransitionGroup
tag="ul"
name="stagger"
items={() => items()}
keyFn={(item, i) => i}
render={(item) => <li class="stagger-item">{item}</li>}
/>
)
}.stagger-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
.stagger-enter-active {
transition: all 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.stagger-move {
transition: transform 300ms ease;
}SVG and MathML Namespace Support
Pyreon automatically detects SVG and MathML elements and creates them with the correct namespace URI via document.createElementNS(). There are 72 recognized namespace tags (49 SVG + 23 MathML):
// SVG elements are created with the SVG namespace automatically:
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="red" />
<path d="M10 80 Q 95 10 180 80" stroke="black" fill="transparent" />
</svg>
// MathML elements use the MathML namespace:
<math>
<mrow>
<mi>x</mi>
<mo>=</mo>
<mfrac><mn>1</mn><mn>2</mn></mfrac>
</mrow>
</math>No configuration is needed -- the runtime checks the tag name against a built-in set and selects the appropriate namespace. This includes all standard SVG elements (svg, circle, path, rect, text, g, defs, use, clipPath, mask, filter, linearGradient, radialGradient, etc.) and MathML elements (math, mrow, mi, mo, mn, mfrac, msqrt, mtext, etc.).
Custom Elements
When a DOM element tag contains a hyphen (e.g., my-component, sl-button), Pyreon treats it as a custom element and sets props as properties rather than attributes:
// Props are set as properties on custom elements:
<my-slider value={50} min={0} max={100} onChange={handler} />
// Equivalent to: el.value = 50; el.min = 0; el.max = 100;
// Regular elements still use attributes:
<div class="box" data-id="123" />
// Equivalent to: el.setAttribute("class", "box"); el.setAttribute("data-id", "123");This matches the Web Components convention where custom element properties reflect richer types than string attributes.
One carve-out: data-* and aria-* props ALWAYS go through setAttribute — even on custom elements. They have attribute semantics by definition; routing data-name through the property path would set a JS property (el['data-name']) that getAttribute('data-name') / dataset / CSS attribute selectors can't see.
Transition Timeout
CSS transitions have a 5-second safety timeout. If transitionend or animationend events never fire (due to missing CSS, display:none, or other edge cases), the transition completes automatically after 5 seconds. This prevents elements from being stuck in a transitioning state indefinitely.
Duplicate Key Production Guard
In production builds, duplicate key values in keyed lists emit a one-time console warning. Previously, duplicate key warnings were only shown in development mode. The production guard helps catch data issues that cause incorrect list reconciliation in deployed applications.
Performance: Direct DOM vs Virtual DOM
| Aspect | Pyreon (Direct DOM) | Virtual DOM Frameworks |
|---|---|---|
| Initial render | Comparable | Comparable |
| Signal update | O(1) -- direct DOM mutation | O(n) -- full tree diff |
| Static content cost | Zero (no tracking) | Re-traversed on every render |
| Memory per component | Lower (no VNode tree retained) | Higher (VNode tree + fiber tree) |
| Template optimization | cloneNode (5-10x faster) | Not available |
| List reconciliation | LIS-based keyed diffing | Key-based reconciliation |
| Batch updates | Explicit batch() in event handlers | Automatic (scheduler) |
Exports Summary
Hand-written-code API — these are what you actually call:
| Export | Description |
|---|---|
mount | Mount a VNode tree into a container |
render | Alias for mount |
hydrateRoot | Hydrate server-rendered HTML |
createTemplate | Create a template-cloning factory |
applyProp | Apply a single prop to an element |
applyProps | Apply all props to an element |
sanitizeHtml | Sanitize an HTML string |
setSanitizer | Set a custom HTML sanitizer |
Transition | CSS enter/leave animation component |
TransitionGroup | Animated keyed list component |
KeepAlive | Persistent component caching |
enableHydrationWarnings | Enable hydration mismatch logging |
disableHydrationWarnings | Disable hydration mismatch logging |
onHydrationMismatch | Register a structured hydration-mismatch telemetry callback |
setupDelegation | Install the event-delegation root on a container (needed for a custom mount host, e.g. content mounted into a <Portal>) |
nodesForElement | Look up the reactive bindings tracked against a given DOM element (devtools/debugging) |
DELEGATED_EVENTS | The Set of event names handled by the delegation root rather than per-element listeners |
delegatedPropName | delegatedPropName(event) → the expando key (__ev_<event>) a delegated handler is stored under |
foreignAttrNamespace | The namespace a prefixed attribute (xlink:href, xml:lang, …) would have had if parsed on that element, or null |
cx (compose class names) is re-exported from @pyreon/core, not this package.
Compiler-facing API — emitted by @pyreon/compiler's generated code, not meant for hand-written calls, but documented here because you'll see them if you read compiled output or a stack trace. All are prefixed _ by convention:
| Export | Description |
|---|---|
_tpl | Compiler-emitted template instantiation (cloneNode + run the bind function) |
bindPolymorphicText | The general reactive text/subtree binding — handles a value that may be text, a VNode, an array, or null, upgrading in place if the shape changes |
_bindText | Compiler-emitted O(1) direct text binding for a signal/computed source (or a plain callable, via a slower fallback) |
_bindProp | Compiler-emitted text binding for a bare props.x read — takes the O(1) direct path when the prop's getter carries .direct, else falls back |
_bindDirect | Compiler-emitted direct attribute binding for a signal/computed source |
_setClass / _setStyle / _setAttr / _setValue / _setHtml | The SAME normalizer functions applyProp uses internally, exported so the compiled template path applies values identically to the h() path (see Reactive Props above and @pyreon/compiler's Dynamic Bindings in Templates) |
_setChild / _setChildAt | One-time (non-reactive) child content set, used for statically-captured values in a template bind function |
_textSlot | Resolve (or, when hydrating, adopt) a text node over a template's <!> placeholder |
_mountSlot / _mountChild | Mount a reactive/absorbed child into a template's placeholder or hole |
_applyProps / _bindSpread | Apply a (static or reactive) prop spread on a template's root element, wiring ref in addition to what applyProps does |
_rsCollapse family | The @pyreon/rocketstyle build-time collapse variants |
mountChild | The general mount dispatcher these compiled helpers build on |
Type Exports
| Type | Description |
|---|---|
SanitizeFn | (html: string) => string |
TransitionProps | Props for the Transition component |
TransitionGroupProps | Props for the TransitionGroup component |
KeepAliveProps | Props for the KeepAlive component |
DevtoolsComponentEntry | DevTools component registration entry |
PyreonDevtools | DevTools interface |
HydrationMismatchType | 'tag' | 'text' | 'missing' |
HydrationMismatchContext | Payload passed to an onHydrationMismatch handler |
HydrationMismatchHandler | (ctx: HydrationMismatchContext) => void |