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.
// 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.
// source
<h1>Hello, {name}!</h1>
// emitted
__bextSafe("<h1>Hello, " + __bextChild(name) + "!</h1>")
Tier 3a — dynamic attribute values#
Including the canonical conditional className:
// 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#
// 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#
// 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:
// 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#
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#
# 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:
"use signals";
<p>Count: {count.value}</p>
// ^^^^^^^^^^^^ already materialized — not reactive
With the pass (transparent to the developer):
<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:
{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:
- The file's first non-whitespace directive is
"use signals"or'use signals'. - The file has at least one
<character (skips pure.tsfiles with no JSX). - The bundler's
jsxImportSourceis@bext-stack/framework/signals. - The env var
BEXT_PRISM_COMPILEis not0/false/off.
Implementation#
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. |
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.
Related#
- 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