Framework adapters
How zfb supports both Preact and React behind a single renderer, and the shape of the boundary that makes adding a third framework additive.
zfb renders TSX. JSX is a syntax — the runtime that turns it into HTML is supplied by a framework. zfb supports two: Preact (default) and React.
The need
Two goals pull opposite ways. One: a single renderer pipeline that works the same for both frameworks, so we never fork the build. Two: many users want React (existing components, React-only libraries) and many want Preact (footprint, signals). zfb lets them pick:
// zfb.config.ts
export default {
framework: "preact", // or "react"
};
Read once at config-load time and threaded through the build. A one-line change for the user. For the renderer, the choice never appears — it talks to a uniform Adapter trait and has no if framework == "react" branches past adapter construction.
The boundary
The contract lives in crates/. Every adapter implements the same narrow trait:
pub trait Adapter {
fn name(&self) -> &'static str;
fn jsx_import_source(&self) -> &'static str;
fn render_to_string_module(&self) -> &'static str;
fn pre_render_setup(&self, host: &mut dyn RenderHost) -> Result<(), RenderError>;
fn hydrate_shim_specifier(&self) -> &'static str;
fn hydrate_shim_source(&self) -> &'static str;
}
Three groups of responsibility, no more:
- Tell the SWC pipeline which JSX import source to inject (
jsx_import_source). This drives thetransform-reactpass withruntime: "automatic"so user code never writesimport { h } from "preact"orimport React from "react"and never spells a@jsxImportSourcepragma. The framework choice lives in config; SWC threads it everywhere. - Tell the runtime where the synchronous
renderToStringlives (render_to_string_module) and install a shim (pre_render_setup) that aliases it asglobalThis.__zfbRenderToString. The render orchestrator inrender.rsthen calls__zfbRenderToString(vnode)once per page, with no branching on framework identity. - Provide the client-side hydration shim (
hydrate_shim_specifier+hydrate_shim_source). The islands bundler folds this module into the islands bundle as the framework-specific entry. It exportshydrateIsland(Component, props, element), and the framework-agnostic hydration runtime inzfb-islandscalls it for every[data-zfb-island]element in the DOM.
That is the entire surface. Hook semantics, signal interop, event delegation strategy — none of these are inside the boundary. Each framework keeps its own behaviour; the adapter only controls which framework is reached through.
Why a setup-phase shim
pre_render_setup runs once, before the first page render, and installs __zfbRenderToString on globalThis. Per-page rendering is then one function call from Rust into JS — no module re-resolution, no shim re-installation, no per-call import dance. The cost is paid once per host lifetime; the benefit accrues across thousands of pages.
Two tempting alternatives are ruled out. We are not generating per-page JS modules that import preact-render-to-string directly — that pushes module resolution onto the per-render hot path. We are not exposing a hydrate_call() API that returns a JS expression for Rust to template into source — that puts JS-expression concatenation in Rust, the wrong language for it. The shim keeps the Rust-to-JS boundary expressed purely as static module strings.
The Preact adapter
Lives at crates/. JSX import source "preact"; render module "preact-render-to-string"; hydration shim wraps hydrate(vnode, container). Preact is the default because its footprint fits zfb’s static-output target cleanly — preact-render-to-string is purpose-built for synchronous SSR and the runtime image stays small.
The React adapter
Lives at crates/. Larger than Preact, opt-in for users who need the React ecosystem. JSX import source "react"; render module "react-dom/server"; hydration shim wraps React 18+‘s hydrateRoot(container, vnode) — note the swapped argument order versus Preact, which the adapter owns so user code does not have to.
The React adapter uses synchronous renderToString, not renderToReadableStream or renderToPipeableStream. zfb produces ahead-of-time static HTML; a sync string is simpler, deterministic, and avoids dragging Node streaming primitives into the build host. The cost is no streaming HTML and no React Server Components in v1.
Future adapters
Solid, Vue SSR, Svelte SSR — none ship today. The Adapter trait is small enough that adding one is purely additive: a new adapter file, a new Framework enum variant, a new make_adapter arm. The render orchestrator does not change.
If a future framework cannot satisfy the trait — for example, it requires an async render entry that does not fit renderToString → string — that is a reason to widen the trait deliberately, not to special-case it.