Internals
How a <div> becomes lit pixels, and where the boundary is.
:::info These pages are generated from checked data
The pipeline stages, the shared-memory table roles and the guard list on these pages
are read from guards/architecture/data.ts, which bun run arch:check validates against the
repo: a cited file that moves, a guard script that gets renamed, or a shared table with
no documented writer and reader all fail that run.
For the long-form argument behind each decision, see ARCHITECTURE-REVIEW.md — its
Part 1 §4 is the authority on what a refactor must not touch.
:::
The shape of it
Six hops, and five of them happen before the app ever runs.
- You write TSX and a stylesheet.
- Evaluate — importing the module runs the components, once. No renderer, no virtual DOM; the tree that comes back is the tree that gets compiled.
- Resolve — selectors match, specificity sorts, inheritance applies, shorthands expand, units convert. The answer is a set of numbers. This is the step a browser would redo every frame.
- Emit — the numbers are written out as a TypeScript module of typed arrays. Not serialized data that something will parse: the in-memory representation, already in memory.
- Write — past the boundary. Bun holds typed-array views over memory the engine allocated, so uploading is a store instruction rather than a call.
- Draw — Rust, Skia, Taffy.
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. Struct-of-arrays, because the engine reads one field across every node at a time and a struct-of-structs layout would drag six unused fields into cache for each one.
The 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 the direction of each table, which is what you actually need 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 proves the two halves still agree on offsets, field identity,
enums and FFI symbols. bun run boundary-diff validates the tables Bun is about to
hand over — link consistency, index ranges, sibling-chain cycles, arena bounds.
Further reading
- The reactive rewrite — how
count * 2compiles. - Two threads — the engine thread, the app Worker, and the lock.
ARCHITECTURE-REVIEW.md— the fix-order authority.bun run arch— the interactive map, with six animated figures.