Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

Chroix — View Layer Design (draft, pre-implementation)

Источник: dev/fera/chroix-project/CHROIX-VIEW-DESIGN.md (read-only; сгенерировано scripts/sync-docs.mjs)

Chroix — View Layer Design (draft, pre-implementation)

Section titled “Chroix — View Layer Design (draft, pre-implementation)”

Status: draft, for author review before any code. This is the public API surface of the view layer — the most expensive thing to change later, so it gets a design pass first. Implements V2 §3 (reactivity: B+C), §12 (compile-time strategy), §20.1 (the macro system is ONE system).

Three layers, top to bottom:

app! { } <- root macro: collects the whole graph
view!(state => ...) <- per-component macro: emits template + masks
select!(state.field) <- lenses: the authoring vocabulary

The macros are one system (§20.1): view! parses the component body, extracts signal reads via lenses, and emits (a) a static template descriptor, (b) a dirty bitmask, (c) entries for the root dependency table that app! assembles.

A cell is the unit of invalidation. The framework owns the cell store; components never touch cells directly.

// What the macro emits for `select!(state.chats[id].unread)`:
// a handle into the cell store + a bit in the component's dirty mask.
struct CellId(u32); // index into the cell arena
struct DirtyMask(u128); // one bit per cell this component reads
  • Cells are typed: Cell<T> holds a T plus its epoch.
  • Cells live in a flat arena (Vec<CellInner>), generational indices (slotmap) — stable identity, O(1) access, no pointer chasing.
  • A cell is dirtied by the update loop writing through it; the write raises the cell’s epoch. Readers compare epochs — no subscription lists, no callbacks.

2. Lenses (the authoring vocabulary — the “C” of B+C)

Section titled “2. Lenses (the authoring vocabulary — the “C” of B+C)”

A lens is a typed path into the state. Written by hand, consumed by macros.

// Author-written:
let unread = lens!(ChatState, chats => [chat_id] => unread_count);
// Which is sugar for:
let unread: Lens<ChatState, u64> = Lens::new(
|s: &ChatState| s.chats.get(&chat_id).map(|c| c.unread_count),
LensPath::Static(&["chats", "unread_count"]), // for the macro
);
  • Lens::new(getter, path) — the getter is what runs; the path is what the macro sees. They must agree; a lint checks simple cases.
  • Keyed lenses ([chat_id]) are the escape hatch for data-driven graphs: the key is resolved at runtime, the path records the shape. Keyed cells are created on demand in the arena and garbage-collected when the key set shrinks (OQ-6 territory, noted not solved).
  • Lens composition: lens_a.then(lens_b) — paths concatenate statically.
view! {
fn message_row(state: &AppState, id: MsgId) -> Row {
let text = select!(state.messages[id].text);
let unread = select!(state.chats[text.chat_id].unread);
Row {
icon: Avatar::new(text.author),
body: text.body,
badge: unread.map(Badge::new),
}
}
}

What the macro emits:

  1. Template descriptor (static): the Row { ... } shape with holes for icon, body, badge. Static parts live in rodata.
  2. Dirty mask constant: bits for the cells text and unread resolve to.
  3. Dep-table entries: (cell_id, component_id, bit) triples, collected by app! into the flat table.
  4. The render fn: fn render(&State, &CellStore) -> TemplateInstance — reads cells (checking epochs), fills holes, returns the instance.

Rules the macro enforces:

  • Reads only through select! — a raw field access is a compile error. (This is the load-bearing rule: it’s what makes the graph complete.)
  • Keyed lenses need a key in scope (id here) — error otherwise.
  • No side effects in the body (it’s a pure function of cells).
app! {
state: AppState,
update: update, // fn(&mut AppState, Msg) -> Vec<Effect>
view: root_view, // the top view! component
}

Emits:

  • static EDGES: &[(u32, u32)] — the flat dependency table (cell → mask).
  • static WRITE_MASKS: &[(MsgVariant, u128)] — per-message dirty bits (from analyzing update handlers’ lens writes).
  • fn frame(app: &mut App) -> FrameAction — the scheduler loop: drain messages, apply update, raise cell epochs from write masks, walk EDGES, re-render only components whose dirty mask intersects.

Cycle detection: app! sees the whole graph (that’s the point of a root macro) and rejects cycles at compile time with a readable error naming the lens chain.

fn frame(app: &mut App) {
// 1. drain messages
for msg in app.inbox.drain() {
let effects = update(&mut app.state, msg);
app.cells.raise(WRITE_MASKS[msg.variant()]);
app.effects.extend(effects);
}
// 2. re-render dirty components
for (cell, mask) in EDGES {
if app.cells.changed(*cell) {
app.dirty.set(mask);
}
}
for comp in app.dirty.components() {
comp.render_into(&mut app.element_tree, &app.cells);
}
// 3. hand the element tree to layout → display list → backend
}

All tables are static; the runtime state is the cell arena + dirty bitset + element tree. No allocation in the loop body beyond what changed.

  • No runtime read-tracking. Cells are declared by the macro from the lens paths, never captured by executing the read. (OQ-2 resolution.)
  • No dev-mode interpreter. The compiled graph is the only mode (OQ-8 resolution). Fast builds come from dynamic linking, not from a second runtime.
  • No hot reload. Rebuild to change a view. (OQ-4 resolution.)
  • No VDOM. The element tree is retained and keyed; the template system is a construction mechanism, not a diffing target.

7. Open questions this design raises — RESOLVED 2026-10-04

Section titled “7. Open questions this design raises — RESOLVED 2026-10-04”
  • Keyed cell GC: resolved: epoch + sweep on a slow lane. Cells carry their epoch; a background pass (on the data lane, not the frame lane) sweeps keyed cells whose keys no longer resolve in state. Accepted.
  • Write-mask analysis limits: resolved: loud. A handler that writes through a computed path gets a compile warning naming the handler and the path, plus the conservative whole-handler mask is applied. Silent fallback is not acceptable — the author must see when precision degrades.
  • Cross-component lenses: resolved: allowed, and the app! cycle check covers derived-of-derived chains. A cycle is a compile error naming the lens chain.
  • Where the element tree lives: resolved: the host. The view-DLL receives it in batches across the fload boundary (display list out, events/hit-tests back) — the tree itself never crosses; only its products do. Consistent with V2 §13 (one module, draw commands across) and the host-leased buffer pattern (§19).

Drafted 2026-10-04 against V2 §3/§12/§20.1 and the research dump. For author review; no code until accepted.