Skip to content

Spec 004B — The UI structural tree and DOM conversion contract

Status: v1-required. The public ABI for a substrate's structural render tree and the DOM conversion table every emitter consumes — the tree/conversion half of the portability law. Consumers: the projections of Fresco's test kit (re-frame.fresco.test, usually aliased ht — §Projections), tree traversal (ordinary Clojure — (tree-seq map? :children tree)), parity/fingerprints (per 008 and 011), and the day8/re-frame2-ssr artifact (§The SSR consumption boundary). The optimizer/compiler AST is explicitly private: the public contract is this tree plus the conversion table. Rows written to React's published behaviour but not yet exercised against real React are tagged [S1-CONFIRM] — confirmed as the parity corpus grows.

Scope — what is public, and the one privacy sentence

Public, versioned, and owned by this contract: (a) the structural tree node schema and its canonical form; (b) the semantic normalization N that feeds parity and fingerprints; (c) the DOM conversion table every emitter consumes; (d) the tree→re-frame2-ssr consumption boundary. The optimizer/compiler AST is explicitly private: the public contract is this tree plus the conversion table, not the AST.

Producers and the consumer

The tree is a value one party builds and another reads, and this contract is all the two share. In this repository re-frame.fresco.test/tree — Fresco's L2 test kit — builds a version-1 tree from one hook-free Fresco body, and re-frame.ssr/emit-ui-tree folds a version-1 tree into HTML (§The SSR consumption boundary). Any renderer that builds a version-1 tree from its own render output reaches that consumer. The node schema, the canonical form, the normalization N, and the conversion table are stated once, here, and never restated per producer — which is what makes "the same declaration means the same thing" a checkable claim rather than a slogan.

A refusal belongs to the producer. Where a row below refuses an authored value or form, the rule is enforced by whatever reads the author's form, when it reads it: re-frame.fresco.test/tree refuses as it walks the body. The rule is one rule; the tier it fires in, and the id it fires under, are the producer's.

Author space and final space stay separate. The tree carries the names the author wrote, and only a consumer projects them into final DOM or React names, through its half of the conversion table. That separation is what lets the JVM, which has no React, owe nothing to React's vocabulary — and it is why a structural assertion cannot stand in for a mounted one.

The tree records event intent and never materializes it. An :on-* site carrying an event vector, an options map or an opaque marker is recorded in :events (§Element fields). What dispatches that intent, and into which frame, is not stated in this corpus (there is no 004 view contract); this contract owns only what the tree records.

Cross-host equality

A version-1 tree is one value on the JVM and in ClojureScript: one declaration answers one equal tree on both. That is the law the structure rows are stated against, and it is not a formality: the hosts disagree about number formatting ((str 1.0) is "1.0" on the JVM and "1" in JavaScript), about which values are callable, and about map ordering — and each of those disagreements reaches an ordinary view body. Every rule in this contract that touches a value's spelling is therefore stated in host-neutral terms and proven on both hosts; a rule proven on one is a gap, not a pass.

The node schema — version 1

The tree is plain, serialisable Clojure data — plain maps and strings, no wrapper types, no metadata-carried contract (EDN print/read round-trips losslessly). The node variants are a closed set, and this table is their roster:

Variant Shape Required field Optional fields
element map :tag :ns :attrs :events :children :key + reserved keys
fragment map :children :key + reserved keys
view-boundary map :view-id :props :children :key + reserved keys
trusted-HTML map :html :key
host map :rf.ui/host :rf.ui/host-ssr :children :props :rf.ui/host-children :rf.ui/host-map-props :key
text the host string itself — —

The host variant is one v/defhost crossing. The reasons for its individual fields are not stated in this corpus; what this contract owns is its place in the closed set, its place in the discrimination order, and what the projections and N answer for it. Its :children are the declared SSR projection — retained when empty, like a fragment's.

Discrimination is pinned, in order: a string is a text node; a map with :tag is an element; else :view-id → view-boundary; else :html → trusted-HTML; else :rf.ui/host → host; else a map with :children → fragment. The primary discriminators are the four a map may carry only one of — :tag, :view-id, :html, :rf.ui/host. :children is the fallthrough rather than a fifth, which is why an element, a view-boundary and a host all carry it without ambiguity. A map carrying two primaries, or no primary and no :children, is malformed — every consumer (the traversal helpers, the serialiser, the fingerprint fn) fails loud with the typed error :rf.error/ui-tree-malformed, and so does the walk that would otherwise build one. The text variant is deliberately not a map: text carries no attributes, no key, no identity — it is content, and ht/text is its read surface. Text is therefore not a queryable node: selectors never match it and pred-fn selectors never receive it.

Element fields, pinned

  • :tag — an unqualified keyword, exactly as authored after .class#id sugar is stripped; no case folding anywhere (SVG camelCase tags — :clipPath, :feGaussianBlur, :foreignObject — pass verbatim). Keywords keep the selector grammar's tag-kw match (:button matches :tag :button) and hiccup authoring one vocabulary; the serialiser stringifies. Foreign components never appear (no JVM execution — they sit under client-only).
  • :ns — :svg or :mathml, per the namespace context rules in the conversion table. MUST be absent for HTML — the canonical form has exactly one representation per node (fingerprint stability), so :ns :html is never written.
  • :attrs — the author-space attribute map. Keys are the prop keywords per the pinned DOM spelling (hyphenated lowercase — :tab-index, :aria-hidden, :data-priority, :view-box), with .class#id sugar already merged into :class/:id. Values are normalized to semantic form (§Attr value normalization below). Final DOM name conversion (tabindex, for, viewBox, className) is the serialiser's/React emitter's half of the table and is not stored in the tree — tests and selectors match what the author wrote. Nil-valued entries never appear — except the one controlled-slot row below, which records the controlled empty value rather than the author's nil; the map is absent when empty.
  • :events — handler-position keys (:on-*, spelled as authored; no -capture name suffixes — capture is a listener option per the handler grammar) mapped to exactly one of:
    1. a literal event vector, verbatim — placeholders retained as the authored keywords ([:todo/toggle 1 :rf.ui/checked]);
    2. an options map {:event [:…] :prevent-default true …}, verbatim;
    3. the opaque marker (below) for fn-carried sites (v/event, v/handler, bare fn, v/raw-fn) — the site's existence and spelling are testable, its behaviour is Tier-3. Handler expressions a producer cannot read before render — which, for a producer that walks the body as it renders, is every one of them — classify by the value present at render (vector → 1, map → 2, fn → 3, nil → the entry is dropped). Absent when empty. :attrs and :events key domains are disjoint by construction — every emitter routes every :on-* name to :events — so the merged projection (below) is collision-free.
  • :key — present iff the site was explicitly keyed; holds the authored key value (any rf=-comparable value), not React's string coercion. A view-boundary node records the :key its call carried on the same footing, so a keyed boundary is distinguishable from an unkeyed one. Duplicate-key diagnosis happens upstream at the indexed list site and applies React's string coercion (key 1 collides with key "1") [S1-CONFIRM].
  • :children — a vector of nodes in document order; absent when empty (§Child normalization).

