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).
0. What this is
Section titled “0. What this is”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 vocabularyThe 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.
1. Signals and cells
Section titled “1. Signals and cells”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 arenastruct DirtyMask(u128); // one bit per cell this component reads- Cells are typed:
Cell<T>holds aTplus 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
updateloop 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.
3. view! — the component macro
Section titled “3. view! — the component macro”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:
- Template descriptor (static): the
Row { ... }shape with holes foricon,body,badge. Static parts live in rodata. - Dirty mask constant: bits for the cells
textandunreadresolve to. - Dep-table entries:
(cell_id, component_id, bit)triples, collected byapp!into the flat table. - 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 (
idhere) — error otherwise. - No side effects in the body (it’s a pure function of cells).
4. app! — the root macro
Section titled “4. app! — the root macro”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.
5. The frame loop (runtime side)
Section titled “5. The frame loop (runtime side)”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.
6. What this deliberately is NOT
Section titled “6. What this deliberately is NOT”- 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.