UI tree, layout and rendering
The Worker builds a UI tree and sends it to the native core as ops; the core lays it out and draws it. This page records what works and how it’s tested.

What works
Section titled “What works”The protocol has one source of truth. schema/protocol.ts defines every op, node kind, style property and event. bun run gen writes the TypeScript bindings (runtime/gen/protocol.ts), the Rust bindings (core/crates/mantou-tree/src/protocol.rs) and a golden op batch (schema/fixtures/basic.*). The TypeScript encoder writes that batch; the Rust decoder must decode it to exactly what the TypeScript decoder does. bun run gen:check fails CI when anything is stale.
The shadow tree (mantou-tree, no platform dependencies):
- Applying batches: whole batches are validated first, and a malformed one changes nothing. Ops that make no sense (unknown ids, cycles) are skipped and counted.
- Moves: a node already in place is never re-inserted.
- Styles: shared styles (
STYLE_DEF/STYLE_USE) with per-node overrides. - Layout: flexbox through Taffy, rounded to device pixels by Mantou rather than Taffy.
- Output: a scene of rectangles, and hit-testing for presses.
The renderer (mantou-render): solid rectangles drawn as instanced quads with wgpu, colours converted to linear for sRGB targets. It draws to a window’s surface or offscreen.
The native core (mantou-ffi, C ABI version 3): every UI is an instance with its own tree, scene, events and counters; the window is one (mt_app).
- Window: commits apply, lay out and publish a new scene on the Worker’s thread. The main thread draws the latest scene and lays out again on resize without involving JS. A press (down and up on the same node) whose path has a listener is sent as a
PRESSevent. - Headless instances (
mt_headless_new, §12.2) run without a window, synchronously on the caller’s thread, with a manual clock: input (mt_test_input), viewport (mt_test_viewport), stepping (mt_test_step), hit-testing, node boxes, a text dump of the scene (mt_test_scene) and offscreen captures (mt_test_capture).
The runtime:
- Nodes (
runtime/ui.ts): a mirror tree ofNodes withstyle,use,append,insertBefore,remove,releaseandonPress. Presses bubble, andstopPropagationstops them. - Batching: one commit per JS turn.
- Ids: released ids are reused.
- Tests:
runtime/mock.tsrecords decoded commits;runtime/headless.ts(HeadlessCore) drives a real headless instance.
The counter example (examples/counter, bun run counter): a Solid component, Counter.tsx, written with View, createSignal, Index and shared styles. When this page was written there was no text, so the count showed as squares; it now has labels and the count as text (Text and Mantou Mail).
The Solid layer (§7.2):
- Compilation:
.tsxfiles are compiled by a Bun plugin (runtime/solid/plugin.ts) withbabel-preset-solidin universal mode; compiled JSX calls the renderer inruntime/solid/renderer.ts. - Rendering: Solid’s universal renderer over the mirror tree;
<View>takesstyle(own properties, a shared style, or a list of them),onPressandref. Nodes are released when the Solid owner that created them is disposed. - Entry points:
run(app)renders in the window from the Worker;mount(ui, app)renders into anyUi, which is how tests use the mock core and headless instances.
| Suite | Command | What it checks |
|---|---|---|
| Tree unit tests | bun run test:core |
Layout, moves, release, cycles, malformed batches, device-pixel snapping, presses, shared styles |
| Cross-language fixture | bun run test:core |
Rust decodes the TypeScript-encoded batch exactly as the TypeScript decoder does |
| Incremental vs from-scratch layout | bun run test:core |
400 random UIs × 40 frames of random valid changes, plus 10 runs of 400 frames: after each frame, every node’s box and the whole scene equal those of a tree rebuilt from scratch, to the bit |
| Random and corrupted batches | bun run test:core |
20,000 random batches and 3,000 corrupted valid ones never panic, and the tree stays consistent (Tree::check) |
| Fuzzing | cargo +nightly fuzz run <target> in core/fuzz |
decode (any bytes), apply (any batches, then layout, scene, hit-testing, consistency), layout (fuzzer-driven valid changes, incremental vs from scratch) |
| Runtime | bun run test |
Encoder/decoder round trip, colours, validation, op counts, moves, bubbling, listening; the Solid counter against the mock core: one batch for the first render, 4 ops per square, one commit for several presses in a turn, everything released on dispose |
| Headless core | bun run test:headless |
The Solid counter on the real core without a window: layout, presses (a press commit is exactly 4 ops), wrapping, releasing, resizing at 2×, <Show> adding and removing nodes, captured pixels, independent instances |
| Window | bun run selftest |
The threading checks, plus, with the Solid counter: ops → layout → quads; a press → handler → one commit → frame (about 7–13 ms); no press without a listener; relayout while the Worker is blocked (8 resizes → 8 layouts, 0 commits) |
Fuzzing so far (60 s per target, Linux): decode 5.5 million runs, apply 657,000, layout 98,000; no failures. The random batch test found one bug before that: a STYLE_DEF with a garbage property count reserved memory for it before checking it, and a count of 2³² aborted the process. The count is now checked against the op’s length first, with a regression test.
Layout gap found by the headless tests: with flexWrap, CSS’s default align-content: stretch spreads wrapped rows over the container’s height. The protocol had no alignContent, so rows couldn’t be packed at the top; it does now.