Targ Apps Docs
Chroma

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_tracking clears the binding's previous dependencies before each evaluation, so subscriptions always match the latest run — unsubscribing from signals that stopped being read.
  • The UNTRACKED sentinel (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 arena
  • bindings: Vec<Binding> — expression + BindingTarget + optional cleanup
  • contexts: Vec<Vec<JsValue>> — per-context provider stacks
  • layout_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 element
  • on* + function → add_event_listener_with_callback("click"/"input"/…)
  • other function (non-event) → reactive attribute binding (BindingTarget::Attribute); falsy false/null/undefined removes the attribute
  • everything else → static attribute, once (classNameclass, htmlForfor)

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 → f itself (auto-tracking)
  • deps array → wrapper that calls each getter (subscribing) and then runs f under untracked

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.error messages with a chroma: prefix (e.g. render with a non-Element container, e with an invalid tag, Show/For missing required props).
  • Unexpected internal panics go through console_error_panic_hook for a readable stack trace instead of an opaque WASM trap.
  • Errors thrown inside user callbacks (effect bodies, reducers, Provider children, Show/For render 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.rs is pure usize bookkeeping with no JsValue and 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 the e/Fragment/render surface in lib.rs) touches JsValue, web-sys, or the DOM, so it's compiled to wasm32-unknown-unknown and run for real in a browser via wasm-bindgen-test. These tests live in a #[cfg(test)] mod tests at the bottom of the module they cover, gated with wasm_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-futures is used for the one async test (useEffect's deferred-to-microtask first run): it awaits a resolved Promise to flush the microtask queue before asserting the effect ran.
    • chromedriver must match the installed Chrome version, or session creation fails with session not created.
    • In sandboxed/containerized environments, headless Chrome typically needs --no-sandbox; a webdriver.json at the repo root can supply that (plus --headless=new, --disable-dev-shm-usage) as goog:chromeOptions.
    • --firefox (with geckodriver) 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-init

Release 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 install provisions Rust, the wasm32-unknown-unknown target, wasm-pack, and vendors the engine source + compiled pkg/ into ~/.chroma. chroma new scaffolds a static or dynamic template; chroma dev serves it; chroma build produces the release bundle (plain minification for static, esbuild bundling + optional WASM recompile for dynamic).
  • Chroma Server (sibling repo, binary engine) — the static file server the CLI installs and shells out to for chroma dev. Single-purpose, dependency-free (std only), binds to 127.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 build zip 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

  • For rebuilds wholesale — no keyed reconciliation.
  • The npm name chroma is 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.

On this page