Architecture
How a <div> becomes pixels, and where the boundary between TypeScript and
the native engine sits.
Dziry's design follows one rule: work is done at compile time unless it provably cannot be. Every feature is asked, in order: can the compiler resolve it? precompute it? enumerate its variants? If the answer is yes, no runtime code ships for it. What remains dynamic — current state values, list contents, text measurement, hit-testing, the window size — is a short, deliberate list, and the runtime is held to a byte-count ratchet in CI to keep it that way.
The pipeline stages, shared-memory table roles and guard lists on these pages
are rendered from guards/architecture/data.ts, which bun run arch:check
validates against the repository — a cited file that moves or a table with no
documented writer fails that check.
The pipeline
Six stages, five of which happen before the app runs.
- You write TSX and a stylesheet.
- Evaluate — importing the module runs your components, once. There is no renderer and no virtual DOM; the tree they return is the tree that gets compiled.
- Resolve — selectors match, specificity sorts, inheritance applies, shorthands expand, units convert. This is the step a browser redoes every frame; Dziry does it once, and the result is a set of numbers.
- Emit — the numbers are written out as a TypeScript module of typed arrays: the in-memory representation itself, not a format that needs parsing.
- Write — across the boundary. Bun holds typed-array views over memory the engine allocated, so an upload is a store instruction, not a call.
- Draw — Rust, with Skia for paint and Taffy for layout.
Build time
- app.tsx + app.css — JSX, a stylesheet, and signals declared at module scope
- Evaluate, don't render — Importing the module is running the components — once, at build time
- Resolve the cascade — Selectors, specificity, inheritance and shorthands collapse to numbers
Invariant: Resolve each pseudo-state as a full cascade, not a diff over the base. The merge story depends on it. - Precompile the interaction states — Every toggle and pseudo-state becomes a list of style-table writes
Invariant: Patch the style table per (field, slot). Do not 'simplify' to swapping per-node style pointers — conflict detection and the predicate-mask table both depend on it. - Map live objects back to exports — `{count}` and `onClick={increment}` become named imports
- Emit app/ui.gen.ts — Typed arrays, `satisfies CompiledUi` — the artifact is the IR
The boundary
- dlopen and describe — The engine allocates; Bun wraps each field span as a typed-array view
Invariant: The arena stays a bare `*mut u8`, with slices materialised only inside function bodies. No Rust reference into shared memory may be live across a return to Bun. - Write into the staged arena — A style patch is a memory write, not a call
Invariant: Keep the staged/live/bounds split and span-wise commit. This — not monomorphism — is the real argument for struct-of-arrays. Do not collapse to one arena; do not go AoS.
Every frame
- tick() — The one FFI call per frame
Invariant: Keep the FFI boundary shape in full: catch_unwind, i32 status never a value, out-pointers, poisoning, and `panic = "unwind"` pinned in both Cargo profiles. - Input, then commit — Span-by-span diff turns 'some bytes changed' into a narrow patch
- Taffy — Flex and grid, rounded to whole pixels, bounds published back
Invariant: Keep the systematic distrust of host-written table contents: budgeted walks, range-checked ids, and a bad string slot reading as "". - Skia — Raster paint; an idle tick presents nothing at all
- Drain events → signals — A click writes a signal; batching makes it one repaint
Invariant: Append-and-abandon list growth: no node id is ever invalidated, which is the only reason focus survives a reorder.
The frame phase runs on two threads: the engine thread owns the engine handle and services the OS, while the application runs in a worker that writes the same engine memory. See Two threads — in particular why the engine thread may only try the lock, and why a missed commit is skipped rather than delayed.
The shared-memory tables
The boundary is memory, not a call surface. The layout is struct-of-arrays, because the engine reads one field across every node at a time, and a struct-of-structs layout would drag unused fields into cache on every read.
Table definitions live in src/protocol/schema.ts and are imported by the
architecture map directly, so the two cannot disagree. What the schema does
not record is each table's direction — who writes and who reads — which is
the first thing to know when debugging a wrong frame:
| Table | Written by | Read by | Note |
|---|---|---|---|
nodes | compiler, then list relinking and `hidden` | engine | Link fields are prefilled to -1: zero is a valid node id, so zeroed memory would say every node is its own first child. |
styles | compiler, then variant patches | engine, every frame | Style values stay zeroed, and there zero is real — `width: 0`, not auto. Auto is NaN. |
variants | compiler | engine painter | Per interactive node: a bitmask of the predicates its styling reads, and where its style run begins. |
media | compiler | engine, re-evaluated from the surface size each frame | One row per *atomic* condition, not per @media block, so the variant machinery resolves `and` for free as the combination where both bits are live. Thresholds are px — the engine never learns that rem exists. On the wire at all because a media query is the first styling input whose answer changes. |
variantSlots | compiler | engine painter | Entry runStart+i is the style for the predicate combination whose compacted bits equal i; entry 0 is the base style. |
lists | list runtime | engine | The one place node count is a run-time value. Arenas grow by appending; ids are never reused. |
layout | engine | Bun — hit-testing and the imperative API | The only table that flows the other way. |
strings | Bun, incrementally | engine | JS strings cannot be shared, so Bun writes UTF-8 into an arena and records (offset, length) here. |
bun run protocol-guard verifies the two sides agree on offsets, field
identity, enums and FFI symbols. bun run boundary-diff validates the tables
before they are handed over — link consistency, index ranges, sibling-chain
cycles, arena bounds.
Further reading
- The reactive rewrite — how
count * 2compiles whencountis a signal. - Two threads — the engine thread, the app worker, and the lock between them.
bun run arch— the interactive architecture map, in the repository.