Skip to main content

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.

Generated from checked data

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.

  1. You write TSX and a stylesheet.
  2. 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.
  3. 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.
  4. Emit — the numbers are written out as a TypeScript module of typed arrays: the in-memory representation itself, not a format that needs parsing.
  5. Write — across the boundary. Bun holds typed-array views over memory the engine allocated, so an upload is a store instruction, not a call.
  6. Draw — Rust, with Skia for paint and Taffy for layout.

Build time​

  1. app.tsx + app.css — JSX, a stylesheet, and signals declared at module scope
  2. Evaluate, don't render — Importing the module is running the components — once, at build time
  3. 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.
  4. 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.
  5. Map live objects back to exports — `{count}` and `onClick={increment}` become named imports
  6. Emit app/ui.gen.ts — Typed arrays, `satisfies CompiledUi` — the artifact is the IR

The boundary​

  1. 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.
  2. 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​

  1. 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.
  2. Input, then commit — Span-by-span diff turns 'some bytes changed' into a narrow patch
  3. 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 "".
  4. Skia — Raster paint; an idle tick presents nothing at all
  5. 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:

TableWritten byRead byNote
nodescompiler, then list relinking and `hidden`engineLink fields are prefilled to -1: zero is a valid node id, so zeroed memory would say every node is its own first child.
stylescompiler, then variant patchesengine, every frameStyle values stay zeroed, and there zero is real — `width: 0`, not auto. Auto is NaN.
variantscompilerengine painterPer interactive node: a bitmask of the predicates its styling reads, and where its style run begins.
mediacompilerengine, re-evaluated from the surface size each frameOne 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.
variantSlotscompilerengine painterEntry runStart+i is the style for the predicate combination whose compacted bits equal i; entry 0 is the base style.
listslist runtimeengineThe one place node count is a run-time value. Arenas grow by appending; ids are never reused.
layoutengineBun — hit-testing and the imperative APIThe only table that flows the other way.
stringsBun, incrementallyengineJS 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 * 2 compiles when count is 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.