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 aliasedht— §Projections), tree traversal (ordinary Clojure —(tree-seq map? :children tree)), parity/fingerprints (per 008 and 011), and theday8/re-frame2-ssrartifact (§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#idsugar is stripped; no case folding anywhere (SVG camelCase tags —:clipPath,:feGaussianBlur,:foreignObject— pass verbatim). Keywords keep the selector grammar's tag-kw match (:buttonmatches:tag :button) and hiccup authoring one vocabulary; the serialiser stringifies. Foreign components never appear (no JVM execution — they sit underclient-only).:ns—:svgor: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 :htmlis 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#idsugar 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-capturename suffixes — capture is a listener option per the handler grammar) mapped to exactly one of:- a literal event vector, verbatim — placeholders retained as the authored
keywords (
[:todo/toggle 1 :rf.ui/checked]); - an options map
{:event [:…] :prevent-default true …}, verbatim; - 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.:attrsand:eventskey domains are disjoint by construction — every emitter routes every:on-*name to:events— so the merged projection (below) is collision-free.
- a literal event vector, verbatim — placeholders retained as the authored
keywords (
:key— present iff the site was explicitly keyed; holds the authored key value (anyrf=-comparable value), not React's string coercion. A view-boundary node records the:keyits 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 (key1collides 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"},0stays"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
ToStringsemantics, on both hosts — integral doubles render without a trailing.0(a.cljcemitter 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 ECMANumber::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 nearest1.3990134524153749e17is exactly139901345241537488, where JavaScript prints139901345241537490), and the JVM's ownDouble/toStringis not always the shortest one either (it answers4.9E-324forDouble.MIN_VALUE, where JavaScript answers5e-324).-0.0renders"0", and the infinities take their JavaScript spellings.NaNhas 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 noNaNis ever a prop for this row to render. A serialiser answering##NaNis 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: avalueorcheckedslot 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:""forvalue,falseforchecked. 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/valueclears the field precisely as:valuedoes. 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>'svalue, 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 thevalueslot on theselecttag and nothing else: acceptance deliberately does not consultmultiple, 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 thenilrow 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:
Nand 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-propsis the v1 member — the property classification it carries decides which:attrskeys are omitted from markup (§Property-only and form-control special forms, §Custom elements, andNstep 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-versionis 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/boundaryand:rf.ui/top-layerare 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 →
:attrsmerged with:events(collision-free by construction; event slots carry vectors/options-maps/opaque markers as data); - view-boundary → the
:propsthe call site passed; - fragment →
{}(no attributes exist; total, not an error); nil→nil(nil-punning threads through a missedht/find);- a string (text content) →
:rf.error/ui-tree-malformed(text is not a node).
- element →
(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:
- 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/hostto 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. - 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-treeaccepts a host, soNcannot rule the variant out — it has to mean something here, and what it means is the projection. - Splice fragment nodes.
- Drop
:eventsentirely and drop:keyvalues (neither has HTML presence; keyed order survives as the child order itself). - 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). - 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 ).
- Carry trusted-HTML nodes as opaque raw-markup leaves, compared verbatim (the
serialiser writes an
:htmlnode's string unescaped, as React writesdangerouslySetInnerHTML.__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:valuestring 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 doctorsdangerouslySetInnerHTML.__htmlthe 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 rejectsdangerouslySetInnerHTMLon a textarea — its content isvalue/defaultValueor a text child; see thedangerouslySetInnerHTMLrow 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-ssrartifact,re-frame.ssrnamespace — 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:childrenbeing its declared SSR projection and therefore exactly the markup it folds to — the fallback, or nothing for:client-only; writes trusted-HTML nodes verbatim.optscarries a single current option,:doctype?, which prefixes<!DOCTYPE html>; other keys are ignored (therender-to-stringoption 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) → digestwould hash the canonical-EDN serialisation ofN(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 / manifestrender-fingerprintchannel — 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-versionfirst, before any emission. A missing field, a non-integer, or an unsupported version throws:rf.error/ssr-ui-tree-version-unsupportedwith ex-data{:got … :supported #{1}}— fail-loud at the boundary, matching the artifact's construction-time error posture (:rf.error/ssr-missing-payload-policystyle). 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-malformedis the shared tree-consumer id, which also carries the semantic-Nroot-version-gate arm, and:rf.error/ssr-ui-tree-version-unsupportedis 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 ordinaryre-frame2-ssrsurfaces.
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-treeneither 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-rootmarker 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)¶
- 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. - Namespace context rules: svg inheritance,
foreignObjectreversion, MathML,annotation-xmlHTML island. xlink:/xml:attribute mapping.- Boolean-attribute set completeness — DISCHARGED (S1b probe). No
hidden="until-found"enumerated exception:hiddenis a pure boolean. - Booleanish strings (
content-editable/draggable/spell-check) — DISCHARGED (S1b probe). - Overloaded booleans (
download,capture) — DISCHARGED (S1b probe). - Property-only never-serialised names — DISCHARGED (S1b probe):
muteddoes serialise (muted=""); the set is empty. - Form-control special forms (
textareavalue→child;selectvalue→selected;default-value/default-checked→value/checked). - Style unitless-set copy + custom-property (
--*) rows. - Integral-double text/attr values (JS
ToString, no.0) — DISCHARGED. The rule is the fullNumber::toString(10)for every finite double; a separate integral branch would be wrong above 2^53. - Duplicate-key detection under React string coercion.
- Void-element set + children-rejection parity with React's throw list —
DISCHARGED (S1b probe): 15-tag void set,
paramandkeygenincluded;menuitemrejects children but is not self-closing, so it is children-rejected only. - Adjacent-text hydration separators (011-owned fixture).
:for→for/htmlForalias — DISCHARGED (browser probe):htmlFormounts as theforattribute.- 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:keythe 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/findandht/find-alltake such a predicate over that walk (the root included, document order, node maps only); there is no selector grammar beyond it. A:view-idpredicate matches the view-boundary node, so fragment-rooted and nil-rooted views are matchable. - 008 §The
ui.testcontract points at this contract; the 009 catalogue carries rows for:rf.error/ui-tree-malformedand:rf.error/ssr-ui-tree-version-unsupported.