bext.dev
DocsPRISM (bext-native)
PRISM (bext-native)10 min read
On this page

PRISM compile pass

A build-time SWC-style pass that folds static / prop-driven JSX subtrees into direct string concatenations before the bundler emits jsx() runtime calls. Equivalent in spirit to Marko's compiled templates, but operating on existing TSX without any authoring change.

Default-on for any site with [framework] type = "prism". Opt out per-build with BEXT_PRISM_COMPILE=0. Runtime-verified byte-equivalent at every layer (source rewrite → JS bundle → V8 render → HTTP TTFB).

What it folds (six tiers)#

The pass is additive: anything it can't statically prove safe falls through to the existing runtime h() path. Every tier is additive on top of the previous; you get the union by default.

Tier 1 — fully-static subtrees#

Element tag is a host tag, every attribute is a string literal, every child is text or another foldable JSX element.

tsx
// source
<head>
  <meta charSet="utf-8" />
  <title>my-page</title>
</head>

// emitted (literal body, branded as rendered HTML)
__bextSafe("<head><meta charset=\"utf-8\"><title>my-page</title></head>")

Tier 2 — static-shape with dynamic JSX expression children#

Static element + static attrs + at least one {value} child position. The dynamic value goes through the same renderChild boundary as the runtime: plain strings are escaped and SafeHtml subtrees pass through.

tsx
// source
<h1>Hello, {name}!</h1>

// emitted
__bextSafe("<h1>Hello, " + __bextChild(name) + "!</h1>")

Tier 3a — dynamic attribute values#

Including the canonical conditional className:

tsx
// source
<button className={active ? "on" : "off"}>{label}</button>

// emitted
__bextSafe('<button class="' + __bextEsc(active ? "on" : "off") + '">' + __bextChild(label) + "</button>")

__bextSafe, __bextEsc, and __bextChild are imported once at file top from @bext-stack/framework/jsx-runtime — they are the same safe, escapeHtml, and renderChild implementations used by the runtime, so attributes and child values are handled byte-identically with or without the pass.

Tier 4 — array.map() unrolling#

tsx
// source
<ul>{items.map(i => <li>{i}</li>)}</ul>

// emitted shape (formatted for readability)
__bextSafe((() => {
  let __bextOut = "<ul>";
  for (const i of items) {
    __bextOut += "<li>";
    __bextOut += __bextChild(i);
    __bextOut += "</li>";
  }
  __bextOut += "</ul>";
  return __bextOut;
})())

The single accumulator avoids the intermediate array and .join("") used by the older output shape. Tier 4+ also handles multi-arg (item, idx) => and block-bodied i => { return <li/>; } arrows.

Tier 5 — zero-prop component inlining#

tsx
// source
function Brand() { return <span class="b">bext</span>; }
function Header() { return <header><Brand/></header>; }
function Page() { return <main><Header/></main>; }

// Page's emitted body
return __bextSafe("<main><header><span class=\"b\">bext</span></header></main>");

Recursive: <Page><Header><Brand/></Header></Page> → one concat. Catches the canonical <Header/>, <Footer/>, <Logo/>, layout shell patterns.

Tier 5.5 — prop-bearing component inlining#

Components with a single destructured object param fold when the call site supplies every required prop:

tsx
// source
function Card({ title, body }: { title: string; body: string }) {
  return <div class="card"><h2>{title}</h2><p>{body}</p></div>;
}
function Page({ user }: { user: { name: string; bio: string } }) {
  return <Card title={user.name} body={user.bio} />;
}

// Page's emitted body
return __bextSafe("<div class=\"card\"><h2>" + __bextChild(user.name) + "</h2><p>" + __bextChild(user.bio) + "</p></div>");

The substitution is identifier-only — {title} in the body gets replaced with the call-site value. String-literal props are substituted as literals and receive normal compile-time escaping in their final position; dynamic children go through __bextChild, while dynamic attrs become a Dynamic span pointing back to the call-site source and use __bextEsc.

What it does NOT fold (intentional bails)#

Pattern Why
<div {...rest}> Spread shape unknowable at build time
function Card(props) (Ident param) props.X member access requires scope analysis; only destructured { a, b } is supported
<Card>{slot}</Card> (component with children) Slot threading needs scope-aware substitution — deferred
<main>{props.children}</main> props.children can be AsyncIterable<string>; string-coerce would render [object AsyncGenerator]
style={{...}}, dangerouslySetInnerHTML={{...}} Object-shape attribute semantics — runtime applies them, fold can't
i => { console.log(i); return <li/>; } Block bodies with side effects bail; only single-Return blocks fold
async function Page(), function* Page() Async/generator components handled by streaming runtime, not fold

The props.children rule was once stricter (any props reference caused the whole subtree to bail). It's now children-specific: props.title, props.amount, props.user.name etc. all fold.

Empirical results#

Source-level (bext-core::transform::prism_compile)#

The current source contains 52 #[test] cases covering the rewrite on synthetic fixtures + the actual sites/prism-demo/src/app/page.tsx. On a representative page (11 jsx() calls in the unfolded output):