Attr value normalization (in-tree, semantic space)

  • :class → one canonical string (merge + ordering rules in the conversion table) .
  • :style → a map of keyword → canonical CSS value string: the px rule applied to numerics ({:padding 16} → {:padding "16px"}, {:opacity 0.5} → {:opacity "0.5"}, 0 stays "0" ); custom-property keys (:--main-color) verbatim, no px rule, values stringified verbatim [S1-CONFIRM]; keyword values → name; nil entries dropped.
  • keyword/symbol values (any attr) → (name x) (:data-priority :high → "high" ); a namespaced keyword's namespace is silently ignored — the conversion applies (name x) with no diagnostic (:data-priority ::high → "high", the namespace dropped without a warning).
  • numbers → JS ToString semantics, on both hosts — integral doubles render without a trailing .0 (a .cljc emitter must not leak (str 1.0) → "1.0"), and the plain/exponential switch follows ECMA's (-6, 21] decimal-exponent window rather than the JVM's own layout. This is the row cross-host equality (§Cross-host equality) turns on most often, because a number reaches a view body more or less constantly. The rule is ECMA Number::toString(10) in full, for every finite double: the shortest decimal that round-trips to the value, and among decimals of that length the one closest to it, ties broken to even. Two near-misses are worth naming because both are reachable from ordinary application data and both survive a three-example test: the double's exact decimal is not always the shortest one (the double nearest 1.3990134524153749e17 is exactly 139901345241537488, where JavaScript prints 139901345241537490), and the JVM's own Double/toString is not always the shortest one either (it answers 4.9E-324 for Double.MIN_VALUE, where JavaScript answers 5e-324). -0.0 renders "0", and the infinities take their JavaScript spellings. NaN has no row here at all, and that is the value grammar speaking rather than an omission: §The opaque marker rejects it at the site that recorded it, so no NaN is ever a prop for this row to render. A serialiser answering ##NaN is therefore printing EDN's spelling of a value the tree may not carry — the repair is the refusal upstream, never a spelling exception here.
  • JVM integers are a wider domain than JavaScript's, deliberately. A JVM integral type (Long, BigInt) renders its exact decimal at any magnitude — printing an approximation of a value the host holds exactly would be a lie. Inside JavaScript's exactly representable integer range (|n| ≤ 2^53−1) that spelling is also the double's, so the hosts agree; outside it they do not, and that is not a defect to repair. The ClojureScript reader turns such a literal into a double — a different number, not the same number spelled differently — so cross-host equality is a claim about one value rendered on two hosts, and it holds for every double.
  • booleans stay booleans in the tree — the boolean/booleanish/overloaded emission decision is the serialisation row's job, and tests get the semantic truth ({:disabled false} is present-false, distinguishable from absent).
  • nil → the entry is dropped (canonical trees carry no present-nil attrs) — absent is what an author means by writing nothing. One exception: a value or checked slot on a supported native control (input, textarea, select) is a controlled slot, and there absence is the host's own signal for uncontrolled. An explicitly present nil is a controlled field with nothing in it — the door has already put the element's sites on the synchronous lane for it (the controlled-input contract) — so the entry is kept, carrying the controlled empty value the host emitters write: "" for value, false for checked. One projection, so the structural tree and the React props describe one declaration the same way, and a server render and its client hydration agree rather than differing by exactly this attribute. Scoped as the door is, and read through the same normalized slot, so :x/value clears the field precisely as :value does. A native <select multiple> is the one control whose empty value is not a scalar: its selection is a list, so its controlled empty is the empty collection [], and the empty string is a shape error the client reports. Whether the element is a multiple select is a property of the whole element, settled once from its attributes exactly as the controlled-input door's element half is.
  • collection values outside :class/:style (e.g. :data-foo {:a 1}) → rejected, didactic (React would render "[object Object]" garbage), by the producer where it reads the form (§Producers and the consumer). One host element is excepted: a native <select>'s value, because a <select multiple>'s value is not a scalar — what is selected is the list of chosen option values, and the client contract reads the prop as an array. A sequential value there converts member by member through the rows above ([:a "b" 3] → ["a" "b" "3"]) and the tree records ordinary data; a set is refused with every other collection, because a set has no order and the vector read out of one is not a value the two hosts agree on. Turning the recorded collection into the host array is the client emitter's own final step, exactly as every other final-shape conversion is. The exception is the value slot on the select tag and nothing else: acceptance deliberately does not consult multiple, whose value a producer reading the form before render may not be able to see, so one declaration cannot be accepted by one producer and refused by another. The empty value does consult it — see the nil row above, where a multiple select's controlled empty is [] rather than "".
  • everything else — a function in an attribute slot, a host object — is likewise rejected: the value grammar above is closed, and a value outside it has no cross-host spelling to carry.

The opaque marker

{:rf.ui/opaque form} where form ∈ #{:v/event :v/handler :v/render-fn :v/raw-fn :fn} — the single sentinel for non-data values, used in :events (case 3 above) and in view-boundary :props (a fn-valued prop). The :rf.ui/* namespace is reserved (Conventions), so author data can never collide with the marker. :fn is the mode-neutral member — a bare function at either kind of site — and it is the one re-frame.fresco.test/tree produces; the members naming a specific authoring form are produced only by an emitter that implements that form.

The marker occupies a site, never a value inside one. Three slots record a value the tree did not itself build — a view boundary's :props, and an element's :events entry when it holds an event vector or an options map — and each is recorded verbatim, so each is a way a host value could walk into a tree that promises to print and read back. The rule is one rule at every depth: a prop or handler value that is a function records as the marker, because the grammar names that site and the site's existence and spelling are what a structural test asserts on. A non-data value nested inside a recorded value is rejected — :rf.error/ui-tree-malformed, raised at the recording site, naming the prop or handler key and the path to the offender. Below a prop or handler key the grammar names no sites, so a marker written there would claim one that does not exist and would silently replace a value the author will go looking for; and an event vector is dispatched as data at run time as well as compared as data in a test, so an intent carrying a marker would no longer mean what its site does. "Data" here is the EDN value grammar exactly — nil, booleans, strings, characters, keywords, symbols, numbers, #uuid, #inst, and EDN's four collections (list, vector, map, set) of those; the grammar the round-trip promise is stated in. Ordinary nested EDN is untouched, and the check is read-only: nothing is rewritten, so a prop that passes is the value the author passed.

