Architecture
Internal design of Chroma — Rust module layout, the WASM boundary, and error handling.
Overview
┌─ USER APP (index.html) ─────────────┐
│ const [n, setN] = hookState(0) │
│ e("button", { onClick }, n) │
└──────────────┬──────────────────────┘
│ import { ... } from "pkg/chroma.js"
│ (loader auto-generated by wasm-bindgen)
┌──────────────┴──────────────────────┐
│ chroma.wasm (Rust) │
│ · hookState / render / e / Fragment │
│ · signal arena (JsValue values) │
│ · dependency tracking │
│ · node ↔ signal bindings │
│ · DOM and events via web-sys │
└─────────────────────────────────────┘The entire engine is Rust compiled to WebAssembly. The only JavaScript in the package is the wasm-bindgen loader (pkg/chroma.js), which is generated code, not framework code. build.sh appends an auto-init so importing the module instantiates the .wasm automatically.
The diagram below expands that boundary into the actual module graph — where each layer in Module responsibilities sits relative to the JS ↔ WASM crossing:
graph.rs sits at the bottom of the dependency chain by design — it has no JsValue or DOM awareness, which is what makes it natively testable (see Testing below).
Source layout
Chroma/
├── Cargo.toml # crate-type cdylib+rlib; deps: wasm-bindgen, web-sys, js-sys
├── src/
│ ├── lib.rs # wasm-bindgen exports: e, Fragment, render
│ ├── graph.rs # dependency graph (native tests)
│ ├── signals.rs # values, bindings, effects, memos, contexts
│ ├── dom.rs # nodes, bindings, events, ref prop
│ ├── components.rs # Show, For
│ ├── variants.rs # cva, cx (reusable components with variants)
│ └── action/
│ └── hooks/ # one file per hook
│ ├── mod.rs # reactive_expr helper (deps → expression)
│ ├── hook_state.rs
│ ├── hook_effect.rs
│ ├── hook_layout_effect.rs
│ ├── hook_memo.rs
│ ├── hook_callback.rs
│ ├── hook_ref.rs
│ ├── hook_reducer.rs
│ ├── hook_context.rs # createContext + useContext
│ └── hook_debug_value.rs
├── build.sh # wasm-pack + auto-init
├── example/
│ ├── index.html # hooks demo
│ └── targapps_kvdb.html # larger real-world app
└── pkg/ # wasm-pack output (generated, gitignored)Naming convention: files and Rust functions use the hook_ prefix. Most JavaScript-facing names keep the React-style use* casing via #[wasm_bindgen(js_name = ...)] (useEffect, useMemo, useCallback, useRef, useReducer, useContext, useDebugValue). The state hook is the deliberate exception: hook_state is exported as hookState, not useState — see Hooks Guide.
Module responsibilities
graph.rs — pure dependency graph
Signal ↔ binding subscriptions as plain usize ids (SignalId, BindingId). It knows nothing about values, JS, or the DOM, which makes it natively testable (cargo test, no browser).
Key mechanics:
- A tracking stack (
Vec<BindingId>) of bindings under evaluation; reads register dependencies on the top entry (track_read). begin_trackingclears the binding's previous dependencies before each evaluation, so subscriptions always match the latest run — unsubscribing from signals that stopped being read.- The
UNTRACKEDsentinel (BindingId::MAX) suppresses subscription for scopes that must read without tracking.
signals.rs — reactive runtime
A thread_local! static RUNTIME: RefCell<Runtime> (WASM is single-threaded) holding:
values: Vec<JsValue>— the signal arenabindings: Vec<Binding>— expression +BindingTarget+ optional cleanupcontexts: Vec<Vec<JsValue>>— per-context provider stackslayout_queue: Vec<js_sys::Function>— layout effects pending until mount
Re-entrancy rule: never hold the RUNTIME borrow while invoking JS — a binding expression may itself read or write signals. Every public function copies what it needs out of the store, drops the borrow, then calls into JS.
write_signal compares with Object.is (js_sys::Object::is); on a real change it collects the signal's subscribers and re-runs them after releasing the borrow.
dom.rs — nodes and bindings
create_element walks props via js_sys::Object::entries and, per key:
ref→ object (.current) or callback, receiving the freshly created elementon*+ function →add_event_listener_with_callback("click"/"input"/…)- other function (non-event) → reactive attribute binding (
BindingTarget::Attribute); falsyfalse/null/undefinedremoves the attribute - everything else → static attribute, once (
className→class,htmlFor→for)
Children go through append_child_value: DOM Nodes are appended as-is, functions become a reactive DynamicChild slot (<span style="display: contents">), arrays are flattened recursively, null/undefined/booleans render nothing, everything else becomes a text node via to_display_string.
components.rs — control flow
Show and For mount content once (preserving inner hooks) and drive updates through an Effect binding. Show toggles display between two sibling slots; For rebuilds list items under untracked so item rendering doesn't pollute the list binding's own dependencies (only the each getter is tracked).
variants.rs — reusable components with variants
cva and cx are pure class-name utilities: no DOM, no signals, no JsValue mutation beyond reading the config once. cva parses its config object (base, variants, compoundVariants, defaultVariants) into owned Rust maps up front, then returns a closure that resolves props against those maps on every call — a component still calls it like any other plain function (e("button", { class: buttonClass(props) }, ...)), so "reusable component" stays "function built with e()", just with its class attribute computed declaratively instead of concatenated by hand. See Components & Variants.
action/hooks/ — the hook surface
One file per hook. The shared helper reactive_expr(f, deps) converts a (fn, deps) pair into a binding expression:
- no deps →
fitself (auto-tracking) - deps array → wrapper that calls each getter (subscribing) and then runs
funderuntracked
lib.rs — WASM surface
Exports e (variadic), Fragment, render, and installs console_error_panic_hook at startup via #[wasm_bindgen(start)]. render appends the component's returned tree to the container, then flushes the layout-effect queue — synchronously, before paint.
The WASM boundary
Every DOM operation crosses WASM→JS. This is an explicit design decision: the goal is the architecture ("the whole engine in Rust"), not beating pure-JS frameworks in benchmarks.
The .wasm cannot be imported directly by browsers for two platform reasons: (1) ESM integration for WebAssembly is not shipped in browsers yet, and (2) WASM only exchanges numbers with JS — strings, objects, and callbacks need the auto-generated wasm-bindgen bridge.
Error handling
- No WASM panics reach the user for expected misuse: usage errors are reported via clear
Err/console.errormessages with achroma:prefix (e.g.renderwith a non-Element container,ewith an invalid tag,Show/Formissing required props). - Unexpected internal panics go through
console_error_panic_hookfor a readable stack trace instead of an opaque WASM trap. - Errors thrown inside user callbacks (effect bodies, reducers, Provider children,
Show/Forrender functions) are caught and logged rather than propagated, so one broken callback doesn't take down the rest of the tree.
Testing
Two tiers, split by whether the code under test needs a JS engine:
- Native unit tests (
cargo test):graph.rsis pureusizebookkeeping with noJsValueand no DOM, so it's tested with plain#[test]functions on the host target — subscription, deduplication, cleanup on re-evaluation, nested tracking. - Browser tests (
wasm-pack test --headless --chrome): everything else (signals.rs,dom.rs,components.rs,variants.rs, every hook, and thee/Fragment/rendersurface inlib.rs) touchesJsValue,web-sys, or the DOM, so it's compiled towasm32-unknown-unknownand run for real in a browser viawasm-bindgen-test. These tests live in a#[cfg(test)] mod testsat the bottom of the module they cover, gated withwasm_bindgen_test_configure!(run_in_browser). They build props/getters/closures directly in Rust — no JS bundle involved — and assert on real DOM state (get_attribute,text_content, dispatched events) or on the reactive behavior itself (a binding rerunning, a memo updating, a cleanup firing before a re-run).wasm-bindgen-futuresis used for the one async test (useEffect's deferred-to-microtask first run): it awaits a resolvedPromiseto flush the microtask queue before asserting the effect ran.chromedrivermust match the installed Chrome version, or session creation fails withsession not created.- In sandboxed/containerized environments, headless Chrome typically needs
--no-sandbox; awebdriver.jsonat the repo root can supply that (plus--headless=new,--disable-dev-shm-usage) asgoog:chromeOptions. --firefox(withgeckodriver) works the same way if Chrome isn't available.
Manual end-to-end check: serve the repo, open example/index.html (or example/targapps_kvdb.html), interact with it, and confirm the DOM updates without re-renders.
Build
./build.sh # wasm-pack build --target web + auto-initRelease profile: opt-level = "s", LTO enabled — this optimizes the compiled .wasm binary for size. The wasm-bindgen glue (pkg/chroma.js) is generated separately and its size is independent of this profile.
Ecosystem: CLI, dev server, and desktop shell
Three sibling projects extend the engine into a full toolchain:
- CLI (
cli/chroma.sh/cli/chroma.ps1) —chroma installprovisions Rust, thewasm32-unknown-unknowntarget,wasm-pack, and vendors the engine source + compiledpkg/into~/.chroma.chroma newscaffolds astaticordynamictemplate;chroma devserves it;chroma buildproduces the release bundle (plain minification forstatic, esbuild bundling + optional WASM recompile fordynamic). - Chroma Server (sibling repo, binary
engine) — the static file server the CLI installs and shells out to forchroma dev. Single-purpose, dependency-free (std only), binds to127.0.0.1, one thread per connection, no keep-alive/TLS/directory listing, and rejects paths that escape the served root. - Chroma Desktop (sibling repo) — a Tauri application that imports
chroma buildzip bundles (drag-and-drop or file picker), extracts each into its own folder under the app's data directory, and serves them all from a second local-only instance of the same style of static server, so built apps can be run and revisited without a browser or dev server.
Known limitations & notes
Forrebuilds wholesale — no keyed reconciliation.- The npm name
chromais taken (a color library); publishing would require a scope (e.g.@vmaspad/chroma) or another name. - No SSR, no hydration (by design there is nothing to hydrate).
See Limitations for the full list and rationale.