pyreon

@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.

@pyreon/compilerstable

Installation

npm install @pyreon/compiler
bun add @pyreon/compiler
pnpm add @pyreon/compiler
yarn add @pyreon/compiler

Most 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:

  1. Template emission -- Multi-element DOM trees are compiled to _tpl() calls that use cloneNode(true) for fast instantiation.

  2. Static VNode hoisting -- Fully static JSX expressions inside expression containers are lifted to module scope.

  3. 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 imports

  • Safe — already-called signals (count()) are NOT double-called; import type is 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:

  1. Is it an arrow function or function expression? -- Skip. The user explicitly wrapped it or it is a callback.

  2. Is it a static literal? -- Skip. String literals, numeric literals, template literals without substitutions, true, false, null, and undefined have no reactive dependencies.

  3. Does it contain a call expression? -- Wrap it. Signal reads are always function calls (count(), name()). If the expression tree contains any CallExpression or TaggedTemplateExpression, it is treated as reactive.

  4. 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 params

Component 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:

PropReason
keyUsed for reconciliation identity, not a DOM attribute
refA 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 handler

Spread Attributes

Spread attributes (&#123;...props&#125;) 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 (&#123;...props&#125;)

// ---- 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 child

Multiple 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:

ConditionEligible?Reason
1+ DOM elements, all lowercase tagsYesPure DOM tree — single elements (<div>{x()}</div>) emit _tpl() too
Contains component (<MyComp />)NoComponents need runtime instantiation
Has spread attributes (&#123;...props&#125;)NoSpread requires dynamic prop application — handled by _tpl() + _applyProps() on root elements, h() otherwise
Has key propNoKeyed elements need reconciliation metadata
Contains fragment child (<>...</>)NoFragment breaks DOM structure assumptions
Mixed element + expression childrenYes<!> comment placeholder + replaceChild keeps positions exact
Multiple expression children in same parentYesOne <!> placeholder + text node per expression
Expression child containing nested JSXNoToo 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 AttributeHTML Attribute
classNameclass
htmlForfor
// 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 sequential createElement + setAttribute calls

  • Zero 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 cleanup

  • Persistent TextNode reuse 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 warnings

Parameters:

  • 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

PatternTransformReason
<div>&#123;expr()&#125;</div>&#123;() => expr()&#125;Dynamic child with signal read
<div :class='expr()'>class=&#123;() => expr()&#125;Dynamic prop with signal read
<div>&#123;a() ? b : c&#125;</div>&#123;() => a() ? b : c&#125;Ternary containing a call
<div>&#123;show() && x&#125;</div>&#123;() => show() && x&#125;Logical expression containing a call
<div>&#123;count() + 1&#125;</div>&#123;() => count() + 1&#125;Binary expression containing a call
<div>&#123;`hi ${name()}`&#125;</div>&#123;() => `hi ${name()}`&#125;Template literal containing a call
<div>&#123;obj.get()&#125;</div>&#123;() => obj.get()&#125;Method call
<div>&#123;css`...`&#125;</div>{() => css...}Tagged template expression
<button :onClick='fn'>UnchangedEvent handler
<div :key='id'>UnchangedKey prop
<div :ref='r'>UnchangedRef prop
<div>&#123;() => expr()&#125;</div>UnchangedAlready wrapped
<div>&#123;"literal"&#125;</div>UnchangedStatic string
<div>&#123;42&#125;</div>UnchangedStatic number
<div>&#123;true&#125;</div>UnchangedStatic boolean
<div>&#123;null&#125;</div>UnchangedStatic null
<div>&#123;undefined&#125;</div>UnchangedStatic undefined
<div>&#123;identifier&#125;</div>UnchangedPlain identifier (no call)
<div>&#123;&#123; x: 1 &#125;&#125;</div>UnchangedObject literal (no call)
<div>&#123;[1, 2]&#125;</div>UnchangedArray literal (no call)
<div>&#123;a + b&#125;</div>UnchangedBinary expression (no call)
<div>&#123;a ? b : c&#125;</div>UnchangedTernary (no call)
<Comp :prop='expr()'>UnchangedComponent prop (not wrapped)
<div>&#123;<span>text</span>&#125;</div>Hoisted to module scopeStatic JSX child
<div><span>&#123;t()&#125;</span></div>_tpl(...) callTemplate-eligible tree (2+ elements)
<div &#123;...props&#125;>UnchangedSpread 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 changes

Rules

ConditionInlined?Reason
const x = props.yYesDirect props member access
const x = props.y ?? 'default'YesExpression containing props access
const [own, rest] = splitProps(props, ['y']) then const x = own.yYessplitProps results are tracked
const a = props.x; const b = a + 1YesTransitive resolution
let x = props.yNolet is mutable — unsafe to inline
var x = props.yNovar is mutable — unsafe to inline
console.log(x) where x is prop-derivedNoNon-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 via signalVars / 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, atanh

  • JSON: stringify, parse

  • Object: keys, values, entries, assign, freeze, is, fromEntries, hasOwn, create, getPrototypeOf

  • Array: isArray, from, of

  • Other: 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. Uses oxc_parser/oxc_ast Rust crates for zero-copy AST traversal. Compiled to a platform-specific .node binary via napi-rs.

  • JS fallback (src/jsx.ts): uses oxc-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 sizeJS fallbackRust nativeSpeedup
Small (9 lines)112K ops/sec410K ops/sec3.7x
Medium (24 lines)12.6K ops/sec103K ops/sec8.2x
Large (500+ lines)309 ops/sec1,544 ops/sec5.0x

Native binary loader + per-platform packages

transformJSX() resolves the native binary in two steps via loadNativeBinding():

  1. In-tree: a same-OS-and-arch .node binary in packages/core/compiler/native/ (workspace-internal builds, monorepo dev).

  2. Per-platform npm package: @pyreon/compiler-<platform>-<arch>[-<libc>] declared as an optionalDependency. npm / bun installs only the matching one for the consumer's platform — package manifest expresses this via the os / cpu fields per platform package.

Currently published platform packages:

PlatformArchlibcPackage
darwinarm64@pyreon/compiler-darwin-arm64
darwinx64@pyreon/compiler-darwin-x64
linuxx64gnu@pyreon/compiler-linux-x64-gnu
linuxx64musl@pyreon/compiler-linux-x64-musl
linuxarm64gnu@pyreon/compiler-linux-arm64-gnu
linuxarm64musl@pyreon/compiler-linux-arm64-musl
win32x64@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

ExportTypeDescription
transformJSXFunctionTransform JSX source code (auto-selects native or JS)
transformJSX_JSFunctionJS-only path — bypasses the native binary
loadNativeBindingFunctionResolve the native .node binding (or null if absent)
TransformResultTypeInterface for the transform output
CompilerWarningTypeInterface for compiler warning objects
@pyreon/compiler