Tier 1 only Tier 1+2 Tier 1+2+3a+4+5+5.5
8 of 11 eliminated (73%) 11 of 11 (100%) 11 of 11

Bundle-level (bext-turbopack::prism::tests)#

The pass is integrated into bext-turbopack's compile pipeline at two places: compile_closure (entry source) and transform_with_analysis_opts (transitive imports). E2E tests compile the same fixture with/without the pass, count jsx() calls, verify the helper import is emitted correctly.

Runtime-verified byte-equivalence#

html_byte_equivalence_with_and_without_pass compiles a fixture twice (env-on / env-off), evaluates both bundles in V8 via bext_v8::eval::render_prism, and asserts the rendered HTML is byte-identical. Currently 237 bytes match across all six tiers.

Current SSR microbench#

The canonical fixture is now the 38.3 KB, 100-row Tailwind catalog in harnesses/jsx-shootout. It rejects byte differences before timing and escapes every dynamic text value in both compiled output and the hand-written ceiling. On Bun 1.3.13, pinned to one core:

Path avg p50 Throughput
Safe hand-written JS ceiling 15.8 µs 14.3 µs 63K renders/s
Compile pass 15.7 µs 14.0 µs 64K renders/s
Interpreted, direct h() 69.3 µs 62.1 µs 14.4K renders/s
Automatic JSX runtime 75.3 µs 67.9 µs 13.3K renders/s

The compile pass is 4.4× faster than direct interpreted h() and 4.8× faster than the actual automatic-JSX path. It now matches the hand-written ceiling within measurement noise, so sub-microsecond codegen changes must be evaluated with alternating, batched runs rather than a single timer sample.

Historical HTTP TTFB on sites/status (separate measurement)#

This older status-site result was not rerun as part of the current 38.3 KB shootout and should not be mixed with the rendering-only table above. It used an ApacheBench-equivalent against the bext-server PRISM dispatcher serving the bext.dev status page:

Mode avg p95
BEXT_PRISM_COMPILE=0 17.59 ms 23.69 ms
default 6.50 ms 8.39 ms
Speedup 2.71× 2.82×

In that run, HTTP overhead was ~4-5 ms and constant between modes; the SSR delta dominated the measured TTFB.

Activation rule#

code
options.jsx_import_source == Some("@bext-stack/framework")
  && filename ends with .tsx | .jsx
  && BEXT_PRISM_COMPILE != "0" | "false" | "off"

So no config required — every site with [framework] type = "prism" picks it up automatically on next compile. It is the normal production path for PRISM sites, not a demo-only optimization.

Adding the pass to a new site: nothing required beyond [framework] type = "prism" in bext.config.toml and jsxImportSource = "@bext-stack/framework" in tsconfig.json.

Verifying on your machine#

bash
# Source-level unit tests (fast, ~1s after warm build)
cd ~/bext && rustup run nightly-2026-04-02 cargo test \
  -p bext-core --lib transform::prism_compile

# End-to-end compile-diff + V8 byte-equivalence (~2 min cold rebuild)
cd ~/bext && rustup run nightly-2026-04-02 cargo test \
  -p bext-turbopack --lib prism::tests

# Release-mode SSR perf microbench
cd ~/bext && rustup run nightly-2026-04-02 cargo test --release \
  -p bext-turbopack --lib prism::tests::bench_render_speedup \
  -- --ignored --nocapture

# Eyeball what the rewrite does to sites/prism-demo/src/app/page.tsx
cd ~/bext && rustup run nightly-2026-04-02 cargo test \
  -p bext-core --lib transform::prism_compile::tests::dump_rewrite \
  -- --nocapture --ignored

How it compares to other engines#

Same byte-validated 38.3 KB product-listing fixture (Bun 1.3.13, pinned core):

Engine avg p50
Rust string builder 3.9 µs 3.9 µs
Safe hand-written JS ceiling 15.8 µs 14.3 µs
bext-PRISM compile pass ¹ 15.7 µs 14.0 µs
Marko 5 38 µs 22 µs
PRISM interpreted, direct h() ² 69.3 µs 62.1 µs
PRISM automatic JSX 75.3 µs 67.9 µs
Solid 1.9 (generate: "ssr") 78 µs 61 µs
React 19 1,823 µs 1,646 µs