Two boundaries follow from stating that grammar as EDN's, rather than as whatever the host's collection and number predicates happen to admit. A collection a host implements outside those four is not data however collection-like it is: a persistent queue prints #object[…] on the JVM and #queue […] in ClojureScript and reads back on neither host's counterpart, and a record prints a tag no EDN reader has — so a tree holding either would print and fail to read, or read on one host only, which is the same defect as a host object. And ##NaN is not data either, because the promise is that the tree prints and reads back equal: ##NaN is the one value that survives print/read while never comparing equal to itself, so a tree carrying one is not equal to itself — and the tree is an equality input (a fingerprint hashes one, a structural assertion compares one, §Canonical uniqueness). Both are rejected exactly as any other non-data value is, at the site that recorded them.

A template option is not a prop at all. A reserved internal view whose option holds markup rather than a value — v/error-boundary's :fallback — has that option dropped from the record, for the same reason :children is dropped: a form is structural, and it is visible as the expansion it produces (a contained boundary's children are the walked fallback). Recorded verbatim it would put an unwalked template — whose head is a view descriptor in the documented {:fallback [broken-page {}]} spelling — into a slot the schema says holds data. Nothing is lost: the option is required, so its presence proves nothing.

:v/render-fn is the render-slot member, produced only by an emitter that implements the v/render-fn form (§The opaque marker): a v/render-fn value carried as a component-call-site prop is recorded on that view-boundary's :props as {:rf.ui/opaque :v/render-fn}. The render-fn's rendered output is not a marker — a v/slot invocation produces the ordinary child subtree the render-fn built, spliced into the enclosing children like any other child, so the structural test surface renders slotted trees headlessly with no special representation.

