Decision records
ADR 0101 — Blueprint IR 2.4.0: layout ids
- Status: Accepted — implemented (2026-09-23)
- Date: 2026-09-23
- Implements: the loom half of loom-ui gap G6 (a container keeps its identity across a recompose) — genUI migrating onto loom-ui, phase 2.
- Builds on: ADR 0094 (node identity across a structural
change, whose walk this ADR extends to containers), ADR 0096
(the version pin, and D2’s major/minor rule this bump follows),
ADR 0099 (layout roles; this ADR amends its D3 sentence
“a Layout still has no id”, and copies its D4 reasoning for a
warn), ADR 0100 (the previous minor bump). - KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.18.0).
BLUEPRINT_IR_VERSION2.3.0 → 2.4.0.
Context
A renderer that reconciles a recomposed Blueprint against what it has mounted can keep a WIDGET
by its id — Widget.id is required — but it has nothing to keep a CONTAINER by. loom-ui’s DOM
adapter keys containers by ordinal + type + role among their container siblings (ADR 0099
D3). So inserting a container mid-list, or reordering containers, shifts every later ordinal:
each shifted container is swept out and mounted fresh, and every widget inside it with it —
typed input and focus are lost. The loss is reported (remounted), but it happens. Appending
remounts nothing, which is why the defect hid.
loom’s own React renderer has the same defect. A nested container reaches its parent as an
unkeyed element, Children.toArray gives it an INDEX key, and inserting a container before a
sibling matches the sibling’s old subtree to the new container: React re-creates the sibling’s
widgets.
loom-ui’s core already has a stable container id (ContainerNode.id); its compile has no
Layout field to carry it into the Blueprint. ADR 0099’s context rejected a layout id because
it would be “a second identity axis that preservesBlueprintNodes (ADR 0094) does not cover”.
This ADR answers that objection by making it the SAME axis: the identity walk covers layout ids.
Decisions
D1 — Every Layout gains an optional id, with Widget.id’s rule
GridLayout, FlexLayout and StackLayout gain id?: string. The grid()/flex()/stack()
builders take it and omit the key when it is absent.
The rule is the same as Widget.id: a non-empty string. No charset, no length cap. loom-ui’s
NodeId is z.string().min(1); a stricter loom rule would force its compile to drop ids its
own schema accepts, splitting one rule in two. Unlike a role (ADR 0099 D2), an id is never a
registry-object key, a CSS selector or a DOM attribute — a renderer uses it only as a Map key
or a React key — so __proto__ or a 10 000-character id is harmless.
isLayoutId(value: unknown): value is string is the one spelling of the rule, exported from
@loom/blueprint-runtime beside isLayoutRole, so a compiler’s schema (loom-ui core), a
renderer’s keying (loom-ui’s DOM adapter, loom’s React renderer) and validateBlueprint apply
the same predicate and cannot drift apart.
D2 — One node-id namespace: duplicate-node-id spans widgets and layouts
Layout ids and widget ids are one namespace. validateBlueprint’s duplicate-node-id (error)
now fires for widget↔widget (as before), widget↔layout and layout↔layout collisions, on the
second occurrence in pre-order; its kind is "<layout>" when that node is a layout. loom-ui
already guarantees this upstream (its insertNode refuses a duplicate across all nodes), so its
compile gains no new finding.
D3 — A malformed id is a layout-id-invalid warning, and is otherwise ABSENT
An id that is present (not undefined) but not a non-empty string — "", a number, null, an
object — is reported once as layout-id-invalid, a warn. ADR 0099 D4’s reasoning holds:
validateBlueprint is the only gate a model-composed artifact passes, so an unreported malformed
id would pass silently everywhere; and nothing is lost but state preservation across a recompose,
so ok stays true.
Everywhere else a malformed id is absent: renderers key the container positionally,
blueprintLayoutIds skips it, and it collides with nothing (two "" ids are two warnings, not a
duplicate).
Layout-level findings (role-invalid, columns-invalid, and duplicate-node-id on a layout) set
nodeId to the layout’s id when it has a well-formed one, and "<layout>" otherwise. A
cell-level finding (span-out-of-range, placement-out-of-range) on a cell wrapping a nested
layout names that layout’s id the same way. layout-id-invalid itself is always "<layout>".
D4 — Absent ⇒ today’s behaviour, exactly
A layout without an id is keyed positionally by every renderer, exactly as under 2.3.0, and its bytes — hence its content address — are 2.3.0’s (the builders omit the key). Existing blueprints are byte-identical.
D5 — The id enters the content address
An id is in the Blueprint’s canonical bytes, so it is part of the address. Leaving a field a
renderer acts on out of the address would make the address lie about what renders. A field that
stayed out of the digest was rejected as a side channel; so was a separate key or
containerKey vocabulary, a second identity axis exactly as ADR 0099 objected.
What changes: a Blueprint compiled with ids, from the next recompile on — the ids plus the 2.4.0 stamp — the same effect every minor bump already has. What does not: loom-ui addresses the Experience it compiles FROM, and container ids are already inside it; stored blueprints are never rewritten and keep their addresses.
D6 — The identity walk covers layout ids (the same axis, not a second one)
blueprintLayoutIds(bp)(new export): every well-formed layout id, pre-order, the root included — the same total, iterative walk asblueprintNodeIds.preservesBlueprintNodes(before, after)compares widget ids ∪ layout ids in one pre-order walk. A re-identified container (a planner re-minting its ids) is adropped-node-idfinding instead of a silent loss of the user state under it. Abeforewith no layout ids — any pre-2.4.0 artifact — contributes none, so upgrading only adds toaddedandokstaystrue.blueprintNodeIdsstays widget-only. Its callers read its output as widget ids (renderer-lit’sremountedOf, the a2ui bridge’sidentityDiff); adding container ids would silently change a published function’s meaning — rejected. Leaving the whole walk widget-only was rejected too: ADR 0099’s objection would still hold.
This amends ADR 0099 D3’s “a Layout still has no id”: a Layout may now have one, and it is node
identity. A role still is not.
D7 — loom’s renderers: React keys by it, the terminal ignores it
- React (
renderReactContainer) setskey={layout.id}on the element it returns (the<div>, or the titled<section>) when the id is well-formed.Children.toArraykeeps an existing key, and the span > 1 grid wrapper already inherits the child’s key, so the id reaches React on every path — eager and fiber. The rendered DOM is identical with or without an id; only reconciliation changes. - Grid cell keys are prefixed. An id may be
"0"or"1"— exactly what an index key stringifies to — so a grid cell’s key isk:<child key>for a keyed child andi:<index>for an unkeyed one, two disjoint spaces. A span-1 cell (formerly emitted bare) is carried by a keyed<Fragment>, which renders no element, so the DOM is unchanged. Before the prefixes a span > 1 wrapper around a containerid: "1"and an unkeyed wrapper at index 1 shared the key1: a duplicate-key warning and exactly the misreconcile this ADR fixes. The non-grid path was already disjoint (Children.toArraywrites a key as.$1, an index as.1); both are pinned. Without an id React keys by index, as before. Pinned: a step inserted above a typed-in, focused step keeps the same<input>node, its value and focus, on both paths, and the same test without ids still re-creates it. - Terminal (
renderBlueprintToString) ignores it: a one-shot string has nothing to reconcile, and its output is byte-identical with or without ids (malformed included). - Ignoring it in React was rejected: it would leave the same defect in loom’s own renderer for no saving.
D8 — A minor bump, and 2.3.0 artifacts are read as they are
An optional field on every Layout is ADR 0096 D2’s minor case. The three layout manifests gain
id:optional; irVersion.test.ts pins a sixth digest beside the frozen 1.0.0–2.3.0 rows.
IR_VERSIONS_READ_AS_IS gains 2.3.0, so validateBlueprint knows 2.0.0–2.4.0 and
upgradeBlueprint is identity on all five; a 1.0.0 layout carrying an id keeps it when wrapped.
Writers (blueprint$) stamp 2.4.0 from here on.
Consequences
BLUEPRINT_IR_VERSIONis"2.4.0". Every artifact written under 2.x is read as it is.BlueprintFindingCodegainslayout-id-invalid(warn), andduplicate-node-idcan now name a layout. A consumer that switches exhaustively over the codes sees a new case; one that gates onoksees a new error only for a genuinely colliding id.preservesBlueprintNodescan now report a dropped CONTAINER id. A caller comparing an id’d tree against its own earlier id’d tree gets strictly more findings, all of them real losses of identity.@loom/blueprint-runtimeexports two new values,blueprintLayoutIdsandisLayoutId. Both are part of the IR contract, not vocabulary: they name no container.- loom-ui’s
packages/core/src/blueprintIr.test.tspins the IR version it compiles against and goes red when loom-ui bumps itsLOOM_REFpast this ADR. That is the intended signal: the same PR teaches itscompileto emitContainerNode.idon every container and its DOM adapter’sreconcileContainerto key by it. KERNEL_ABI_VERSIONstays 1.1.0 andEVENT_SCHEMA_VERSIONstays 1.18.0.@loom/speclists this ADR inNOT_A_CAPABILITY, for the same reason as 0096–0100: an IR contract and renderer behaviour, not a runtime subsystem to grade.- The next minor bump adds
2.4.0toIR_VERSIONS_READ_AS_IS.
Source: docs/adr/0101-blueprint-ir-layout-ids.md in the loom repository.