Under the Hood
This page is for the enthusiasts. Nothing here is required to use JAF - the
point of the machinery below is that you never think about it. But if you want
to know what happens between the .html file you write and the
pixels on screen, this is the tour.
The one-sentence version: JAF is a compiler attached to a very small
runtime. Your components are compiled twice - the
<script> through an AST transform, the markup through a
template compiler that emits real DOM construction code - and what ships is
ordinary JavaScript modules. No HTML parsing at runtime, no interpreter, no
virtual DOM.
The compiler: Rust for the script, JAF for the markup
The <script> block is parsed and rewritten at the AST level
by SWC, the Rust compiler toolchain.
let count = 0 becomes a registered reactive signal; every read
inside an expression becomes a tracked read; every write becomes an
invalidation that notifies exactly the bindings that depend on it.
const stays static. This is why JAF has no
useState, no ref(), no $: labels - the
compiler sees let and does the rest.
Picking SWC puts JAF in unusual company. Most framework compilers are pure JavaScript - Svelte's, Vue's, Solid's Babel transform, even the new React Compiler - which keeps them portable at the cost of speed. The Rust camp is mostly toolchains: Next.js's entire compiler is SWC plugins, and Parcel, Deno and Rspack build on it. The only other framework whose own compiler sits on SWC is Qwik. JAF takes the same trade: Rust-speed transforms on every build, and a WASM build of SWC for the one place a browser has to run the compiler itself (the REPL).
The markup goes through JAF's own template compiler. A component like this:
<div class="counter">
<span>Count: {count}</span>
<button onclick="count++" class:active={count> 0}>+</button>
</div>
<script>
let count = 0
</script>
compiles to a module shaped like this (simplified):
import { template } from "jafjs/runtime/dom/template"
import { insert } from "jafjs/runtime/dom/insert"
import { toggleClass } from "jafjs/runtime/dom/classList"
import { on } from "jafjs/runtime/dom/on"
const _tmpl0 = template(`<div class="counter"><span>Count: <!--></span><button>+</button></div>`)
const _ev0_0 = (ctx) => ctx.count
const _ev0_1 = (ctx) => ctx.count > 0
function _render0(ctx) {
const _r = _tmpl0() // cloneNode(true)
const _e0 = _r.firstChild // div.counter
const _e1 = _e0.firstChild // span
const _a0 = _e1.childNodes[1] // the comment anchor
const _e2 = _e1.nextSibling // button
insert(_a0, () => _ev0_0(ctx))
toggleClass(_e2, "active", () => _ev0_1(ctx))
on(_e2, "click", ctx.$handlers[0])
return _r
}
function _setup0(jaf) {
let count = jaf.reactive("count", 0)
jaf.handler(0, () => { count.set(count.get() + 1) })
}
export const component0 = { template: _tmpl0, render: _render0, setup: _setup0, meta: {...} }
Three properties of this shape do most of the work:
-
The template parses once. Every mount after the first is a
cloneNode(true), roughly an order of magnitude cheaper thaninnerHTML. -
Element access is a compiled path walk -
firstChild,nextSibling. NoquerySelector, no marker attributes. The DOM ships clean. -
Bindings are closures, not strings. Each
{expression}is a tiny compiled function. Nothing is parsed, interpreted oreval'd in the browser.
Reactivity: signals, deep, batched
The runtime half is a fine-grained signal system. A binding subscribes to exactly the values it read; a write notifies exactly those bindings. There is no diffing pass and no re-render - an update is the handful of closures whose inputs changed.
Tracking is deep by default: push to an array, assign a nested field, and the
bindings that read it update. That convenience is the product; for the rare
hot list where per-row proxies matter, {@static ...}
opts out. Writes in one task batch into one update, effects carry automatic
cleanup, and timers and listeners registered in a component die with it.
Templates all the way down
A component stops being one template the moment it contains control flow.
Every <show> arm, <for> row body,
overlay body and piece of projected slot content becomes a
unit: its own template(), its own render function,
compiled by the same emitter recursing into itself. In the parent, each unit
is a single comment anchor; runtime helpers render the right unit at the
right anchor and own the resulting range - when a branch flips or a row
leaves, exactly that range is disposed.
-
Lists are keyed by identity. Rows whose keys survive keep
their exact DOM nodes - asserted in the test suite by reference equality.
An
<input>in a row keeps focus and its half-typed value while rows shuffle around it. - Slot scope is lexical by construction. Content you write in the parent compiles as a unit of the parent, so its bindings close over the parent's state - there is no runtime ownership protocol to get wrong. A slot that offers values passes them as an argument to that unit, typed, reaching both component tags and expressions in the content.
-
Components are imports, not lookups. A child tag resolves
at build time to a module reference the bundler settled. No runtime
registry, no name collisions at runtime, and no wrapper element - a
component contributes its own nodes, so it works inside a
<table>, a flex row, and aroundposition: fixed.
React components without React
JSX islands are compiled by JetShake, a sister compiler that turns React-API
components into signals and direct DOM - no React runtime ships. A compiled
island mounts at an anchor like any other unit, and props cross as compiled
thunks driving JetShake signals from JAF effects. A keyed
.map() of JSX rows compiles to a hosted list module with
JetShake's keyed reconciler behind it. The practical upshot: the React
component ecosystem is available, at single-digit kilobytes.
Routes, layouts, state
Routing is file-based and compiled like everything else: pages and layouts
are components, layout inheritance chains are resolved at build time, and a
route group's records are placed in the chunk that group loads. Guards
compile their denial destination in. State channels -
localStorage, session, URL query and path segments, cross-file
unite(), the global store - are declared in the script and
wired by the compiler; the runtime keeps only the channels your app uses,
because the build computed the bill.
HTML comes back out
A second backend renders the same emitted modules to strings with no
document, held equal to the DOM backend by equivalence tests.
JAF_PRERENDER=1 vite build writes static routes as real HTML -
scoped CSS included - and the client adopts that markup instead of wiping
it. Opt-in in 0.1, with its limits documented; it is also the foundation the
future SSR story stands on.
The honesty rule: named errors
The compiler's rule is: express it fully, or refuse it by name. Everything in the framework compiles; when the compiler meets something it cannot express - a typo'd component name, an unterminated block - the build stops with an error naming the component and the reason, grouped by root cause. There is no silent middle ground, and the test suite pins every message. That includes source a visitor typed: the REPL on this site runs this same compiler in the browser, so a component it refuses is refused in the words the build would have used, and one it accepts is the module the build would have written.
Security is a compile artefact
A compiled app contains no eval, no new Function,
and no inline handlers - listeners are attached with
addEventListener from module code. It runs under
script-src 'self'. That is not a hardening pass; it is what the
output is made of. Bound URL attributes are guarded against
javascript: on every backend.
How we know it is correct
Three tiers hold the machinery honest. Parity suites drive both the compiled path and the interpreter through the same mounts and assert identical DOM, before and after interaction. A golden corpus of real pages is compiled on every test run with each emitted module pinned as a snapshot. And a booted tier builds real production bundles, boots them in a DOM, clicks buttons and reads the result - the tier that has caught every bug the others could not see, including two found in the framework's own test week that had shipped quietly for days.
What all this buys you
- Bytes - hello-world lands under 10 kB gzipped, and a build includes only the helpers your templates call.
- Speed - clone-and-walk mounting, fine-grained updates with no diffing, keyed lists that move nodes instead of rebuilding.
- Strict CSP - by construction, not configuration.
- Honest failure - a build error with a name beats a page that quietly renders nothing.
If the architecture sounds familiar, it should: compiled templates and
fine-grained reactivity are where the fastest frameworks converged - Solid's
compiled JSX, Svelte's framework-as-compiler, Qwik's Rust optimizer. JAF's
contribution is applying it to plain .html files: you write
HTML, and the compiler meets you there.