Reserved :rf.ui/* keys — the three roles (required gate, semantic, diagnostic)

A closed v1 set of reserved-namespace keys that decorate a node. Consumers MUST ignore unknown :rf.ui/* keys, and normalization removes every :rf.ui/* key from its output — no reserved key survives into a semantic node or a fingerprint. But absent from the output is not safe to strip from the input: these keys fall into three roles, and only one is a droppable diagnostic.

The host variant's fields are outside this roster, and the word "decorate" is what puts them there. :rf.ui/host, :rf.ui/host-ssr, :rf.ui/host-children and :rf.ui/host-map-props do not annotate a node — they are one, enumerated as a variant in §The node schema. Neither rule above reaches them: a consumer that "ignored" :rf.ui/host would read a host as a fragment, which is the single wrong answer this namespace is able to produce, and N holds its removal step back until the splice has read them (§Semantic normalization N, steps 1–2).

  • Semantic — consumers MUST honor. Load-bearing conversion input: N and the serialiser READ it during conversion and only then drop the marker. Its absence changes the semantic output and the fingerprint, so it is not optional and a consumer that strips it before conversion corrupts the result. :rf.ui/property-props is the v1 member — the property classification it carries decides which :attrs keys are omitted from markup (§Property-only and form-control special forms, §Custom elements, and N step 5). It is required whenever a property-only classification exists and must be consumed before the marker is removed from the output.
  • Required gate. :rf.ui/tree-version is neither droppable nor conversion input: it is the root schema-version gate every consumer validates first (fail-loud on missing / non-integer / unsupported — §Versioning, §The SSR consumption boundary). It is stripped from the semantic output, but a tree without it is malformed, not "a broken diagnostic".
  • Diagnostic — genuinely optional. Evidence-only keys a consumer may find absent, broken, or stripped without any semantic effect — a broken or absent diagnostic never changes app semantics or a fingerprint; that neutrality is this section's own rule, and it is what makes the diagnostic tier safe to strip. :rf.ui/presence, :rf.ui/boundary and :rf.ui/top-layer are the v1 members.
Key Role Where Meaning
:rf.ui/tree-version required gate root node only the schema-version integer (1 for this document); validated first, then removed from N's output
:rf.ui/property-props semantic custom-element element nodes the set of :attrs keys a producer classifies as custom-element properties (§Custom elements); consumed at conversion (the serialiser and N omit those props from markup — step 5) and only then removed from the output. Required whenever a property-only classification exists; removing it changes semantics — the props would leak back into the attribute space
:rf.ui/presence diagnostic the fragment node a presence boundary renders as {:phase :present :timeout-ms n} — the presence metadata exposed structurally; phase is always :present on the JVM
:rf.ui/boundary diagnostic the fragment node wrapping a deterministic fallback :client-only (the structural "fallbacks" evidence; :portal reserved)
:rf.ui/top-layer diagnostic the element node a DOM top-layer desired-state property is declared on {:popover-open? bool} or {:modal-open? bool} — the desired state a top-layer declaration expressed, recorded as a FACT and never as a claim that anything was promoted; a structural host has no top layer, and the property is vocabulary rather than an attribute, so it never appears in :attrs

Child normalization (canonical form)

At tree build: nil/false/true children are dropped (grammar — React renders none of them, and a boolean that survived would put the two emitters out of step); numeric children become text via JS ToString (same rule as attr values); adjacent text runs are coalesced into one string; empty strings are dropped after coalescing; for/seq results are flattened into the parent's single children vector in document order (keys live on the nodes; keyed-run scoping is a per-list-site concern, upstream of the tree). Children of void elements are rejected [S1-CONFIRM] (React throws at render; we reject earlier).

Forwarded children are a run, not markup. A view that forwards the children it was given writes the :children value into its own markup, and that value is a vector — which, in child position, is otherwise markup. Vector-head classification is total and carries no heuristic arm, so the distinction is not inferred from the value: the emitter that placed the value there marks it, and a marked run splices in document order exactly as a seq does. The marker is invisible to the author, invisible in the tree, and does not disturb the props map's equality — the value a body splices and the value a props assertion compares are one value, not two.

Canonical uniqueness, stated once: absent-when-empty for :attrs/:events/ :children; no nil attr entries; :ns absent for HTML; text coalesced. One pinned exception: a fragment node retains :children [] when empty, because :children is its required discriminator (§Node schema) — an empty fragment is {:children []}, never {} (which is malformed). Element and view-boundary :children, being optional, are absent when empty as normal. One semantic tree has exactly one representation — this is what makes the tree a legitimate fingerprint input.

Versioning

A producer returns the root node — always a map node — carrying :rf.ui/tree-version 1. A form that denotes text, several nodes, or nothing roots in a fragment, which is the variant whose job is to hold a run of children; that is what keeps the return type total without a second shape. Interior nodes carry no version (subtrees handed to a traversal inherit their tree's). Bump rules: any change to the variant set, discrimination order, required/optional fields, canonical-form rules, normalization N, projection behaviour, the opaque marker, or a conversion-table row's semantics bumps the integer. Adding a new optional diagnostic :rf.ui/* key does not bump (consumers must-ignore); adding or changing a semantic reserved key (§Reserved :rf.ui/* keys — e.g. :rf.ui/property-props) does bump, since it changes conversion. Consumers seeing an unsupported version fail loud (§SSR boundary names the error).

Projections — how nodes are read

Nodes are plain maps, so field reads ((:tag node), (:events node), (:children node)) are ordinary and public — the field names above are the versioned ABI. But attribute reads go through the projection: (:on-click node) is a field miss, never an attribute read — attrs and events live under their own keys.

The projections ship in Fresco's test kit, re-frame.fresco.test (usually aliased ht), and read the tree its ht/tree builds: element, fragment and view-boundary nodes, and text. The kit refuses a h/defhost crossing and a raw React escape as opaque, so the trees it builds hold no host or trusted-HTML node.

  • (ht/attrs node) — the merged projection:
    • element → :attrs merged with :events (collision-free by construction; event slots carry vectors/options-maps/opaque markers as data);
    • view-boundary → the :props the call site passed;
    • fragment → {} (no attributes exist; total, not an error);
    • nil → nil (nil-punning threads through a missed ht/find);
    • a string (text content) → :rf.error/ui-tree-malformed (text is not a node).
  • (ht/text node) — concatenation of text descendants in document order, descending through elements, fragments and view boundaries; nil → nil; a string → :rf.error/ui-tree-malformed. A view-boundary node records the call rather than the child's rendering, so its text is the children the call site wrote.

Intent assertion, respelled to this contract: (is (= [:cart/add 42] (:on-click (ht/attrs (ht/find tree #(= :button (:tag %))))))).

Semantic normalization N — the parity/fingerprint input

N(tree) produces the semantic-node tree — the exact input to normalized structural equivalence and to the render fingerprint. Pinned, in order:

  1. Remove every :rf.ui/* reserved key from the output (version, presence, boundary, and the property-props marker) — no reserved key reaches a fingerprint. The property-props marker is semantic conversion input, not a diagnostic: step 5 consumes it (property-classified props are omitted from markup) and only then is the marker itself dropped. Stripping it before conversion is unsafe — it moves the fingerprint boundary (§Reserved :rf.ui/* keys). A host node's :rf.ui/host* fields are the same kind of exception for a stronger reason: they are the node's variant, and step 2 reads :rf.ui/host to know it has one. Remove them first and a host is indistinguishable from a fragment, so removal waits on the splice exactly as it waits on the property-props read.
  2. Splice view-boundary and host nodes (children replace the node — HTML has neither; dev data-rf2-* annotation is excluded by the same rule). A host's children are its declared SSR projection, so splicing is not a normalization choice about hosts — it is the same fold §The SSR consumption boundary performs, restated in semantic space so the two cannot answer differently. The fields step 1 held back go with the node: read here, then gone, and no :rf.ui/* key reaches a fingerprint either way. emit-ui-tree accepts a host, so N cannot rule the variant out — it has to mean something here, and what it means is the projection.
  3. Splice fragment nodes.
  4. Drop :events entirely and drop :key values (neither has HTML presence; keyed order survives as the child order itself).
  5. Per element, convert to final attribute space via the conversion table: final attribute names (tabindex, for, viewBox); serialised values (booleans → presence/absence or "true"/"false" per their class; style → a map of CSS property → value string, compared order-insensitively; class as the exact canonical string); omit property-only names and custom-element property-classified props (they never reach markup).
  6. Coalesce adjacent text again post-splice; text is compared decoded (entity- and escaping-free semantic space — escaping is a serialisation concern the comparator normalizes away ).
  7. Carry trusted-HTML nodes as opaque raw-markup leaves, compared verbatim (the serialiser writes an :html node's string unescaped, as React writes dangerouslySetInnerHTML.__html).

The semantic node is {:ns … :tag … :attrs {final-name → serialised-value} :children […]} with attribute maps order-insensitive and child vectors order-significant. Fingerprint input = the canonical-EDN serialisation of N(tree). The hash algorithm, digest encoding, and the root manifest's render-fingerprint field are owned by Spec 011/008 (FNV-1a is the checked-in choice) — this contract owns only the input. CLJS-side parity uses the same space: rendered HTML parsed into semantic nodes.

The DOM conversion table — normative rows

One table, two consumers (the React emitter and the JVM serialiser). Evidence per row: untagged = exercised (including the S1b/S1f react-dom 19.2.0 probes, noted inline); [S1-CONFIRM] = written to React's published behaviour, not yet exercised against real React.

Namespaces

Row Rule
default elements are HTML; :ns absent
:svg element enters :svg; descendants inherit [S1-CONFIRM]
:foreignObject its children revert to HTML [S1-CONFIRM]
:math element enters :mathml; descendants inherit [S1-CONFIRM]
:annotation-xml children revert to HTML when its :encoding attr is text/html/application/xhtml+xml (HTML-spec integration point; confirm React 19's actual branch) [S1-CONFIRM]

Attribute names

Row Rule
pass-through default unrecognized names emit verbatim (React 16+ behaviour); name grammar validated (the 011 attr-key check), and an illegal name is refused
hyphen-collapse :tab-index → tabindex (the kebab spelling mirrors React camelCase; DOM attr is the collapsed form)
React prop names the React emitter writes React's canonical prop, not a DOM attribute spelling: :tab-index → tabIndex, :content-editable → contentEditable, :accept-charset → acceptCharset, :char-set → charSet, :stroke-width → strokeWidth. The vocabulary is react-dom 19.2.0's own possibleStandardNames; data-*/aria-* pass verbatim and an unrecognized name passes verbatim, which is React 16+'s pass-through. The mapping takes no namespace context — a canonical prop name is canonical at any depth and on either side of a declared-view boundary, which React renders as a real component whose body runs with no walk above it. Probed against React 19.2 in Chromium: the non-canonical spelling is not a harmless variant — React reports Invalid DOM property \contenteditable`. Did you mean `contentEditable`?and **omits the attribute** (likewisereadonly,maxlength,acceptcharset,strokewidth,fillopacity,viewbox`)
:class / :for → class / for attributes (React emitter: className / htmlFor)
data-* verbatim, and written lowercase: an ASCII uppercase letter in a data-* name does not survive, because the HTML DOM cannot store the casing (setAttribute ASCII-lowercases the name) and .dataset drops either the word boundary or the attribute (and SSR parsing lowercases in every namespace, so server and client can diverge on a foreign element). Write :data-foo-bar; the platform reads it back as dataset.fooBar. A lowercase / correctly-hyphenated data-* stays verbatim
aria-* verbatim names; values always stringify — :aria-hidden false → aria-hidden="false", never omitted
SVG camelCase aliases the kebab keyword maps through React's published SVG alias table: :view-box → viewBox, :stroke-width → stroke-width (SVG's own hyphenated attrs stay hyphenated); mirrors possibleStandardNames — implemented, and probed in a real browser both directly under <svg> and beneath a declared view
reserved props :children is React's reserved prop, and the two emitters part here. The tree tier refuses it in an element's :attrs: re-frame.ssr/emit-ui-tree raises :rf.error/ui-tree-malformed, because through the attribute path it would render DOM content the structural tree does not carry. dangerouslySetInnerHTML, and every spelling that canonicalises onto either name, is refused the same way; the tree's trusted-markup spelling is the :html node variant. Fresco's React emitter passes React props, content props included, through to React, whose own semantics apply (children on a void element throw); its trusted-markup spelling is React's dangerouslySetInnerHTML
refusal reads the emitted name every reserved/rejected refusal above judges the prop name the emitters write, never the raw map key. A key is classified and projected by its name, so a namespace, a string or a symbol changes the spelling at the site and nothing about where the value lands: :x/children, "children" and 'children all reach React's children slot and are refused exactly as :children is. Fresco's codec resolves every attribute rule through this same canonicalization (canonical-slot), so one authored key has one verdict wherever it is written. A key whose name projects onto an ordinary prop is untouched — data-*/aria-* stay verbatim, and a qualified attribute like :x/title is an ordinary title
an alias of :key refused, on the same law. React's key is not a prop: the reconciler consumes it and it never reaches the DOM, so an alias routed into that slot would not misspell an attribute — it would change which element React considers the same element across renders. The failure mode is wrong element reuse (preserved DOM state landing on the wrong row, or a remount where none was intended), which is :children's structural hazard class rather than a misspelled attribute's, so :key keeps exactly one spelling. A bare :key inside an element's :attrs is refused too: the tree carries a key as the node's own :key field, so one in :attrs is a malformed tree, and emit-ui-tree raises :rf.error/ui-tree-malformed for it
an alias of :class / :style routed, not refused: an authored key whose emitted name is className or style is that key spelled differently, and is canonicalized to it. These reach the DOM as ordinary props, and Fresco's codec emits every spelling of an attribute under its one canonical slot (canonical-slot), so refusing an alias here would make the tree stricter than the React emitter for the same key. A routed :class composes into the class string beside the .class#id sugar exactly as the exact spelling does, never replacing it — and where one map carries both an exact :class and an alias projecting onto the same slot, those two compose as well, rather than the later key winning last: the class string is the union of every source taken in the fixed order .class#id sugar → exact :class → alias. (Last-wins would silently drop one value, and for a hash map which one survives is iteration order — no host contract; composition is the same set-valued union the class grammar already takes for sugar.) :style routes plainly, since it has no sugar to compose. Only these two names are canonicalized — an ordinary qualified attribute stays in author space, because the structural tree carries authored names
xlink:/xml: attrs :xlink-href → xlink:href, :xml-lang → xml:lang (note href supersedes xlink:href in SVG2 — emit what was authored) [S1-CONFIRM]