bext-PRISM-compiled renders well ahead of both Marko 5 and Solid 1.9 on the same fixture. Both Marko and Solid are themselves compile-time-template engines (Marko emits direct out.push(…); Solid's babel-preset-solid with generate: "ssr" emits _$ssr(template, ...args)) — the PRISM compile pass beats both because it emits a single growing-accumulator IIFE ((() => { let __bextOut = ""; for (...) __bextOut += "..."; return __bextOut; })()) with for-loops over .map/Array.from patterns instead of the intermediate-array .map().join("") shape Solid emits.

¹ The compile pass moved from .map().join("") expression form to an imperative IIFE + for-loop and now batches adjacent output segments. Current compiled output matches the security-equivalent hand-written JavaScript ceiling within measurement noise. The 3.9 µs Rust row is a native lower bound and is not the equivalence target.

² The interpreted runtime uses a regex bail-out in escapeHtml, primitive fast paths in h(), direct reuse of compiler-created intrinsic props, guarded component normalization, and a bounded two-entry cache of exact class escape results. Plain string children remain escaped; rendered subtrees pass through via the SafeHtml brand.

The full bench harness lives at harnesses/jsx-shootout/ in the bext repo — runs all five engines against three identical-DOM fixtures with one command. See its COMPILE-PASS.md for the design discussion that motivated each tier.

Signals tier — auto-wrap reactive JSX expressions#

A separate companion pass at crates/bext-core/src/transform/prism_signals.rs operates on "use signals" files (signals jsxImportSource) rather than the base PRISM source. Where the main pass folds static subtrees into string literals, the signals pass does the inverse: it wraps dynamic expressions in arrow-function thunks so the signals runtime sees a re-evaluatable function rather than a value materialized once at JSX-call time.

Without the pass:

tsx
"use signals";
<p>Count: {count.value}</p>
//                ^^^^^^^^^^^^ already materialized — not reactive

With the pass (transparent to the developer):

tsx
<p>Count: {(() => count.value)}</p>
//        ^^^^^^^^^^^^^^^^^^^^^^^^^^ thunk; runtime re-evaluates on signal change

What it wraps#

JSX expression containers in "use signals" files where the expression contains anywhere inside it a read of <knownSig>.value. "Known" means an identifier the pass identified earlier in scope as bound by signal(…) or computed(…). Examples that wrap:

tsx
{count.value}                          // bare read
{count.value > 0 ? "yes" : "no"}       // conditional
{Math.max(a.value, b.value)}           // call expression
{items.value.length}                   // chained member
{`hello, ${name.value}!`}              // template literal
{count.value as number}                // type assertion (preserved verbatim)

What it does NOT wrap#

Why
count.value = 5 (assignment) LHS of assignment is a write, not a tracked read
<p>{1 + 2}</p> no signal in the expression — the pass is precise
<p>{user.name}</p> (no .value on user) user isn't a known signal; pass leaves it alone
Function bodies (onClick={() => count.value++}) callbacks are deferred — wrapping the outer wouldn't capture them anyway
Object methods, getter bodies same — nested function scopes

Activation rule#

The pass fires only when all of these hold:

  1. The file's first non-whitespace directive is "use signals" or 'use signals'.
  2. The file has at least one < character (skips pure .ts files with no JSX).
  3. The bundler's jsxImportSource is @bext-stack/framework/signals.
  4. The env var BEXT_PRISM_COMPILE is not 0/false/off.

Implementation#

text
crates/bext-core/src/transform/prism_signals.rs       ~370 LOC

Same tsc_rs_ast walker pattern as the main PRISM pass: collects signal-bound identifiers from var/let/const = signal(…) / = computed(…) declarations, walks all JSX expression containers, asks expr_contains_signal_read whether to wrap, queues span replacements, applies them end-to-start so byte offsets stay valid.

What's deferred#

Reason
Tier 6 — static-string atomization V8 already interns parsed string literals, so atomization saves bundle bytes (modest) but not runtime cost. Implementation cost didn't pencil out for the bundle-size win alone. Documented as deferred in source.
Component children threading <Card>{slot}</Card> — slot threading into the body's {children} reference needs scope-aware substitution.
Ident-style props param function Card(props) { return <div>{props.title}</div>; } — member-access substitution is more fragile than identifier matching. Use destructured function Card({ title }) to opt into Tier 5.5 inlining.
CI HTML-diff regression check A check that diffs HTML output of every PRISM page with/without the pass on every PR. Cheap insurance against future regressions.
Tip

To opt out of the compile pass for a single build (for example, to diff compiled vs uncompiled output), set BEXT_PRISM_COMPILE=0 in the environment. Normal use never needs this.

Implementation reference#

File Role LOC
crates/bext-core/src/transform/prism_compile.rs The visitor + fold logic ~3,840
crates/bext-turbopack/src/direct.rs::compile_closure Hook for entry source ~17
crates/bext-turbopack/src/direct.rs::transform_with_analysis_opts Hook for transitive imports ~17
crates/bext-server/src/handler.rs::handle_ssr Routes bext run standalone through PRISM dispatch ~25
crates/bext-turbopack/Cargo.toml bext-core = { path = "../bext-core" } 1
harnesses/jsx-shootout/ 5-engine bench harness

The pass uses tsc_rs_ast (bext's own TypeScript parser) — no swc dep. Runs as part of bext-turbopack's existing transform pipeline, which means no new infrastructure for hot reload, file watching, or incremental compile — those are already there.

  • PRISM Framework — the framework the compile pass targets
  • JSX Runtime — the h() function the pass replaces at build time
  • Signals — companion signals-pass that auto-wraps reactive expressions
  • V8 Render Pool — the isolate pool that runs compiled bundles
  • Transform Pipeline — how the compile pass fits in the broader bundler
Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.