@pyreon/compiler provides the JSX transform that makes Pyreon's fine-grained reactivity work. It analyzes JSX expressions at build time and wraps dynamic values in () => arrow functions so the runtime receives reactive getters instead of eagerly-evaluated snapshots. It also performs static VNode hoisting and template emission for optimal DOM creation performance.
Installation
npm install @pyreon/compilerbun add @pyreon/compilerpnpm add @pyreon/compileryarn add @pyreon/compilerMost users do not need to install the compiler directly. It is used internally by @pyreon/vite-plugin. Install it directly only if you are building a custom build tool integration.
Architecture Overview
The compiler performs three sequential optimization passes on your JSX source code:
Template emission -- Multi-element DOM trees are compiled to
_tpl()calls that usecloneNode(true)for fast instantiation.Static VNode hoisting -- Fully static JSX expressions inside expression containers are lifted to module scope.
Reactive wrapping -- Dynamic expressions containing signal reads are wrapped in
() =>arrow functions.
Each pass is applied during a single AST walk. The compiler has a dual-backend architecture: a Rust native binary (napi-rs, 3.7-8.9x faster) using oxc_parser/oxc_ast Rust crates directly, with an automatic JS fallback via oxc-parser when the native binary isn't available. Both backends emit positional, non-overlapping {start, end, text} edits against the original source, so the output stays close to the input.
Source maps
The transform shifts line counts (a one-line JSX element can expand into a multi-line _tpl(...) factory), so a source map is required for stack traces and debugger breakpoints in Pyreon components to resolve to the right source line. The JS backend applies its edits through magic-string and returns a correct V3 source map alongside byte-identical code; @pyreon/vite-plugin forwards it to Vite. Caveats, stated honestly:
The native (Rust) backend does not emit a source map yet — a scoped follow-up. When the native binary is active (the default in production builds), the map is absent and frames fall back to the transformed positions until that lands.
In dev mode, the small extra HMR / signal-name injections the Vite plugin applies after the compiler are not re-mapped — a minor residual offset, still far better than no map.
A no-op compile (nothing to transform) returns no map, since the emitted code is byte-identical to the input and needs no remapping.
Pass 1: Reactive Wrapping
Signal Auto-Call
Signals and computeds declared via const x = signal(...) or const x = computed(...) are automatically called in JSX — no () needed:
const count = signal(0)
const doubled = computed(() => count() * 2)
// You write (plain JavaScript):
<div class={count > 0 ? 'active' : ''}>{doubled}</div>
// Compiler emits (fully reactive):
<div class={() => count() > 0 ? 'active' : ''}>{() => doubled()}</div>This makes Pyreon the first signal framework where JSX looks like plain JavaScript. The feature is:
Scope-aware — inner variables that shadow a signal name are NOT auto-called
Cross-module — the Vite plugin pre-scans all files for
export const x = signal(...)exports and resolves importsSafe — already-called signals (
count()) are NOT double-called;import typeis excluded
How It Works
Dynamic expressions in JSX are wrapped in arrow functions so the Pyreon runtime can re-evaluate them when their dependencies change:
// Input (explicit calls — also works)
<div class={active() ? "on" : "off"}>{count()}</div>
// Output
<div class={() => active() ? "on" : "off"}>{() => count()}</div>The wrapping applies to both child expressions and prop values on DOM elements (lowercase tags).
The shouldWrap Decision Tree
The compiler uses a precise set of rules to determine whether an expression needs reactive wrapping. The decision tree is:
Is it an arrow function or function expression? -- Skip. The user explicitly wrapped it or it is a callback.
Is it a static literal? -- Skip. String literals, numeric literals, template literals without substitutions,
true,false,null, andundefinedhave no reactive dependencies.Does it contain a call expression? -- Wrap it. Signal reads are always function calls (
count(),name()). If the expression tree contains anyCallExpressionorTaggedTemplateExpression, it is treated as reactive.Otherwise -- Skip. Plain identifiers, object literals, array literals, and member accesses without calls are left as-is.
// ---- WRAPPED (contains function calls) ----
<div>{count()}</div> // → {() => count()}
<div>{a() ? "yes" : "no"}</div> // → {() => a() ? "yes" : "no"}
<div>{show() && <span />}</div> // → {() => show() && <span />}
<div>{count() + 1}</div> // → {() => count() + 1}
<div>{`hello ${name()}`}</div> // → {() => `hello ${name()}`}
<div>{obj.getValue()}</div> // → {() => obj.getValue()}
<div>{items().map(x => x)}</div> // → {() => items().map(x => x)}
<div>{store.getState().count}</div> // → {() => store.getState().count}
<div>{css`color: red`}</div> // → {() => css`color: red`} (tagged template)
// ---- NOT WRAPPED (no reactive dependency) ----
<div>{"literal"}</div> // Static string literal
<div>{42}</div> // Static numeric literal
<div>{true}</div> // Static boolean
<div>{null}</div> // Static null
<div>{undefined}</div> // Static undefined
<div>{`hello`}</div> // Template literal without substitutions
<div>{title}</div> // Plain identifier (no call)
<div>{a ? b : c}</div> // Ternary without calls
<div>{show && <span />}</div> // Logical expression without calls
<div>{{ color: "red" }}</div> // Object literal without calls
<div>{[1, 2, 3]}</div> // Array literal without calls
<div>{obj.value}</div> // Member access without call
<div>{a + b}</div> // Binary expression without calls
<div>{() => count()}</div> // Already an arrow function
<div>{function() { return x }}</div> // Already a function expression
<div>{(x: number) => x + 1}</div> // Arrow function with paramsComponent vs DOM Element Props
Props on DOM elements (lowercase tags like div, span) are wrapped in reactive getters when they contain signal reads. Props on component elements (uppercase tags like MyComp) are not wrapped -- components receive plain values and manage reactivity internally:
// DOM element: props ARE wrapped
<div class={cls()}> // → class={() => cls()}
<div title={getTitle()}> // → title={() => getTitle()}
<div data-id={getId()}> // → data-id={() => getId()}
<div aria-label={getLabel()}> // → aria-label={() => getLabel()}
// Component element: props are NOT wrapped
<MyComponent value={count()} /> // → value={count()} (unchanged)
<Button label={getText()} /> // → label={getText()} (unchanged)This distinction is made by checking the first character of the tag name. Uppercase tags are treated as components; lowercase tags as DOM elements.
Children in expression containers on components are still wrapped, since the runtime processes them as DOM content:
<MyComponent>{count()}</MyComponent>
// → <MyComponent>{() => count()}</MyComponent>Special Props That Are Never Wrapped
Certain props are always excluded from wrapping regardless of their content:
| Prop | Reason |
|---|---|
key | Used for reconciliation identity, not a DOM attribute |
ref | A callback ref or ref object, not a reactive value |
onClick, onInput, onMouseEnter, ... | Event handlers (any prop matching /^on[A-Z]/) are callbacks, not reactive |
// These are NEVER wrapped:
<div key={id} /> // key is identity
<div ref={myRef} /> // ref is a callback
<button onClick={handleClick} /> // event handler
<button onClick={() => doSomething()} /> // event handler (arrow)
<input onInput={handler} /> // event handler
<input onFocus={handler} /> // event handler
<input onChange={handler} /> // event handler
<div onMouseEnter={fn} /> // event handlerSpread Attributes
Spread attributes ({...props}) are left unchanged by the compiler. They are not wrapped in reactive getters. If a spread coexists with other dynamic props, only the non-spread dynamic props are wrapped:
// Input
<div {...props} class={cls()} />
// Output — spread unchanged, dynamic class wrapped
<div {...props} class={() => cls()} />Object and Array Literal Props
Object and array literals are not wrapped unless they contain a function call:
// NOT wrapped — static object literal
<div style={{ color: "red" }} />
// WRAPPED — object contains a signal read
<div style={{ color: theme() }} />
// → style={() => ({ color: theme() })}Pass 2: Static VNode Hoisting
How It Works
Fully static JSX expressions inside expression containers are hoisted to module scope. They are created once at module initialization, not per component instance:
// Input
function App() {
return <div>{<span>Hello</span>}</div>
}
// Output
const _$h0 = /*@__PURE__*/ <span>Hello</span>
function App() {
return <div>{_$h0}</div>
}Hoisted declarations include the /*@__PURE__*/ annotation so bundlers can tree-shake them if unused.
What Counts as "Static"
A JSX node is considered static if all of the following are true:
All props are string literals, boolean shorthands, or expression containers with static literal values
All children are text nodes, other static JSX elements, or expression containers with static values
There are no spread attributes (
{...props})
// ---- HOISTABLE (fully static) ----
<span>Hello</span> // Text-only child
<br /> // Self-closing, no props
<span class="foo">text</span> // String literal prop
<input disabled /> // Boolean shorthand
<>text</> // Static fragment
// ---- NOT HOISTABLE (has dynamic parts) ----
<span class={cls()}>text</span> // Dynamic prop
<span>{count()}</span> // Dynamic child
<span {...props}>text</span> // Spread attribute
<>{count()}</> // Dynamic fragment childMultiple Hoists
When multiple static JSX expressions appear in the same file, each gets an independent hoisted variable:
// Input
<div>{<span>A</span>}{<span>B</span>}</div>
// Output
const _$h0 = /*@__PURE__*/ <span>A</span>
const _$h1 = /*@__PURE__*/ <span>B</span>
<div>{_$h0}{_$h1}</div>Performance Implications
Hoisting eliminates per-render VNode allocations for static subtrees. In a component that renders thousands of list items each containing a static icon or label, this avoids creating thousands of identical VNode objects on every render cycle. The /*@__PURE__*/ annotation ensures dead code elimination in production builds.
Pass 3: Template Emission
How It Works
JSX element trees made of DOM elements (no components, no spread attributes) — including a single element like <div>{x()}</div> — are compiled to _tpl() calls instead of nested h() calls. The HTML string is parsed once via <template>.innerHTML, then cloneNode(true) for each instance. For sole-dynamic-text children the template bakes a single space (<span> </span>) so the text node already exists in the clone — the bind function grabs it via firstChild and subscribes with _bindText:
// Input
;<div class="box"><span>{text()}</span></div>
// Output
import { _tpl, _bindText } from '@pyreon/runtime-dom'
_tpl('<div class="box"><span> </span></div>', (__root) => {
const __e0 = __root.firstElementChild
const __t1 = __e0.firstChild
const __d0 = _bindText(text, __t1)
return () => {
__d0()
}
})Eligibility Rules
A JSX tree is eligible for template emission when:
| Condition | Eligible? | Reason |
|---|---|---|
| 1+ DOM elements, all lowercase tags | Yes | Pure DOM tree — single elements (<div>{x()}</div>) emit _tpl() too |
Contains component (<MyComp />) | No | Components need runtime instantiation |
Has spread attributes ({...props}) | No | Spread requires dynamic prop application — handled by _tpl() + _applyProps() on root elements, h() otherwise |
Has key prop | No | Keyed elements need reconciliation metadata |
Contains fragment child (<>...</>) | No | Fragment breaks DOM structure assumptions |
| Mixed element + expression children | Yes | <!> comment placeholder + replaceChild keeps positions exact |
| Multiple expression children in same parent | Yes | One <!> placeholder + text node per expression |
| Expression child containing nested JSX | No | Too complex for template codegen — falls back to h() |
What Gets Baked Into HTML
Static parts of the template are baked directly into the HTML string, avoiding any runtime prop application:
// Input
<input disabled />
<span>Static text</span>
// The HTML string contains all static attributes and text:
// "<div class=\"container\"><input disabled><span>Static text</span></div>"The compiler handles JSX-to-HTML attribute mapping automatically:
| JSX Attribute | HTML Attribute |
|---|---|
className | class |
htmlFor | for |
// Input
<div className="box">
<label htmlFor="name">Name</label>
</div>
// HTML string: <div class="box"><label for="name">Name</label></div>Dynamic Bindings in Templates
Dynamic attributes and text content are handled by the bind function:
Reactive attributes subscribe directly via _bindDirect():
// Input
;<div class={cls()}>
<span>{name()}</span>
</div>
// Output bind function:
;(__root) => {
const __d0 = _bindDirect(cls, (v) => {
__root.className = v == null ? '' : String(v)
})
const __e0 = __root.firstElementChild
const __t1 = __e0.firstChild
const __d1 = _bindText(name, __t1)
return () => {
__d0()
__d1()
}
}One-time static expressions (no calls, so not reactive) are set directly without _bind():
// Input
;<div>
<span>{label}</span>
</div>
// Output bind function:
;(__root) => {
const __e0 = __root.firstElementChild
__e0.textContent = label
return null
}Event handlers use the runtime's delegation slots for delegated events (click, input, etc.), addEventListener otherwise:
// Input
;<div>
<button onClick={handler}>click</button>
</div>
// Output bind function:
;(__root) => {
const __e0 = __root.firstElementChild
__e0.__ev_click = handler
return null
}The event name is the full-lowercased prop name (onMouseEnter → "mouseenter"), with onDoubleClick remapped to "dblclick" via REACT_EVENT_REMAP.
Ref props support both object refs (.current assignment) and callback refs:
// Input
;<div>
<input ref={myRef} />
</div>
// Output bind function:
;(__root) => {
const __e0 = __root.firstElementChild
{ const __r = myRef; if (typeof __r === 'function') __r(__e0); else if (__r) __r.current = __e0 }
return null
}Reactive Text Nodes
For dynamic text content, the compiler binds a persistent TextNode and updates its .data property rather than setting .textContent on the parent. This avoids destroying and recreating the text node on every reactive update. Two shapes:
Sole dynamic text (<span>{name()}</span>) — the template bakes a single space so the text node already exists in the clone; the bind function grabs it via firstChild:
// template HTML: "<span> </span>"
const __t0 = __e0.firstChild
const __d0 = _bindText(name, __t0)Mixed content (<span>Count: {count()}</span>) — the template bakes a <!> comment placeholder at the expression's position; the bind function creates the text node and replaceChilds it over the placeholder (never appendChild — positions stay exact):
// template HTML: "<span>Count: <!></span>"
const __t0 = document.createTextNode('')
__e0.replaceChild(__t0, __e0.firstChild.nextSibling)
const __d0 = _bindText(count, __t0)Cleanup / Disposal
The bind function returns a cleanup function that disposes all reactive bindings. When no dynamic bindings exist, it returns null:
// No dynamic parts → null cleanup
_tpl('<div><span>static</span></div>', () => null)
// Multiple dynamic parts → composed cleanup
_tpl('...', (__root) => {
const __d0 = _bind(() => {
__root.className = cls()
})
const __d1 = _bind(() => {
__t0.data = name()
})
return () => {
__d0()
__d1()
}
})Element Access Paths
The bind function accesses child elements using firstElementChild / nextElementSibling chains from the root:
// Input
<div>
<span>{a()}</span>
<em>{b()}</em>
</div>
// Paths:
// __root → <div>
// __root.firstElementChild → <span>
// __root.firstElementChild.nextElementSibling → <em>For deeply nested structures, paths chain through each level:
// Input
<table>
<tbody>
<tr>
<td>{text()}</td>
</tr>
</tbody>
</table>
// Paths chain: __root.firstElementChild.firstElementChild.firstElementChild → <td>Void Elements
HTML void elements (br, img, input, hr, etc.) are emitted without closing tags in the HTML string:
// Input
<div>
<br />
<span>text</span>
</div>
// HTML string: "<div><br><span>text</span></div>"
// Note: <br> not </br>The full list of recognized void elements: area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr.
Auto-Imported Runtime Helpers
When template emission is used, the compiler automatically prepends import statements:
import { _tpl, _bindText, _bindDirect } from '@pyreon/runtime-dom'These imports are only added when at least one _tpl() call is emitted (each helper only when used). The usesTemplates flag on the transform result indicates whether this happened.
Performance Benefits
Template emission provides significant performance improvements:
cloneNode(true)is 5-10x faster than sequentialcreateElement+setAttributecallsZero VNode, props-object, or children-array allocations per instance
Static attributes are baked into the HTML string -- no runtime prop application needed
Dynamic text uses
_bindText()and dynamic attributes_bindDirect()-- direct subscriptions for efficient reactive updates with automatic cleanupPersistent
TextNodereuse avoids destroy/recreate overhead on text updates
Real-World Template: Benchmark Row
Here is a realistic benchmark-style table row showing all template features working together:
// Input
;<tr class={cls()}>
<td class="id">{String(row.id)}</td>
<td>{row.label()}</td>
</tr>
// Output
_tpl('<tr><td class="id"></td><td> </td></tr>', (__root) => {
const __d0 = _bindDirect(cls, (v) => {
__root.className = v == null ? '' : String(v)
})
const __e0 = __root.firstElementChild
__e0.textContent = String(row.id) // pure call, no signal read — set once
const __e1 = __root.firstElementChild.nextElementSibling
const __t2 = __e1.firstChild
const __d1 = _bindText(row.label, __t2, () => row.label())
return () => {
__d0()
__d1()
}
})Static class "id" is baked into the HTML. Dynamic class cls() and text children String(row.id) / row.label() use _bind().
Compiler Warnings
The compiler emits warnings for common mistakes. Warnings are returned in the warnings array on the transform result.
missing-key-on-for
Emitted when a <For> component is used without a by prop:
// Triggers warning:
<For each={() => items()}>{(item) => <li>{item.name}</li>}</For>
// Fix:
<For each={() => items()} by={(item) => item.id}>
{(item) => <li>{item.name}</li>}
</For>Without by, the runtime falls back to index-based diffing, which is slower and can cause bugs with stateful children.
Warning Types
interface CompilerWarning {
message: string
line: number // 1-based line number
column: number // 0-based column number
code: 'signal-call-in-jsx' | 'missing-key-on-for' | 'signal-in-static-prop'
}API Reference
transformJSX(code, filename?)
The main API. Transforms JSX source code, applying reactive wrapping, static hoisting, and template emission.
import { transformJSX } from '@pyreon/compiler'
const result = transformJSX(code, 'MyComponent.tsx')
console.log(result.code) // Transformed source code
console.log(result.usesTemplates) // true if _tpl() was emitted
console.log(result.warnings) // Array of compiler warningsParameters:
code(string) -- The JSX source code to transform.filename(string, optional) -- The filename for parser context. Defaults to"input.tsx". All files are parsed as TSX regardless of extension.
Returns: TransformResult
TransformResult
interface TransformResult {
/** Transformed source code (JSX preserved, only expression containers modified) */
code: string
/** Whether the output uses _tpl/_bind template helpers (needs auto-import) */
usesTemplates?: boolean
/** Compiler warnings for common mistakes */
warnings: CompilerWarning[]
}CompilerWarning
interface CompilerWarning {
/** Warning message */
message: string
/** Source file line number (1-based) */
line: number
/** Source file column number (0-based) */
column: number
/** Warning code for filtering */
code: 'signal-call-in-jsx' | 'missing-key-on-for' | 'signal-in-static-prop'
}Complete Transform Rules Reference
| Pattern | Transform | Reason |
|---|---|---|
<div>{expr()}</div> | {() => expr()} | Dynamic child with signal read |
<div :class='expr()'> | class={() => expr()} | Dynamic prop with signal read |
<div>{a() ? b : c}</div> | {() => a() ? b : c} | Ternary containing a call |
<div>{show() && x}</div> | {() => show() && x} | Logical expression containing a call |
<div>{count() + 1}</div> | {() => count() + 1} | Binary expression containing a call |
<div>{`hi ${name()}`}</div> | {() => `hi ${name()}`} | Template literal containing a call |
<div>{obj.get()}</div> | {() => obj.get()} | Method call |
<div>{css`...`}</div> | {() => css...} | Tagged template expression |
<button :onClick='fn'> | Unchanged | Event handler |
<div :key='id'> | Unchanged | Key prop |
<div :ref='r'> | Unchanged | Ref prop |
<div>{() => expr()}</div> | Unchanged | Already wrapped |
<div>{"literal"}</div> | Unchanged | Static string |
<div>{42}</div> | Unchanged | Static number |
<div>{true}</div> | Unchanged | Static boolean |
<div>{null}</div> | Unchanged | Static null |
<div>{undefined}</div> | Unchanged | Static undefined |
<div>{identifier}</div> | Unchanged | Plain identifier (no call) |
<div>{{ x: 1 }}</div> | Unchanged | Object literal (no call) |
<div>{[1, 2]}</div> | Unchanged | Array literal (no call) |
<div>{a + b}</div> | Unchanged | Binary expression (no call) |
<div>{a ? b : c}</div> | Unchanged | Ternary (no call) |
<Comp :prop='expr()'> | Unchanged | Component prop (not wrapped) |
<div>{<span>text</span>}</div> | Hoisted to module scope | Static JSX child |
<div><span>{t()}</span></div> | _tpl(...) call | Template-eligible tree (2+ elements) |
<div {...props}> | Unchanged | Spread left as-is |
Integration with Vite Plugin
The compiler is used automatically by @pyreon/vite-plugin. For custom integrations, call transformJSX in your build tool's transform hook:
import { transformJSX } from '@pyreon/compiler'
function myBuildPlugin() {
return {
name: 'my-pyreon-transform',
transform(code: string, id: string) {
if (id.endsWith('.tsx') || id.endsWith('.jsx') || id.endsWith('.pyreon')) {
const result = transformJSX(code, id)
// Log any warnings
for (const warning of result.warnings) {
console.warn(`[pyreon] ${id}:${warning.line}:${warning.column} ${warning.message}`)
}
return { code: result.code, map: null }
}
},
}
}Webpack Loader Example
import { transformJSX } from '@pyreon/compiler'
export default function pyreonLoader(source: string) {
const result = transformJSX(source, this.resourcePath)
for (const warning of result.warnings) {
this.emitWarning(new Error(warning.message))
}
return result.code
}Rollup Plugin Example
import { transformJSX } from '@pyreon/compiler'
export default function pyreonPlugin() {
return {
name: 'pyreon-compiler',
transform(code: string, id: string) {
if (!/\.[jt]sx$/.test(id)) return null
const result = transformJSX(code, id)
if (result.warnings.length > 0) {
for (const w of result.warnings) {
this.warn({ message: w.message, id, pos: { line: w.line, column: w.column } })
}
}
return { code: result.code, map: null }
},
}
}Reactive Props Inlining
The compiler auto-detects when const variables are derived from props.* or splitProps results and inlines them at JSX use sites, making them automatically reactive.
The Problem
In Pyreon, components run once. A plain variable assignment from props captures the value at setup time — it does not track future changes:
// Before this feature — x was static:
function Greeting(props) {
const x = props.name ?? 'World'
return <div>{x}</div> // never updated when props.name changed
}The Solution
The compiler now traces const declarations back to their props origin and inlines the original expression at each JSX use site:
// Input
function Greeting(props) {
const x = props.name ?? 'World'
return <div>{x}</div>
}
// Compiler output (template mode):
_tpl('<div> </div>', (__root) => {
const __t0 = __root.firstChild
const __d0 = _bind(() => { __t0.data = (props.name ?? 'World') })
return () => { __d0() }
})The variable x is replaced with its original expression (props.name ?? 'World') inside the _bind(), so it re-evaluates whenever props.name changes.
Transitive Resolution
The compiler resolves chains of const assignments transitively:
function Profile(props) {
const name = props.name
const greeting = name + '!'
const upper = greeting.toUpperCase()
return <div>{upper}</div>
}
// Compiler inlines upper as:
// ((((props.name)) + '!').toUpperCase())
// → fully reactive to props.name changesRules
| Condition | Inlined? | Reason |
|---|---|---|
const x = props.y | Yes | Direct props member access |
const x = props.y ?? 'default' | Yes | Expression containing props access |
const [own, rest] = splitProps(props, ['y']) then const x = own.y | Yes | splitProps results are tracked |
const a = props.x; const b = a + 1 | Yes | Transitive resolution |
let x = props.y | No | let is mutable — unsafe to inline |
var x = props.y | No | var is mutable — unsafe to inline |
console.log(x) where x is prop-derived | No | Non-JSX usage stays static (captured value) |
Non-JSX Usage
The inlining only applies to JSX text and attribute positions. Using a prop-derived variable in regular JavaScript code (e.g., console.log(x), passing to a function) still uses the captured value. This is correct — those contexts are not reactive scopes:
function MyComponent(props) {
const label = props.label ?? 'default'
console.log(label) // ✓ Correct — logs the setup-time value
return <div>{label}</div> // ✓ Reactive — compiler inlines props.label ?? 'default'
}Per-Text-Node Independent Bindings
Each reactive text node in a template gets its own independent binding. Previously, multiple text bindings could share a single _bind(), meaning a change in one signal would re-evaluate all bindings in the group. Now each text node tracks only its own dependencies:
// Input
<div>
<span>{firstName()}</span>
<span>{lastName()}</span>
</div>
// Output — each text node has its own binding:
_tpl('<div><span> </span><span> </span></div>', (__root) => {
const __e0 = __root.firstElementChild
const __t1 = __e0.firstChild
const __d0 = _bindText(firstName, __t1)
const __e2 = __root.firstElementChild.nextElementSibling
const __t3 = __e2.firstChild
const __d1 = _bindText(lastName, __t3)
return () => { __d0(); __d1() }
})Changing firstName only re-executes __d0, not __d1. This is fine-grained reactivity at the individual text node level.
Auto-promoted Fast Paths
For canonical reactive patterns the compiler emits an effect-free subscription instead of the default _bind(() => …) wrap. Same observed behaviour, ~5 → ~2 allocations per binding, no renderEffect machinery setup. Three shapes are auto-promoted today; all share the same conservative bail catalog (uncertain ⇒ no promotion).
selector(k) ? a : b ternary in className/attr bindings (PR #898)
const isSelected = createSelector(selectedId)
;<For each={rows} by={(r) => r.id}>
{(row) => <tr class={() => isSelected(row.id) ? 'selected' : ''}>...</tr>}
</For>
// Compiles to (effect-free per-key fast path):
const __d0 = isSelected.subscribe(row.id, (m) => {
__root.className = (m ? 'selected' : '')
})
// Instead of the default _bind(() => …) shape:
const __d0 = _bind(() => {
__root.className = isSelected(row.id) ? 'selected' : ''
})The runtime API (createSelector.subscribe) ships with @pyreon/reactivity 0.25+. The promoted updater receives a boolean and applies the ternary inline; only the deselected and newly-selected rows re-run on selection change, never the entire <For> list.
selector(k) ? a : b ternary as a text-child (PR #899)
<For each={rows} by={(r) => r.id}>
{(row) => <td>{() => isSelected(row.id) ? '✓' : ''}</td>}
</For>
// Compiles to:
const __d0 = isSelected.subscribe(row.id, (m) => {
__t0.data = (m ? '✓' : '')
})Companion to the className path — same detector, different emission target. Common in row checkmark / badge columns of selection-bound tables.
signalRef().method(...args) formatter in text-child bindings (PR #899)
const count = signal(0)
const name = signal('hello')
;<span>{count().toFixed(2)}</span>
;<h2>{name().toUpperCase()}</h2>
;<code>{n().toString(16)}</code>
// Compile to (subscribes directly to the signal, applies formatter in updater):
const __d0 = _bindDirect(count, (v) => { __t0.data = v.toFixed(2) })
const __d1 = _bindDirect(name, (v) => { __t1.data = v.toUpperCase() })
const __d2 = _bindDirect(n, (v) => { __t2.data = v.toString(16) })Detects signalRef().method(...staticArgs) where the method is in a curated safelist of pure Number / String / Boolean prototype methods (toFixed, toExponential, toPrecision, toString, valueOf, toUpperCase, toLowerCase, toLocaleUpperCase, toLocaleLowerCase, trim, trimStart, trimEnd, slice, substring, substr, charAt, charCodeAt, codePointAt, padStart, padEnd, repeat, normalize, concat, startsWith, endsWith, includes, indexOf, lastIndexOf, at). The safelist is intentionally narrow — methods that mutate (Array.sort) or depend on call-time state are excluded.
Bail catalog (same shape for all three)
Auto-promotion falls back to _bind(...) when:
The receiver isn't a known signal or
createSelector()result (tracked at module scope viasignalVars/selectorVars— same scope-awareness as signal auto-call)The selector call has 0 or 2+ arguments (not the standard shape) / the method receiver has args
The key, branch, or method args contain a reactive read
The expression isn't a ternary (selector path) or method call (formatter path)
The method callee is computed (
sig()["toFixed"](2))The expression chains methods (
sig().a().b())
Dual-backend parity
Both the JS path and the Rust native binary implement all three detectors byte-for-byte. Cross-backend equivalence tests lock the parity so the two backends can't drift.
Pure Static Call Detection
The compiler recognizes 40+ standard library functions as pure (side-effect-free). Expressions containing only pure calls are not wrapped in reactive getters, since they cannot contain signal reads:
// NOT wrapped — Math.round is pure:
<div>{Math.round(3.7)}</div>
// NOT wrapped — JSON.stringify is pure:
<span>{JSON.stringify(data)}</span>
// NOT wrapped — Object.keys is pure:
<ul>{Object.keys(config).length}</ul>
// STILL wrapped — user function may contain signals:
<div>{formatPrice(price())}</div>The full list of recognized pure functions includes:
Math:
abs,ceil,floor,round,max,min,pow,sqrt,log,random,sign,trunc,clz32,imul,fround,cbrt,hypot,log2,log10,log1p,expm1,cosh,sinh,tanh,acosh,asinh,atanhJSON:
stringify,parseObject:
keys,values,entries,assign,freeze,is,fromEntries,hasOwn,create,getPrototypeOfArray:
isArray,from,ofOther:
String(),Number(),Boolean(),parseInt(),parseFloat(),isNaN(),isFinite(),encodeURIComponent(),decodeURIComponent()
Spread Props on Root Element
When the root element of a template has spread attributes, the compiler now emits _tpl() + _applyProps() instead of falling back to h() calls. This preserves the performance benefits of template cloning:
// Input
<div {...props}>
<span>{text()}</span>
</div>
// Output — template cloning + _applyProps for spread:
_tpl('<div><span> </span></div>', (__root) => {
_applyProps(__root, props)
const __e0 = __root.firstElementChild
const __t1 = __e0.firstChild
const __d0 = _bindText(text, __t1)
return () => { __d0() }
})Previously, any spread attribute would bail out of template emission entirely and fall back to h() calls. Now only spreads on non-root elements cause bailout.
Known Limitations
Nested JSX in Expression Containers
Expressions inside nested JSX within a child expression container are not individually wrapped. They are still reactive because the outer wrapper re-evaluates the whole subtree, just at a coarser granularity:
// The inner name() is NOT individually wrapped — but the outer () =>
// re-evaluates the entire subtree when show() changes.
<div>{() => show() && <span>{name()}</span>}</div>Fine-grained nested wrapping is planned for a future pass.
Fragment Children in Templates
Templates bail out when encountering fragment children, since fragments break the assumed DOM structure:
// This will NOT use template emission:
<div><>text</></div>
// This will use template emission:
<div><span>text</span></div>Mixed Element and Expression Children
A parent element with both element children and expression children is not eligible for template emission, because childNodes indexing becomes unreliable:
// NOT template-eligible (mixed element + expression children):
<div><span />{text()}</div>
// Template-eligible (expression-only children per parent):
<div><span>{text()}</span></div>Implementation Details
The compiler uses a dual-backend architecture:
Rust native binary (
native/src/lib.rs): the primary path, 3.7-8.9x faster. Usesoxc_parser/oxc_astRust crates for zero-copy AST traversal. Compiled to a platform-specific.nodebinary via napi-rs.JS fallback (
src/jsx.ts): usesoxc-parser(Rust NAPI binding) for parsing + a JS reactive pass. Activated automatically when the native binary isn't available (CI, WASM environments, unsupported platforms).
Both backends collect positional string replacements, then apply them in a single left-to-right O(n) pass. Prop-derived variable resolution is fully AST-based — collect_prop_derived_idents walks IdentifierReference nodes in the expression subtree, never scanning source text. This prevents false matches inside string literals, comments, or template literal quasis.
The implementation is a single recursive walk that visits every node in the source file. Template emission is checked first to avoid double-processing elements that get compiled to _tpl() calls. When a template-eligible subtree is found, the entire subtree is replaced with a single _tpl() call and the walker does not recurse into it.
Performance (Pyreon reactive pass only):
| File size | JS fallback | Rust native | Speedup |
|---|---|---|---|
| Small (9 lines) | 112K ops/sec | 410K ops/sec | 3.7x |
| Medium (24 lines) | 12.6K ops/sec | 103K ops/sec | 8.2x |
| Large (500+ lines) | 309 ops/sec | 1,544 ops/sec | 5.0x |
Native binary loader + per-platform packages
transformJSX() resolves the native binary in two steps via loadNativeBinding():
In-tree: a same-OS-and-arch
.nodebinary inpackages/core/compiler/native/(workspace-internal builds, monorepo dev).Per-platform npm package:
@pyreon/compiler-<platform>-<arch>[-<libc>]declared as anoptionalDependency. npm / bun installs only the matching one for the consumer's platform — package manifest expresses this via theos/cpufields per platform package.
Currently published platform packages:
| Platform | Arch | libc | Package |
|---|---|---|---|
darwin | arm64 | — | @pyreon/compiler-darwin-arm64 |
darwin | x64 | — | @pyreon/compiler-darwin-x64 |
linux | x64 | gnu | @pyreon/compiler-linux-x64-gnu |
linux | x64 | musl | @pyreon/compiler-linux-x64-musl |
linux | arm64 | gnu | @pyreon/compiler-linux-arm64-gnu |
linux | arm64 | musl | @pyreon/compiler-linux-arm64-musl |
win32 | x64 | — | @pyreon/compiler-win32-x64-msvc |
detectLibc() distinguishes glibc vs musl on Linux at load time (necessary because the wrong libc would silently fail to load, not throw). If neither path resolves (CI without the platform package, WASM, or a platform we don't ship for), the call falls through to the JS path silently — transformJSX() always returns a result.
To diagnose the resolved path in dev: set DEBUG=pyreon:compiler and the loader logs which path it took (in-tree, per-platform package, or JS fallback) on first call.
Exports Summary
| Export | Type | Description |
|---|---|---|
transformJSX | Function | Transform JSX source code (auto-selects native or JS) |
transformJSX_JS | Function | JS-only path — bypasses the native binary |
loadNativeBinding | Function | Resolve the native .node binding (or null if absent) |
TransformResult | Type | Interface for the transform output |
CompilerWarning | Type | Interface for compiler warning objects |