Booleans and their neighbours

Row Rule
boolean attrs true → checked="" (empty-string presence), false/absent → omitted; the set is seeded from the react-dom/server 19.2.0 boolean-attribute list, probed row-by-row (S1b) — it includes hidden and muted (rows below). Every member is react-dom's except where a row of this table names the divergence and gives its reason: that is ismap (next row), and value (last row) is the converse — a name react-dom classifies that this table deliberately does not. A member kept against react-dom is named here or it does not exist
ismap (a deliberate divergence) presence-class here, and react-dom is not the reason. true → ismap="", false/absent → omitted. react-dom/server 19.2.0 accepts no boolean ismap, in any spelling: the name appears nowhere in react-dom's possibleStandardNames, so {ismap: true} warns Received true for a non-boolean attribute ismap and emits nothing, while the camel isMap warns as an unrecognized prop and also emits nothing. A string ismap still reaches markup, but only through the pass-through default — react-dom has no other route for it. This grammar keeps the attribute presence-class anyway, because ismap is a genuine HTML boolean attribute on <img>, where presence is the HTML-correct rendering and ismap="false" would still read as truthy. So where HTML and react-dom part on a real HTML boolean attribute, this table tracks HTML, and says so at the row rather than leaving the reader to infer it from the set.
hidden a pure boolean attr, not an enumerated exception: true/"until-found"/any truthy → bare presence (hidden=""), false/absent → omitted. There is no "until-found" string-value carve-out — react-dom/server 19.2.0 renders hidden="until-found" as bare presence like any truthy value (S1b probe)
booleanish strings :content-editable / :draggable / :spell-check: true/false → "true"/"false", never omitted (S1b probe: contentEditable/draggable/spellCheck)
overloaded booleans :download, :capture: true → bare presence, false → omitted, any other value → stringified value (S1b probe)
value (a named non-member) ordinary, so a boolean value reaches markup as nothing at all — and this is the one row where that is a decision rather than a reading of react-dom. react-dom/server 19.2.0 does stringify a boolean value (value="true" / value="false"), but because value shares react-dom's generic push-what-you-were-given branch with the booleanish names, not because it is one of them. The classification bites only for a boolean: a string or number value — the only kinds a form control carries — serialises identically under the ordinary rule, and a boolean value on a form control is an author error rather than a state. value is also a form-control special form in the table below, whose meaning already varies per element (a value attribute on <input>, the text child of <textarea>, selected on the matching <option> for <select>), so its class is this table's to set and not a shared attribute roster's to change from underneath it

Property-only and form-control special forms

Row Rule
property-only names names React never serialises to markup emit nothing on the JVM (the React emitter sets the DOM property). :muted is not one: react-dom/server 19.2.0 serialises muted="" on <video> (S1b probe), so :muted is a boolean attr (above). The property-only-attrs set is empty, the named home for any member the parity corpus finds
:value on :input serialises as the value attribute
:default-value / :default-checked serialise as value / checked attributes [S1-CONFIRM]
:value on :textarea serialises as the element's text child, not an attribute [S1-CONFIRM]
:value on :select serialises as selected on the matching :option(s) [S1-CONFIRM]
dangerouslySetInnerHTML is not a tree attribute: the tree's trusted-markup spelling is the :html node variant, and a dangerouslySetInnerHTML in an element's :attrs is refused (reserved props, above). Fresco's React emitter passes the prop through to React, where it is Fresco's trusted-markup spelling. A trusted-markup (:html) child beneath <textarea> is rejected at the SSR seam through :rf.error/ui-tree-malformed (react-dom/server 19.2 rejects dangerouslySetInnerHTML on a textarea — its content is value/defaultValue or a text child). The seam validates the effective child stream — a :html leaf spliced in through a transparent fragment or view boundary is caught at its actual path, not only an immediate child
:ref absent from the tree entirely: a ref is a commit-phase host hook and carries no markup, so the tree never shows it as an ordinary attribute that React silently consumes as a reserved prop. :ref is the one accepted spelling of React's ref slot, so an alias (:x/ref) is refused on the same one-spelling-per-name law, rather than reaching that slot as an ordinary attribute. A bare :ref inside an element's :attrs is refused as well, since the tree carries no ref at all: emit-ui-tree raises :rf.error/ui-tree-malformed for it

:style

Row Rule
px rule numeric values gain px unless the property is in the unitless set: {:padding 16} → padding:16px; {:opacity 0.5} → opacity:0.5; 0 stays 0
unitless set adopt React's published isUnitlessNumber set verbatim, version-pinned to the React release the React emitter targets; the JVM serialiser carries the copy; the parity corpus detects drift [S1-CONFIRM]
custom properties :--main-color → --main-color:<value> verbatim; no px rule, no case mapping [S1-CONFIRM]
keyword values stringify via name

:class — composition and deterministic order

Row Rule
string verbatim
vector elements in vector order; nils dropped; each element a string or keyword (name)
flag map entries whose value is truthy render in lexicographic class-name order — one deterministic rule for literal and runtime maps alike (map iteration order is never trusted)
sugar merge .class sugar classes render first, in source order, then the explicit :class form's classes; no de-duplication (class order/duplication has no CSS semantics; the pinned order exists for fingerprints and exact-string tests)

.class#id sugar vs explicit :class/:id

Row Rule
.class + :class merge, sugar-first (above)
#id + :id the explicit :id wins over #id sugar — the rule Fresco's codec applies when it folds the tag's shorthand onto the emitted props, and the one the test kit takes from it

Children, text, and escaping

Row Rule
escaping full 5-char escaping (& < > " ') in text and attribute values; a trusted-markup (:html) node is the single bypass. Raw-text exception — <script>/<style> only: these two HTML raw-text elements emit their text content verbatim (no entity escaping — the HTML parser does not decode character references inside them, so routing script/style text through the 5-char escape would corrupt valid JS/CSS), with only a context-safe closing-sequence rewrite so the raw-text parser cannot terminate the element early: an embedded </</ followed by script has its s/S rewritten to the JS unicode escape \u0073/\u0053, and one followed by style to the CSS escape \73/\53 (byte-parity with react-dom/server 19.2.0's scriptRegex/styleRegex + replacers). title/textarea are escapable RCDATA and escape normally (not raw-text). This narrows the blanket rule for these two elements only, and only because their content is trusted server-authored rendered-tree text (never user input); attribute values and every other element escape in full, and the :html bypass applies as it does everywhere else
void elements the void set that self-closes, and whose children React rejects: area base br col embed hr img input keygen link meta param source track wbr — 15 tags, param and keygen among them (react-dom/server 19.2.0 throws for children on both; S1b probe). React also rejects children on menuitem, which is not self-closing and so is not in the void set. Self-closing normalized (S1b probe)
raw-text child shape a <script>/<style> is an HTML raw-text element React renders from a single text body. The accepted shapes are: no body, one text child, or a sole trusted-markup ({:html …}) child. Any other body — a structural child (an element, fragment or view boundary), text mixed with one, or several structural children — is refused at the SSR seam through :rf.error/ui-tree-malformed, locating the element: React drops or stringifies such a body, and the serialiser would otherwise print it into the element
textarea child shape a <textarea> (escapable RCDATA, not raw text) renders its content from one channel — its :value/:default-value, or a single ordinary text child, never both and never several. Multiple children (React allows at most one child), a :value/:default-value combined with a child (React rejects the value-plus-child pair), and a structural sole child (React renders an element as [object Object]; the JVM serialiser would emit a divergent <span>…</span>) are refused at the SSR seam through :rf.error/ui-tree-malformed, validated against the effective child stream (after transparent fragment / view-boundary splicing) at the actual offending path. A sole text child, and :value alone, stay valid
leading-LF compensation (<pre>/<listing>/<textarea>) react-dom/server 19.2 prefixes one compensating LF inside a newline-eating element whose body is a single string beginning with LF, because HTML parsing eats the first LF after the start tag — so the authored content survives the parse round-trip. It applies to a lone string child (or a <textarea> :value) under any of the three, and to a lone trusted-markup (:html) child under <pre>/<listing> only — never under <textarea>, whose trusted-markup child is rejected outright (row above). Multiple/element children are left untouched. The full newline rule is §Leading-newline compensation below; this row states only the scope this grammar's serialiser enforces
numeric text JS ToString (integral doubles without .0) [S1-CONFIRM]
adjacent text coalesced in the tree (canonical form); the serialiser's hydration text-separator behaviour (<!-- --> between originally-distinct dynamic text runs) is an open 011-owned row — React hydration distinguishes text-node boundaries, renderToStaticMarkup does not; a hydration fixture must settle what our emitter writes [S1-CONFIRM]
no handler attributes event data never serialises into HTML — no onclick="…", ever

Leading-newline compensation — the newline-eating elements

The compact row above states the enforced scope; this is the normative rule the serialiser meets in full.

The HTML parser drops one leading U+000A LINE FEED that immediately follows the start tag of <pre>, <listing>, and <textarea> — the newline-eating elements (HTML tree construction's "if the next token is an LF character token, ignore it" step). <pre>/<listing> carry ordinary element content and <textarea> is escapable RCDATA, but all three share that single leading-LF drop. Left uncompensated, a body that itself begins with LF would serialise, parse, and come back one newline short — the authored blank first line silently vanishes, and under SSR the hydrated DOM diverges from the server markup (a correctness gap).

The rule. When a newline-eating element's body is a single string beginning with LF, the serialiser emits one compensating LF immediately after the start tag, ahead of the (escaped) body. The parser's drop then cancels that compensating LF and the authored content survives the round-trip. Only the first LF is ever compensated; interior newlines are emitted verbatim. This is byte-parity with react-dom/server 19.2, which prefixes the same LF under its single-string-body guard (typeof … === 'string', applied to a string child and to dangerouslySetInnerHTML.__html alike): a multi-child or element body is left untouched because React does not doctor a body that is not one string.

Scope — what counts as "a single string body" (exactly the set the serialiser compensates):

  • <pre> / <listing> / <textarea> — a lone ordinary string child beginning with LF is compensated. <textarea> additionally sources its content through :value/:default-value (the form-control special form in Property-only and form-control special forms above); a :value string beginning with LF is compensated on the same footing as a string child.
  • <pre> / <listing> only — a sole trusted-markup ({:html s}) child whose string begins with LF is compensated as well (React doctors dangerouslySetInnerHTML.__html the same way). <textarea> is absent from this arm: a trusted-markup child beneath a textarea is rejected outright at the SSR seam (react-dom/server 19.2 rejects dangerouslySetInnerHTML on a textarea — its content is value/defaultValue or a text child; see the dangerouslySetInnerHTML row above), so it never reaches compensation. The exclusion is a consequence of that rejection, not a second rule.
  • Nothing else. A multi-child body, a structural element child, a body that does not begin with LF, or a non-string trusted-markup body is left untouched: the parser's leading-LF drop still applies, but neither React nor this serialiser doctors a body that is not a single string.

This is a narrow parser-parity rule, not a general whitespace-normalisation policy — the tree is emitted verbatim apart from this one compensating LF.

Custom elements (per the RULED grammar)

A custom element is an element whose tag contains a hyphen. Its props live in :attrs, in author space, like any element's, and every name is an attribute unless a producer classifies it as a JS property. A property-classified prop stays in :attrs (one map) and is named by the :rf.ui/property-props reserved key; the JVM serialiser emits attributes only (property-props omitted — a property is set on the live element, never written as markup), and normalization omits them likewise. No producer in this repository classifies properties, so the trees it builds carry no :rf.ui/property-props and every custom-element prop is an attribute. Custom-event handlers ride :events under their authored :on-* keys, like every handler (§Element fields); the DOM event type is the kebab tail verbatim (:on-my-event → "my-event") — confirm against React 19's custom-element event registration [S1-CONFIRM].

The SSR consumption boundary

re-frame.ssr/render-to-string consumes the hiccup render-tree contract; that entry point is frozen with the stock-Reagent compatibility tier [TRANSITION] — there is no adapter shim between the two tree shapes.

  • Owner: the day8/re-frame2-ssr artifact, re-frame.ssr namespace — the SSR artefact consumes the version-1 tree; there is no second server product. See 011 §Resolved decisions and the API artefact table.
  • Signature — the shipped seam. (re-frame.ssr/emit-ui-tree tree opts) → HTML string — consumes a version-1 structural tree; applies the serialisation half of the conversion table (final names, boolean emission, property-only omission, escaping, void handling); erases view boundaries (dev coord annotation policy stays 011-owned) and hosts, a host's :children being its declared SSR projection and therefore exactly the markup it folds to — the fallback, or nothing for :client-only; writes trusted-HTML nodes verbatim. opts carries a single current option, :doctype?, which prefixes <!DOCTYPE html>; other keys are ignored (the render-to-string option set does not transfer to this seam). This is the shipped, final contract (final naming rides the diff-time facade rule); a reader who greps the name finds the function that meets it.
  • Deferred candidate — not an owed function. (re-frame.ssr/ui-tree-fingerprint tree) → digest would hash the canonical-EDN serialisation of N(tree) (§Normalization), algorithm/encoding owned by 011. It is a non-binding candidate: no such function exists and none is owed. It would be warranted only if Spec 011 adds the structural render-hash / manifest render-fingerprint channel — which 011 §Hydration-mismatch detection designs the adoption tier without, calling it "a deliberately-deferred future leaf, not a defect" — and a named concrete consumer needs it. The algorithm prose below (§Markup and fingerprint) is design-of-record for that case.
  • Version incompatibility: the seam validates :rf.ui/tree-version first, before any emission. A missing field, a non-integer, or an unsupported version throws :rf.error/ssr-ui-tree-version-unsupported with ex-data {:got … :supported #{1}} — fail-loud at the boundary, matching the artifact's construction-time error posture (:rf.error/ssr-missing-payload-policy style). Malformed nodes past the version gate throw :rf.error/ui-tree-malformed (shared with all tree consumers). Both ids have Spec 009 §Error event catalogue rows: :rf.error/ui-tree-malformed is the shared tree-consumer id, which also carries the semantic-N root-version-gate arm, and :rf.error/ssr-ui-tree-version-unsupported is its SSR-seam sibling. The two ids partition one seam's failures by class — version-skew vs. structure — and it is the machine discriminator :rf.error/id, not ex-data sniffing, that separates them (both carry the same {:got … :supported #{1}} shape at the version gate).
  • The server-side root render pipeline (011's per-root flow) is: producer → tree → emit-ui-tree; the response accumulator, error projection, and payload machinery are the ordinary re-frame2-ssr surfaces.

Stage — the emit seam is shipped; the fingerprint is a deferred candidate

This section's two functions have very different standing. The emit seam ships: re-frame.ssr/emit-ui-tree is the JVM's tree→HTML path, and its version gate raises the catalogued :rf.error/ssr-ui-tree-version-unsupported. Where the repo names emit-ui-tree it points at this contract, and a reader who greps the name finds the function that meets it. That contract is final.

The fingerprint half is a deferred, non-binding candidate — not an owed function. re-frame.ssr/ui-tree-fingerprint has no function, and none is owed. Spec 011 owns whether the structural render-hash / manifest render-fingerprint channel exists, and it would exist only if 011 adds that channel and a named concrete consumer needs it. The channel is deliberately absent: 011 §Hydration-mismatch detection states that the adoption tier "deliberately carries no such hash" (a React-element root has no hashable client render-tree), and records that reviving it is "a deliberately-deferred future leaf, not a defect." The hash algorithm and digest encoding this candidate would apply are Spec 011's (§Normalization, and 011 §Root Manifest v1) — kept below as design-of-record. A reader who greps that name and finds no function has found a deferred candidate, not a gap or an outstanding obligation.

011 §The render-tree → HTML emitter serves the Reagent/hiccup tier and does not transfer — for the reason below, which is also why no shim between the two tree shapes can be written.

What the seam consumes

render-to-string and emit-ui-tree both end in an HTML string, and that shared ending is the whole of their similarity. render-to-string consumes a hiccup form and does the rendering itself: it walks the form, calls views through their callable head, and resolves each subscription against the frame's static app-db. emit-ui-tree consumes a structural tree that has already been rendered — the version-1 value a producer built — and calls nothing. Every view has run, every subscription is resolved, and every dynamic value is already a literal in the tree.

The two seams therefore sit at different points of one pipeline rather than at two ends of a translation. The hiccup emitter's central job is invoking views, and that is work emit-ui-tree must never do: a view invoked here would be a second render, against a frame whose state has already moved past the one the tree was built from. emit-ui-tree needs no bound frame and no request context. It is a function of its arguments.

Emission is pure, deterministic, and JVM-runnable

Pure. No React, no DOM, no JS runtime, no reactive substrate. The tree is data and the seam is a fold over it: nothing is subscribed, dispatched, mounted or scheduled, a call leaves no mark on the frame the tree came from, and two calls on one tree are indistinguishable from one.

Deterministic to the byte. One tree emits one string, on every run. That is what makes server output cacheable and golden-file testable, and it is the serialiser's half of the proof that a server-rendered root hydrates without mismatch. The tree admits values whose comparison is order-insensitive — an :attrs map, a :style map (§Normalization) — and for those the seam emits in a pinned total order rather than iteration order, because map iteration order is trusted here exactly as little as it is in the :class flag-map row above. Which total order is a code-half choice the parity corpus pins; that there is one, and that it is total, is the contract.

One table, no seam-local rows. Emission applies the serialisation half of §The DOM conversion table and adds nothing to it. Where a row is version-pinned to a React release — the unitless style set, the boolean-attribute set — the seam carries the copy the React emitter targets, and the parity corpus is what catches drift between them. Divergence between the two emitters is detected, not prevented: they are separate code by design, and this contract's job is to give them one table to be separate against.

What the seam does not do

The seam emits the markup for one root's tree. Everything else a real page carries belongs to the SSR artefact's other surfaces, and drawing the line here is what keeps the code half from re-implementing them:

  • No manifest, no payload, no ledger. The per-root manifest script, the page-wide hydration payload, and the install ledger are 011 §Root Manifest v1 and the sections following it. emit-ui-tree neither writes them nor reads them.
  • No container, no identity. Root-ids, identifier prefixes and element locators are root identity (004C §7), settled before the seam is called and stamped by whatever assembles the page around its output. The data-rf-root marker is not identity, and it does not belong on the emitted tree either: it is a bare discriminator on the manifest script that the page assembler writes immediately after the container, saying only that a manifest is here and never which root (011 §The wire form). Which root is the manifest's :root-id, spelled once, in the content.
  • No response. Status, headers, cookies and redirects live in the per-request response accumulator (011 §HTTP response contract). A string is the seam's entire return value.
  • No recovery. The seam fails loud, above, and never substitutes a fallback for a tree it cannot emit. Containing a failed root so its siblings still render is a page-assembly decision, and it is taken above this seam.

Markup and fingerprint read one tree by two rules

This subsection is design-of-record for the deferred fingerprint candidate (see the Stage note above), not a description of a shipped second function: it records how a ui-tree-fingerprint would read the tree if Spec 011 adds the channel. So framed: emit-ui-tree and a ui-tree-fingerprint would be handed the same tree value, and they disagree about it on purpose. Markup is the conversion table applied to the tree as built; the fingerprint is the canonical-EDN serialisation of N(tree) (§Normalization), which has already spliced view boundaries and fragments, dropped :events and :keys, and coalesced text.

The consequence is worth stating outright: a difference normalization erases does not move the fingerprint, even where it moves the bytes. Two renders differing only in a stripped dev annotation agree structurally, and are meant to. The fingerprint answers did the server and the client build the same thing, not are these two strings equal — and hashing the emitted HTML instead would answer the second question while appearing to answer the first.

This contract owns the fingerprint's input only; the hash algorithm, the digest encoding, and the manifest field that carries it are Spec 011's (§Normalization, and 011 §Root Manifest v1).

[S1-CONFIRM] roster (collected)

  1. SVG camelCase attribute alias table (mirror React's possibleStandardNames) — DISCHARGED (browser probe). Implemented from react-dom 19.2.0's vocabulary and mounted in Chromium, directly under <svg> and beneath a declared view; the non-canonical spelling warns and is omitted, so this row is a behaviour question rather than a warning-noise one.
  2. Namespace context rules: svg inheritance, foreignObject reversion, MathML, annotation-xml HTML island.
  3. xlink:/xml: attribute mapping.
  4. Boolean-attribute set completeness — DISCHARGED (S1b probe). No hidden="until-found" enumerated exception: hidden is a pure boolean.
  5. Booleanish strings (content-editable/draggable/spell-check) — DISCHARGED (S1b probe).
  6. Overloaded booleans (download, capture) — DISCHARGED (S1b probe).
  7. Property-only never-serialised names — DISCHARGED (S1b probe): muted does serialise (muted=""); the set is empty.
  8. Form-control special forms (textarea value→child; select value→selected; default-value/default-checked→value/checked).
  9. Style unitless-set copy + custom-property (--*) rows.
  10. Integral-double text/attr values (JS ToString, no .0) — DISCHARGED. The rule is the full Number::toString(10) for every finite double; a separate integral branch would be wrong above 2^53.
  11. Duplicate-key detection under React string coercion.
  12. Void-element set + children-rejection parity with React's throw list — DISCHARGED (S1b probe): 15-tag void set, param and keygen included; menuitem rejects children but is not self-closing, so it is children-rejected only.
  13. Adjacent-text hydration separators (011-owned fixture).
  14. :for → for/htmlFor alias — DISCHARGED (browser probe): htmlFor mounts as the for attribute.
  15. Custom-element event-type registration (kebab tail verbatim) vs React 19.

Coverage — the implementer questions this contract answers

  • Q10 (node schema incl. fragments, trusted HTML, events, keys, view boundaries, presence metadata, fallbacks, text) — §Node schema + §Reserved :rf.ui/* keys (presence and fallback markers).
  • Q11 (keyword lookup vs opaque; where event vectors live) — §Projections: plain maps, field reads public, attribute reads via ht/attrs, events under :events.
  • Q12 (view-id selectors on fragment/nil-rooted views; boundary survival under nesting) — a view-boundary node records the view CALL: its :view-id, the props and :key the call site passed, and the children the call site wrote (§Projections). The view's own body does not run, so a fragment-rooted or nil-rooted view records the same boundary as any other view and a (:view-id %) predicate matches it; boundaries nest only where a call site's children hold further calls. Assert what a view renders by building a tree from its own body.
  • Q13 (path vectors, find!) — demand-bar items in the selector draft (OPEN-2/OPEN-3), not settled by this contract.
  • Q14 (the conversion table) — §The DOM conversion table.
  • Q15 (sugar precedence, class order) — §:class + §sugar rows.
  • Q16 (custom-element declaration) — §Custom elements: there is no declaration form; a producer's property classification rides :rf.ui/property-props.
  • Q23 (SSR seam owner/signature/version error) — §The SSR consumption boundary.

Ripples

  • Tree traversal is ordinary Clojure — (tree-seq map? :children tree) and a (:view-id %) / (:tag %) predicate. ht/find and ht/find-all take such a predicate over that walk (the root included, document order, node maps only); there is no selector grammar beyond it. A :view-id predicate matches the view-boundary node, so fragment-rooted and nil-rooted views are matchable.
  • 008 §The ui.test contract points at this contract; the 009 catalogue carries rows for :rf.error/ui-tree-malformed and :rf.error/ssr-ui-tree-version-unsupported.