Spec 011 — Server-Side Rendering & Hydration¶
SSR is a core goal; see 000-Vision.md.
The
:rf.server/*per-request fxs are managed external effects — per Managed-Effects, the surface MUST satisfy the nine properties. The:rf.server/*fxs shape the HTTP response synchronously inside the per-request frame and never report a completion across an event boundary, so property 9 (the uniform async-reply envelope) is exempt — the eight synchronous properties are the ones that bite: effect-as-data, framework-owned per-request lifecycle, structured failure taxonomy under:rf.ssr/*, trace-bus observability,:sensitive?/:large?composition, built-in retry / abort / teardown semantics, in-flight per-request registry, per-frame interceptor scoping.
Abstract¶
Server-side rendering (SSR) is part of the target architecture. The design supports:
- rendering views on the server from explicit state and inputs
- serialising the initial state needed for hydration
- separating render-time computation from browser-only side-effects
- evaluating derived state without a browser runtime
- making the client/server handoff explicit rather than magical
This Spec captures the contract; the per-host implementation realises it.
Artefact (CLJS reference). Per the per-feature artefact-split strategy, the CLJS reference's SSR & hydration surface ships in the separate Maven artefact day8/re-frame2-ssr — re-frame.ssr namespace, the pure hiccup → HTML emitter (render-to-string), the FNV-1a structural render-tree hash (render-tree-hash), the :rf/hydrate event with :replace-frame-state semantics, the seven :rf.server/* server-only fxs (set-status, set-header, append-header, set-cookie, delete-cookie, redirect, safe-redirect) registered at ns-load time, the per-request HTTP response accumulator in a framework-private side-channel atom keyed by frame-id (read via get-response, NOT an app-db path — see §Response storage substrate), the reg-error-projector registry kind plus the built-in :rf.ssr/default-error-projector, and the SSR error-projection trace listener. (The data-rf2-source-coord / data-rf-view view annotations are NOT part of this artefact — they are stamped at the core reg-view registration boundary and merely serialised by the SSR emitter; see §Source-coord annotation under SSR.) The core artefact (day8/re-frame2) does not carry any of this; apps that don't render server-side build an :advanced bundle clean of every re-frame.ssr / :rf.ssr/* / :rf.server/* symbol and trace string. See MIGRATION §M-32 for the deps swap.
Pattern-level requirements¶
Views are pure functions of (state, props) → render-tree¶
A view does not read its frame from ambient context at render time. The frame is a parameter (or implicit-parameter via a serialisable id). React-context-driven frame resolution may exist as a CLJS-implementation optimisation, but the underlying contract is explicit-frame addressing — otherwise SSR cannot resolve the right frame on the server.
The render-tree is serialisable data¶
The output of a view is a nested data structure (hiccup, JSX-as-data, virtual-DOM nodes, template strings — implementation choice). It must be serialisable enough that the server can render it to a string and the client can hydrate against it.
Frames are per-request¶
A server-side request creates a frame, runs setup events (the frame's :initial-events, computed per request from the request), renders, serialises the resulting state, destroys the frame. The frame contract is unchanged from Spec 002 — frames are isolated runtime boundaries; "per-request" is just one more use case alongside multi-instance / per-test.
The override seam is id-based¶
The dispatch envelope's :fx-overrides and :interceptor-overrides cannot be raw functions — functions don't serialize across the wire. Overrides are {registered-fx-id → registered-fx-id} maps, looked up at consumption time. The CLJS reference may keep function-valued overrides as a client-only convenience, but the pattern's contract is id-based.
Hydration is a defined protocol¶
Not magic:
- Server creates a frame for the request.
- Server dispatches setup events (events that resolve via JVM-runnable handlers).
- Server serialises the resulting
app-db(and any other frame state needed for hydration). - Server renders the view to a string and ships both the HTML and the serialised state to the client.
- Client creates a frame, dispatches a
:rf/hydrateevent with the serialised state as payload. - Client renders against the now-seeded state.
Hydration equivalence rule (canonical)¶
Equivalence is structural, not textual. The contract is: the client, given the hydrated payload, computes the same view the server rendered. Operationally this is verified by hashing the canonical-EDN serialisation of the render-tree on both sides and comparing the hashes (per §Hydration-mismatch detection below).
This rule is the lock. Byte-for-byte HTML equality is not required — different HTML serialisers may emit semantically-equivalent strings that differ in attribute order, whitespace, or boolean-attribute spelling. Tools and tests assert structural equivalence (the canonical-EDN hash) and may also compare HTML strings as a stricter check, but the contract requires only the structural form. The single equivalence rule applies to body, head (§Mismatch detection — head), and any other render-tree fragment the runtime hashes.
Mismatches at step 6 are detectable and surface as structured trace events per 009.
Payload scope (canonical boundary)¶
The :rf/hydration-payload is bounded — it carries the minimum data the client needs to recompute the server's view. The schema is in Spec-Schemas §:rf/hydration-payload; the canonical scope at this layer is:
| In payload | Purpose |
|---|---|
:rf/version |
Integer pattern-protocol version stamp (per Spec-Schemas §:rf/hydration-payload — :int, NOT a semver string); mismatches emit :rf.ssr/version-mismatch. Source-of-truth: the payload builder resolves the stamp in this order — (1) an explicit :version opt passed to the payload builder; (2) the SSR artefact's compiled-in pattern-protocol version constant (v1 = 1) — the SAME value the client-side :rf.ssr/check-version fx reads (per §The :rf/hydrate event), so both sides of the wire pin the same value with no host wiring. The version is a fact of the SSR artefact's own wire code, so the artefact owns it; it is never host-published. An explicit :version is coerced/validated to an integer: an int is taken verbatim, a whole-number string ("7") is tolerantly parsed, and any other value (a semver "1.0.0", a float, a keyword) is REJECTED with a :rf.ssr/invalid-version warning so resolution falls through to the compiled constant — the assembled payload therefore always carries an integer :rf/version (per Spec-Schemas §:rf/hydration-payload). A build that passes no :version ships the compiled constant, and the client-side check compares that constant against its own copy of the same constant — equal on a matching build, so the check-version fx stays silent rather than skipping. |
:rf/frame-id |
The frame the server rendered under — the SSR-wire spelling of the frame stamp. Payload metadata + validation evidence, not a no-opts target resolver: the client passes its hydration target explicitly to hydrate!, and the runtime validates this :rf/frame-id against that explicit target. A present-and-different value raises :rf.error/hydration-frame-id-mismatch; an absent value is no conflict (the explicit target stands). The validation is enforced at two boundaries: the boot helper hydrate! validates + throws pre-dispatch, AND the :rf/hydrate handler itself fails CLOSED on a present-and-different :rf/frame-id against the dispatch target — so the direct-dispatch-sync split path (hydrate!'s documented post-mount-verify escape hatch) cannot silently install a server slice into the wrong frame. |
:rf/app-db |
The serialised app-db partition — server's authoritative application state. Replace policy on hydrate. |
:rf/runtime-db (optional) |
The serialised SERIALIZABLE runtime-db projection — machine snapshots, route slice, elision declarations, SSR metadata. Carries only durable facts; transient side channels are excluded. Together with :rf/app-db it installs a coherent frame-state. Replace policy on hydrate. |
:rf/schema-digest (optional) |
Hash of the server's registered app-schema set; mismatches emit :rf.ssr/schema-digest-mismatch. |
:rf/render-hash (optional) |
Structural hash of the server-rendered body render-tree (body-only, rf2-1oxjxk). The :rf/hydrate handler stashes it at [:rf.runtime/ssr :hydration :server-hash] for verify-hydration! to compare against the client's first-render hash (per §Hydration-mismatch detection). |
:rf/head-hash (optional) |
Structural hash of the server-rendered canonical head model (the EDN active-head returns, not emitted <head> HTML) — a separate channel from :rf/render-hash (rf2-1oxjxk). Client-reconstructible via active-head against the hydrated state (§Mismatch detection — head). Omitted for explicit-:head-STRING requests (no reconstructible model). |
:rf/ssr-rendered-at (optional) |
ms-since-epoch the server completed the render; SSR metadata for diagnostics. |
Route slice (carried inside :rf/runtime-db at [:rf.runtime/routing :current]) |
Active route, populated by :rf.route/handle-url-change server-side. Rides the runtime-db projection (the route slice is runtime-db state). |
Machine snapshots (carried inside :rf/runtime-db at [:rf.runtime/machines :snapshots]) |
State-machine snapshots; survive the round-trip per 011 §:after is no-op under SSR. Ride the runtime-db projection (machine snapshots are runtime-db state). |
:rf/sub-warmups (optional, future-additive) |
Pre-computed sub values; absent in v1 (see §Hydration of non-state runtime artefacts). |
Out of payload scope (explicitly not carried):
- The trace stream and trace ring buffer (dev-only; see 009 §Production builds).
- Server-side handler closures, fx implementations, and any function-valued state (overrides are id-based per §The override seam is id-based).
- In-flight HTTP request continuations (host-side concern; not part of v1).
- Sub-cache contents beyond the optional
:rf/sub-warmupsslot. - Internal trace-event detail (the security boundary in §Server error projection — error pages carry only the locked
:rf/public-errorshape).
The bounded payload is the lock: implementations may emit additional optional keys per the additive-fields rule, but never alter the required keys, and the boundary above is what consumers (clients, tests, host adapters) rely on.
:rf/app-db projection — explicit fail-closed policy¶
The :rf/app-db slice is projected from the request frame's app-db per an explicit, fail-closed policy. The host adapter MUST receive a single declarative opt — :payload — at construction time. It carries the policy in one of two shapes:
:payload [<top-level-app-db-keys>](a non-empty sequential of keywords — a vector, the canonical spelling, or a list / lazy-seq, e.g. a computed(filterv …)/(keep …)result) — an allowlist. Only the listed keys ride the wire; everything else is dropped, including any keys added later as the app evolves. This is the recommended primary mechanism — a denylist would silently leak each new server-only key as the app evolves; an allowlist forces a deliberate edit per new wire-bound key. The policy selector is collection-vs-keyword, so any sequential keyword collection is accepted (a sequential can never be confused with the whole-app-db keyword); a set is rejected — the allowlist is an ordered key selection. Every element MUST be a keyword (top-levelapp-dbkeys are keywords); a non-empty sequential coll carrying a non-keyword element — a string typo for a keyword (["public/articles"]or'("public/articles")), a straynil, a nested coll — is a malformed allowlist and fails loud at construction time with:rf.error/ssr-malformed-payload-allowlistrather than silently shipping a wrong/emptyselect-keysslice.:payload :rf.ssr.payload/whole-app-db(the policy keyword) — explicit opt-in to ship the wholeapp-dbverbatim. Use only when the app'sapp-dbis structurally safe to expose end-to-end (e.g. small SPAs where every server-set key is intended for the client).
Absence of :payload is a structural error — the host adapter throws :rf.error/ssr-missing-payload-policy at handler-construction time so misconfigured deployments fail at boot, not at first request. The contract makes the privacy decision explicit at every host site (no fail-OPEN default at a security boundary). The CLJS reference's projection helper lives in re-frame.ssr.payload-policy/apply-policy (consumed by both re-frame.ssr.ring.payload/build-payload for non-streaming and re-frame.ssr.streaming/build-final-payload for streaming SSR — the policy contract is shared).
The single-opt shape is deliberate: a single value holds exactly one policy, so there is nothing to arbitrate. The allowlist-vs-whole-app-db choice is the value's SHAPE (sequential keyword collection vs keyword), not a contest between two opts — which removes the prior surface's precedence rule and silent-ignore branch. An empty :payload ([] / '()) is treated as no-allowlist — shipping zero keys is almost certainly a programmer error, not intent — and falls into the missing-policy bucket. An unrecognised :payload keyword surfaces as the distinct :rf.error/ssr-unknown-payload-policy, and a non-empty sequential allowlist with a non-keyword element surfaces as the distinct :rf.error/ssr-malformed-payload-allowlist (carrying the offending entries under :bad-entries), so the developer can tell the three failure modes apart at construction time.
SSR flow¶
Server flow (per request)¶
HTTP request arrives
│
▼
rf/make-frame { :initial-events [[:rf/server-init request-context]] } ;; record-config path: :initial-events rides make-frame
│
▼
:initial-events dispatched-sync
└─ run setup events (read session, load initial data via :http server-platform fx)
│
▼
drain to fixed point (run-to-completion)
│
▼
final app-db captured via (app-db-value frame-id)
│
▼
view rendered to render-tree by calling the registered root view fn against (state, props)
│
▼
render-tree → string by hiccup→HTML emitter (pure, JVM-runnable)
│
▼
serialise app-db → wire format (EDN by default in the CLJS reference; JSON acceptable for cross-language)
│
▼
HTTP response: HTML + serialised state injected as a `<script>` payload
│
▼
destroy-frame!
:initial-eventsat two layers — the same name, one feeding the other. The frame config key:initial-eventsis the declarative per-request frame setup: an ordered vector of events, computed per request and passed tomake-frame. There are no:on-create/:initial-dbframe keys — a supplied:on-createfails loud with:rf.error/on-create-retired. Thessr-ringadapter's handler-construction opt:initial-eventsonssr-handler/stream-handler(re-frame.ssr.ring.lifecycle) is the adapter-level surface that produces that frame key: it accepts a vector directly OR a(fn [request] → initial-events-vector), resolves it once per request, and lowers the result verbatim into the per-request frame's:initial-events. The handler opt and the frame key share the name: the opt's value becomes the frame's:initial-events— the(fn [request] …)form is the adapter's request→vector lowering, an adapter detail (per EP-0027 §SSR / Out of scope).
Client flow (on page load)¶
HTML loads, browser parses
│
▼
client bootstraps; reads serialised state from the embedded `<script>` payload
│
▼
make-frame on the client
│
▼
dispatch-sync [:rf/hydrate serialised-state] — installs the frame-state (app-db + serializable runtime-db)
│
▼
client renders root view; first render-tree should match the server's HTML
│
▼
react/reagent attaches event listeners to the existing DOM
│
▼
hydration-mismatch detector compares first client render-tree against server-supplied marker (if any) and emits a trace event on mismatch
│
▼
app is interactive
Detailed design¶
Server-side init flow¶
Per the SSR namespace exports an adapter Var of the same ten-fn shape Spec 006 §The reactive-substrate adapter contract specifies. Server-side bootstrap is one explicit call:
The SSR adapter is plain-atom-shaped — make-state-container is clojure.core/atom, read-container is deref, replace-container! is reset!, make-derived-value is a recompute-on-deref IDeref reify; the JVM has no React reactivity layer. The adapter binds re-frame.ssr/render-to-string directly into the :render-to-string slot, so callers using rf/render-to-string (which delegates through the installed adapter) get the SSR emitter without any late-bind wiring at the call site. The :render slot throws (:rf.error/render-on-headless-adapter) — SSR uses render-to-string exclusively; calling render on a server-side process is a programmer error.
CLJS hosts that ship Reagent on the browser AND need SSR on the JVM use the appropriate adapter per platform branch:
;; .cljc shared between JVM (server) and CLJS (browser):
#?(:cljs
(defn ^:export run []
(rf/init! reagent-adapter/adapter)
(rdc/render react-root [(rf/view :app/root)])))
#?(:clj
(defn ssr-handler [request]
(rf/init! ssr/adapter)
...))
Per Spec 006 §Adapter selection at boot the init! call is idempotent — re-calling it after the adapter is installed is a no-op. It installs the adapter/runtime capabilities only; it does not create or ensure any frame.
:platforms metadata on reg-fx¶
Every registered effect handler declares which platforms it runs on:
(rf/reg-fx :http
{:doc "HTTP request — runs on both server and client"
:platforms #{:server :client}
:schema HttpFxSchema}
(fn [m args] ...))
(rf/reg-fx :localstorage
{:doc "Browser localStorage — client only"
:platforms #{:client}}
(fn [m args] ...))
(rf/reg-fx :rf.server/set-status
{:doc "Set HTTP response status — server only"
:platforms #{:server}}
(fn [m args] ...))
Default if absent: #{:server :client} (universal). Fx run wherever they are dispatched, including JVM headless tests. Fx that cannot run server-side (:localstorage/set, browser-DOM mutations, things that require js/window) declare :platforms #{:client} explicitly.
The fx resolver consults the active platform on dispatch (a runtime-static value: :server on the JVM-side server, :client in the browser). If an effect's :platforms set doesn't include the active platform, the resolver:
- Emits a
:rf.fx/skipped-on-platformtrace event with{:fx-id :localstorage, :platform :server}. - Treats the effect as a no-op for that invocation.
This gives a clean, deterministic story for SSR: every effect declares its compatible platforms; the resolver enforces. No runtime (when (browser?) ...) checks scattered through handler bodies.
The render-tree → HTML emitter (CLJS reference)¶
A pure function (hiccup-form, opts) → string. Pattern-level: implementations supply equivalent. CLJS reference shape:
(rf/render-to-string
view-or-hiccup ;; a hiccup form (including [:view-id arg…] refs)
{:doctype? true ;; prepend "<!DOCTYPE html>"
:emit-hash? true}) ;; embed data-rf-render-hash on the root element
The active frame is the one bound by the surrounding (rf/with-frame frame-id …) call (host adapters wrap their per-request frame). View arguments travel as inline hiccup positions — [view-fn arg1 arg2], where view-fn is the Var reg-view defs or (rf/view :id) — not via a separate :props opt. Per Q5 / cluster.
Return shape — locked to STRING. render-to-string always returns one shape: an HTML string. It does NOT return a map of {:html :hash :status :headers ...}. Callers that need the structural hash use the separate render-tree-hash fn (or read the data-rf-render-hash attribute the emitter embeds when :emit-hash? is set). Callers that need the HTTP response triple read the per-request response accumulator via get-response (per §HTTP response contract; the accumulator lives in a framework-private side-channel atom keyed by frame-id, NOT an app-db path — see §Response storage substrate) — that accumulator is the carrier for :status / :headers / :cookies / :redirect. Hosts that want the bundled {:html :payload :response} request-result shape (per §Request-handler return shape) build it from these three primitives — render-to-string is the string-yielding piece, get-response reads the resolved response, and the host builds the hydration payload.
The emitter:
- Walks the hiccup tree.
- Resolves DOM tags into HTML strings; void elements (
<br>,<img>, ...) self-close per HTML5 rules. - Escapes text content per the position (attribute values, text nodes, raw inside
<script>/<style>). - Calls views inline through their callable head — the Var
reg-viewdefs, or(rf/view :id); same path as client-side. A keyword head is an HTML element, never a view (see §The head grammar is not Spec 011's to extend, below). - Resolves
subscribecalls inside view bodies against the frame's staticapp-dbvalue (no reactive tracking; subs are pure derivations during SSR). - Returns a string.
JVM-runnable. No React, no DOM, no JS runtime. Hiccup is data; the emitter is a pure function over data.
The head grammar is not Spec 011's to extend¶
A keyword head in a render tree is a DOM / custom element. On every host, without exception. Views are referenced by a callable head: the Var that rf/reg-view defs, or the (rf/view :id) runtime handle.
This is a correction, not a change (rf2-j81hs). Spec 004D §Calling a registered view and Conventions §Render-tree shape vs runtime lookup own the head grammar, and both already said a bare [:keyword args] head is an HTML element that the runtime does not intercept — the rejection recorded under rf2-n82bbu. Earlier revisions of this document described the JVM emitters resolving a keyword head through (registrar/lookup :view head). That was a non-owning spec extending someone else's grammar, and the implementation followed it, so the two JVM emitters were the last surface out of conformance. They no longer resolve keyword heads.
Why this mattered enough to correct rather than keep. The divergence was measured (rf2-o4rbh): the same tree containing [:dashboard/card :revenue] rendered four correct <div class="card"> subtrees through the JVM streaming emitter and four phantom <card> elements through stock Reagent + react-dom/server. Reagent's parse-tag runs (name tag), so the namespace is discarded and the trailing argument becomes a text node; UIx is not hiccup at all, so a keyword head cannot even occur there. A .cljc application sharing views across both — the entire point of the SSR story — therefore could not write a keyword head that meant one thing.
The failure mode is what made it expensive: the server fails loud and the client fails silent. A keyword head renders correctly server-side, so it survives every server-side test; only a client render reveals it, and it reveals it as wrong pixels rather than an error. That is how it shipped in the flagship streaming example and went unnoticed.
No compatibility shim (pre-alpha). No hydratable page can depend on the removed behaviour: a keyword ref cannot render client-side at all, so the feature only ever served server-only trees, where [(rf/view :id) …] is a drop-in replacement.
Scalar children are spelled by name¶
Aligning the head left the child diverging, which is the same bug one layer down
(rf2-53lsj). A keyword or symbol child is spelled by its name on every host: no
leading colon, and the namespace is dropped. [:div :a/b] paints b.
The correction is the same shape as the head one — the JVM emitter moved onto the client
behaviour, not the reverse. It had been sending a keyword child through a generic
stringify, which preserves the colon (:revenue) and the namespace (a/b); every client
substrate routes a named child through (name x) before React sees it. Namespaced
symbols were measured to diverge identically and are covered by the same rule.
Note which client spelling this is. Stripping the leading colon looks like the fix and
is not: it yields a/b where the client paints b, so the hosts stay apart on exactly
the trees where a namespace was used to disambiguate.
A text-node mismatch is a real hydration failure, not a cosmetic one. React
reconciles text nodes as well as element structure, so server :revenue against client
revenue does not hydrate — React discards the server tree for that root and re-renders
it on the client, which is the cost SSR exists to avoid. The reference implementation
pins this with React itself as the judge: it hydrates the emitter's real bytes and
asserts React reports nothing, and — as a permanently executable red-before — hydrates
the pre-fix bytes and asserts React reports the mismatch. Its verdict on those:
Hydration failed because the server rendered text didn't match the client.
<card>
+ revenue
- :revenue
Unrecognised reserved heads fail loud. With keyword heads uniformly elements, a head in the framework-reserved :rf/* scheme that is not one this emitter implements — a misspelt :rf/suspense-boundry, say — would otherwise sail through the element branch and paint a phantom <suspense-boundry>, reintroducing the same silent-mis-render one keystroke away. The recognised reserved heads are :<>, :> and :rf/suspense-boundary; anything else under :rf/* raises :rf.error/invalid-hiccup-head. The :rf/* root is framework-owned per Conventions §Reserved namespaces, so there is no legitimate author element being rejected.
The OWNED client substrates fail loud on the same grammar (rf2-01zvu), so a reserved-head typo is caught wherever it is written rather than only on the server. reagent-slim raises the same :rf.error/invalid-hiccup-head, checking at its parse-tag cache-miss so steady-state rendering pays nothing; re-frame.ui catches it a stage earlier still, at compile time, as :rf.ui.compile/bad-tag. On the client the reject is total — :rf/suspense-boundary included, since it is a streaming-SSR marker with no client meaning. Stock Reagent is untouched: it is an external dependency whose element dispatch is not ours to extend, and that diagnostic asymmetry is accepted.
Streaming/chunked emission ships as the :rf/suspense-boundary primitive — see §Streaming SSR under Detailed design.
XSS at output boundaries¶
The emitter renders a host render-tree to a string that crosses the trust boundary into a browser. Three emission positions have different escaping rules — text nodes, attribute keys/values, and raw-script bodies (<script> for JSON-LD, <style> for inline CSS) — and the emitter MUST apply the position-appropriate escape at every leaf. Mixing positions or under-escaping any one of them is the XSS vector. The CLJS reference's escapes are catalogued below; other-language ports re-bind the same three rules to their HTML emitter.
- JSON-LD
<script>body — escape<as<. String values inlined into a<script type="application/ld+json">body MUST have every<re-encoded so an attacker-supplied substring (a product title, an article summary, a partner-supplied payload) cannot close the script context with</script>and pivot into HTML. The escape applies to every string leaf in the JSON-LD payload; other characters inside the script body (&,>,",') are JSON-string-safe and need no additional encoding at the script-body layer. Per Security.md §XSS at output boundaries. - Body-position raw
<script>/<style>author content is emitted verbatim as raw text — not escaped, not refused. A<script>or<style>element in the body render-tree (the author's view hiccup, not the structured<head>channel or the trusted host shell) with a raw STRING child emits its content VERBATIM as HTML raw text. Entity-escaping is not applied — the HTML parser never decodes character references inside a raw-text element, so routing the string through the text-node 5-char escape would silently corrupt valid JS/CSS. The one transformation is react-dom/server's context-safe closing-sequence rewrite, which respells an embedded</script>/</style>breakout so the parser cannot terminate the element early while JS, JSON, and CSS all decode the escape back; the detailed rule (the exacts/\73rewrite, byte-parity with react-dom/server) lives in its owning doc, 004B §Children, text, and escaping. The emission is byte-identical acrossrender-to-string, the streaming shell walk, andemit-ui-tree. The visible inline element is the author's trust assertion — the framework trusts the programmer and emits the script raw; the closing-sequence rewrite guards data an attacker may interpolate into the body (it cannot break out of the element), and is not sanitisation of the author's script. Structured data still travels its own channel: JSON-LD / structured head content throughreg-head(whose emitter applies the stricter JSON-LD<escape), the hydration payload through the__rf_payloadwire. An element-only or empty<script>/<style>(no raw string child) is inert and emits unchanged. - Attribute key escape, not just value. Attribute keys (not just values) MUST be escaped at the emitter so an attacker-controlled key cannot break out of the attribute namespace. The threat path: a registered view receives keyed data (a routing-param map, an MCP-resolved props bag, a deserialised hydration slot) and uses the key as an attribute name; under-escaping the key allows a
key=" onload=alert(1) garbage="shape to insert an event handler. The emitter MUST treat attribute keys as untrusted text by default — strings containing",',>,<,=, or whitespace surface as a structured emission error rather than as wire output. Per Security.md §XSS at output boundaries. on*event-handler prop filter + reserved-prop-keys gate. SSR static-markup emission MUST stripon*event-handler props (:on-click,:on-mouse-down, …) and function-valued props at attribute-emit time, matching react-dom/server behaviour. The client-side substrate adapters (Reagent, reagent-slim, UIx) wire event handlers at hydration; the server-side rendered string MUST NOT carry them inline. Additionally, the emitter MUST drop reserved prototype-pollution keys (__proto__,constructor,prototype) from the props map before they reach the underlying host'screateElement-equivalent. Closes both the event-handler-injection vector and the prototype-pollution path on the client. Per Security.md §XSS at output boundaries.
The three escapes compose at the emitter walk: per-element attribute-key check → per-attribute prop-name filter (on* / reserved keys) → per-attribute value escape → per-text-node escape → per-script-body JSON-LD escape. The composition order is locked — relaxing any one position breaks the closed-set guarantee. Other-language ports MUST mirror all three rules against their host's render-tree shape; the rule numbers are the contract, not the CLJS function names.
Cross-reference: see Security.md §XSS at output boundaries for the framework-wide threat-model entry and rationale.
Source-coord annotation under SSR¶
Per Spec 006 §Source-coord annotation and §View tagging contract, every host MUST stamp two dev-mode DOM annotations on the root element of each registered view: data-rf2-source-coord="<ns>:<sym>:<line>:<col>" (maps a rendered node back to its reg-view call site) and data-rf-view="<str id>" (the view-hierarchy fallback). Under SSR both appear in the server-rendered markup exactly as they do in a client render.
The annotation lives at the reg-view registration boundary, not in the SSR emitter (rf2-8vi4q). On the JVM, reg-view*'s :clj branch wraps the stored :handler-fn with a debug-gated hiccup walk (re-frame.views.jvm-source-coord-annotation) that stamps both attributes on the render output's root — the server-side twin of the CLJS substrate wrappers. The pure hiccup → HTML emitter carries no annotation logic; it stringifies hiccup the registration boundary already annotated. The orphaned emitter-side fns format-view-source-coord and inject-coord-on-root-hiccup are deleted.
Locating annotation at registration rather than in the emitter is deliberate. The rejected alternative (rf2-8vi4q Option A) stamped inside the emitter's keyword-view branch — a branch rf2-j81hs removed (a keyword head is a DOM element on every host), and one that never fired on the callable-head shape ((rf/view :id) / Var heads) a hydratable page actually composes through. Annotation is a property of the registered view, not of the emitter, so it belongs at the boundary every host shares.
SSR therefore mirrors the full Spec 006 view-tagging contract at the registration boundary on both hosts: both attributes, the value format, the non-DOM-root exemption, author-supplied-value preservation, and the interop/debug-enabled? production gate. A dev SSR page hydrates as a clean adoption because server and client emit the same evidence on the same roots.
The attribute value format is identical to the CLJS-side wrapper: <ns>:<sym>:<line>:<col>, derived from the registry id and the coords stamped onto the slot at reg-view macro-expansion time.
Production elision holds. Source coords are internal ns / symbol / line information and MUST NOT reach public HTML in a production render (rf2-wtd8z finding 3). The registration-boundary wrapper sits behind interop/debug-enabled?: in a production SSR build (interop/debug-enabled? false) reg-view* stores the raw unwrapped :handler-fn, so server markup carries neither attribute — symmetric with the CLJS :advanced + goog.DEBUG=false build where Closure DCE folds the client walk away.
Exempt-keyword enumeration. The "non-DOM root" exemption is closed-set: a hiccup vector is exempt from :data-rf2-source-coord (and from the data-rf-render-hash root injection per §Hydration-mismatch detection) when its head keyword is one of:
| Exempt head | Meaning |
|---|---|
:<> |
Fragment shorthand — no DOM element emitted; children are spliced into the parent. |
:> |
Reagent-native interop head — children pass through to a React component, not a DOM tag. Not statically renderable server-side: the JVM emitter raises :rf.error/ssr-reagent-native-head — there is no React on the JVM, so the author must wrap the component in a reg-view and reference it by callable head, or render it client-only. The exemption row remains for ports/substrates that can render a native head server-side. |
:rf/suspense-boundary |
Streaming-only marker — recognised ONLY by the streaming shell walker (§Streaming SSR), which materialises a <template> fallback + registers a continuation for the subtree. Not renderable by the standard emitter: its name passes the [A-Za-z][A-Za-z0-9-]* tag grammar, so a misused marker that reaches render-to-string outside a stream would otherwise emit a phantom <suspense-boundary> DOM element with the {:id … :fallback …} attrs serialised as bogus attributes. The non-streaming emitter raises :rf.error/ssr-suspense-boundary-outside-stream (parallel to :>) so the misuse fails loud. Render trees containing :rf/suspense-boundary via stream-handler. |
A hiccup vector whose head is a callable — a fn or Var reference, which is how a registered view is referenced ([card-view 7], [(rf/view :dashboard/card) 7]) — or a lazy-seq is passed through the injection: the attribute lands on the eventual DOM root once the indirection resolves. The exemption is per-call: it only skips the current level. A view that returns [:<> ...] resolves to the fragment, so the injection no-ops (consistent with the CLJS-side wrapper).
A keyword head is not an indirection — it is the DOM element itself, so there is nothing to pass through; it is annotated or exempt on its own terms per the table above.
The data-rf-render-hash root-attrs injection follows a related but deliberately asymmetric rule (rf2-58zvy1 / rf2-a73idu). The render-hash is a whole-tree structural marker, so — unlike the source-coord annotation, which drops at a :<> / lazy-seq root — it threads through a :<> fragment root, a lazy-seq / list root, a fn-headed component, and a registered-view ref, landing exactly once on the FIRST DOM-tag element the root path resolves to. The marker landing under a fragment / seq root is the ruled, more-useful behaviour; only source-coord takes the fragment-drop exemption above (a view whose root resolves to :<> / a lazy-seq no-ops its source-coord injection, matching the CLJS-side wrapper — the render-hash still threads through those same roots to the DOM element beneath). :> is not a threadable head: the JVM emitter fails loud on it (:rf.error/ssr-reagent-native-head), so there is no DOM root to stamp. Other-language ports MUST mirror the threading — the contract is that the render-hash lands on the eventual DOM root even under a fragment / seq / component indirection.
Production-elision differs from CLJS: the JVM has no goog.DEBUG constant-fold concept and no Closure DCE. The JVM half of the interop layer provides the runtime counterpart — re-frame.interop/debug-enabled? is a def read ONCE at ns-load from the system property -Dre-frame.debug=false or the env var RE_FRAME_DEBUG=false (false-y vocabulary: false, 0, no, off, empty string; system property wins on conflict). The annotation site is gated on the same interop/debug-enabled? as CLJS, so an SSR / long-running JVM that sets the flag at process startup gets equivalent suppression — a runtime short-circuit rather than compile-time DCE. The default is true (dev parity); SSR / webhook receivers / long-running JVMs facing untrusted input MUST set the gate false explicitly per 009 §JVM builds and Security §Production gates. Hosts that want finer-grained per-request control can still branch on the resolved frame's :ssr config (or a host-supplied flag) on top of the gate.
The :rf/hydrate event¶
Pattern-level standard event:
The hydration-payload is the canonical :rf/hydration-payload shape (per Spec-Schemas). The reference handler is registered automatically by the runtime:
(rf/reg-event :rf/hydrate
{:doc "Install a coherent frame-state (app-db + serializable runtime-db) from the server-supplied payload."
:platforms #{:client}} ;; hydration is client-side only
(fn [_ [_ {:rf/keys [version frame-id app-db runtime-db render-hash schema-digest] :as payload}]]
;; Replace policy: server is authoritative for the INITIAL client frame-state.
;; The framework :rf/hydrate handler is framework-authority, so it may emit
;; the reserved :rf.db/runtime effect — it installs BOTH partitions in one
;; atomic frame-state transition (per [002 §Write authority is by convention]).
{:db app-db ;; app-db partition (server-authoritative)
:rf.db/runtime (-> runtime-db ;; serializable runtime-db projection
(assoc-in [:rf.runtime/ssr :hydration :server-hash] render-hash)
(cond-> version (assoc-in [:rf.runtime/ssr :hydration :version] version)))
:fx [(when schema-digest
[:rf.ssr/check-schema-digest schema-digest])
[:rf.ssr/check-version version]]}))
Merge policy is :replace-frame-state. Server is authoritative for the initial client frame-state: the handler sets :db to the server's serialised app-db slice AND :rf.db/runtime to the server's serialized runtime-db projection (machine snapshots, route slice, elision declarations, SSR metadata), installing a coherent frame-state in one atomic transition — replacing whatever the client bootstrap had pre-seeded. This is locked. Hydration installs a frame-state, not just an app-db slice.
Only the SERIALIZABLE runtime-db projection rides the payload. The payload carries the durable runtime-db facts needed to reconstitute the client (machine snapshots, route slice, elision declarations, the SSR hydration metadata). It MUST NOT carry transient runtime state — server-only request/response accumulators, head snapshots, streaming continuation registries, pending-error buffers, in-flight HTTP handles, or host handles (per 002 §Durable vs transient). The server-side allowlist projects app-db plus the serializable runtime-db projection; transient side channels are absent by default.
The payload is an untrusted transport input — the handler fails CLOSED on a malformed one. The payload is the server's pr-str'd EDN round-tripped through cljs.reader/read-string at the boot site; a truncated render, mid-stream corruption, or a hostile fragment can deliver a non-map payload or a non-map partition slice. Because the merge policy is :replace-frame-state, blindly installing a non-map slice would coerce corrupt input into a partition (a fail-OPEN). The handler therefore REJECTS a payload that is not a map, or whose app-db / runtime-db slice is present-but-not-a-map: the existing client frame-state is left unchanged, no compatibility-check fxs fire, and :rf.error/malformed-hydration-payload (per 009 §Error event catalogue) is emitted. Both partitions validate fail-closed before installation. A wholly-absent app-db or runtime-db slice is NOT malformed — it is the documented client-only first-load fallback. The boot helper's read-server-payload applies the symmetric guard: a payload script that does not parse as EDN fails closed to nil (client-only) rather than throwing through the mount.
The handler ALSO fails CLOSED on a frame-id mismatch. Beyond shape, the handler validates the payload's :rf/frame-id against the frame the dispatch is installing into (the :rf.frame/id coeffect). A present-and-different :rf/frame-id means the server's slice was rendered for a different frame, so installing it here would defeat the frame-isolation evidence the payload carries. The handler REJECTS it: the existing client frame-state (app-db AND runtime-db) is left unchanged, no compatibility-check fxs fire, and :rf.error/hydration-frame-id-mismatch is emitted on both the dev trace and the always-on error-emit axis (carrying :target-frame / :payload-frame-id). This is the same validation the boot helper hydrate! runs pre-dispatch (where it throws — §Client-side hydration boot helper), enforced at the handler boundary so the direct-dispatch-sync split path (the post-mount-verify escape hatch the boot helper's docstring documents) cannot bypass it. An absent :rf/frame-id is no conflict — the dispatch target stands.
Server-hash slot at [:rf.runtime/ssr :hydration :server-hash]. As shown above, the reference handler writes the payload's :rf/render-hash value at [:rf.runtime/ssr :hydration :server-hash] in the installed runtime-db partition per Conventions §Reserved runtime-db keys (it is durable, serializable SSR metadata — a runtime-db fact, not transient side-channel state). This slot is the carrier verify-hydration! reads later (after the first client render) to compare against the client-side render-tree hash — see §Hydration-mismatch detection. The slot is a runtime-managed runtime-db path; user code MUST NOT write to it. The companion :version key under [:rf.runtime/ssr :hydration] carries the payload's :rf/version value when present (consumed by :rf.ssr/check-version). Implementations that override the reference :rf/hydrate handler with their own merge policy MUST preserve the [:rf.runtime/ssr :hydration :server-hash] write (or pass a :server-hash opt to verify-hydration! per the fn's docstring) — otherwise verify-hydration! has nothing to compare against and silently no-ops.
Off-box redaction. runtime-db is redacted/omitted off-box by default — the hydration payload ships only the serializable runtime-db facts the client needs, and Xray / pair / epoch egress redact or omit runtime-db per projection policy (per Privacy §Rule summary and 009 §Privacy). Trusted-local tools may request richer diagnostics explicitly; the default fails closed.
If the user wants client-only transient state to survive hydration: the customisation point is re-registering :rf/hydrate with a custom handler that performs an explicit merge in the user's intended order. The default is replace; opt-in merge is the user's choice and they own the semantics.
Mismatch detection between server and client schemas runs as part of :rf/hydrate's :fx:
:rf.ssr/check-versioncompares the payload's:rf/versionagainst the runtime's. A mismatch emits:rf.ssr/version-mismatch(a structured trace event) and the handler still applies (best-effort).:rf.ssr/check-schema-digest(when the payload includes one) hashes the client's currently-registeredapp-schemaset and compares to the server's digest. Mismatch emits:rf.ssr/schema-digest-mismatch. Useful for catching deploy drift where the server is rendering against a newer/older schema set than the client's bundle.
fx-input shape: each fx accepts either a scalar — [:rf.ssr/check-version <server-value>] (the form the reference handler dispatches) — or an explicit map — [:rf.ssr/check-version {:expected <server-value> :actual <client-value>}]. The scalar form treats the value as the server-supplied "expected" and resolves the client-side "actual": the version check reads the SSR artefact's compiled-in pattern-protocol constant (always resolves — the same value the server stamped, so a matching build compares equal), while the schema-digest check reads the :schemas/app-schemas-digest published hook and emits :rf.ssr/compatibility-check-skipped (warning) when the schemas artefact is absent, no-opping the comparison rather than crashing. Both fxs gate on :platforms #{:client} — server-side dispatches no-op via the standard fx-gating contract. The fxs NEVER throw — degraded-but-running is the locked posture.
The three compatibility-check trace categories — :rf.ssr/version-mismatch, :rf.ssr/schema-digest-mismatch, :rf.ssr/compatibility-check-skipped — are catalogued in 009 §Error event catalogue (the single source of truth for every :rf.ssr/* category, per Ownership).
Hash-based render-tree mismatch (a separate concern) lives in §Hydration-mismatch detection.
The payload shape is fixed; implementations may emit additive optional keys (per Spec-Schemas §:rf/hydration-payload) but never alter the required keys.
Client-side hydration boot helper¶
The server side ships ONE explicit handler-constructor (ssr-handler — §HTTP response contract) that renders, builds the __rf_payload <script>, and writes the wire response. The client side ships its symmetric counterpart — ONE explicit boot call that reads __rf_payload, dispatches :rf/hydrate, and verifies. The pair reads as one contract; a host should not have to re-derive the read → dispatch → render → verify ordering by hand at every boot site.
The CLJS reference ships it as re-frame.ssr/hydrate! (re-exported from the façade; implemented in re-frame.ssr.boot):
#?(:cljs
(defn ^:export run []
(rf/init! reagent-adapter/adapter)
;; hydrate! seeds app-db AND verifies synchronously (it computes the
;; client render-tree itself via :render-tree-fn — see step 3) BEFORE
;; the host mounts. Then the host renders that same tree. `:frame` is
;; required (EP-0002): the hydration target is carried — the same frame
;; flows to hydrate! and the root frame-provider {:frame …} SCOPE-only
;; shape (the hydration target frame already exists, so the root scopes
;; it into the React tree rather than ensuring a new one; per EP-0024).
(let [payload (ssr/hydrate! {:frame :app/main
:render-tree-fn #((rf/view :app/root))})]
(rdc/render react-root
[rf/frame-provider {:frame :app/main}
[(rf/view :app/root)]])
payload)))
hydrate! performs the three steps in the order the §Client flow mandates:
- READ — the payload. Supplied explicitly via
:payload, or read from the DOM's__rf_payload<script>(viaread-server-payload, which reads the id pinned in the CLJS reference'sre-frame.ssr.constants/payload-script-id— the same id the host shell stamps) when:payloadis omitted. Returnsnilon a client-only first load (no payload script) — the host renders against the empty app-db. - HYDRATE —
dispatch-sync [:rf/hydrate payload]against the target frame BEFORE the first render, so the frame's frame-state (app-db + serializable runtime-db) is the server's authoritative slice when the view first evaluates (the locked:replace-frame-statepolicy above).:frameis required: the client hydration target is carried — the host passes the same frame tohydrate!, the root provider, streaminginstall!, resource preload, and Xray. An absent:frameraises:rf.error/no-frame-context; the runtime never synthesises:rf/default. The payload's:rf/frame-id(below) is validated against this explicit target — a conflict raises:rf.error/hydration-frame-id-mismatchrather than silently picking a side. - VERIFY —
verify-hydration!compares the client render-tree hash against the server hash stashed at[:rf.runtime/ssr :hydration :server-hash](see §Hydration-mismatch detection). The verify step takes a:render-tree-fn— a 0-arity fn returning the client render-tree to hash (typically#((rf/view :app/root))).hydrate!calls it synchronously, immediately after:rf/hydrateand before the host's own render. This is sound because a re-frame2 view is a pure function of app-db: evaluating(rf/view :app/root)against the just-hydrated app-db yields the same render-tree the host is about to mount, so hashing it pre-mount is equivalent to hashing the mounted tree — without a post-render callback the helper cannot observe (it does not own the DOM mount).hydrate!is therefore a seed-and-synchronously-compute-tree convenience, not a true post-mount verifier;:render-tree-fnis a pure client-tree computation, not a "read back what was mounted" hook. Omit:render-tree-fnto skip verification (the host opts out of hash-mismatch detection, or runsverify-hydration!itself at its own render site).
hydrate! returns the applied payload (or nil) so the caller can branch on "was this server-rendered?" without re-reading the DOM. It is the convenience that fuses the common ordering. The synchronous-compute model fits any host whose view tree is a pure projection of app-db (the re-frame2 norm — Reagent and UIx both qualify). A host that genuinely must observe the mounted DOM tree (e.g. a substrate that mutates the tree at mount time, or an async mount where the render-tree is not yet computable when hydrate! returns) splits the convenience: call dispatch-sync [:rf/hydrate …] to seed, mount, then call verify-hydration! at the post-mount site with the observed tree. read-server-payload is CLJS-only (it reaches into the DOM); hydrate! is platform-neutral so a JVM test harness can drive the server-build-payload → hydrate! → post-hydrate-sub round-trip on a :client-platform frame without a browser.
Root Manifest v1¶
A server-rendered page is N roots referencing M frames. The hydration payload above is
the page's frame-state wire; the Root Manifest is a per-root wire carrying the
identity and render-time facts ui/hydrate-root needs to adopt server markup instead of
guessing at it.
The manifest is a versioned superset of the S1 root descriptor, not a replacement.
The schema family (:rf.root/*), its keys, and the compatibility rule are owned by
004C §2;
this section owns what that contract defers to Spec 011: the extension keys the
server render supplies, the wire form, and discovery.
Root Manifest v1 = Root Descriptor v1 (minus the dev-only :root-id-provenance) plus
six render-time keys:
| Extension key | Meaning | Shape |
|---|---|---|
:element-locator |
the root's container | {:id "shop-root"} — the closed v1 vocabulary (004C §4) |
:props |
the render-time prop values | map; every value EDN-carryable |
:frame-payload-ids |
the full payload set the render referenced — plans ∪ provider-scoped frames | collection of keywords |
:render-fingerprint |
over the rendered structural output | string |
:identifier-prefix |
the prefix the server actually used | string |
:phase |
:server — the only value a manifest carries (the server renders in :server phase); the reserved field's additive :client counterpart is the runtime phase a hydrating root flips to, per §Phase flip |
:server |
Every extension key is OPTIONAL, and that optionality is the contract. The only
required key is :rf.root/schema-version, whose value is 1 for both shapes. An
unmodified S1 root descriptor is therefore already a valid Root Manifest v1 — no
edit, no upgrade step, no migration. Making any extension key mandatory would silently
convert this superset into a second schema, which is exactly the failure the reference
implementation's subset-property suite pins against the real compiler's descriptor
output.
There is deliberately no version-negotiation protocol and no migration mechanism.
v1 is the first version, not a compatibility layer: additive keys never bump
:rf.root/schema-version, and a manifest declaring any other value is simply not from
this family (:rf.error/root-manifest-invalid). Readers ignore unknown keys, so an
S1-era reader sees a manifest as the descriptor it already understands.
The wire form¶
The manifest rides one script element per root:
<div id="shop-root"> … server-rendered root … </div>
<script type="application/edn" data-rf-root>{:rf.root/schema-version 1, :root-id :page/shop, …}</script>
type="application/edn"— the body is EDN and the browser must not execute it.data-rf-rootis a bare marker: no value, no identity. It exists only so an ordinaryapplication/ednscript — the__rf_payloadhydration payload, a data island — is never mistaken for a manifest.- The body is the
pr-str'd manifest, escaped by the same EDN script-body escape the hydration payload uses (see §HTTP response contract), so data cannot break out of the element and the body still round-trips through the EDN reader. It is read with the safe EDN reader — nevereval. - The body holds exactly one EDN form, plus whitespace. A body carrying trailing
content or a second form is a corrupt wire and is rejected, never truncated to its
first form. This has to be stated because the obvious implementation does the wrong
thing silently: a
read-stringreturns the first form and discards the rest, so a truncated render, two manifests concatenated by a faulty page assembly, or an injected suffix would all hydrate happily against the leading map while the evidence that the render went wrong was thrown away. A manifest is one value.
The round trip is exact, and emission is where that is enforced. For every manifest
that reaches the wire, reading the body back yields the same manifest — not merely a
readable one. The acceptance predicate is therefore the round trip itself: a value rides
only if pr-str prints it in a form the reader on the OTHER host reconstructs
equal. Three consequences are easy to get wrong, and all three are real defects rather
than hypotheticals:
- Map-shaped is not map-printing. A record satisfies
map?yet prints as the tagged literal#my.ns.R{…}, which the safe reader has no constructor for. Testing shape rather than printed form admits it and defers the failure to client hydration. - Both halves of a prop entry ride the wire, so both are validated. Screening only values admits an opaque key, which prints as a host-object literal and fails the same way.
- Not every number crosses. The server holds numeric types the browser has none of,
and integers wider than a double can hold.
9007199254740993Nprints, and the browser's reader reconstructs9007199254740992;1/3arrives as0.3333333333333333. This one does not throw at the far end — it succeeds, with a different value, so the app hydrates against props the server never rendered. The admitted subset is therefore fixed-width integers within ±(2^53−1) and every double except##NaN— including##Infand##-Inf, which EDN prints and reads back exactly; no ratio, bigdec, bigint or float.##NaNalone is excluded, by the round-trip property on its own terms: it is not=to itself, so it cannot read back equal on any host. A large double still rides: the bound is about representability, not magnitude — a double of any size carries only precision a double carries.
The first two defects fail loud at the far end; the third is silent, which is why the predicate is stated as the crossing rather than as "is this printable EDN". A same-host suite cannot see it — the server reads its own bigint back perfectly — so the conformance proof is joined by the wire bytes, each host reading one pinned body with the shipped reader and reporting what it got.
Rejecting rather than coercing is the rule in all three cases. A record silently read back as a plain map, or a bigint as a plain number, changes the value's type between server and client — the very defect a fail-loud wire exists to prevent.
The check belongs at emission, not only at prop assembly: descriptor keys reach the
wire too, so gating :props alone leaves the property true of one key and false of the
manifest. One predicate, enforced at the one door onto the wire.
Per-value carryability is not whole-collection carryability. The predicate above
clears each value alone; a whole map or set can still fail to cross. The server holds
1 and 1.0, or 0 and -0.0, as two distinct map keys (or set elements); each
rides the wire alone, yet the browser reads every number as one double and collapses the
pair. The emitted body then fails to read back — the reader rejects a duplicate key — so
the manifest changes cardinality across the wire, and no same-host suite can see it (the
server reads its own two-key body back perfectly, exactly as with a bigint). Emission
therefore also rejects, at that same one door, any map whose distinct keys or set whose
distinct elements collapse under the browser's numeric identity — at any nesting depth,
naming the offending collection so the author narrows one key deliberately.
Identity is spelled exactly once, in the content. The root-id lives in the
manifest's :root-id key and nowhere else on the wire. Putting it in the attribute as
well would give a reader a cheaper copy to trust and two places to disagree.
This follows the conformance-fixture precedent: a wire artefact names things
explicitly, never implicitly. The manifest names the mounted view through the
explicit :view-id key — it never re-spells a view as a callable or a head position, so
the keyword-head ambiguity that forced the explicit [:view-ref <id> & args] marker in
the conformance fixtures cannot arise here.
Discovery¶
A root's manifest is the immediately following element sibling of its container. Nothing else is searched — no document-wide scan, no id lookup, no selector.
Adjacency is what makes a manifest unambiguously that root's manifest on a page of N roots, and the pair survives fragment reordering because it moves together. Discovery is positional; identity is then read from the content, so position locates the manifest and never determines what it says.
Absence is not an error at the discovery layer — it simply means "no manifest here", and
the caller decides. ui/hydrate-root fails loud
(:rf.error/root-manifest-invalid, data {:missing :manifest}); a client-only mount
never asks.
Server-emit failures¶
Two arms fail the server render for that root only, per §Failed-root isolation — never a silently degraded manifest, because hydration applies the manifest as the server-rendered truth and a missing fact yields a different tree rather than a smaller one:
- a host-authored container without an
id—{:missing :container-id}. The emitter never synthesises an id onto host-owned markup, or the host's own markup and the manifest would disagree. A container the emitter itself produces is synthesised deterministically as"rf2-root-" + root-id-slug, collision-free because the slug is injective (004C §1). - an unserialisable prop —
{:unserialisable-prop :chart-fn}, naming the offender, and{:unserialisable-half :key}or:valuesaying which half of the entry is at fault (a fn used as a prop key and a fn used as a prop value are different authoring mistakes).
A third arm fails at the wire rather than at assembly: a manifest carrying a value the
EDN wire cannot carry under any key — {:unserialisable-manifest-key :static-props}.
This is the emission gate above; it is what makes "every emitted manifest round-trips"
total rather than true of :props alone.
Artefact (CLJS reference). re-frame.ssr.manifest — problems / valid? /
validate! (the schema verdict), manifest (assembly), script-html / read-manifest
(the wire), and discover (the CLJS adjacency read). The marker attribute is pinned in
re-frame.ssr.constants/root-manifest-marker-attribute alongside the payload script id.
The manifest is pure data, so the SSR artefact does not depend on the UI artefact to
carry it.
How hydrate-root reaches discover. The dependency runs the other way and it,
too, is not a require: re-frame.ui is a separate optional artefact and a static
ui → ssr require would fail to compile every non-SSR ui app and pull server code into
every client bundle. So discover is published as the late-bind hook
:ssr/discover-root-manifest — (fn [container]) returning the validated manifest
or nil — registered by re-frame.ssr.manifest at namespace load and resolved by
ui/hydrate-root through the core registry. The packaging graph is
ui → core late-bind ← ssr, the same shape ui/route-link uses to reach the routing
artefact, and neither optional artefact requires the other.
The hook does exactly what its name says: it resolves one manifest. It carries no
payload install and no other SSR operation — re-frame.ssr/hydrate! remains the
explicit state-boot call (see §Client-side hydration boot
helper), so the public boot stays two calls in
order: ssr/hydrate!, then ui/hydrate-root.
When the SSR artefact is absent the hook is unbound and ui/hydrate-root fails loud
with :rf.error/ssr-artefact-missing, naming the day8/re-frame2-ssr coordinate and
the re-frame.ssr namespace to require. That is distinct from the artefact being
present and finding nothing adjacent, which is the {:missing :manifest} case above.
Hydration preflight and idempotent payload install¶
A server-rendered page is N roots referencing M frames, and M is routinely smaller
than N — several roots on one page hydrate the same frame. Every one of those roots
boots, and every one of them reads the same page-wide __rf_payload. Something must
decide which of them actually installs it.
004C §10 names the sequence a hydrating root runs: manifest discovery/validation → payload install → hydrate. Steps one and two are preflight — everything that must be settled before any frame-state moves.
Step 1 — the manifest¶
Resolve the root's Root Manifest (discovered positionally per §Discovery, or supplied explicitly by a host that already has it) and validate it. The root's identity is then read from the manifest's content.
Asking for a hydrating root's manifest and finding none is not a degraded case to
paper over: hydrating mounts take root-id and identifier-prefix from the manifest
(004C §3), so
there is nothing left to hydrate as. It fails loud with
:rf.error/root-manifest-invalid, data {:missing :manifest}.
A host that supplies its own payload and no container runs no manifest step at all — preflight adds a step, it does not make manifests mandatory for the single-root boot path.
Step 2 — the install decision¶
Payload install is idempotent and order-independent (ratified, 004C §6): the first hydrating root referencing a payload installs it; later roots find it live and do not re-seed.
A payload id is a frame id. 004C §6 defines the manifest's :frame-payload-ids as
the render's full referenced set — plan ids ∪ provider-scoped frame ids — so the thing a
root "references a payload for" and the thing it hydrates into are one identifier, not
two.
Each installed payload is recorded under its payload id with its content digest and the root-id that installed it. Three outcomes, and only three:
| Ledger state | Verdict |
|---|---|
| no entry | install — this root is first; record the claim |
| entry, equal digest | no-op — the ratified idempotent case; do not re-seed |
| entry, differing digest | :rf.error/frame-payload-conflict, thrown |
The third arm is the S5 hydrate half of :rf.error/frame-payload-conflict that
004C §7
reserved — the same error id as the plan-:config-fingerprint arm, a distinct conflict
trigger, carrying its own content-:digest slot. It is what a page composed from
fragments rendered by two different server responses looks like from the inside. The
throw happens before any install: the live payload, the frame it seeded, and the
ledger record are all untouched — a bad frame payload affects exactly the roots
referencing it. There is no first-wins silent merge and no last-wins overwrite.
Why re-seeding is not harmless. :replace-frame-state is the locked merge policy
(§The :rf/hydrate event), so a second install is not additive
corruption — it is a silent reset. Whatever happened between the first root's boot
and the second root's is discarded without a diagnostic. That is the harm the ledger
exists to prevent, and the reference implementation's acceptance test measures exactly
it: a client mutation interleaved between two installs must survive the second.
The claim and the observation are one atomic step, so two roots racing an unclaimed id cannot both see "unclaimed" and both install; the loser observes the winner's record and takes the no-op or conflict path against it.
A no-op install skips verification too. With nothing newly installed there is no new
server slice to verify against, and the payload's :rf/render-hash covers the whole
server-rendered body — hashing a second root's own subtree against it would manufacture
a mismatch that says nothing about either root. Per-root structural agreement is the
manifest's :render-fingerprint fact, not this hash.
Release. A payload id's claim is released when its frame is destroyed (the same per-frame teardown that clears the request, response, pending-error, and head-snapshot side channels). A frame re-created under that id must not meet a phantom conflict raised by a lifetime that no longer exists.
Artefact (CLJS reference). re-frame.ssr.install — preflight! (steps 1 and 2 as
one call, returning the verdict), payload-install-decision! (the atomic claim),
payload-content-digest, installed-payload / release-payload!. re-frame.ssr.boot/hydrate!
runs preflight and performs the remaining hydrate step only on the install verdict;
it gains :container / :manifest / :root-id opts for the multi-root path. The
digest is the canonical-EDN structural hash (re-frame.ssr.hash/render-tree-hash), whose
JVM/CLJS byte-parity is already pinned — two roots reading the same script agree on
either host.
Failed-root isolation¶
A page is N roots, and one of them failing must not stop the others from hydrating and running. That is the contract, and it is the point of building a page out of independently hydratable roots at all: a page assembled from separately rendered regions stays up when one region is broken.
Several sections already scope individual failures to one root — the server-emit arms above fail the render "for that root only"; a bad frame payload "affects exactly the roots referencing it"; the client-tier duplicate-root registry leaves "the existing root untouched" (004C §7). This section states the contract those arms have been citing, and names the boundary that makes it hold for a whole page rather than one failure at a time.
The isolation unit is one root's whole boot¶
A root is booted inside its own boundary, spanning preflight → install → hydrate → the host's own mount. A throw at any point is contained, reported, and the page moves on to the next root.
The host's mount belongs inside the boundary, not outside it. A root that hydrated but could not mount is exactly as dead to the page as one that never hydrated, and a boundary covering only the framework's half would leave the host's half able to take the page down.
What a failed root leaves behind¶
Nothing that can harm a sibling. Precisely, by where it died:
| Died at | Leaves behind |
|---|---|
| preflight — no manifest, a manifest outside the schema family, a payload conflict | nothing. The throw precedes any claim, so the ledger is untouched. |
the seed — the payload was claimed but :rf/hydrate did not commit |
nothing. The claim is released. |
| after the seed committed — verification, or the host's mount | the installed payload, deliberately. |
The middle row is the one that bites, and it is not a throw case. Dispatching into an absent or destroyed frame is a no-op, not an exception: a root can claim a payload id and never seed it with nothing failing loudly. Left in place, that claim poisons the id for the page's lifetime — the next root referencing it reads no-op / already-installed, a legitimate verdict, and skips its own install. The page then runs a frame nobody ever hydrated, and there is no error anywhere to say so. So the claim is transactional over the seed: it is released unless the seed provably landed, which is checked against the frame's live incarnation rather than inferred from the absence of a throw. Release is guarded on the claim's own record, so a late release cannot evict a successor that legitimately re-claimed the id.
The last row is deliberate and is not a leak. The claim covers the install,
not the whole root boot. Once :rf/hydrate commits, the payload is installed
and siblings must keep finding it live; releasing it because the root later died
would invite a sibling to re-seed and silently reset everything that ran in
between — the precise harm the ledger exists to prevent
(§Step 2). A root may fail after a successful
install; its payload does not thereby become uninstalled.
A contained failure is never silent¶
Isolation must not mean silence. A page quietly serving N−1 roots with no signal
is the failure mode this contract exists to prevent, so each contained failure
emits :rf.error/root-boot-failed naming the root, its frame, and the :phase
it died in (:hydrate before the seed committed, :mount after). The throwable
also rides back to the caller as part of that root's outcome.
That record is always-on, not a dev diagnostic. A root can fail in
production, so its containment must be observable in production — the record
survives CLJS :advanced + goog.DEBUG=false and reaches an off-box shipper,
on the same reasoning as :rf.error/malformed-hydration-payload, the other
absorbed hydration-boot failure. Nothing in the boundary — the containment, the
claim release, or the report — sits behind the debug gate.
Isolation is not recovery¶
There is no retry, no supervision, no fallback render, and no attempt to make a failed root work. A failed root stays failed; the single guarantee is that it stays failed alone. Streaming SSR's inline fallback (§Failure semantics) is the server-side analogue at boundary granularity, and it is likewise containment rather than repair.
Artefact (CLJS reference). re-frame.ssr.boot/hydrate-page! — boots a
collection of per-root opt maps (each the map hydrate! takes, plus an optional
:mount-fn run inside the boundary) and returns per-root outcomes
{:root-id … :status :hydrated :payload …} / {:root-id … :status :failed
:error …} in input order, so a caller can correlate a root that died before its
id could be read from a manifest. re-frame.ssr.install/release-claim! is the
record-guarded release. Failure isolation is pinned by
re-frame.ssr.failed-root-isolation-cljs-test (both hosts; four independent
levers, each failed at every position on a three-root page, each with a
boundary-less counterpart measuring the damage) and
re-frame.ssr.failed-root-isolation-dom-cljs-test (a real multi-root document,
where a broken root is broken because its markup is).
Hydration-mismatch detection¶
Two tiers, keyed by render-tree representation — not by adapter brand. Detection has two mechanisms, chosen by the substrate's client-render representation — a hashable data render-tree or a React element:
- Hiccup tier (the render-tree hash channel, described below). Hosts that supply the same pure data render-tree on server and client — Reagent and Reagent-slim, whose views are functions returning a hashable hiccup tree — let the server hash its render-tree, the client re-hash its first render, and compare the two. This is the
:rf/render-hash/:render-tree-fn/verify-hydration!channel. The:ssr {:on-mismatch :hard-error}escalation (§Mismatch recovery and configuration) belongs to this tier. Native UIx views are not in this tier — their render-fn output is a React element, not a hiccup data tree (re-frame.views/apply-adapter-wrap-view), so there is no pure data render-tree to hash; see the native-root note below. - Compiled tier (React-native adoption). A compiled
re-frame.uiroot has no hashable client render-tree — its views compile to React elements, not the structural tree the server hashes — so there is no client hash to compare and the hash channel does not apply. A compiled root instead verifies by React-native adoption: its first:server-phase render (theui/client-onlyfallbacks) is what React hydrates against the server DOM, and React reports the adoption errors it automatically recovers from — a text-content mismatch, or a missing / extra / wrong-type element — through the root'sonRecoverableError(subject to the attribute-only boundary noted immediately after this list). The runtime surfaces that adoption-window signal (before the root's phase flip — see §Phase flip) as the same:rf.ssr/hydration-mismatchdiagnostic, tier-discriminated by:wherere-frame.ui/hydrate-rootand carrying:root-id/:errorrather than a hash. Recovery is React's own — it patches the divergent DOM (a native warn-and-replace); the compiled tier has no:hard-errorescalation, because React has already recovered by the timeonRecoverableErrorfires. The canonical compiled boot is thereforessr/hydrate!without:render-tree-fn, thenui/hydrate-root. - Native UIx roots — the adoption channel (React-element roots). A native UIx root is a React-element root: it has no data render-tree to hash (ruling out the Hiccup tier), and it is not a compiled
re-frame.uiroot, so it does not boot through the compiled tier'sui/hydrate-root. It VERIFIES by the same React-native adoption the compiled tier uses: its shared React-hook hydrate path (re-frame.substrate.spine/make-render) hydrates the client element against the server DOM and React reports the divergences it recovers from — a text-content mismatch, or a missing / extra / wrong-type element, not attribute-only mismatches (the attribute-only boundary the compiled and native tiers share, described immediately after this list) — through the root'sonRecoverableError.make-rendersurfaces that adoption-window divergence as the same:rf.ssr/hydration-mismatchdiagnostic (rf2-qfz65), tier-discriminated by:wherere-frame.substrate.spine/make-renderand carrying the recoverable:error(no:root-id— a native root has nore-frame.uiroot-id — and no hash). The framework reporter is installed on the hydrate path only, composed over any host-supplied:on-recoverable-error(framework emit first, then delegate — never clobber), and both the reporter installation and the emit are debug-gated + DCE'd, so non-hydrating native mounts and production builds pay zero cost. Recovery is React's own warn-and-replace — like the compiled tier there is no:hard-errorescalation, because React has already recovered by the timeonRecoverableErrorfires. The framework emit is bounded to the hydration adoption window (rf2-qfz65): React holdsonRecoverableErrorfor the root's whole lifetime and invokes it for post-hydration recoverable errors too, so emitting on every call would mislabel a later recovery as a hydration mismatch. A root-local#js {:adopting true}flag gates the emit — anadoption-window-closermounted into the hydrating tree clears it on the hydration commit, after which the reporter still delegates to the host / React-default handler but no longer emits the framework trace. This mirrors the compiled tier'sadoption-ref(there thePhaseFlipperclears it on the:servercommit); a native React-element root has no:server→:clientphase flip, so a dedicated closer shuts the window on the first (hydration) commit instead. Canonical entry: this shared React-hook render path is the only native mount route that installs the reporter, so a hydrating native root routes through the Spec 006 client mount entry (re-frame.substrate.adapter/renderwith{:hydrate? true}— the adapter:renderslot) to get framework mismatch detection; hydrating via the substrate-native renderer directly (uix.dom/hydrate-root, react-domhydrateRoot) bypasses the reporter and falls back to React's default (silent) handling.
What React-native adoption does not catch — attribute-only mismatches. The adoption tiers (compiled and native) surface only the divergences React itself treats as recoverable — a text-content mismatch, or a missing / extra / wrong-type element — because onRecoverableError is, per React's own contract, the "callback called when React automatically recovers from errors". An attribute-only mismatch — a stale class, style, or ARIA value on an element whose tag and text still match — is deliberately not in that set: React documents that "there are no guarantees that attribute differences will be patched up in case of mismatches" (validating every attribute would be prohibitively expensive), so an attribute divergence takes React's development-only warning path, leaves the server-supplied attribute in the DOM, and calls neither onRecoverableError nor any production equivalent. Consequently a compiled (or native) root can hydrate with a divergent attribute and emit no :rf.ssr/hydration-mismatch trace — the adoption signal is React-recoverable adoption errors, not exhaustive server-vs-client divergence detection. This is an intrinsic boundary of React-native adoption, not a re-frame2 gap: the hiccup tier's structural render-hash would catch it, but the compiled and native tiers deliberately carry no such hash (a compiled root has no hashable client render-tree), so attribute-level verification is out of scope for them by construction. Closing it would mean reviving the structural render-hash / manifest render-fingerprint channel these tiers were designed without — a deliberately-deferred future leaf, not a defect. A stale attribute is a real, deterministic bug either way; the framework simply does not promise to trace it on these tiers.
The rest of this section describes the hiccup-tier hash channel.
After the first client render, a comparison pass:
- Server emits the rendered string AND a structural marker (a hash of the render-tree, computed before stringification) on the root element. Placement: a
data-rf-render-hash="<hex-string>"attribute on the root view's outermost element. Encoding: lowercase hex of the raw hash bytes; no prefix. - Injection point — structural injection on the hiccup root. When
render-to-stringis called with:emit-hash? true, the attribute is injected structurally on the first DOM-tag element of the hiccup tree before stringification — not as a post-emit regex pass on the output string. The implementation threads aroot-attrsmap down through the hiccup walk: callable heads ((ifn? head)— fns and Var references, which is how views are referenced) and the resolved bodies they return all passroot-attrsthrough to the eventual DOM root; the first DOM-tag emission merges and consumes it. Existing user-supplieddata-rf-render-hashon the root wins (the merge is non-overwriting). Root indirections thread the injection through to the eventual DOM root: a:<>fragment root, alazy-seq/ list root, and a callable-headed component (including a view referenced by Var /(rf/view :id)) all passroot-attrsdown their root path onto the FIRST DOM-tag element (rf2-58zvy1 / rf2-a73idu — the marker lands exactly once on the resolved DOM root). This is deliberately asymmetric with the source-coord annotation, which no-ops on a:<>/ lazy-seq root (§Source-coord annotation — matching the CLJS-side wrapper): the whole-tree render-hash marker is more useful landed under a fragment / seq root than dropped.:>is not a threadable head — the JVM emitter fails loud on it (:rf.error/ssr-reagent-native-head), so no DOM root exists to stamp. The two opts:doctype?and:emit-hash?compose::doctype?prepends<!DOCTYPE html>to the already-injected body string. Non-DOM-rooted trees silently no-op on the injection — the structural mechanism cannot over-inject onto a doctype or text node the way a string-level regex would. The on-wire shape (data-rf-render-hash="<hex>"on the outermost rendered DOM element) is the contract; other-language ports MUST mirror the structural-walk semantics against their substrate's hiccup-equivalent tree.1
Hash algorithm (CLJS reference): the FNV-1a 32-bit hash over a canonical EDN serialisation of the render-tree (depth-first traversal; attribute maps in sorted-key order; nil pruned). FNV-1a is chosen because it is fast, has no platform dependencies (no crypto/SubtleCrypto), and produces an 8-character hex string that fits comfortably in a <meta> / data- attribute. Cryptographic strength is not needed — the hash is a tamper-evident structural marker, not a security primitive.
Other-language ports may pick a different hash as long as the canonical-EDN traversal is the same; the hash value crosses the wire only between one server and one client of the same implementation, so the algorithm choice is a per-host commitment rather than a pattern-level one. The traversal IS pattern-level: render-tree shape, sorted attribute keys, nil pruning. The ssr-render-tree-hash.edn conformance fixture pins the canonical-traversal output for a small render-tree corpus so a port can verify its serialiser: it pins the reference FNV-1a hash value for representative trees (the concrete target a port mirroring the reference hash aims at) plus same-hash law pairs — attribute-key-order independence and nil pruning — that a port hashing under a different algorithm must still uphold.
- On mismatch, the runtime emits a trace event:
{:id (gensym)
:operation :rf.ssr/hydration-mismatch
:op-type :error
:tags {:server-hash "abc123"
:client-hash "def456"
:frame :app/main ;; the app's carried frame-id (placeholder; the runtime stamps the active frame)
:failing-id :rf/hydrate ;; the only value the bundled v1 runtime emits (see §Mismatch detection — head)
:first-diff-path [...]} ;; optional, host-supplied: path into the render tree where divergence first occurs (see §The `:first-diff-path` tag below)
:start (...)
:end (...)}
The :failing-id tag is a generic host-supplied attribution seam, not a runtime-toggled enum. The mismatch-detection entry point (verify-hydration!) accepts a host-supplied :failing-id override and, when none is supplied, defaults it to :rf/hydrate. The bundled v1 runtime never supplies an override for the body channel, so under one :operation the body-mismatch path emits exactly one shape: :failing-id :rf/hydrate. A host that runs its own head diffing over the separate :rf/head-hash channel (§Mismatch detection — head) MAY attribute a mismatch with any value of its choosing — e.g. :rf.ssr/head-mismatch — and that value flows through to the trace. Consumers branch on :failing-id rather than maintaining a parallel category keyword per case, and the seam keeps that branch open for hosts (and for runtime-side head-mismatch attribution, a post-v1 follow-on) without a runtime change.
The :first-diff-path tag¶
The :first-diff-path tag is the same shape of host-supplied seam as :failing-id above — a value the host produces and the runtime carries verbatim, not a value the bundled runtime computes.
Producer — the host, not the runtime. verify-hydration! accepts a host-supplied :first-diff-path opt and, when supplied, assoces it verbatim onto the emitted trace's :tags; the bundled v1 runtime never supplies one. This is deliberate: the runtime compares two render-tree hashes, and a hash proves that the trees diverged — it does not locate which node diverged. Locating the divergent node is a full tree-diff, which the hash channel does not do and v1 does not ship. A host (or a tool) that runs its own structural tree-diff between the server and client render-trees MAY compute the divergence path and pass it through the seam; it then rides the trace for consumers. (Same producer contract as the docs/ssr/concepts.md debugging surface, which names the empty-slot default.)
Absence — the default. Unlike :failing-id (which defaults to :rf/hydrate), :first-diff-path has no default: when the host supplies none, the key is simply absent from the trace :tags (the assoc is guarded on the opt being present). An absent :first-diff-path therefore means "no host diff ran," never "divergence at the root" — consumers MUST treat the key as optional and branch on its presence, not read an absent value as a path.
Segment vocabulary. A :first-diff-path is a vector of segments naming the descent from the render-tree root to the first divergent node, in the same spirit as a get-in path over the hiccup tree. Each segment is one of three kinds:
| Segment kind | Shape | Meaning |
|---|---|---|
| index | integer (0, 1, …) |
the ordinal position of a child within its parent's child sequence — descend into the Nth child. |
| tag | element-head keyword (:head, :title, :div) |
the hiccup head (a DOM tag, or the fragment head :<>) of the node being descended into — a by-tag step rather than a by-ordinal step. |
| key | map/attribute keyword (:children, :class) |
a key into a node's attribute or structured-children map — descend into the value at that key. |
A path mixes the three freely as the descent alternates between ordered child sequences (index), tag-addressed nodes (tag), and structured sub-maps (key) — e.g. [:head :title] (two tag steps) or [:body 0 :children 0] (tag → index → key → index). The runtime does not validate the segment shape — the seam is host-owned; the host's diff and the host's consumer agree on the path convention, and the runtime only carries it. Other-language ports mirror the seam (host-supplied, absent-by-default, carried verbatim), not any one path spelling.
Recovery is implementation-policy: the CLJS reference defaults to warn-and-replace — log the trace event, then re-render client-side, replacing the server's HTML. Strict mode (the frame's :ssr {:on-mismatch :hard-error} config — see §Mismatch recovery and configuration) escalates to a hard error for dev/CI builds that want to fail fast on misalignment.
Hydration on the Freehand paved path¶
The Freehand substrate realises the hydrating mount above under Freehand
names, through its one public door (re-frame.freehand, conventionally
aliased v). Nothing in the contract changes; this section adds the
paved-path spelling and the two facts an interpreted hydrating root
settles first.
hydrate-root adopts the markup already in the container instead of
replacing it — the page the reader has been looking at since first paint
becomes the live page, with no flash of a re-rendered tree. Identity comes
from the server, so client-side identity opts (:root-id,
:disambiguator, :identifier-prefix) are refused with
:rf.error/root-manifest-invalid naming the conflicting key: a client that
renders under its own identifierPrefix breaks use-id hydration
outright, and a "helpful" override is the one thing that must not be
allowed to succeed. :frame and the host error callbacks are accepted,
exactly as at v/mount.
Identity is READ, not derived. "Comes from the server" is a wire fact,
not a convention the two ends independently honour: the hydrating root
takes its :root-id and its identifierPrefix from the content of the
Root Manifest the server emits as the container's
immediately following element sibling, and the Root Descriptor it registers
records :root-id-provenance :manifest
(004C §1).
Deriving them client-side would put the same value on screen only while
the two derivations agreed, and would fail silently the first time the
server rendered under an id or a prefix the client had no way to compute —
which is precisely the case a manifest exists to carry. :view-id remains
the client's own fact: it records which declared view this site mounted.
Discovery is the same seam the compiled tier uses and is reached the same
way — the :ssr/discover-root-manifest late-bind hook, freehand → core
late-bind ← ssr (see §Discovery). A direct require is
forbidden by the Independence rule and would drag server code into every
client bundle. Two absences, two diagnostics, neither of them new:
- the SSR artefact is not on the classpath —
:rf.error/ssr-artefact-missing, naming theday8/re-frame2-ssrcoordinate and the namespace to require; - the artefact is present and discovery finds nothing adjacent to a
container that does carry server markup —
:rf.error/root-manifest-invalidwith{:missing :manifest}. Markup without a manifest is a broken server render, not a client-only load: there is no identity to hydrate as, so it fails loud rather than guessing one.
Verification is React's own adoption, and it is bounded exactly as the
compiled and native tiers' is (see §Hydration-mismatch
detection). React diffs the client's first
render against the server DOM and reports the divergences it RECOVERS from
— a text mismatch, or a missing, extra or wrong-type element — through the
root's onRecoverableError; the framework surfaces that as the same
:rf.ssr/hydration-mismatch diagnostic, tier-discriminated by :where
and carrying :root-id. The reporter is composed over any
host-supplied :on-recoverable-error — framework emit first, then
delegate, never clobber — and is bounded to the adoption window: React
holds that callback for the root's whole lifetime, so an unbounded emit
would mislabel a later recovery as a hydration mismatch. A component
mounted into the hydrating tree closes the window on the hydration commit;
after that the reporter still delegates but no longer emits.
An attribute-only divergence is outside that signal, by React's own contract rather than by omission here: React documents that it makes no guarantee to patch attribute mismatches, so it takes a development-only warning path and calls no production callback. The adoption signal is React-recoverable adoption errors, not exhaustive server-vs-client divergence detection.
The mismatch is reported, and React's recovery is a replacement, not a patch: the divergent DOM is discarded and re-rendered from the client's truth. So the page ends up correct and the disagreement ends up on the diagnostic bus — never a server value silently left on screen under a client tree that disagrees with it.
Conformance: FH-ROOT-006.
The fallback — a container with nothing to adopt¶
An empty container is NOT by itself a fallback. Manifest presence is
what proves a DOM is a server render, and a non-empty container is not: a
declared view may render nothing — a nil-rooted v/defview — so the server
can emit an empty container that still carries its
Root Manifest beside it. That is an empty server
render, and hydrate-root ADOPTS it under the manifest's identity,
:root-id-provenance :manifest, exactly as a non-empty adoption. Reading
the manifest is how the returned root reports hydrated? true and tells
that empty server render apart from a client-only first load.
The fallback is manifest ABSENCE, not container emptiness. A page the
server never rendered for this root — the client-only first load, or a
region composed in on the client — emits neither markup nor a manifest. So
an empty container with nothing adjacent falls back to an ordinary client
mount under the derived identity an ordinary v/mount would give it,
and the returned root says hydrated? false. It asks for no manifest, and
so needs no SSR artefact on the classpath: discovery is consulted only when
there is something that could be a server render, which is why a
client-only app — one that never loads the SSR artefact — still falls back
cleanly rather than tripping :rf.error/ssr-artefact-missing.
The fallback is what stops a plain misconfiguration from turning into a whole-root failure. Hydrating a non-empty client tree against an empty container with no manifest is not a near miss React can reconcile — it is a total divergence, and React answers it by discarding and re-rendering everything. The fallback reaches that same end state deliberately, on the one input recognisable before React is involved, and reports it as the mount kind rather than as N adoption errors.
A container carrying markup that merely disagrees is a real hydration with
a real mismatch, and it takes the mismatch path above — falling back there
would discard the adoption the server render was paid for on the strength
of one wrong character. And a container that does carry server markup
with no manifest adjacent is neither a fallback nor a silent guess: it
is a broken server render, and fails loud with
:rf.error/root-manifest-invalid {:missing :manifest}.
Conformance: FH-ROOT-007.
The server render on the Freehand paved path¶
The hydrating mount above adopts a server render. v/render-static is the
Freehand verb that produces one — the pure :server-phase static-HTML
render, the JVM/server counterpart of React's renderToStaticMarkup and the
door's counterpart of the compiled tier's ui/render-static.
;; a .clj / .cljc-on-JVM server-render namespace
(v/render-static [app {}])
;; => an inert HTML string
It is the static-page path, not the SSR-then-hydrate path: it emits no
Root Manifest, no hydration payload, and no phase flip (§Phase flip). A page
rendered this way is served as final HTML; there is nothing for a client to
adopt as a live root, so a v/client-only site under it renders its
capability-free fallback and stops there, exactly as ui/render-static does.
It is a MACRO, JVM/server only. Like v/mount it enforces the LITERAL root
form at the call site — a runtime-assembled vector is the same
:rf.ui.compile/runtime-root-form compile error the mount verbs raise — and the
root-id derives from the ONE mounted view
(004C §1),
registered into the build-tier duplicate-root-id index exactly as a mount site
is. A CLJS expansion is the ruled :rf.ui.compile/ui-render-static-jvm-only
compile error: the client emitter targets React directly, and there are no
structural trees in the browser.
No silent elision (004C §3, EP-0034 §2). A pure
:server static render cannot honour a live-runtime capability, so a
subscription, a committed handler, an effect, a foreign or lazy head — anywhere
in the root's server-reachable view closure — fails LOUD, never as a
capability quietly dropped from the static output. Each tier proves that with
what it actually has. A deterministic use-id is exempt throughout, and
v/client-only stays legal because only its fallback is server-reachable.
A {:compiled true} declaration is proved at build time. Its analysis
projects the server-reachable {:caps :deps} closure onto the view's
manifest and into the build's view-static index — one projection published
twice, so the two cannot disagree — and the macro walks that closure
transitively and cycle-safe, resolving each dependency from the index first and
from its declared manifest second. The manifest is what keeps a view compiled in
ANOTHER build answerable here: an AOT artefact contributes nothing to this
build's index, but its declaration runs when its namespace loads. A breach is the
:rf.ui.compile/static-root-requires-runtime build error with source
coordinates either way. A dependency answerable from NEITHER route is
:rf.ui.compile/static-root-unproven-dependency, and the diagnostic says which
route failed — a compiled manifest predating the facts wants a recompile, an id
naming no reachable declaration wants a require — because sending an author to
recompile a view that was never stale is worse than saying nothing.
The paved path is interpreted, and it is proved at render. An interpreted
declaration has no finite grammar, no analysis step and no manifest —
v/manifest reports nil for one rather than inventing a roster — so there are
no build-time facts about it and there never can be. It is ADMITTED at
expansion, which is what makes (v/render-static [app {}]) above the ordinary
spelling rather than a compiled-only island. The law does not weaken; it is
proved where an interpreted body's capabilities actually become visible. A
v/sub fails on its own account with :rf.error/view-read-outside-render:
render-static opens no declared render, so the read has no owner and is
refused before it probes anything. A committed handler reaches the structural
tree, which records it faithfully under :events, and the fold refuses to drop
it — :rf.error/static-render-requires-runtime, naming the handler slot, the
element and the nearest enclosing view boundary. That the SSR serialiser drops
:events is CORRECT on the hydrating path, where the payload reinstalls them;
only a render whose output nothing will ever adopt can call the same drop a
defect, which is why the gate lives on the render-static seam and not in the
serialiser.
The re-frame.freehand → re-frame.ssr wall stays intact. re-frame.freehand
takes NO static require on re-frame.ssr (the Independence rule — a direct
freehand → ssr require would drag server code into every client bundle). The
macro emits a call into the door-side seam
re-frame.freehand.tree/emit-static-html, which late-resolves
re-frame.ssr/emit-ui-tree at render time (the same
requiring-resolve-behind-a-hook shape v/hydrate-root uses for manifest
discovery). So a documented render-static call in a namespace that requires only
re-frame.freehand COMPILES and RENDERS instead of failing with a raw
ClassNotFoundException, and the SSR serialiser folds the version-1 structural
tree to HTML through the version-gated re-frame.ssr/emit-ui-tree consumption
boundary (004B §The SSR consumption boundary).
When day8/re-frame2-ssr is absent from the server classpath the seam raises the
ruled, typed :rf.error/ssr-artefact-missing naming the day8/re-frame2-ssr
coordinate and the re-frame.ssr namespace to require — never a raw host
exception, never a silent fallback.
A view that throws mid-render is the host's to project. v/render-static
produces a value; it is not a request pipeline. It holds no frame and writes no
response accumulator, so a view-time exception propagates out of the call
unchanged — never swallowed, never wrapped, never degraded to partial HTML. The
server code that called it is the render-time seam
(§View-time exceptions): it catches the throwable and
routes it through re-frame.ssr/project-render-exception!, which synthesises
:rf.error/ssr-render-failed and drives the active projector for the request's
server frame. What crosses to the wire is then the ordinary public projection —
the four locked :rf/public-error keys, falling through to the generic 500.
The view's props, the exception's message and ex-data, and the host stack stay
on the internal trace surface
(§Internal trace events are not leaked);
detail reaches the public shape only under the explicit per-frame
:ssr {:dev-error-detail? true} opt-in.
The outward React bridge has no server arm¶
v/->react (004 §The outward React bridge)
exports a declared view as a React component value for a foreign React tree to
render. It is a browser verb and is absent on the JVM, so it can appear in
neither arm of the server story above: a v/render-static call folds the
version-1 structural tree, which has no React in it, and a hydrating root adopts
markup that render produced.
That is the bridge's whole server policy, and it is a stated absence rather
than an inference about any particular foreign library. Two things follow, and
both are contract:
- No server-renderer context path exists, so none is maintained. A
frame reaching an exported subtree is scoped through the ordinary shared
React frame context and read the ordinary way. The bridge reads no
server-renderer internal to make a Provider visible under
react-dom/server, because there is no Freehand render path on which it
would ever be asked to.
- A use site that must appear in server output declares its own fallback.
The bridge does not infer server capability from the foreign library it is
handed to, and it never renders a stand-in of its own choosing.
Phase flip¶
The phase flip is the stage that completes a ui/client-only site. The macro
(004D §Interop and boundaries) ships in S3: it
compiles a site with a mandatory, capability-free :fallback that the JVM/SSR path and
the first hydration render both produce, and a client subtree the browser is meant to
run instead. What S3 does not settle is how the fallback becomes the client subtree
after a server-rendered root hydrates. The phase flip is that mechanism, and it is the
S5 half of ui/client-only. Every promise that a client-only site "swaps to its client
subtree in one root-wide update" is discharged here.
The phase model — :server and :client¶
A root renders in exactly one of two phases, :server or :client. This is
deliberately the same vocabulary as the Root Manifest :phase key
(§Root Manifest v1): the manifest reserved :phase so a second
value could be added without a schema bump, and this contract cashes that reservation in
rather than minting a third word. The manifest still carries only :server on the wire —
the server always renders in :server phase — while :client is the runtime
counterpart a hydrating root advances to.
A compiled ui/client-only site is phase-conditional: it reads a root-scoped phase
value and renders its fallback in :server phase, its client subtree in :client phase.
The phase value defaults to :client when no hydrating root supplies one, which is
what makes every non-SSR path unchanged. The non-hydrating mounts — ui/mount,
ui/render!, ui.test/render — are born in :client phase and never flip: a site
under them renders its client subtree on the first and only render, exactly as it did in
S3. Their behaviour is therefore byte-identical to the pre-phase-flip runtime, and the
existing S3 tests are undisturbed (there is no fallback pass to flip away from — see
004C §8).
ui/render-static is the other end of the same rule: it is pure :server output with no
manifest and no flip (004C §8; it emits no manifest/payload),
so its client-only sites render the fallback and stop there.
One root-scoped write, one update¶
The phase is one root-scoped value, written exactly once. When a hydrating root flips,
that single write moves the root from :server to :client, and every ui/client-only
site in the root swaps its fallback for its client subtree in the single update that write
produces. There is no per-site flip state.
Per-site flip state is non-conforming. A design that flipped each site independently would swap them across N separate updates, so a page with several client-only regions would tear — some regions live while their neighbours still show fallbacks — and would pay N re-renders for what is one logical transition. "One root phase-flip that swaps all sites in a single update" is precisely what the authoring surfaces promise, and a per-site implementation would break that promise while appearing to satisfy it.
Timing — after the mismatch check, as the root's next ordinary update¶
A hydrating root boots in :server phase. Its first compiled render — the hydration
render — therefore renders fallbacks, and this is load-bearing on two counts. It is what
makes React adoption clean: hydration runs against markup structurally identical to what
the server produced. And it is what makes the compiled tier's hydration
verification honest: that verification is React's
adoption of this :server-phase fallback tree — React diffs it against the server DOM and
reports the divergences it recovers from (text / structural, not attribute-only — see the
two-tier split's attribute-only boundary) through the root's onRecoverableError. The
mismatch check runs over the :server-phase tree, and the flip MUST NOT run
before it. This holds by construction: the flip is a post-commit passive effect
(scheduled as the root's next ordinary update, below), so it runs strictly after React has
committed — and thus adopted-and-verified — the :server-phase render. A flip that beat
that check would swap in a :client-phase tree the server never rendered and manufacture a
spurious mismatch.
After the root's hydration commit, the runtime schedules the flip as the root's next
ordinary update — the write that moves the root to :client phase. This is React's own
documented two-pass pattern for content that legitimately differs between server and client:
hydrate against the server's tree, then update. A painted fallback frame before the swap
is permitted and is by design. The fallback is not a spinner to be hidden as fast as
possible — it is mandatory, presentable UI the user has been looking at since first paint —
so there is nothing to race. The runtime therefore does no synchronous pre-paint flush:
a flushSync in the hydration-commit path buys nothing the user can see and is hostile to
streaming and readiness-driven hydration, which want the commit to return promptly.
A failed root never flips¶
The flip is a stage of a successful root boot. A root that failed to boot
(§Failed-root isolation, the rf2-1b0po contract) never
flips: its server-rendered fallback markup stays in place, inert, as part of what the
failed root leaves behind. This is not a gap — the capability-free fallback is exactly the
part of the page designed to stand without a runtime, so a dead root that keeps showing it
degrades gracefully rather than blanking. No additional error is emitted for the
un-flipped root: :rf.error/root-boot-failed already fired for the boot failure, naming the
root, its frame, and the :phase it died in, and isolation is containment, not recovery.
A root that booted but took the mismatch warn-and-replace path DID boot — the warn is
recovery after a successful hydration, not a boot failure — so it flips normally. Its
replacement client render is an ordinary post-commit client render at :client phase; the
flip needs no special casing for it.
Per-root and independent¶
The flip is per-root and independent: each root flips when its own hydration commits. There is no page-wide barrier — no root waits for its siblings before it may flip, and one slow or failed root never delays another's swap. A barrier would recouple the roots that §Failed-root isolation deliberately decoupled: a single dead root would deadlock the whole page's completion, which is precisely the page-wide failure that section exists to prevent. Independence follows directly — the flip is a per-root event keyed on a per-root commit.
Observability — one :rf.ssr/phase-flip info trace¶
The flip emits one diagnostic trace per hydrating root, at the flip commit:
{:id (gensym)
:operation :rf.ssr/phase-flip
:op-type :info
:tags {:root-id :page/shop} ;; the root that flipped
:start (...)
:end (...)}
It rides the diagnostic channel and is DCE'd under CLJS :advanced + goog.DEBUG=false
like every other non-error :rf.ssr/* row (see 009 §Error event
catalogue). It answers the first question
debugging SSR'd client-only content raises — is this region showing its fallback or its
live subtree? — and it corroborates the S5 phase-flip hydration fixture
(API.md) without a production-cost commitment.
It is not an event and mints no epoch. The Spec 009 epoch unit is a dequeued event; the
flip is render-layer bookkeeping on one root, not application logic. Routing it through
dispatch to earn an epoch would pollute the app's event streams and epoch history with a
framework lifecycle signal, so the flip stays a plain trace on the diagnostic bus. Its
catalogue row (an :op-type :info diagnostic, no recovery) lands in this same PR per the
one-catalogue co-edit invariant.
The fallback rides the client bundle¶
Accepted consequence, stated plainly. Before this contract, the client emitter
(emit_cljs.cljc) dropped a client-only site's fallback template entirely — the browser
compiled straight to the client subtree. Under the phase-flip contract the compiled site is
phase-conditional, so its fallback template now rides the client bundle: React must
be able to materialise the fallback vdom during the :server-phase hydration render. This is
unavoidable — the client cannot hydrate a fallback it does not carry — and it cannot be
compile-time-gated per root, because a site cannot see through defview boundaries to know
which roots will host it hydrating versus mounting.
The cost is cheap by construction: a client-only fallback is mandatory capability-free
static markup — no reactive read, no host state, no handler — in the same emit-standalone
shape a react/lazy fallback already ships. There is deliberately no new elision or size
gate for it. The existing perf-bundle budget gate (npm run test:perf-bundle) already
measures total bundle size and catches any real regression; a dedicated gate would be
machinery for a cost the general budget already bounds. The consequence is documented here in
prose, and nothing more.
The phase flip on the Freehand paved path¶
Freehand carries the same phase, the same single write, and the same ordering. What differs is where the boundary comes from, and that difference is worth stating rather than leaving a reader to assume parity that does not hold.
v/client-only is an interpreted form (004 §Client-only
subtrees). The compiled grammar refuses it, so
there is no compiled site to lower and no compile-time fallback template to carry:
the fallback rides the client bundle as ordinary markup in an ordinary body, which
is what the whole interpreted mode already is. The paragraph above — the fallback's
compile-time cost, and the deliberate absence of a gate for it — is therefore a
statement about the compiled tier alone.
Everything else is the contract above, unchanged and unweakened:
- The default is
:client.v/mountinstalls no phase provider, so every site under it reads the default and renders its client subtree on the first and only render.v/render-staticinstalls none either and stays pure:server— no manifest, no payload, no flip (§The server render on the Freehand paved path). The structural render on either host is likewise:serverphase, which is what lets one.cljcstructural test assert the fallback on both hosts. - One root-scoped write.
v/hydrate-root's ADOPTION path — and nothing else — installs the provider above the whole root element, boots it:server, and writes it once. Everyv/client-onlysite in the root swaps in the single update that write produces; there is no per-site state, so several regions cannot tear against one another. - After the adoption commit, by construction. The write is a post-commit
passive effect, so it cannot run until React has committed — and so adopted and
verified — the
:server-phase fallback render. A hydrating root's fallbacks are what React diffs against the server DOM, and the divergences it recovers from are reported as:rf.ssr/hydration-mismatchbefore any flip (§Hydration-mismatch detection). - One commit, two jobs. The same
:servercommit that schedules the flip CLOSES the root's adoption window, so a later recoverable error is no longer reported as a hydration mismatch. A hydrating Freehand root therefore carries one component for both, rather than a flipper and a separate window closer that would have to agree about when the hydration commit landed. - One
:rf.ssr/phase-flipinfo trace per hydrating root, at the flip commit, carrying the:root-id— the same diagnostic, on the same channel, under the same debug gate. A failed root never commits the flipper, so it never flips and emits nothing extra; its server fallback markup stays on the page, inert, which is precisely what capability-free markup is for.
Server-only reg-cofx for request context¶
A standard cofx for accessing the current request:
(rf/reg-cofx :rf.server/request
{:doc "The active HTTP request. Server only."
:platforms #{:server}}
(fn [] *current-request*)) ;; value-returning supplier (EP-0017)
Setup events take delivery by declaring {:rf.cofx/requires [:rf.server/request]} on their registration metadata; the request map then arrives flat under :rf.server/request in the handler's coeffects so it can read the URL, headers, session, etc. The :platforms metadata mirrors reg-fx.
:rf.server/request is an ambient coeffect (the default grade): its supplier reads the per-frame request slot at context assembly, the value is never recorded, and replay re-runs the supplier. Per EP-0017 §1 the ambient grade is legal only where no durable write depends on the value — so :rf.server/request is for non-durable request reads (branching on :request-method, reading a header for a decision that does not fold into durable app-db / runtime-db). A handler that folds a request-derived fact into durable state through this ambient read is the SSR analogue of the ambient-localStorage replay hole: replay re-runs the live supplier instead of re-presenting the value the recorded run actually folded (and reads nil after the per-request frame's slot is cleared). Durable request-derived facts use the boundary pattern below.
Durable request-derived facts¶
When a setup handler writes durable state from the request — auth user / session state read from a cookie folded into the hydration payload, an accept-language folded into a locale slice — the value MUST arrive as a recordable fact so the causal token carries it and replay re-presents it verbatim (002-Frames §The recordable-coeffect rule). The host adapter sanitizes the request at the boundary and supplies the derived projection (never the whole request map) by one of two slice-A-legal shapes:
- Event payload. The host dispatches the setup event WITH the derived fact:
[:auth/server-init {:user (extract-user request)}]. The fact rides:event, recorded as part of the dispatch. - Provided recordable
:rf.cofxleaf. The app registers an owner-qualified{:recordable? true :provided? true}coeffect (e.g.:auth.session/user) and the host adapter STAMPS the sanitized value onto the boot dispatch token ({:rf.cofx {:auth.session/user …}}). The handler declares:rf.cofx/requires [:auth.session/user]; a record missing it fails loudly with:rf.error/missing-required-cofxrather than silently re-reading the host.
The whole request map MUST NOT ride the causal token — it carries Cookie / Authorization / raw bodies (secrets / PII) and is a host handle (recording a secret makes it durable, not safe). Stamp only the sanitized derived projection.
Request storage substrate¶
The :rf.server/request cofx surfaces host-controlled wire-shape input (Ring request map, Pedestal context, raw-HTTP request, edge-runtime request, etc.) into the handler's :coeffects. The storage substrate for the active request map is normative — getting this wrong has direct privacy consequences.
- MUST NOT ride
app-db. The active request map MUST NOT be stored under any key inapp-db— neither the request frame's nor any other frame's.app-dbis the hydration payload's source (§Payload scope,:rf/app-db): every value in it is a candidate to ship to the client on bootstrap. Request maps routinely carryHost,Cookie,Authorization,X-Forwarded-For, raw bodies, and other secrets-or-PII whose leakage to the client is a security incident. The hydration boundary must remain shippable-without-redaction; storing the request inapp-dbviolates that invariant. - MUST use a framework-private side-channel keyed on frame-id. The substrate is per-frame so two simultaneous per-request frames (the canonical SSR shape under concurrent load) carry independent request slots that cannot bleed into each other. The slot is framework-private — not a public app-db key, not a registered cofx-able value — read exclusively by the runtime's
:rf.server/requestcofx and written exclusively by the host adapter. The CLJS reference uses adefonceatom keyed by frame-id (mirroringpending-error-traces); a JVM-only port may equivalently use aConcurrentHashMap; any other-language port chooses its own concurrent-map shape. The contract is what's pinned — not the data structure: per-frame isolation, framework-private access, and exclusion fromapp-db. - Host adapter MUST populate and clear the slot. The host adapter (the bundled Ring adapter; future Pedestal / raw-HTTP / edge-runtime adapters) MUST set the slot before kicking off the drain and MUST clear it after the response is materialised. The CLJS reference exposes
re-frame.ssr/set-request!/clear-request!as the host-adapter surface; ports name their equivalents per host conventions. Failure to clear after the response is built constitutes a memory leak across request lifetimes; failure to set before the drain causes a handler declaring{:rf.cofx/requires [:rf.server/request]}to resolvenil. - Per-frame isolation under load. Two concurrent per-request frames MUST observe their own request maps and only their own. The substrate MUST NOT use a single dynamic
Var/ thread-local / module-level binding that could bleed across frames sharing a thread (e.g., async drain steps, ForkJoin work-stealing). Frame-id keying is the canonical implementation; any equivalent isolation primitive that preserves per-frame separation under concurrent drains satisfies the contract.
The :platforms #{:server} gate on the cofx itself is orthogonal to substrate choice — it ensures client-side dispatches no-op via :rf.cofx/skipped-on-platform per the standard cofx-gating contract. Substrate isolation is the privacy guarantee; the platform gate is the dispatch guarantee.
HTTP response contract¶
SSR is not just HTML + state — it is a full HTTP response. The runtime owns a per-request response accumulator keyed on the request frame's frame-id; the canonical shape is registered as :rf/response in Spec-Schemas. Standard server-only fx populate the slot during the drain; the host adapter consumes the resolved value (via re-frame.ssr/get-response) to build the wire response.
The accumulator's default shape:
{:status 200 ;; default if no fx sets one
:headers {"content-type" "text/html; charset=utf-8"} ;; default content-type for HTML
:cookies []
:redirect nil}
Response storage substrate¶
The response accumulator's storage substrate is normative for the same reasons as the request slot's (§Request storage substrate) — getting it wrong has direct privacy and performance consequences.
- MUST NOT ride
app-db. The response accumulator MUST NOT be stored under any key inapp-db— neither the request frame's nor any other frame's.app-dbis the hydration payload's source (§Payload scope,:rf/app-db): every value in it is a candidate to ship to the client on bootstrap. The response accumulator routinely carries server-only data —Set-Cookieheaders (auth tokens, session ids), internalX-*headers, redirect URLs that may encode internal hostnames — whose leakage to the client is a security incident. The hydration boundary must remain shippable-without-redaction; storing the accumulator inapp-dbwould default-leak that surface onto the wire and force every host adapter to remember a defensive(dissoc :rf/response)before serialising the payload. A privacy boundary that's a constant caller-vigilance burden is a leak waiting to happen; side-channel storage makes the boundary self-enforcing. - MUST use a framework-private side-channel keyed on frame-id. The substrate is per-frame so two simultaneous per-request frames (the canonical SSR shape under concurrent load) carry independent accumulators that cannot bleed into each other. The slot is framework-private — not a public app-db key, not a registered subscription — read exclusively by the runtime (via
re-frame.ssr/get-response) and written exclusively by the seven:rf.server/*fxs and the projector's status-stamp. The CLJS reference uses adefonceatom keyed by frame-id (mirroringrequest-slotsandpending-error-traces); a JVM-only port may equivalently use aConcurrentHashMap; any other-language port chooses its own concurrent-map shape. The contract is what's pinned — not the data structure: per-frame isolation, framework-private access, and exclusion fromapp-db. - Per-fx writes MUST be O(small-map). A naïve implementation that stored the accumulator in
app-dbpaid a full app-db replacement on every:rf.server/*fx (read-container → assoc → replace-container!); for a 7-fx response shape (typical login flow:set-status+ 2×set-cookie+ 3×set-header+redirect), that's seven full-app-db replacements per request. The side-channel substrate's swap is O(small-map): one atom CAS against a{frame-id → response-map}table. The contract is the algorithmic class — per-fx response writes scale with the response shape, not with app-db size. - Runtime MUST clear the slot on frame teardown. Per §Per-request frame teardown contract the slot MUST be released when the request frame is destroyed. The CLJS reference clears via the
:ssr/on-frame-destroyedlate-bind hook — the same hook that drops the request slot and the pending-error-trace buffer.
Standard fx¶
All seven are :platforms #{:server} — registered, guarding their own arguments (per §The reserved fx guard their own args below), and silently no-op'd by :rf.fx/skipped-on-platform if dispatched client-side.
| Fx | Args | Notes |
|---|---|---|
:rf.server/set-status |
<int> (e.g., 404) |
Set the response status code. Last-write-wins (per §Multiple-status policy below). |
:rf.server/set-header |
{:name "X-Foo" :value "bar"} |
Replaces an existing header (case-insensitive name match). Wire format is host-adapter business. |
:rf.server/append-header |
{:name "X-Foo" :value "bar"} |
Appends another instance — required for Set-Cookie-style multi-valued headers. Deduplication is host-adapter policy. |
:rf.server/set-cookie |
a :rf.server/cookie map (:name, :value, :max-age, :secure, :http-only, :same-site, :path, :domain, :expires) |
Adds a structured cookie to :cookies; the host adapter serialises to wire form (avoids cookie-attribute quoting bugs). |
:rf.server/delete-cookie |
{:name "session" :path "/"} |
Adds a delete-cookie marker (set-cookie with :max-age 0); semantics are host-adapter business. |
:rf.server/redirect |
{:status 302 :location "/login"} (:status defaults to 302) |
Sets :redirect on the accumulator and short-circuits HTML rendering (per §Redirect precedence below). The redirect target is keyed under :location — the fx writes an HTTP Location response header, so it uses header vocabulary (one name per fact, EP-0007; routing/navigation surfaces may use :url / :to). The :url / :to spellings are rejected with :rf.error/redirect-retired-target-key naming :location — no back-compat alias. Caller-trusted :location — accepts arbitrary URL strings without allowlist or relative-only gating. For caller-untrusted strings (e.g. a ?next= query param), use :rf.server/safe-redirect (below). |
:rf.server/safe-redirect |
{:location "/dashboard" :relative-only? true} or {:location "https://app.example.com/..." :allow ["app.example.com" "alt.example.com"]} |
Validates :location before populating :redirect. Validation order (per 009 §Error event catalogue): (1) URL must parse — :rf.error/safe-redirect-invalid-url on failure; (2) reject javascript: / data: / vbscript: schemes — :rf.error/safe-redirect-scheme-rejected; (3) :relative-only? true and the URL has a host — :rf.error/safe-redirect-host-disallowed (:reason :relative-only-violation); (4) :allow [...] allowlist mismatch — :rf.error/safe-redirect-host-disallowed (:reason :not-in-allowlist); (5) on pass, sets :redirect (same shape as :rf.server/redirect). Mitigation for the open-redirect class — an attacker-controlled ?next=… URL parameter cannot redirect off-origin. A rejection is a no-op on the wire: :redirect is left unset, the response status is untouched, and the request answers as it otherwise would. All three rejections ship an always-on error record and never project a status — see §Substrate. |
The fx-args schemas (:rf.fx.server/set-status-args, etc.) are registered per Spec-Schemas §Standard fx args schemas. In a development build those schemas are what rejects a malformed args map, through the standard :schema boundary check (per 010 §Validation timing). They are half of the contract; the other half is below and does not depend on them.
The reserved fx guard their own args¶
The step-5 fx-args boundary those schemas hang off is dev-posture. validate-fx! reads re-frame.interop/debug-enabled? once at namespace load, so a JVM started with -Dre-frame.debug=false — the posture an SSR host is told to run in, per 009 §JVM builds — performs no fx-args check and skips nothing. For a :schema an application declares over its own effect that is the right answer: the programmer is trusted in release, and validating every effect in every app is a cost the design refuses.
These seven are not that. They are a closed set the framework itself publishes, their arguments become the status line, the header block and the Set-Cookie lines, and the accumulator they write is a public host-adapter surface whose shape must not depend on which build you are running. So each of them checks its own arguments, in every build. The checks are cheap predicates over the published types in the table above — an integer :status in the 100–599 range the status line admits, string header :name and :value, a cookie carrying a string :name and :value and attributes of the types §Cookie shape names, a string :location, a boolean :relative-only?, a sequential :allow of strings. Args maps stay open: an unrecognised key is not a violation.
Two readings of "the published type" are settled here rather than left to each enforcement, because a disagreement between them is a posture-dependent accumulator wearing a smaller hat — the same defect this section exists to close, one key deep.
- An optional key present with
nilis absent.{:path nil}and{}say the same thing in Clojure, and code that builds a cookie from an options map ({:secure (:secure? opts)}, no:secure?in it) writes the first while meaning the second. Both the guard and the registered schema read it as absent, so neither refuses it. A required key is not an optional one:{:value nil}is a cookie with no value and is refused, andnilis not a status. - A cookie
:nameis a string. A keyword or symbol is a well-formed token —(name :csrf)is"csrf"— and the fx boundary once admitted one, mirroring a host materialiser's own tolerance. It is still refused here, because §Cookie shape publishes:stringand an adapter reading(:name cookie)is owed the type it was promised. Admitting it would hand every host adapter aNamedto unwrap, which is the obligation-shifting this section's whole argument rejects.
Neither reading is a licence the schemas grant and the guard withholds, or the reverse: one acceptance corpus is driven through both, so a widening that lands on one side alone fails.
A violation throws :rf.error/server-fx-args-invalid (009 §Error event catalogue) before the first write, so the offending effect contributes nothing to the accumulator. The registered-fx containment then does the rest with no machinery of its own: the sibling effects in the same :fx vector still run, an always-on :rf.error/fx-handler-exception record ships to error listeners, and the projector answers with a sanitised 500 (§Server error projection). The message names the failing effect, the offending key and the expected type; the value itself never egresses raw, only a shape summary — a cookie :value is a session token.
This is the shape half of the self-guarding pattern the wire-grammar guards already carry (§CRLF fail-fast on header values). Those reject what the wire cannot carry; these reject what the argument was never allowed to be. Both are checks the framework relies on to keep a promise of its own, which is precisely the set C-000.35 puts outside the elidable one — what may be elided is settled by what the check is for, not by who declared the schema it reads. It follows that nothing here generalises to fx args at large: step 5 is untouched, no user effect acquires a production check, and there is no flag that turns one on.
What still differs between builds is the diagnostic, and that is documented rather than hidden. In dev with the schemas artefact present the Malli boundary gets there first: the effect is :skipped, the programmer gets :rf.error/schema-validation-failure :where :fx-args naming the failing path, and the page still renders. In a release build — or a dev build that never loaded the schemas artefact — the guard is what remains, and it fails closed instead. A wire-adjacent programmer error that survived to production is better answered by an honest, observable 500 than by a silently dropped Set-Cookie under a 200.
The line the guard does not cross is :rf.server/safe-redirect's policy arm. A malformed call is a programmer error and throws; an untrusted input that is a well-formed string but points somewhere the policy refuses stays the existing non-projecting no-op (:rf.error/safe-redirect-*, per the table above). A blank :location is still a string, so it still takes the parse-failure arm — a rejected redirect must not become a denial of service. Likewise :rf.server/redirect's :location remains optional, and its documented no-target path is unchanged.
Every adapter inherits a well-formed accumulator¶
Because the invariant belongs to the handlers, re-frame.ssr/get-response never publishes a malformed :rf/response — in any build, to any host. An adapter reading (:status response) gets an integer; (:headers response) carries string names and string values; (:cookies response) carries a string :name and the attribute types §Cookie shape names. What an adapter must still read the Clojure way is an optional attribute: a cookie may carry :path nil where another carries no :path at all, and the two mean the same thing — (when (:path cookie) …), never (contains? cookie :path).
Adapter-side coercion is therefore defence-in-depth, not a normative obligation. An adapter may keep a last-line coercion of its own — ssr-ring's materialiser does, and it stays — but no host adapter is required to repair the accumulator, and one that trusts what get-response hands it is not thereby unsafe. Placing the obligation there would not have worked anyway: repair happens after the malformed value has already crossed the public boundary, and it cannot be done correctly. Nothing at the materialiser can tell whether a cookie arriving without a :value was meant to be empty, meant to be omitted, or was a mistake. What adapters do owe the contract is unchanged — the wire format, the error categories they emit, and the fail-closed behaviours this document already requires of them.
CRLF fail-fast on header values¶
Header values cross the HTTP wire boundary as text lines terminated by CRLF. A \r or \n embedded inside a header value would split the header into adjacent header lines on the wire — a response-splitting attack (injection of an additional header, or even a second response body). The framework's policy is fail-fast at fx-handler time, no strip-and-warn: silent normalisation masks bugs and lets through downstream-encoded attack vectors.
Normative contract.
:rf.server/set-headerand:rf.server/append-headerMUST reject CRLF in:value. A:valuestring containing\ror\nthrows the fx with:rf.error/header-invalid-valueand the rejecting fx-id in:tags. No fall-through; no strip-and-pass; no normalised reissue. Per Security.md §CRLF injection at HTTP-response boundaries.:rf.server/redirectMUST reject CRLF / NUL in:location. TheLocation:header is a header value subject to the same CRLF check; a\r,\n, or NUL in the redirect target surfaces:rf.error/redirect-invalid-location. This is the only gate on the caller-trusted path: the header-splitting invariant (RFC 7230 §3.2.4) is enforced, but no structural URL-shape check is applied —:rf.server/redirectis caller-trusted, so a raw space or other RFC 3986 shape quirk that every browser accepts in aLocationheader is passed through. The fail-fast CRLF/NUL posture applies whether:locationcame from a constant, a route binding, or a?next=query parameter forwarded through:rf.server/redirect. (URL-shape and origin validation belong to:rf.server/safe-redirectabove — caller-untrusted strings should route through it for open-redirect mitigation; the CRLF/NUL check applies to both.):rf.server/set-cookieMUST CRLF-check every attribute, not just:value.Set-Cookie's attribute fields (:name,:value,:max-age,:same-site,:path,:domain,:expires) are individually checked before the host adapter serialises the cookie line. Apps frequently build cookies from host-data values (a user-id flowing into:value, a partner-supplied tenant string into:domain, an arbitrary:pathfrom request context); an attacker who controls any one of those values must not be able to re-enter the header line as CRLF-bearing payload. The attributes concatenated VERBATIM into the wire line (:path/:domain/:max-age/:same-site/:expires) additionally reject the raw;cookie-attribute delimiter (RFC 6265 §4.1.1): a;inside such a value escapes its assigned attribute and fabricates extra ones (SameSite=None,Secure,Max-Age=0, …), so the rejected-delimiter set for them is{CR, LF, NUL, ;}. Cookie:valuestays delimiter-tolerant — the serialiser percent-encodes it (;→%3B, so a;there stays data), so it is gated on{CR, LF, NUL}only. Both violation classes throw:rf.error/cookie-invalid-attribute(per 009 §Error event catalogue) carrying the offending field in the:attributeslot — enforced at both there-frame.ssr.responsefx boundary and the Ringcookie->set-cookie-headerserialiser from one shared grammar (re-frame.ssr.http-validation). Per Security.md §CRLF injection at HTTP-response boundaries.
The error categories :rf.error/header-invalid-value and :rf.error/redirect-invalid-location are catalogued in 009 §Error event catalogue. These grammar checks and the shape guards of §The reserved fx guard their own args are one posture applied to two questions, and both surface the bug at the dispatch site rather than at the wire boundary. Where they overlap the grammar check runs first, so a nil or keyword header :name keeps its catalogued :rf.error/header-invalid-name rather than being re-reported as a shape violation.
Cross-reference: see Security.md §CRLF injection at HTTP-response boundaries for the framework-wide threat-model entry and the rationale for fail-fast over strip-and-warn.
Request-handler return shape¶
After drain settles, the runtime returns the structured request result to the host adapter:
{:html "<!doctype html>..." ;; absent when :redirect is set
:payload hydration-payload ;; the :rf/hydration-payload (per Spec-Schemas)
:response response-map} ;; the resolved :rf/response
The host adapter is responsible for materialising :response into the wire format its server framework expects (Ring map, Express response, Fastify reply, etc.). The runtime never writes to a network socket directly — the response shape is the contract; transport is the host's concern.
Redirect precedence¶
Lock: redirect truncates HTML. If :rf.server/redirect fires anywhere in the drain (an :initial-events setup step, an :on-match route handler, a downstream dispatch), the runtime:
- Sets
:redirect {:status N :location "..."}on the accumulator. - Skips the HTML render step entirely —
:htmlis absent from the request result. - Skips the hydration-payload serialisation —
:payloadis also absent (no client to hydrate). - The host adapter sees
{:response {:redirect {:status 302 :location "/login"} ...}}and emits a status-and-Location-header response with no body.
Multiple redirects: last-write-wins on the :redirect slot itself, with a :rf.warning/multiple-redirects trace (same shape as the multiple-status warning below).
Multiple-status policy¶
Lock: last-write-wins, with a structured warning. If two handlers in the drain both emit :rf.server/set-status, the runtime:
- Records each write to the
:statusslot. - After drain, if more than one distinct write occurred, emits a
:rf.warning/multiple-status-settrace event (per 009 §Error event catalogue). - The final
:statusis the last write — same default:db-style semantics re-frame uses elsewhere.
The warning is advisory: production response is the last-write value. Tools (10x, error monitors) surface the warning so authors can find the conflicting handlers.
Header replacement vs append¶
Lock: :rf.server/set-header replaces; :rf.server/append-header adds another instance. Both are documented; choosing the wrong one is a contract bug, not a runtime error.
The runtime stores headers internally as an ordered vector of [name value] pairs (case-insensitive name match). On serialisation, the host adapter chooses how to wire the multi-valued case (most frameworks accept [name value] arrays; Ring uses string-or-vector values).
Deduplication is host-adapter policy. The runtime does not deduplicate — if user code emits two :rf.server/append-header calls with the same value, both go on the wire. The adapter may collapse them or pass them through.
Cookie shape¶
Lock: structured maps, not raw header strings. The :rf.server/cookie schema (registered in Spec-Schemas) names the canonical attributes:
{:name "session"
:value "abc123"
:max-age 3600
:secure true
:http-only true
:same-site :lax ;; one of :strict :lax :none
:path "/"
:domain "example.com"
:expires <int-ms> ;; optional; either :max-age or :expires (or neither)
}
The host adapter serialises this to a Set-Cookie: header value per RFC 6265. User code never builds the wire string. This intentionally avoids the per-attribute quoting / encoding bugs that plague raw-string cookie APIs.
:name and :value are strings and required; a keyword or symbol :name is refused even though (name :csrf) would serialise cleanly, so that what reaches an adapter is the type published here (§The reserved fx guard their own args). Every other attribute is optional, and an optional attribute present with nil is the same as an absent one — the reading Clojure already gives a map entry whose value is nil, and the one an adapter appending Path= only (when path) already takes.
Attribute-injection safety is enforced at serialisation: the attributes appended verbatim after the ; separators (:path / :domain / :max-age / :same-site / :expires) reject CR/LF/NUL and the ; cookie-attribute delimiter, while the percent-encoded :value rejects CR/LF/NUL only (a ; there stays data, %3B) — see §CRLF fail-fast on header values.
:rf.server/delete-cookie is sugar over :rf.server/set-cookie with :max-age 0 and an empty :value.
Status defaults¶
Lock:
- Default status: 200 when no handler emits
:rf.server/set-status. - Default content-type:
text/html; charset=utf-8for HTML responses (set on the accumulator at request start; user fx can replace it). - Error pages. When the projector (§Server error projection below) maps a server-side exception to a public error, the runtime emits the public-error's
:statusand a defaulttext/html; charset=utf-8content-type unless user code has set a different one. A projected 4xx keeps the app's own body under that status; a projected 5xx ships the error page (§Drain-time error classification). - Route entry denial: 403. A route whose
:can-enterguard rejects on a server frame stamps403before the denial event drains (§Route entry denial — the default 403 below).
These defaults are the runtime's; user fx can override any of them. The runtime never interferes with explicit user-supplied values.
The materialiser is the last line — a non-integer status fails closed to 500¶
Lock: a :status that is not an integer is rewritten to 500 when the response is materialised, and the rewrite reports itself in every build.
One value the wire will not take, whatever the app supplied. Everything else about the status is settled before the body commits — the drain's last :rf.server/set-status wins (§Multiple-status policy) and a projected error stamps over it (§Drain-time error classification). Materialisation runs after all of it, and it is the one step past the projector that can still change the status. Ring requires an integer :status, and the server commits the response after the handler has returned a nominally-successful map — past the :on-error recovery point — so a malformed one is caught nowhere useful. The adapter therefore refuses to ship it, and refuses to guess: there is no faithful reading of a value the caller did not mean ("404" is not taken to mean 404), so the response fails closed to a valid 500.
A backstop on the host seam, not a step in the ordinary path. The seven reserved :rf.server/* fx check their own arguments in every build (§The reserved fx guard their own args), so [:rf.server/set-status "404"] throws before the first write and never puts a non-integer on the accumulator — an application that drives its response through those fx never meets this rewrite. What can still reach it is a host: one that writes the accumulator directly rather than through the reserved fx, or hands the adapter's materialiser a response map of its own. Both step around the write contract §Response storage substrate states, and defending that contract at the last possible moment is this arm's whole job.
Reported, and not a projection. 009 §Error event catalogue carries the pair of rows: an always-on :rf.error/ssr-ring-response-status-invalid record (:recovery :failed-closed-to-500) alongside a dev-only :rf.ssr/ssr-non-integer-status warning, emitted from a single site so the two axes cannot disagree and one rewrite can never ship two records. The record is frameless (:frame nil — the materialiser is a pure map-to-map function with no frame argument, so there is no frame to attribute it to and none is invented), and it sits in the same projection-skip set as the safe-redirect rejections (§Substrate): it fires after the status is resolved and flushed, so projecting it could only fight the 500 it is already reporting. Its slots are built from a closed list rather than filtered down to one, and that list omits :status by name — the offending value's class (:status-type) is all that egresses, while the value itself stays on the dev trace, where a developer reading it is already standing. Promotion added the record, not the rewrite: the 500 was always shipping, and shipping it in silence is what made the promotion worth making. 009 puts the same obligation on symmetric materialisers in other host adapters — fail closed, never coerce, emit the same category.
Route entry denial — the default 403¶
Lock: an unreplaced route entry denial is a 403, and the shell renders under it.
012 §Entry is terminal makes a rejecting :can-enter guard commit nothing — on the server as on the client. HTTP behaviour for that case is not adapter guesswork; it is this contract:
- The runtime stamps the default. When the entry decision denies on a frame whose
:platformis:server, the runtime writes:status 403to the response accumulator before dispatching:rf.route/entry-denied. The stamp is an ordinary:rf.server/set-statuswrite, so it participates in the multiple-status policy like any other. - The application may supersede it, and drains before render. re-frame drains the denial handler before the render step, so an application handler can replace the result. The two supported replacements are the canonical
:rf.server/redirect(normally to a login URL), whose redirect precedence truncates HTML and replaces the status, and an explicit:rf.server/set-status, which wins by last-write. Merely rendering the login route inside the same server frame does not change the response: it intentionally keeps the403unless the handler also changes it. - An unreplaced denial is a hard deny. With the framework's no-op default handler — or any handler that establishes neither a redirect nor another status — the protected route stays uncommitted and the host renders the ordinary application shell under
403, against the unchanged route projection (absent on a first request). No:on-matchruns, no route resource plan is built or awaited, and no resource or hydration data for the denied target is produced: there is nothing to leak, because nothing was activated.
403 (not 404) is the default because the request was well-formed and the route exists — the runtime is reporting a refused entry, not a missing resource. An application that prefers to hide the route's existence sets 404 explicitly from its denial handler; an application that prefers a login bounce emits :rf.server/redirect. Both are one fx in the handler the framework already dispatches.
The floor is server-only. On a client frame a denial emits no status effect at all — there is no response to stamp.
Trusted shell hook contract¶
The host adapter's default HTML envelope (CLJS reference: re-frame.ssr.ring/default-html-shell for non-streaming, default-streaming-prefix + default-streaming-suffix for streaming SSR) exposes four convenience opts the handler-constructor surface accepts as caller-trusted strings. They split by injection position:
- Content-position opts (
:head,:body-end) are injected RAW into free-form HTML content positions, with no escaping, no validation, and no sandbox. Free-form HTML content has no single-correct escape, so the framework cannot escape here without breaking the legitimate use (injecting a<script>analytics tag). These are the parallel of the caller-trusted:rf.server/redirectsurface (§Standard fx above + Security.md §Open-redirect mitigation): the framework's contract is that the caller composes them from caller-controlled data at handler-construction time (app boot decides what analytics tag / doctype-extension / head fragment to inject); the framework names the trust boundary; the content trust itself remains the caller's. - Attribute-value-position opts (
:script-src,:app-element-id) land inside double-quoted HTML attribute values (<script src="...">/<div id="...">). An attribute-value position HAS a single correct escape, so the frameworkescape-attr-escapes them at the shell — escaping&+"is lossless and position-correct. This is structural-correctness, not a sandbox: it stops a stray"in an otherwise-benign id / URL from breaking out of the attribute and emitting malformed markup. The values are still caller-supplied configuration; the escaping does not make them safe carriers for untrusted content (a fully attacker-controlled URL can still point at a malicious origin), but it removes the structural footgun for the trusted-but-quote-bearing case.
The four opts:
| Opt | Wire position | Injection |
|---|---|---|
:head |
Verbatim HTML inside <head>...</head> (overrides the route-resolved head fragment when supplied as a string) |
RAW (content position). Caller-trusted string. The route-driven reg-head path (§Head/meta contract below) is the structured alternative — head models are derived from app-db through registered fns, and the SSR emitter applies position-appropriate escaping at every leaf. The :head string opt is the escape hatch for bespoke fragments the caller composes from trusted data. |
:body-end |
Verbatim HTML before </body> |
RAW (content position). Caller-trusted string. The escape hatch for analytics / third-party scripts / chat widgets the app boot decides to inject. |
:script-src |
Written into <script src=\"...\"></script> (the client-side bootstrap script URL) |
escape-attr-escaped (attribute-value position). Caller-trusted string. Default: the host's bundled bootstrap entry point (e.g. \"/main.js\"). |
:app-element-id |
Written into <div id=\"...\"> wrapping the rendered body |
escape-attr-escaped (attribute-value position). Caller-trusted string. Default: \"app\". The client-side hydrator reads this element by id; changes here must be matched on the client. |
Normative contract.
- The content-position opts (
:head,:body-end) are injected RAW; the attribute-value-position opts (:script-src,:app-element-id) areescape-attr-escaped. The framework MUST accept all four as strings (or nil — meaning "no override, use the default"). For:head/:body-endthe shell MUST inject the string verbatim — no framework-level escaping, the content trust is the caller's; apps that wire either from untrusted input (a CMS field, a tenant-admin form, a query-string parameter, a partner-supplied configuration blob) accept an arbitrary-script-injection XSS vector — the framework will not gate the content. For:script-src/:app-element-idthe shell MUST run the value through attribute-value escaping (&→&,"→") so a quote-bearing-but-otherwise-trusted value cannot break out of the attribute and emit structurally-broken markup; the escape is lossless and position-correct. - Construction-time structural-shape validation. The framework MUST validate at handler-construction time that each of the four, if supplied, is a string (or nil). Non-string non-nil values (a map, a vector, a symbol, a number) surface
:rf.error/ssr-trusted-shell-opt-invalidat boot — the structural mistake is caught before the first request rather than as aClassCastExceptiondeep in the rendering path. The error's ex-data carries:opt-key(the offending opt's keyword),:got(the rejected value),:got-type(its type), and:recovery :supply-string-or-nil. The check is contains?-aware so absent opts pass; nil opts pass. - Documented structured alternative for untrusted-customization use cases. Apps offering admin- or tenant-configurable shell customization (a "customize site head" admin form; per-tenant analytics blocks driven by tenant settings; user-driven body-end widget configuration) MUST NOT wire the raw input through these four opts. The structured alternative is:
reg-headfor head fragments. The head/meta registry (§Head/meta contract below) carries{:title :meta :link :script}shape; the SSR emitter renders the structured head through the standard hiccup → HTML walker, which applies position-appropriate escaping (text-node, attribute-value, raw-script-body — per §XSS at output boundaries) at every leaf. An admin-editable title / meta-description / OG-tag flows through structured data throughreg-head— never as a raw:headstring.reg-view*+:rf.server/*fx for body content. Body content the admin / tenant customizes belongs in a registered view that takes the customization as a sub'd value off app-db. The view's hiccup goes through the same SSR emitter — string content lands as text-node children (HTML-escaped); attribute values land as escaped attribute pairs. The emitter's escaping is the trust boundary here — not the app-db slice the value travelled through. The escaping is a framework check on a framework boundary, so it holds in every build (C-000.35). An app-db schema over that slice is a development aid and elides with the rest of the dev-time validation arms: a release build registers the schema and then never consults it, so a value the schema forbids reaches the emitter with no rejection and no trace (per 010 §Production builds) — reading such a schema as part of this boundary is exactly the false confidence that section warns against. An app that wants a surviving check rather than surviving escaping puts:rf.schema/at-boundaryon the handler that admits the customization: it validates in every build, and a rejection fans a structural-only always-on error record naming the event, carrying no payload. That is a second line. What makes the structured route safe is that the emitter escapes whatever arrives, conforming or not.:rf.server/set-headerfor header-shaped customization. Custom response headers go through the structured fx — CRLF-checked and args-guarded in every build (per §The reserved fx guard their own args), rather than concatenated into a header line by the caller.- No content-shape validation on the trusted-string slot. The framework MUST NOT add a "looks like HTML" / "looks like a URL" / "looks like an identifier" content check to any of the four. Such a check would be a leaky abstraction (it would either accept some XSS vectors as "valid-looking HTML" or reject some legitimate content) AND it would invite false-confidence wiring of untrusted input through the slot. The structural shape check (string vs not-a-string) is the entire framework gate; content trust is upstream. (The
escape-attrpass on:script-src/:app-element-idis position-correct attribute encoding, not a content-shape validation — it is lossless, gates nothing, and rejects no value; it merely encodes the two attribute-value-position opts correctly for the position they land in.)
The contract composes with the runtime production gate (Security.md §Production gates) and the SSR side-channel response substrate (§Response storage substrate): trusted-shell opts ride the construction-time handler-opts map (per-deployment, not per-request); the structured alternatives ride per-request data through app-db; the privacy boundary between response state and hydration payload is enforced by the side-channel substrate. Three layered boundaries, each with one job.
Cross-reference: see Security.md §Trusted shell hook contract for the security-posture entry that names this surface among the framework's named trust boundaries.
Head/meta contract¶
Status: shipped. The
reg-head/render-head/active-headsurface described in this section ships in theday8/re-frame2-ssrartefact (re-frame.ssr.head). Body and head ride separate hash channels (rf2-1oxjxk): the:rf/render-hash/data-rf-render-hashchannel (per §Hydration-mismatch detection) covers the body render-tree only, and a distinct:rf/head-hash/data-rf-head-hashchannel covers the canonical head model (the EDN modelactive-headreturns — not the emitted<head>HTML). The head channel is client-reconstructible —active-headrecomputes the identical model against the just-hydrated state and hashes it the same way (§Default flow step 5) — but reconstructible is not automatic, and the distinction is the whole contract. The v1 server emits the channel; nothing in the bundled client consumes it.re-frame.ssr.hydratestashes and compares the body's:rf/render-hashonly: it never reads:rf/head-hash, never callsactive-head, and never touches the DOM<head>. A host that wants the head checked runs the recompute-and-compare itself and attributes a head-only mismatch as:rf.ssr/head-mismatchthrough the generic:failing-idseam, because the runtime ships one mismatch-detection entry point (verify-hydration!) per hash and head-mismatch attribution by the runtime itself remains a post-v1 follow-on. The head channel is omitted for explicit-:head-STRING requests (a caller-supplied head string has no reconstructible model — graceful degrade, no false-positive).Why body and head are separate channels (not a single unified hash). An earlier draft (rf2-9fw2de) folded the resolved head fragment into a single
:rf/render-hashcovering body and head. That was reverted (rf2-1oxjxk, Mike-RULED Option B): the documented client boot (hydrate!'s:render-tree-fn, §Client-side hydration boot helper) hashes only the bare body render-tree a root view returns — it has no document wrapper to fold a head into — so a body+head server hash could never equal the client's body-only hash, firing a spurious:rf.ssr/hydration-mismatchon every SSR page. A client-side reconstruction of the head fragment would have had to re-emit HTML (breaking the canonical-EDN-tree byte-identity discipline) and could not cover explicit-:head-STRING requests the payload does not carry. The separate:rf/head-hashchannel over the canonical head model is client-reconstructible without either problem.
The server-rendered HTML must carry head metadata — <title>, <meta>, <link>, JSON-LD — on first byte, because crawlers and link-unfurlers don't run JS. The pattern's commitment: the head model is data derived from app-db, not an imperative DOM API.
This section is the normative home of that mechanism. The complementary fact that compiled re-frame.ui templates contribute no head nodes at all — the structural absence of a head form or head channel in the view grammar — is normatively owned by 004D §The document head is host-owned (ruled, rf2-3i7tr).
The standard head model (registered as :rf/head-model in Spec-Schemas):
{:title "Article: re-frame2 SSR"
:meta [{:name "description" :content "..."}
{:property "og:title" :content "..."}
{:property "og:image" :content "https://..."}]
:link [{:rel "canonical" :href "https://example.com/articles/123"}]
:script [{:src "https://..." :async true}]
:json-ld [{"@context" "https://schema.org" "@type" "Article" ...}]
:html-attrs {:lang "en"}
:body-attrs {:class "page-article"}}
Mechanism — registered head function + route metadata¶
Lock: head logic is registered with reg-head; routes name which head to use via :head route metadata.
(rf/reg-head :head/article
{:doc "Article-page head model — derives title/meta/og from the article."}
(fn [db {:keys [params] :as route}]
(let [{:keys [title summary image]} (get-in db [:articles (:id params)])]
{:title (str title " — Example")
:meta [{:name "description" :content summary}
{:property "og:title" :content title}
{:property "og:image" :content image}]
:link [{:rel "canonical" :href (route-url {:to :route/article :params params})}]
:json-ld [{"@context" "https://schema.org"
"@type" "Article"
"headline" title}]})))
(rf/reg-route :route/article
{:head :head/article} ;; route declares which head to use
"/articles/:id") ;; the path is the THIRD slot, never metadata
reg-head adds a new registry kind :head (per 001 §Registry model). The query API surfaces it: (rf/registrations :head) returns id → metadata; tools can enumerate registered heads.
reg-head returns its id argument per the family-wide reg-* return-value convention.
The function signature is (fn [db route] head-model) — pure, deterministic, no side-effects. Same shape and discipline as a sub. Subscriptions inside head functions are evaluated against the static app-db value (same path as views; per compute-sub).
Default flow¶
- SSR request renders the body view.
- The runtime resolves the active route's
:headmetadata → a registered head id. (rf/render-head head-id {:frame frame-id})(or equivalently(compute-head head-id db route)) returns the head model.- The runtime emits
<head>...</head>from the model, in canonical order:<title>first, then<meta>in declaration order, then<link>, then<script>, then JSON-LD<script type="application/ld+json">.:html-attrspopulate<html>;:body-attrspopulate<body>. - The model stays reconstructible on the client: the same
active-headcall the server made, run against the now-seeded state (the hydrated payload's:rf/app-dbplus the route slice carried in:rf/runtime-dbat[:rf.runtime/routing :current]), returns a byte-identical model when server and client agree. The bundled client does not make that call — it is the seam an app- or host-level head manager uses, and the same seam step 6 needs. - Mismatch detection: the head rides its own
:rf/head-hashchannel (rf2-1oxjxk), separate from the body's:rf/render-hash. The server hashes the canonical head model (the EDNactive-headreturned, not the emitted<head>HTML) and ships it as:rf/head-hash(payload) +data-rf-head-hash(on the<head>element). The bundled v1 client stops there — it does not read:rf/head-hash, recompute, or compare. A host that wants the check recomputes the model via step 5, compares it against:rf/head-hash, re-renders the head if it disagrees, and attributes the mismatch as:rf.ssr/head-mismatchthrough the generic:failing-idseam. The head channel is omitted for explicit-:head-STRING requests (no reconstructible model — graceful degrade).
Head-mismatch detection rides the separate :rf/head-hash channel over the canonical head model — client-reconstructible via active-head (§Head/meta contract Status). The body channel (:rf/render-hash) is body-only; folding head + body into one hash was reverted (rf2-1oxjxk) because the documented client boot hashes only the bare body render-tree, so a unified hash could never match the client's — see the Head/meta contract Status rationale. The :failing-id tag is a generic host-attribution seam (per §Hydration-mismatch detection): a host running its own head diffing attributes a head-only mismatch as :rf.ssr/head-mismatch; the v1 server emits the head hash but the bundled client neither stashes nor compares it, and the runtime ships one mismatch entry point per hash, so runtime-side head-mismatch attribution is a post-v1 follow-on. The recompute is available to the client (active-head over the hydrated :rf/app-db plus the route slice carried in :rf/runtime-db) but is never invoked by the runtime — and there is no DOM-head reconciler, so an SPA that routes after load keeps the server-rendered <title> / <meta> until an app- or host-level head manager refreshes them.
render-head¶
(rf/render-head head-id
{:frame :app/main ;; required (EP-0002 — carried, not defaulted)
:route active-route}) ;; optional; defaults to (subscribe [:route])
Returns the head model map. Pure, JVM-runnable, used by the SSR pipeline to materialise <head>...</head> and by tooling to inspect the active head without re-rendering the body. Head rendering is a frame-scoped read (it reads the frame's app-db + the runtime-db route slice), so the frame is carried — supplied explicitly. An absent :frame raises :rf.error/no-frame-context; there is no :rf/default-from-absence floor.
(rf/active-head frame-id) is sugar — looks up the active route's :head for the given frame, calls render-head, returns the model. Useful in dev tools and the SSR pipeline (called inside the request frame's with-frame block with the explicit request frame-id). The frame is required; there is no no-arg (active-head) form — it would synthesise :rf/default.
Single :head per route in v1¶
Lock: one registered :head per route. No composition (parent + child route head fragments) in v1 — that's a follow-up if real cases emerge. Routes that want to share head logic do so by referencing the same registered :head id, or by registering a head fn that calls a helper.
Mismatch detection — head¶
A separate channel from body-mismatch (§Hydration-mismatch detection above), over the canonical head model (rf2-1oxjxk):
- The server hashes the canonical head model — the EDN map
active-headreturns ({:title :meta :link :script :json-ld :html-attrs :body-attrs}), not the emitted<head>HTML string. It ships as:rf/head-hash(payload) anddata-rf-head-hash(a wire attribute on the<head>element). The body'sdata-rf-render-hashon the root element is body-only. - The model can be recomputed on the client via
active-headagainst the just-hydrated state (:rf/app-db+ the:rf/runtime-dbroute slice), hashed the identical way, and compared against:rf/head-hash. Hashing the model (not emitted HTML) is what makes the channel client-reconstructible — nothing has to re-emit<head>HTML, so the canonical-EDN byte-identity discipline holds on both sides. The bundled v1 client does not perform that recompute; it is the host's wiring, and it is the only thing standing between the shipped channel and a working head check. - The head channel is omitted for explicit-
:head-STRING requests. A caller-supplied:headstring (§Trusted shell hook contract) has no reconstructible model, so the server ships no:rf/head-hash/data-rf-head-hash(graceful degrade — omission avoids a guaranteed false-positive, since the client could never match). The same applies to a degraded/failed head resolution. - The recovery a head-only mismatch takes is
:warned-and-replaced— whoever detects it renders the computed head over the server's. A host that runs its own head-only diffing attributes the mismatch as:rf.ssr/head-mismatchthrough the generic:failing-idseam (per §Hydration-mismatch detection); the value is host-suppliable now and flows through to the trace. Since the v1 server emits the head hash but no bundled client reads it, that host is the only party doing either the detection or the replacement today: runtime-side head-mismatch attribution — feeding the head hash through the runtime's ownverify-hydration!with:failing-id :rf.ssr/head-mismatch— is a post-v1 follow-on. The public contract for the value is locked so consumers can branch on it today. (Audit S5.)
Default head when no route declares :head¶
Sensible default (from frame metadata + the runtime's HTML defaults):
{:title (or (:doc (frame-meta frame-id)) "")
:meta [{:name "viewport" :content "width=device-width, initial-scale=1"}]}
The default head does not carry {:charset "utf-8"}: charset is an envelope concern owned by the always-present document shell (which hardcodes <meta charset="utf-8"> as the first <head> byte), not a per-route head-model concern — carrying it here too would double-emit <meta charset>. No registered head is required. The default is silent — no warning. Routes that want explicit head data declare :head.
Server error projection¶
The trace surface (009 §Error contract) carries internal error detail — stack traces, exception data, internal codes — for monitoring and debugging. The HTTP response carries a public projection — a sanitised, client-safe shape that crawlers, browsers, and unauthenticated users may see. The two surfaces have different audiences and different security profiles.
The standard public error shape (registered as :rf/public-error in Spec-Schemas):
{:status 500
:code :internal-error ;; stable category keyword for response-page templates
:message "Something went wrong" ;; one-sentence human-facing
:retryable? false}
Mechanism — registered projector + per-frame :ssr metadata¶
Lock: both a registry-first projector and a per-frame :ssr metadata map naming which projector is active. The :ssr config sits on the frame's metadata (per Conventions §Configuration surfaces bucket 3) — so a single process can run a server-rendering frame with one projector and a dev tooling frame with another.
(rf/reg-error-projector :myapp/public-error
{:doc "Project internal error trace events to public response shapes."}
(fn [trace-event]
(case (:operation trace-event)
:rf.error/no-such-handler {:status 404 :code :not-found
:message "Page not found" :retryable? false}
:rf.error/schema-validation-failure {:status 400 :code :bad-request
:message "Invalid input" :retryable? false}
;; default — generic 500 in prod
{:status 500 :code :internal-error
:message "Something went wrong" :retryable? false})))
;; Wire the projector at frame-creation time — server frames opt in
;; via `:ssr` metadata; the runtime reads it through `frame-meta`.
;; `:platform` and `:ssr` are record-config keys on the one `rf/make-frame`
;; constructor.
(rf/make-frame {:platform :server
:ssr {:public-error-id :myapp/public-error
:dev-error-detail? true}}) ;; dev: include :details with full trace
reg-error-projector adds a new registry kind :error-projector (per 001 §Registry model). The query API surfaces it: (rf/registrations :error-projector) returns id → metadata. The runtime consults exactly one projector per response — the one named in the frame's :ssr {:public-error-id ...} metadata. If unset, the runtime uses its default projector (below).
reg-error-projector returns its id argument per the family-wide reg-* return-value convention.
Default projector¶
The runtime ships a default projector (:rf.ssr/default-error-projector) implementing the canonical mapping:
Internal :operation |
Public :status |
Public :code |
|---|---|---|
:rf.error/no-such-handler (:kind :route) |
404 |
:not-found |
:rf.error/no-such-route (route-id not in registrar — per 009 §Error event catalogue) |
404 |
:not-found |
:rf.error/cofx-value-invalid (a bad client-supplied request coeffect — per 009 §Error event catalogue) |
400 |
:bad-request |
:rf.error/schema-validation-failure (:where :event) |
400 |
:bad-request |
:rf.error/handler-exception |
500 |
:internal-error |
:rf.error/sub-exception |
500 |
:internal-error |
:rf.error/fx-handler-exception |
500 |
:internal-error |
:rf.error/drain-depth-exceeded |
500 |
:internal-error |
:rf.error/ssr-render-failed (render-time view throw) |
500 |
:internal-error |
view exception (during render-to-string) |
500 |
:internal-error |
| anything not enumerated above | 500 |
:internal-error |
The first 404 row is condition-gated — on the miss's :kind tag, and the default projector enforces the gate. :rf.error/no-such-handler covers three distinct registrar misses discriminated by the mandatory :kind tag (per 009 §Error event catalogue), and only one of them is a missing-page condition: it maps to 404 :not-found only when (get-in trace-event [:tags :kind]) is :route — a request URL that resolved to no route handler. :kind :event (a dispatch to an unregistered event id mid-render) and :kind :frame (a Tool-Pair surface naming a frame-id that is not in the registrar) are server-side defects: telling the client its URL was wrong when the server forgot a registration would mislabel the fault and, on the :kind :event path, teach a crawler to drop a page that exists. Both fall through the unenumerated tail row to 500, as does a miss carrying no :kind tag (the 404 arm is opt-in on the route discriminator — fail-safe, symmetric with the :where-gated 400 arm below). :rf.error/no-such-route — the route-url caller-misuse category, which has one failure mode and no :kind discriminator — keeps its unconditional 404. The non-condition fall-through is the locked default: when a category's gating condition does not hold, it is treated as unenumerated and projects 500.
There are two 400 rows, both keyed on client-supplied input:
:rf.error/cofx-value-invalidis a client-input fault — a non-recordable / out-of-:schema:rf.cofxvalue supplied at the dispatch boundary, the surface through which client-supplied request coeffects enter the run (per 009 §Error event catalogue; EP-0017). It maps to400:bad-requestunconditionally — a bad request coeffect is bad client input, a400, never a server-fault500.:rf.error/schema-validation-failureis likewise condition-gated — on the failure's:wheretag, and the default projector enforces the gate: it maps to400:bad-requestonly when(get-in trace-event [:tags :where])is:event(an inbound event payload) — the surface through which client-supplied event input enters the run. A schema-validation-failure on any other surface — notably:where :fx-args(a server-fx args schema, per 010 §Validation order step 5) or:where :sub-return— is a server-side defect: the server's own handler built a malformed fx args map or a sub returned a non-conforming value. Projecting such a failure as a client-facing400would mislabel a server bug as bad user input, so it falls through the unenumerated tail row to500. A schema-validation-failure that carries no:wheretag also falls through to500(the400arm is opt-in on a client-surface:where— fail-safe). The default projector encodes exactly this gate; custom projectors that want:where :fx-argsto stay400may override the arm, but the runtime default does not.
(:rf.error/schema-validation-failure never carries :where :cofx — a bad request coeffect surfaces as its own :rf.error/cofx-value-invalid category above.)
Plus app-level conventions a custom projector typically adds:
| User-defined | Public :status |
Public :code |
|---|---|---|
:auth/unauthorised (or equivalent) |
401 |
:unauthorised |
:auth/forbidden |
403 |
:forbidden |
In dev mode (:dev-error-detail? true), the public shape carries an additional :details key with the original trace event. In prod (default), :details is absent — the public shape is exactly the four locked keys.
Where sanitisation happens — before render¶
Lock: before render. The pipeline is:
- Drain runs; an exception occurs (handler, fx, sub, render-time view).
- Runtime captures the structured trace event (per 009 §Error contract).
- Runtime invokes the active projector with the trace event → public-error map.
- Runtime sets
:rf.server/set-statusto the public-error's:statusand writes any default content-type / cache-control headers per the public-error's:code. - The host adapter classifies by the projected status (§Drain-time error classification below): a projected 4xx keeps the app's own body + hydration payload (
:error-viewis not called); a projected 5xx renders an error page — a registered:error-view(or the host's default error template) — receiving the public-error map as its prop, with no app body or hydration payload. The error-page view sees only the sanitised projection; it cannot accidentally leak the internal trace. - The host adapter materialises and serialises the response. Sanitisation is finished by here, but the status is not quite: materialisation is the one step past the projector that can still change it, rewriting a non-integer
:statusfail-closed to500(§The materialiser is the last line).
The HTML response of a 5xx is the public projection — error pages read the public shape, the rendered HTML never contains internal detail. This is the security boundary.
Drain-time error classification — the pre-commit projected-status arm¶
Lock: classification is semantic, by projected status, decided before the response body commits. A projected error discovered during a drain (or a render-time seam) is one of two very different things, and the host adapter must not conflate them:
- A projected 4xx (
400–499) is an expected / client-fault response: a routing miss, a bad-client-input400, an auth401/403. The app is working correctly — the URL does not exist, or the input was bad — so the app renders its own not-found / bad-request UI and ships the hydration payload, hydrating into a working SPA that can navigate away.:error-viewis not consulted. - A projected 5xx (
500–599) discovered before the body commits is a server fault: a handler / fx / sub exception mid-drain leaves app-db in an arbitrary partial state. Rendering the normal root view against it would present a half-populated page as if real — a quiet lie. The adapter therefore discards the root body and hydration payload and renders the:error-view(or the locked default template) under the projected status. Noroot-view, HTML shell,__rf_payload, app-db, or render/head hashes are emitted — this also removes a needless partial-state egress surface when the caller opted into whole-app-db hydration.
The distinction follows HTTP semantics (a 4xx means the client-side request condition failed; a 5xx means the server failed an apparently valid request — RFC 9110 §§15.5–15.6) and mainstream SSR practice (separate expected/not-found UI from an uncaught-error fallback, selected before a streaming shell commits).
Five refinements make the rule precise:
- Classification is by the projected status, not the accumulator's
:status. An app that manually:rf.server/set-status-es a500with no error projected stays on the app arm — status alone is not proof that an error was projected. The runtime therefore exposes the projected:rf/public-erroralongside the resolved response (the CLJS reference returns both from one drain,ssr/flush-response-result!), so the adapter classifies on the projection, never by re-inferring from(:status response). - An unrenderable root/shell throw always uses the error page — regardless of the projected status — because a throw cannot supply a body. A custom projector that maps a view throw to a
4xx(e.g.418) still gets the error page. Conversely a reactive sub that recovers tonilbut projects500is known before commit and discards the degraded shell in favour of the error page. - Redirect precedence is first. A
:redirecton the response ignores any pending projection and ships bodiless (§Redirect precedence). - Post-commit is telemetry only. Once a streaming shell has committed its head (the first byte is on the wire), status/body selection is irreversible: a later writer failure is recorded as
:rf.error/ssr-streaming-writer-failedand the stream is truncated/closed — never re-projected (§Failure semantics). - The projected status is not quite the last word on the wire. Classification is decided here, before the body commits, and nothing downstream re-classifies. But materialisation still runs after this, and it fails a non-integer
:statusclosed to500— a host-seam backstop that reports itself and never projects (§The materialiser is the last line).
If the projector itself throws (or returns a non-conforming shape), the runtime emits :rf.error/sanitised-on-projection (per 009 §Error event catalogue) and falls back to the locked generic-500 shape {:status 500 :code :internal-error :message "Something went wrong" :retryable? false}. The fallback ensures the boundary cannot be bypassed by a bug in the projector. A conforming shape is exactly the four locked keys (:status / :code / :message / :retryable?) with an HTTP error status in 400–599; a projector that returns an out-of-range status (a 200/3xx), or any extra key — including its own :details — is non-conforming and takes the fallback. This keeps the redaction boundary real: only the runtime appends :details, after validation, under :dev-error-detail? — a projector can never smuggle its own keys across the public boundary.
Substrate — projector rides the always-on error-emit listener¶
the SSR error-projection pipeline installs onto the always-on error-emit substrate (the :errors stream of register-listener!) (per 009 §What IS available in production §Error-emit listener) — NOT the dev-only register-listener! surface. The error-emit substrate is documented as production-survivable; it fires under :advanced + goog.DEBUG=false builds and under the JVM -Dre-frame.debug=false production-hardening gate. Server error projection is a production-required surface, not a dev-only one: SSR / webhook receivers / long-running JVMs facing untrusted input MUST set re-frame.debug=false per 009 §JVM builds, and the projector's status-stamping contract must hold under that posture. The projection-eligible categories that fire on this always-on substrate — and so project a fail-closed status under production hardening — are :rf.error/handler-exception (router), the :rf.error/fx-handler-exception family (fx), :rf.error/flow-eval-exception (flows), :rf.error/sub-exception (reactive sub-run), :rf.error/drain-depth-exceeded (the run-to-completion drain hitting its limit), :rf.error/ssr-render-failed (the render-time seam), :rf.error/no-such-handler — including the :kind :route URL miss, the one this projector maps to 404 — and one arm of :rf.error/schema-validation-failure, the :rf.schema/at-boundary rejection this projector maps to 400 (the category's other arms stay dev-only; see below).
The substrate boundary maps the always-on error-emit record ({:error :event :event-id :frame :time :exception :elapsed-ms :source-coord}) onto the projector's trace-event-shaped envelope ({:operation :op-type :tags}) before invoking the active projector. Custom projectors that case on (:operation event) or read (get-in event [:tags :exception]) work unchanged across both substrates.
Per-frame attribution. The projection pipeline routes an error trace to a response accumulator solely by the frame named in the trace's [:tags :frame] (the always-on record's flat :frame slot, or the dev-trace envelope's :tags :frame). A trace whose frame is absent — or names a non-server frame — is genuinely unroutable and no-ops explicitly: no projector runs and no status is stamped. There is no single-active-server-frame fallback. Under concurrent SSR many server frames are live simultaneously (the canonical request shape), so a fallback that guessed "the one server frame" would silently mis-attribute or drop the projection — shipping a 200 for a request that should have been a 4xx/5xx. Correctness therefore depends on every error-emit site reachable inside a server-frame drain stamping [:tags :frame] from the drain's known frame. The CLJS reference upholds this at every such site (boundary schema validation, reactive sub-exceptions, sub-override validation, programmatic/URL-driven navigation rejects, stale nav-token suppression — alongside the per-step validate-*! validators and the router miss paths, which already stamp it). The same [:tags :frame] contract is what makes these traces visible in the per-frame epoch record / Xray (epoch capture buffers only frame-tagged traces).
One projection-eligible :rf.error/* category does NOT have an always-on emission path — it rides the dev-only trace/emit-error! and DCEs under production hardening:
:rf.error/no-such-routeis theroute-urlcaller-misuse category — a route-id that is not in the registrar, which throws synchronously at the call site. 009 §Error event catalogue catalogues it diagnostic: the caller observes the throw where it made the mistake, so there is nothing for a production shipper to add. It is not the URL-driven miss (that is:rf.error/no-such-handler:kind :route, always-on above).
For dev parity it is routed through the projector via a secondary register-listener! install.
:rf.error/schema-validation-failure is promoted one arm at a time, and the promoted arm is the one an SSR endpoint reaches for. Most of the category is genuinely dev-only: the validate-*! family elides with the rest of the diagnostic channel (per 010 §Production builds), and for those arms there is no reject left to project. The :rf.schema/at-boundary arm is the exception. Its check is ungated and runs on every build, so a boundary rejection under re-frame.debug=false is real — the handler is skipped and the payload never reaches app-db — and since 009's promotion of the arm the report survives alongside the refusal. The router's pipeline tail fans one always-on structural-only record (:source :boundary, :where :event) through the same error-emit substrate this projector listens on, and the substrate boundary's generic tag-lift puts that :where :event exactly where the default projector's condition-gated arm looks. So a refused request payload answers 400 per the table above, not the silent 200 it once did — the status RFC 9110 §15.5.1 asks of a payload the server will not process. The reject and the projection are both production-reachable. SSR needed no change of its own to earn that status: promoting the record was the whole of it.
What still elides is the diagnosis, and deliberately so. The rich trace/emit-error! above the rejection carries the offending value, and a boundary payload is attacker-controlled or user-private by definition — it can hold secrets under keys the declared schema never named, so no schema-aware redactor can be trusted to have seen them. The always-on record therefore carries identifiers only: no event vector, no value, no explanation, no interpolated reason. Structural-only is stricter than a scrub, not weaker. The line is C-000.35's — an ordinary registration diagnostic elides, a check the framework relies on to keep a promise of its own holds in every build — and surviving is not the same as reporting, which is why this arm now does both.
The arm gives you the status, not the page. A rejection that must shape the response — field-level errors, the submitted values preserved for redisplay — still validates in the handler body and emits [:rf.server/set-status 400]; Pattern-FormAction §Validation is the handler's job has the worked shape. An app that wants a production non-200 on client input the boundary interceptor does not cover should let the run land on another always-on category — :rf.error/cofx-value-invalid for a request coeffect, :rf.error/handler-exception for anything the handler itself refuses.
Historical note. Earlier revisions of this section listed four categories with no always-on path:
:rf.error/no-such-handlerand:rf.error/drain-depth-exceededalongside:rf.error/no-such-routeand the whole of:rf.error/schema-validation-failure. Three have since been promoted — drain-depth by the Spec 009 promotion criterion; the:kind :routeURL miss because a production server was answering200for an unroutable URL (a soft 404 that search engines drop) while the framework had already computed the miss and committed the:rf.route/not-foundslice; and the:rf.schema/at-boundaryarm because a production server was answering200over a payload it had already refused. The:rf.error/no-such-handlerpromotion is the reason the404row above is:kind-gated: with the category reaching the projector in production, an ungated arm would answer404for an unregistered event id. The400row's:wheregate carries the same weight for the boundary arm — with the category now reaching the projector, an ungated arm would answer a client-facing400for a server-side:where :fx-argsdefect.
:rf.error/sub-exception is NOT among them. A reactive subscription that throws during a server-frame render emits the category on both the always-on error-emit substrate and the dev-only trace surface. The always-on emission is the production status source of truth: a sub that throws mid-render-to-string is fail-closed to a non-200 (the default projector maps :rf.error/sub-exception → 500) under re-frame.debug=false, exactly as :rf.error/handler-exception / :rf.error/fx-handler-exception / :rf.error/flow-eval-exception are. The runtime recovers the sub's value to nil so the render does not crash, but the projected :status makes the request fail closed rather than ship a silent 200 with broken HTML. The projected response body carries only the locked public-error shape — the exception, its message, and any internal detail never cross the HTTP boundary (§Where sanitisation happens). The dev-only trace emission carries the rich internal detail for monitoring (§Internal trace events are not leaked).
The three :rf.server/safe-redirect rejections are the inverse case: always-on, and deliberately not projection-eligible. :rf.error/safe-redirect-invalid-url, :rf.error/safe-redirect-scheme-rejected and :rf.error/safe-redirect-host-disallowed each fan an always-on error record under re-frame.debug=false (per 009 §Error event catalogue), so a security team can see open-redirect probing against a production app. That record is the whole of what a rejection produces. All three sit in the projection-skip set (re-frame.ssr.error-listener/non-projection-eligible-errors in the CLJS reference), so the projection listener never buffers them and no status is ever projected from a rejected redirect — :redirect is left unset and the request answers exactly as it would have, minus a redirect the framework refused to perform.
That skip is load-bearing rather than cosmetic. Were the three projection-eligible, the default projector's :else arm would map each buffered record to the locked generic 500, and an attacker-supplied ?next=javascript:alert(1) would turn a healthy page into a 500 — a denial of service built out of the mitigation itself. (Mutation-proved in the CLJS reference: drop the three from the skip set and all three wire arms answer 500 under -Dre-frame.debug=false.) The skip sits at the shared projection chokepoint, which is what makes the two postures agree. Before it they did not: a rejected safe-redirect already stamped 500 in a dev build through the trace-buffering path while production answered 200 — a dev/prod wire asymmetry on a security surface, closed by the same change. The rule the whole arrangement rests on is that promotion changes what off-box shippers see, never what the wire does.
View-time exceptions¶
A view or subscription that throws during render-to-string (e.g., a missing key on an attempted (get-in db [...])) flows through the same projector, and is fail-closed to a non-200 in production (re-frame.debug=false) — not only in dev. The user does not write a separate "view exception" path. Two routes reach the projector, both always-on:
- A reactive subscription that throws mid-render emits
:rf.error/sub-exceptionon the always-on error-emit substrate (see §Substrate). The runtime recovers the sub's value tonil(the render does not crash), but the projected:statusmakes the request fail closed: the default projector maps:rf.error/sub-exception→500, so a sub-throw under production hardening yields a500error page — the host discards the degraded recovered-to-nilrender and ships the projected-error arm (§Drain-time error classification), never a silent200, and never the degraded body under the500. - A view fn that throws (an exception the hiccup walker cannot recover) is caught at the render-time seam and routed through
re-frame.ssr/project-render-exception!, which synthesises:rf.error/ssr-render-failedand drives the same projector. This seam is unconditional — it does not depend on the dev trace surface.
Either way the projector runs, the response :status is stamped, and the rendered error page sees only the sanitised public-error shape (§Where sanitisation happens).
Hosts that prefer eager exceptions during dev (to surface bugs early) can opt in via the frame's :ssr {:on-view-exception :throw} metadata — dev convenience; production should always project. The CLJS reference reads this knob at the render-time projection entry point (re-frame.ssr/project-render-exception!,.10): when set to :throw, the original throwable is re-thrown unchanged to the host's outer handler instead of being projected to a sanitised public-error.
The JVM reference adapter (re-frame.ssr.ring) unifies the two render-side failure surfaces under one pipeline. Render-time throws are caught at the host-adapter render call site and routed through ssr/project-render-exception! (synthesises a :rf.error/ssr-render-failed trace event and applies the active projector); the wire body is the projector's :message / :code. The outer :on-error hook is reserved for transport-layer / projector-undeliverable failures (no server frame, projector pipeline catastrophically fails) where the fixed-body contract applies.
:on-error vs :error-view — the error-handling division¶
The host adapter exposes TWO error opts that handle TWO different failures. They are not alternatives — a robust deployment usually wires both. The division (the CLJS reference's ssr-handler docstring carries the same decision table verbatim):
:error-view |
:on-error |
|
|---|---|---|
| Which failure | A projected 5xx the error PROJECTOR catches (a drain-time handler/fx/sub exception, a render-time view throw, an unrenderable root/shell throw). A projected 4xx does NOT reach it — the app keeps its own not-found / bad-request body (§Drain-time error classification). | A transport / Ring-layer failure the projector CANNOT see — per-request frame setup throw, a render-time host exception outside the walker, a header/cookie materialise throw, a thrown :initial-events setup step. |
| What it produces | The PROJECTED error-page body (hiccup) — a registered-view keyword or a (public-error) → hiccup fn, rendered through the SSR emitter. No app body / hydration payload ships alongside it. |
A raw HTTP response map {:status … :headers … :body …} returned verbatim to the server. |
| Its input | ONLY the SANITISED :rf/public-error map — safe to render (never the request, throwable, frame, phase, or trace). |
The raw (request throwable). The locked default NEVER reads the throwable (the .getMessage topology-leak boundary). |
| HTTP path | Normal response, projector's status (a 5xx) + the rendered error page, keeping the safe headers/cookies accumulated before the failure. | Last-resort net OUTSIDE the normal pipeline (the projector never ran / can't run). |
| Default when omitted | Minimal default error template (absence does NOT keep the root body). | Minimal locked 500 (topology-leak-safe generic body). |
Mnemonic: :error-view is the projected page for a server fault (5xx, sanitised, hiccup); :on-error is the transport net (Ring-layer, raw throwable, outside the projector). Both are bug-contained, and the :error-view boundary is one-way: a buggy :error-view — whether it throws OR depends on a reactive sub that recovers to nil under production hardening — falls back once to the locked default template (from the ORIGINAL public-error), emits :rf.error/ssr-ring-error-view-failed, and does not re-project the secondary failure; a buggy :on-error falls back to the locked default-on-error — neither bypasses the error boundary.
Dev vs prod default behaviour¶
Lock:
- Dev (
:dev-error-detail? true, an explicit per-frame opt-in) — public shape carries:details(the original trace event); the error page can render full detail for the developer. - Prod / default (
:dev-error-detail? false) — public shape is the locked four keys only;:detailsis absent; no internal detail leaks regardless of projector implementation.
:dev-error-detail? hard-defaults to false unconditionally — the CLJS reference's frame-dev-error-detail? coerces the per-frame [:ssr :dev-error-detail?] config via boolean and never reads goog.DEBUG (or any build flag). This is the fail-safe direction for a long-running SSR JVM facing untrusted input: enabling dev detail is an explicit :ssr {:dev-error-detail? true} opt-in, never build-flag-implied. The default is safe by default in prod — leaking detail requires explicit opt-in.
Internal trace events are not leaked¶
The internal trace stream remains unchanged by projection. Monitoring listeners (register-listener! — dev-only; the :errors stream of register-listener! — always-on; the latter is the production-survivable channel per 009 §What IS available in production) see the full structured error record with :exception-message, :exception, stack traces, and any other detail the runtime captured. Projection only governs what crosses the HTTP boundary.
This separation is the operational benefit: monitoring stays rich; the wire stays clean.
Operational rules¶
These are normative rules implementations must follow — active contract, not historical notes. Each subsection below is part of the load-bearing SSR surface: platform gating, JVM-runnable rendering, the server/client routing handshake, fragment behaviour, auth/session flow, the
:aftercarve-out for machines, and hydration-payload scope. Read this section as a continuation of §Detailed design. Implementations that want a working SSR surface must satisfy every rule below.
Mismatch recovery and configuration¶
The detection mechanism is in §Hydration-mismatch detection above. Recovery and configuration:
- Default recovery:
:warned-and-replaced— the runtime renders the client's view, replacing the server's HTML. The page becomes interactive. - Strict mode (frame's
:ssr {:on-mismatch :hard-error}metadata): the runtime throws a structured exception with the same payload. Used in dev/CI to surface mismatches loudly. This is a hiccup-tier control only (§Hydration-mismatch detection — the two-tier split): the compiledre-frame.uitier verifies by React-native adoption, where React has already recovered (patched the DOM) by the timeonRecoverableErrorfires, so a synchronous abort cannot be honestly reproduced; the compiled tier is always warn-and-replace-plus-trace. - External monitoring integrations register a trace listener and ship mismatch events to their backend.
- Mismatch detection is mandatory in dev builds; production builds can disable the hash-comparison work via the frame's
:ssr {:detect-mismatch? false}metadata for a small first-render perf win, at the cost of silent mismatches. Default: detection on in all builds.
Effect handling on the server¶
:platforms metadata on every reg-fx and reg-cofx. Server-side fx resolver filters by platform; absence of the key defaults to #{:server :client} (universal).
The full rule:
reg-fx(andreg-cofx) takes optional:platformsmetadata: a set containing:server,:client, or both.- The runtime tracks the active platform — the CLJS reference sets this at startup based on
cljs.core/*target*for client builds, or via an explicit(rf/init-platform :server)for server-side bootstraps. - When the fx resolver encounters an effect whose
:platformsdoesn't include the active platform, it skips the fx and emits a:rf.fx/skipped-on-platformtrace event (not an error;:op-type :warning) with:fx-id,:platform,:registered-platforms. Recovery::skipped(per 009 §Error contract). The fx silently no-ops with the trace. SSR does not get stricter than this; the trace event is sufficient observability without aborting render. Tools that want strict-mode behaviour register a trace listener on:rf.fx/skipped-on-platformand escalate. The same rule applies symmetrically to cofx: when a handler declares (:rf.cofx/requires) an ambient cofx whose:platformsexcludes the active platform, the cofx's value-returning supplier is NOT invoked, no value is delivered into:coeffects, and the runtime emits:rf.cofx/skipped-on-platform(same shape;:rf.cofx/id+:rf.cofx/platform+:rf.cofx/registered-platforms). The event handler still runs — only that one coeffect's delivery is skipped. - Default if
:platformsabsent:#{:server :client}(universal). SSR-shared fx and headless-test fx are universal by default. Explicit:platforms #{:client}is required for fx that genuinely cannot run server-side (browser-only). - Setup events that only matter on the server (
:rf/server-init, handlers declaring the:rf.server/requestcoeffect) carry:platforms #{:server}themselves so they don't run client-side after hydration.
This is the single mechanism for platform-gating; no per-fx branching inside handler bodies. Same handler dispatches the same effects on both platforms; the runtime decides which actually fire.
JVM-runnable view rendering¶
Pure hiccup → string emission as a JVM-runnable function. No JVM React. No component lifecycle on the server.
Concrete contract:
- The CLJS reference ships
re-frame.render/render-to-stringas a pure function over hiccup data. Implementation is a walk: tag → HTML element, attrs → escaped attribute pairs, children recursed, void elements (<br>,<img>,<input>, etc.) self-closed per HTML5, text content escaped per XSS rules. - The function is in
.cljc. No Reagent, no React, no DOM dependencies. JVM-runnable. - Views are invoked through their callable head at emission time (the Var
reg-viewdefs, or(rf/view :id)— a keyword head is an element, never a view, per §The head grammar is not Spec 011's to extend); the registry is just data, queryable from the JVM. - Subscriptions inside view bodies use
compute-sub(not the reactivesubscribe) — pure derivations against the staticapp-dbvalue. - Component lifecycle hooks (
:component-did-mount, etc.) do NOT fire on the server. Form-3 components render their:reagent-renderonly; lifecycle is client-side after hydration.
The JVM-runnable scope in 008's table reflects this: hiccup → string is JVM-runnable; React mount/commit is CLJS-only.
Routing and SSR¶
The request URL is fed in via :rf/server-init, which dispatches :rf.route/handle-url-change with the URL. Same handler runs server- and client-side (:platforms absent, so default-universal).
Concrete handshake:
- Server's
handle-requestcreates the per-request frame with:initial-events [[:rf/server-init request]](a record-config key onmake-frame; the request-derived setup vector is computed per request — see EP-0027 §SSR). :rf/server-init(:platforms #{:server}) reads the request's URI and dispatches[:rf.route/handle-url-change uri].:rf.route/handle-url-change(universal — runs on both platforms) calls(rf.routing/match-url uri), sets the route slice inapp-dbat[:rf.runtime/routing :current]. Per 012-Routing.- Route activation dispatches the route's
:on-matchevents fire-and-forget — synchronous seeding stays symmetric with the client, but an arbitrary asynchronous tail is not awaited (EP-0037 R1). The only route-owned server wait is a blocking:resourcesrequirement: activation builds the route's resource plan, ensures it, and the server waits for the blocking first loads of the current plan / nav-token. Managed page data that must be present before render is a blocking route resource, not an:on-matchevent (per 012 §Server-side rendering integration). - Drain settles and blocking resources resolve. The frame's route slice (
[:rf.runtime/routing :current]) carries the resource-derived readiness projection (:idle, or:loading/:errorwhen a blocking requirement is pending / failed — the same projector the client uses); the view renders against the populated state.
On the client, the same handshake runs after hydration restores state. The client's :rf.route/handle-url-change is fired by popstate listeners (browser back/forward) and by initial-load detection — server-pre-rendered pages already have the right route slice from hydration, so the client's initial render uses it without re-firing.
Fragments under SSR¶
Per 012 §Fragments, the :route slice carries :fragment. SSR rule:
- Parse the fragment from the request URL when the host's request abstraction exposes it. Most server frameworks (Ring, Pedestal, Express, Rails) include the
#fragmentonly in proxy/test scenarios — browsers do not send#fragmentto the server, so a server-side:fragmentis typicallynilfor browser-initiated requests. Static-site generators and crawlers that synthesise URLs with fragments (e.g., for anchored documentation pages) DO supply them; SSR honours those. - Include
:fragmentin the seeded:routeslice. Views that subscribe to:rf.route/fragmentproduce structurally-identical output server-side and client-side (per the hydration equivalence rule). :rf.nav/scrolldoes not run on the server. It's:platforms #{:client}per §Effect handling on the server. The server has no DOM; scroll-to-fragment is meaningless. The post-hydrate scroll behaviour is host-implementation choice (per 012 §Fragments §SSR).
The contract: the :fragment value is preserved across the SSR round-trip; no SSR-specific scroll behaviour exists.
Authentication / sessions¶
Server-side auth/session lives in app-db via a declared coeffect (:rf.cofx/requires) at :rf/server-init time. No SSR-specific auth surface.
The session feeds durable app-db that ships in the hydration payload, so it MUST arrive as a recordable fact via the §Durable request-derived facts boundary pattern — NOT an ambient :rf.server/request read folded into durable state at the write site (that is a replay hole: replay re-runs the live cofx supplier instead of re-presenting the session the recorded run folded).
Concrete:
- Server middleware (Ring/Pedestal/etc.) extracts + sanitizes the session from the request — cookie-based, JWT-based, whatever the host uses — to a wire-safe derived projection (e.g.
{:user "alice" :authed? true}), never the raw cookie / token. - The host adapter supplies the sanitized session as a recordable fact, either as event payload — the per-request frame's
:initial-events [[:auth/server-init {:user … :authed? …}]](a record-config key onmake-frame) — or by stamping a provided recordable:rf.cofxleaf (:auth.session/user) onto the boot token. :auth/server-init(:platforms #{:server}) reads the fact off:event(or declares:rf.cofx/requires [:auth.session/user]) and sets the relevantapp-dbslice:{:auth/user (or user nil) :auth/state (if authed? :authed :idle)}. A missing provided fact fails loudly with:rf.error/missing-required-cofxrather than silently re-reading the host.- The client side is hydrated with this slice; the user's authed-state survives the round-trip, and epoch-restore / replay re-presents the SAME session fact off the token.
The framework provides no auth-specific machinery. The pattern's primitives — events, recordable coeffects, frames per request — are sufficient.
:after is no-op under SSR¶
State machines that declare :after (per 005 §Delayed :after transitions) do not schedule timers in SSR mode. The state node's entry action skips the re-frame.interop/schedule-after! call when the active platform is :server; the synthetic timer-elapsed event is never queued; the request frame is destroyed before any timer could fire anyway.
The rule:
- The server renders the machine's current
:statestatically. Whatever timer-driven transitions might be pending have not happened — they don't exist on the server. - The serialised
app-dbsnapshot includes[:rf.runtime/machines :snapshots <id>]with the current:stateand:data(including the per-decl-path:rf/after-epochmap); the client hydrates that snapshot. - After hydration, the client's first render of the relevant view is what triggers the machine to "enter" the state for client-side purposes — the implementation may re-fire entry actions on hydration to begin scheduling, or may treat hydration as a special case that schedules
:aftertimers without re-running other entry effects. The exact handoff is a host-implementation choice; the contract is that the snapshot value is preserved across the round-trip and that:aftertimers begin running on the client (per the snapshot's epoch) rather than on the server.
Symptoms of getting this wrong: the server schedules a 5-second :dispatch-later; the request frame is destroyed; the timer fires against a destroyed frame (CLJS) or a freed channel (host-equivalent); a stray trace event surfaces or, worse, the timer's effect lands in some other in-flight frame's app-db.
Why it's safe to elide:
:aftertimers are state-entry-relative. They have no semantic meaning until the user is interacting with the page — i.e., until after hydration. There's nothing to lose by deferring scheduling to the client.- Epoch-based stale detection makes the round-trip idempotent: even if a server-scheduled timer somehow leaked, the client's epoch would be different and the timer would be ignored at expiry. Eliding scheduling outright avoids the leak.
- Per
:platformsgating (§Effect handling on the server), the implementation can register the timer-scheduling fx with:platforms #{:client}— the server-side fx resolver silently no-ops it without further machine-handler awareness.
This is the only SSR-specific carve-out the state-machine substrate needs; all other machine semantics (transitions, :always, :spawn, hierarchical entry/exit cascading) run identically on both platforms.
Hydration of non-state runtime artefacts¶
:rf/hydration-payload carries the canonical durable frame-state — :rf/app-db (the app-db partition) plus the optional :rf/runtime-db (the serializable runtime-db projection: machine snapshots, route slice, elision declarations, SSR metadata). Sub-cache warmups, in-flight request continuations, and other transient runtime artefacts are out of scope.
Rationale: the hydration contract is small and tractable. Adding sub-cache warmups requires the client to know the same sub-graph topology the server used (which is true, since registrations are static — but it adds wire bytes and serialisation complexity). In-flight request continuations require persistent fx implementations on both ends. Both can be added later as additive payload fields without breaking the contract.
The schema for :rf/hydration-payload (in Spec-Schemas) lists :rf/sub-warmups as optional.
On hydration:
- Client receives the
:rf/app-db+:rf/runtime-dbslices and replaces its frame-state (both partitions) with them (per the locked:replace-frame-statepolicy in §The:rf/hydrateevent). Server is authoritative for the initial client frame-state. - Client's reactive subscriptions, on first read, compute against the now-seeded state. Same values the server saw.
- There is intentionally no special-case for "warm" subs vs "cold" subs. The first read is the warmup.
Streaming SSR¶
Status: shipped. The
:rf/suspense-boundaryhiccup marker described below ships in theday8/re-frame2-ssrartefact (re-frame.ssr.streamingns); the chunked-response wiring ships inday8/re-frame2-ssr-ring(re-frame.ssr.ring.streamingns +stream-handler).
Streaming SSR lets the server flush a usable shell on first byte, then stream subtrees as their data resolves. Crawlers, low-latency rendering, and SOTA parity with Next/Remix defer and Solid Suspense all benefit. The primitive is one component — declarative, walker-driven, no per-host streaming-render-mode API.
The boundary component — the authoring surface¶
(require '[re-frame.ssr :as ssr])
[ssr/boundary
{:id :news/comments ;; required, namespaced keyword or string
:fallback [:p "Loading comments…"]} ;; required, hiccup
[comments-section]] ;; subtree — the deferred body
The boundary is a component, not a hiccup keyword, and that is normative. A keyword head is an HTML element on every host (§The head grammar is not Spec 011's to extend; Conventions §Render-tree shape vs runtime lookup), so a :rf/suspense-boundary left in a client render tree paints a phantom <suspense-boundary> element — silently, because the name passes the DOM tag grammar. Nor can the marker be given client semantics: stock Reagent is an external dependency whose element dispatch is not the framework's to extend, and UIx views are defui / $ forms where a hiccup keyword head cannot occur at all. A callable component head is the only form expressible on every substrate, so it is the authoring surface.
:rf/suspense-boundary remains, demoted to internal wire syntax between the component and the shell walker. Implementations MUST NOT present it as an authoring surface, and a render tree an application author writes MUST NOT contain it.
Host semantics:
| Host | boundary evaluates to |
|---|---|
Server (:clj) |
[:rf/suspense-boundary {:id … :fallback …} & body] — the marker the shell walker below consumes. The non-streaming emitter's :rf.error/ssr-suspense-boundary-outside-stream reject is unchanged, so a marker escaping a stream still fails loud. |
Client (:cljs) |
body — or :fallback, when this boundary's :id is in the page's failed-boundary record (§Failed boundaries on the client). A lone child renders as itself; several are spliced into a :<> fragment, matching the walker's continuation-subtree construction so the two hosts' structures agree. |
Both hosts hash the same raw tree (§Hydration-mismatch detection): a component head canonicalises to the existing #fn[] token, so no hash change is required. This is a property the alternative could not have — a reader-conditional boundary slot made the two hosts hash structurally different trees.
Missing :id or :fallback raises :rf.error/suspense-boundary-invalid-attrs on either host, so the authoring mistake reads identically whichever one catches it first.
Operational semantics (the emitter contract):
- The streaming emitter walks the hiccup tree top-down.
- On a
:rf/suspense-boundarynode, the emitter: - emits the rendered
:fallbackwrapped in<template data-rf2-suspense-id="<id>" data-rf2-suspense-fallback="1">…</template>, - records a continuation entry
{:id <id> :subtree <body-hiccup>}in the per-request streaming-continuations registry, - continues walking sibling nodes (the shell is single-pass; nested boundaries are recursed into the continuation's body when the continuation later renders).
- After the shell HTML is materialised, the host adapter drains continuations one by one (order is FIFO over registration order; nested boundaries inside a continuation register their own entries during the continuation render). For each continuation:
- render the subtree to HTML via
render-to-string(same emitter, recursion-friendly — a nested:rf/suspense-boundaryre-recurses through this same drain), - build the per-subtree hydration delta (the subset of
app-dbkeys touched between the start of the continuation render and its end; see §Hydration interleaving below for the partitioning rule), - emit one chunk carrying
<template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1">…subtree-html…</template>followed by<script data-rf2-suspense-hydrate="<id>" type="application/edn">…delta-edn…</script>, - flush the chunk.
- After the last continuation drains, the host adapter emits the final-hydration-payload chunk (
<script id="__rf_payload" type="application/edn">…full-payload…</script>) and closes the response. The final payload carries the canonical:rf/hydration-payloadshape — it is the source of truth on hydration; the per-subtree deltas are speculative chunks the client may apply progressively.
The boundary :id is stable per render — the hiccup author picks it. The runtime does not autogenerate boundary ids; that would defeat hydration matching when the client re-walks the tree.
Client-side hydration semantics (the shipped re-frame.ssr.streaming.client/install! contract):
- On install: the client materialises each inert
<template data-rf2-suspense-fallback="1">into a live, visible mount — an<rf-suspense data-rf2-suspense-mount="<id>">…fallback…</rf-suspense>wrapper holding the fallback markup. This is load-bearing: a<template>'s content is inert by the HTML spec (.contentis a detachedDocumentFragment, never painted), so the user would see nothing until swap if the fallback stayed wrapped. The visible mount is also the stable swap target the resolved chunk replaces. (Same model React 18 / Solid use — a visible fallback plus a stable mount — expressed over the server's<template>-marker protocol.) The shell otherwise hydrates against whatever payload is already inlined (none, in the streaming case) — the streaming bootstrap waits for the__rf_payloadscript, which arrives last. - As resolved-subtree chunks stream in (driven by a
MutationObserver, plus an initial synchronous sweep for chunks that landed before the bundle booted): the runtime, per matched boundaryid, replaces the live mount's content with the resolved<template>'s parsed content in-place, and merges the per-subtree hydration delta into the target frame'sapp-dbvia a top-level(into existing delta)merge (so a subscription reading the now-resolved region sees the speculative state). This happens progressively, before the final payload — the speed prop of suspense boundaries. Adata-rf2-suspense-failed="1"chunk swaps the fallback HTML and applies no delta, surfacing a client-side:rf.ssr/suspense-boundary-failedtrace (Spec §Failure semantics — inline fallback) without a 500. - Finalization. The
__rf_payloadnode's arrival means the stream is complete, and triggers a once-only finalization step: process any chunk still pending, consume or quarantine the last deltas and record each boundary's outcome, unwrap every<rf-suspense>mount (preserving children), disconnect the observer, then signal readiness via the:on-readycallback with{:resolved #{ids} :failed #{ids}}. Ordering is normative — sweeping after unwrapping would leave a resolved chunk with no mount to swap into, and signalling readiness before unwrapping would hand the bootstrap a DOM that still carries protocol wrappers. Finalization also runs — synchronously, duringinstall!— when the whole response had already buffered before the bundle booted, so a readiness-driven bootstrap cannot hang on a fast page. - Hydration, from readiness. The bootstrap (
ssr/hydrate!) dispatches:rf/hydratewith the full payload — the consistency moment; the deltas were speculative, the final payload is canonical (:replace-frame-state) — and the adapter'shydrate-rootadopts the DOM. Both run from:on-ready, never on a timer and never on a DOM poll.
The protocol: progressive pre-hydration paint, then one ordinary hydration¶
This is the contract Spec 011 previously left unstated, and its absence is why the shipped example had nothing correct to copy.
Protocol DOM is transport, never part of the application tree. The fallback / resolved <template> chunks, the <rf-suspense> mounts, and the data-rf2-suspense-hydrate delta <script>s exist to carry a streamed page across the wire and to paint it before the bundle boots. None of them is expressible in a render tree on any host, none participates in the render-tree hash, and none may be present when hydration runs. A streamed page's lifecycle therefore has exactly two phases:
- Progressive pre-hydration paint — the shell paints, fallbacks become visible mounts, resolved chunks swap in, deltas merge. No framework handlers exist inside the streamed root; nothing is React-owned; no root has been created.
- One ordinary whole-root hydration — after finalization, against a DOM that is byte-identical to what the equivalent non-streamed render of the same tree produces.
A host MUST NOT take React ownership of the streamed root before readiness — neither hydrate-root (the DOM still carries mounts, so every boundary is a structural mismatch) nor create-root (which discards the streamed markup entirely, paying streaming's cost for none of its benefit). In particular, a bootstrap MUST NOT fall through to create-root merely because the payload has not landed yet: on a live stream that is true for most of the page's life.
Failed boundaries on the client¶
A boundary whose continuation threw ships its fallback HTML in the chunk (§Failure semantics — inline fallback) and no delta, so after finalization the DOM at that boundary holds the declared fallback markup. The client render tree must agree.
The server has always known :failed? per continuation. It is now carried:
- The host adapter accumulates the failed ids as it drains and passes them to
build-final-payload, which records them in the serialisable runtime-db slice at[:rf.runtime/ssr :streaming :failed-boundaries]— under the already-reserved:rf.runtime/ssrkey, a sibling of the:hydrationmetadata. It rides the existing:rf/runtime-dbpayload key and installs through the existing:replace-frame-statehydrate; no new payload key, no hydrate-handler change. An empty set contributes no key — an ordinary page carries nothing extra on the wire. - The streaming client records the same outcomes at finalization, in a frame-free page-level record the
boundarycomponent consults at render time. Frame-free is required, not incidental:boundaryis a plain function component, and a plain fn cannot read an enclosingframe-provider's frame from React context on Reagent (§Frame-provider via React context), so a frame-scoped read is structurally unavailable to it. Boundary ids are unique per page by contract, so an id identifies a boundary without a frame.
The consequence is the ergonomic win: the boundary that declared the fallback is the one that re-renders it. Views need no defensive nil branch duplicating the fallback to keep the client's render agreeing with the DOM the stream painted.
The streaming runtime (re-frame.ssr.streaming.client/install!, re-exported as ssr/streaming-install!) ships in day8/re-frame2-ssr and is host opt-in — a streaming-aware bootstrap calls it; non-streaming pages skip it entirely. It is CLJS-only (it installs a MutationObserver + swaps DOM). install! takes {:frame :payload-id :root} where :frame is required (the delta-merge target is carried, the same frame the bootstrap hydrate!s into; an absent :frame raises :rf.error/no-frame-context, never :rf/default) and :payload-id / :root default to "__rf_payload" / js/document. It returns a 0-arity stop! fn (auto-disconnect on final payload means most hosts never call it). It is idempotent per chunk (a seen-set guards against observer batching + the initial sweep racing the same node).
Delta wire shape (shipped). The <script data-rf2-suspense-hydrate="<id>" type="application/edn"> body is the bare delta-map EDN ((pr-str delta)) — the boundary id is carried by the data-rf2-suspense-hydrate attribute only, NOT a {:rf/app-db-delta … :rf/boundary-id …} wrapper. The client reads the attribute back into an id via the EDN reader and merges the bare delta-map. (Both the template data-rf2-suspense-id and the script data-rf2-suspense-hydrate attributes carry (escape-attr (str id)), so a keyword id round-trips unchanged through read-string.)
Failure semantics — inline fallback¶
Per (a) sub-rec: when a continuation's render throws, the runtime does NOT fail the whole response. Instead:
- The throwable is caught at the continuation drain step.
- A
:rf.ssr/suspense-boundary-failedtrace event fires (per 009 §Error event catalogue) with{:id <boundary-id> :exception t :recovery :inline-fallback}. - The runtime emits the chunk as
<template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1" data-rf2-suspense-failed="1">…fallback-html…</template>— the fallback's HTML, materialised against the original fallback hiccup, with thedata-rf2-suspense-failedmarker for client-side observability. - The per-subtree hydration delta is omitted for failed boundaries (there is no resolved state to ship; the client keeps its pre-failure delta).
- The response continues — sibling boundaries, the final payload, and the close all proceed normally.
Rationale: streaming SSR's whole point is partial-render robustness. A failed sub-subtree (a flaky comments service, a slow third-party dependency) should not 500 a page whose shell, head, and other subtrees rendered successfully. The fallback is already the author's declared "things-are-loading" surface; reusing it on hard failure is a defensible default and the simplest opt-in alternative (:on-error [:hiccup-vec]) is additive future work.
The failure boundary stops at the continuation. An exception inside the shell walk (before the first chunk has flushed) is NOT covered by this contract — the shell is the request's structural foundation and a shell-render throw escalates to :rf.error/ssr-render-failed per the standard error-projection path. The streaming contract only covers continuations.
Pre-commit rule (streaming). Because the chunked head cannot be retracted once its first byte is on the wire, the streaming host MUST decide the response arm on the request thread, before materialising the head. A projected 5xx discovered before commit — a drain-time exception, a shell-render throw, OR a reactive sub that recovered to nil during the shell render (which does not throw but buffers a fail-closed status) — fails closed to the non-streamed projected-error arm (§Drain-time error classification): a plain-String error body (:error-view or the locked default template) under the projected status, with no pipe and no writer thread spawned and no partial-state shell shipped. The streaming response is selected only once the shell is known-renderable (a clean render AND no projected 5xx). Only after the head commits does the inline-fallback / truncate-and-close contract above apply — a post-commit writer failure is :rf.error/ssr-streaming-writer-failed, telemetry only, never re-projected.
Hydration interleaving — per-subtree deltas¶
Per (a) sub-rec: the hydration payload is interleaved per subtree, not shipped last. Each resolved-subtree chunk carries a delta of the app-db keys touched by that continuation's render path; the final payload carries the canonical complete state.
The partitioning rule for what each delta carries:
- The streaming runtime snapshots
app-dbat the start of each continuation render (before-db) and again at the end (after-db). - The delta carries the top-level keys present-or-changed in
after-db, and for each such key its fullafter-dbvalue. The changed-or-new key set is(clojure.data/diff before-db after-db)'s second return slot (the keys only-in / changed-inafter-db); implementations may use any equivalent structural-diff strategy that yields the same key set. The delta ships the full value for each of those keys — notdata/diff's partial second-slot value, which for a changed nested map returns only the changed sub-portion. Shipping the full value is what keeps the client's top-level(into existing delta)merge below lossless: a changed nested top-level key is replaced wholesale with its complete new value rather than having its untouched sub-keys silently dropped. Unchanged top-level keys are omitted (the client already holds them). - The per-subtree delta is shipped as the bare delta-map EDN in the
<script data-rf2-suspense-hydrate="<id>" type="application/edn">chunk body (the boundary<id>is carried by thedata-rf2-suspense-hydrateattribute, not a wrapper); the client merges it intoapp-dbvia(into existing delta)over the top-level keys. Because each delta value is a completeafter-dbvalue, this top-level merge is lossless even for changed nested keys. (This is the shipped contract: the server emits(pr-str delta), not a{:rf/app-db-delta … :rf/boundary-id …}envelope; carrying the id once on the attribute keeps the script body a plain delta-map both sides agree on.)
Boundary ordering (registration → drain → chunk emit) is FIFO over the shell's walk order (depth-first, document order). A nested :rf/suspense-boundary inside a continuation's body registers DURING that continuation's render — the inner entry lands at the tail of the registry and drains after all originally-registered entries. This preserves the document-order intuition: each chunk hydrates a strictly-later DOM region than the previous chunk.
Final-payload precedence: the __rf_payload chunk arrives last and carries the canonical app-db. Implementations may detect drift between the accumulated deltas and the final payload (a non-empty diff after applying both); the v1 contract is the final payload wins — :rf/hydrate runs :replace-frame-state semantics, the same lock as non-streaming SSR. Deltas are the progressive-rendering speed prop; the final payload is the correctness lock.
Chunk-ordering contract (the wire shape)¶
The host adapter MUST emit chunks in this order:
- Shell chunk —
<!DOCTYPE html><html>…<body>…<div id="app"><shell-html-with-template-fallbacks/></div>— flushed immediately after the shell walk completes. - N resolved-subtree chunks — one per boundary, in registration-order — each chunk is
<template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1">…</template><script data-rf2-suspense-hydrate="<id>" type="application/edn">…</script>. - Final-payload chunk —
<script id="__rf_payload" type="application/edn">…full-payload…</script>. - Closing chunk —
</body></html>.
The chunk content uses HTTP Transfer-Encoding: chunked framing; the application-layer ordering above is what the conformance fixture pins.
Streaming does NOT accept :html-shell¶
stream-handler shares the non-streaming handler's construction contract — the required opts (:initial-events / :root-view), the hydration-payload policy, the :on-error precedence, and the four trusted shell-hook opts (:head / :body-end / :script-src / :app-element-id, §Trusted shell hook contract) — with one exception: it does NOT accept a custom one-piece HTML-shell override (the CLJS reference's :html-shell opt, a (body-html payload-edn opts) → string fn honoured by the non-streaming ssr-handler).
The chunk-ordering contract above is why. The non-streaming handler has the full body + payload in hand before it composes the envelope, so a one-piece shell can wrap them arbitrarily. The streaming handler flushes the envelope as a split prefix/suffix straddling the continuation chunks — the prefix on the shell chunk (1), the suffix on the closing chunk (4), with N continuation chunks and the final payload in between. A one-piece shell callback can never run after streaming has started; it could only ever apply to a body the streaming path does not assemble in one piece.
The framework therefore MUST fail closed at handler-construction time: stream-handler rejects a non-nil :html-shell (any shape) with :rf.error/ssr-streaming-unsupported-opt (ex-data carries :opt-key :html-shell, :got, and :recovery). An absent or explicit-nil :html-shell constructs cleanly (no override requested). Silently dropping the opt would be a fail-OPEN gap — a custom shell commonly carries CSP nonces, asset URLs, analytics/script policy, or root markup, and a deployment switching ssr-handler → stream-handler would lose all of it with no signal.
Callers needing custom shell content under streaming use the split-envelope surface instead: the four trusted shell-hook opts above (honoured by default-streaming-prefix / default-streaming-suffix). A caller that genuinely needs a single one-piece shell fn must use the non-streaming ssr-handler.
Boundary nesting and recursion¶
:rf/suspense-boundary nodes nest. When a continuation's render walks into another :rf/suspense-boundary, the inner boundary registers a new continuation at the tail of the drain queue. The inner subtree's resolved chunk arrives after all originally-registered continuations — the order is strictly registration-order (FIFO).
Edge case — the same :id appearing twice (e.g. a buggy author using :id :news/comments on two separate boundaries): the runtime emits :rf.error/suspense-boundary-duplicate-id (per 009 §Error event catalogue) and the second registration overwrites the first in the drain queue. The wire ships only the last-registered continuation's chunk; the earlier <template data-rf2-suspense-id="<id>" data-rf2-suspense-fallback="1"> placeholder is left in place (the client-side runtime never finds a matching resolved chunk and keeps the fallback rendered). Duplicate ids are a programmer error; the contract is fail-soft (no 500), with a visible trace.
Duplicate detection is keyed on the canonical wire id — (str id), the exact value stamped into data-rf2-suspense-id / data-rf2-suspense-hydrate and matched by the client — not the raw :id value. Two boundaries whose ids differ as values but collide under str (e.g. the keyword :a and the string ":a") therefore count as duplicates: they share one data-rf2-suspense-id on the wire, so the client cannot tell them apart, and shipping both chunks would let the client resolve the wrong mount or skip the later chunk via its seen-set. Keying detection on the wire id keeps the server's duplicate contract aligned with the one string the client actually matches against.
Late-bind hook surface¶
The streaming surface composes from three late-bind hooks (per Conventions §Late-bind hook key grammar):
| Hook | Producer | Consumer | Purpose |
|---|---|---|---|
:ssr.streaming/render-shell! |
re-frame.ssr.streaming |
host adapters | Walk the root hiccup, emit the shell HTML with <template> fallbacks, return {:shell-html "…" :continuations [{:id … :subtree …} …]}. |
:ssr.streaming/render-continuation! |
re-frame.ssr.streaming |
host adapters | Render one continuation's subtree to {:html "…" :delta {…} :failed? false} (or fallback-html + :failed? true on throw). |
:ssr.streaming/build-final-payload |
re-frame.ssr.streaming |
host adapters | Build the final __rf_payload chunk's payload map after all continuations have drained — the canonical :rf/hydration-payload shape, including the post-drain app-db. |
Host adapters call these three hooks in order; the streaming runtime owns the walker, the registry, and the delta computation. The Ring adapter wires them via re-frame.ssr.ring.streaming/stream-handler (per §Cross-references).
Fn-form :root-view invocation count under streaming (rf2-t72b1c)¶
A fn-form :root-view is not guaranteed to be idempotent (unsorted-map iteration order, gensym'd keys, time-of-day props can all vary between calls); the non-streaming handler resolves it exactly once per request and reuses that single result for both the wire HTML and the payload's :rf/render-hash (rf2-6t36h). A second, silent invocation would hash two structurally-different trees and fire a spurious :rf.ssr/hydration-mismatch unrelated to any real state change.
Streaming host adapters MUST honour the same exactly-once invocation for a request whose shell contains zero :rf/suspense-boundary continuations — the common case — since nothing runs between the shell render and the final-payload build that could change reactive state in between; the pre-drain render-tree hash is reused verbatim for the final payload.
A request with at least one continuation is the sole exception: the final payload ships the live post-drain app-db (:ssr.streaming/build-final-payload reads it after every continuation has drained), so when a continuation mutates app-db and the root tree reads the mutated key, the pre-drain hash and the shipped post-drain state would describe different moments — itself a violation of the hydration equivalence rule. Host adapters therefore re-resolve :root-view a second time in that case, trading the narrower exactly-once guarantee for a correct post-drain :rf/render-hash. The two hashes coincide whenever no continuation mutates a root-read key.
Writer concurrency model — one daemon thread per in-flight stream¶
The chunked-response body is a PipedInputStream/PipedOutputStream pair: the Ring server reads from the input side while a writer runs on its own thread pumping shell → continuations → final-payload → close into the output side. The CLJS-JVM reference (re-frame.ssr.ring.streaming/stream-handler) spawns one raw java.lang.Thread per in-flight streamed request — there is intentionally no framework-imposed thread pool, executor, or in-flight cap. The concurrency posture is documented here as a deliberate v1 choice:
- The writer thread is a daemon, named
rf2-ssr-streaming-<frame-id>. The daemon flag is load-bearing for shutdown: a writer blocked on.writeto a slow-loris client's full 16 KiB pipe must not pin the JVM open at shutdown. The thread body is wrappedtry/catch Throwable/finally: the catch emits:rf.error/ssr-streaming-writer-failedand thefinallyalways closes the pipe (signalling a clean EOF to the server), then the spawningfinallytears the per-request frame down (destroy-frame-quietly!) off the response-close path. This teardown contract is no-leak — every writer thread terminates and is reclaimed once its request completes or its client disconnects; the count decays to zero (verified byconcurrency_stress_test'sdaemon-thread-count-bounded-during-burst). - The in-flight CEILING is the host server's concern, not the framework's. The number of simultaneously-live writer threads equals the number of in-flight streamed responses, which is bounded by the HTTP server's own accept-queue / worker-thread limits (Jetty, http-kit, Aleph all impose these in front of the handler). The framework does NOT add a second ceiling. Operators sizing for high streaming concurrency — or hardening against a pathological slow-client population that could otherwise hold one ~1 MB-stack platform thread per stuck request — MUST size the host server's request-concurrency limits accordingly; that is the single, authoritative knob. A framework-side pool would either duplicate that limit or, worse, risk breaking the proven no-leak teardown by decoupling thread lifetime from request lifetime.
- Forward path (non-normative). JDK 21+ virtual threads make per-request threads effectively free; a future opt-in writer thread-factory (
:writer-thread-factory) could let hosts supply a virtual-thread or bounded-executor factory without changing the per-request-thread model or its teardown contract. This is additive future work, not a v1 requirement — the raw-daemon-thread-per-request model is the locked baseline.
Other-language ports mirror the contract — one isolated writer context per in-flight stream, daemon/background semantics so it can't pin shutdown, guaranteed pipe-close + frame-teardown on every exit path, and the in-flight ceiling delegated to the host server — not the literal java.lang.Thread.
Payload liveness — fail closed on frame teardown mid-assembly¶
The streaming final-payload build (:ssr.streaming/build-final-payload) reads the request frame's app-db + runtime-db and projects them through THAT frame's per-frame classification / elision registry (§:rf/app-db projection). Because the writer runs on its own daemon thread (§Writer concurrency model), an async host event — client disconnect, timeout, writer error, or cancellation cleanup — can destroy the request frame between the state capture and the projection, or destroy AND re-register a fresh frame under the same id. Either way the frame that held the classification authority is gone, so projecting the captured state against the now-absent or SUBSTITUTED policy would ship classified state raw — a fail-OPEN leak.
The build therefore fails closed by redaction, gated on the frame's per-incarnation identity token (re-frame.frame/frame-incarnation-token, rf2-j538f7.15). It pins the token BEFORE reading state, then re-checks it after capture. The captured state is coherent only when the pin is non-nil (the frame is still live) AND identical? to the current token (the SAME incarnation — the token is the frame record's :drain-lock, preserved across in-place record swaps but distinct across a destroy-frame! + fresh make-frame of the same id). A nil pin (frame already gone) or a changed token (destroyed / re-registered mid-assembly) is not coherent: the build redacts the whole :rf/app-db slice to :rf/redacted and omits the :rf/runtime-db slice, while STILL stamping the requested render-hash and payload shape — so the client receives a well-formed, safe payload rather than a leak or a malformed blob. This is the same fail-closed posture the §:rf/app-db projection applies to an unresolvable / destroyed frame, extended to the destroyed-DURING-projection race the async streaming writer opens.
Per-request frame teardown contract¶
Per §Server flow every per-request server frame ends with destroy-frame!. The destroy step is load-bearing for memory hygiene on a long-running server process — leaks here compound at request-rate, and a slow leak under prod load is the kind of bug that ships SSR-broken.
The framework owns the following per-frame allocation sites; all MUST be released by destroy-frame!:
| Slot | Owning ns | Storage | Released by |
|---|---|---|---|
app-db (the frame's state container) |
re-frame.frame |
per-frame, on the frame record | the frame record is dropped (dissoc-frame!) |
| router queue + drain-lock | re-frame.frame |
per-frame, on the frame record | the frame record is dropped |
| sub-cache | re-frame.subs |
per-frame, on the frame record | tear-down-sub-cache! disposes every cached reaction; the cache atom is reset to {} |
| HTTP response accumulator | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the :ssr/on-frame-destroyed hook drops the slot |
| pending error-trace buffer | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the :ssr/on-frame-destroyed hook drops the slot |
| per-frame HTTP request slot | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the :ssr/on-frame-destroyed hook drops the slot; host adapters MAY also clear inline via clear-request! |
| epoch ring buffer | re-frame.epoch |
defonce atom keyed by frame-id |
the :epoch/on-frame-destroyed hook drops the slot |
| per-frame trace ring (event-keyed) | re-frame.trace.tooling |
defonce atom (trace-rings) keyed by frame-id |
the :trace.tooling/release-frame-ring! hook drops the frame's ring (dev-only; no-op in production, where trace.tooling is elided) |
Fleet knob for request frames. The trace ring is per-frame and released with the frame, so it never accumulates across requests (per Spec 009 §Per-frame trace rings). A fleet running SSR under a dev/debug build that still wants zero per-request trace retention sets :rf.trace/events-retained 0 in the request frame's metadata: the ring is disabled outright (synchronous listener delivery still works, but no history is retained for that frame). Production JVM builds set -Dre-frame.debug=false and the ring machinery is elided entirely — see 009 §Production builds.
What survives a per-request frame's destruction (these are NOT leaks — they are process-wide registries that mirror handler registration shape):
- The global registrar (
re-frame.registrar/kind->id->metadata) — event / sub / fx / cofx / view / route / error-projector registrations are process-wide; they exist independently of any frame and do not leak per-request. (Flows are not among these: the:flowregistrar kind is reserved-empty —reg-flowwrites only to the sole per-frame flow store{frame-id {flow-id flow-map}}, which is per-frame state the destroyed frame's:flows/teardown-on-frame-destroy!recipe releases, so a per-request server frame's flows do NOT survive its destruction — see 002 §Two destroy-hook verbs.) - The process-wide substrate adapter lifecycle slot — one exact installed generation,
set at boot and terminally cleared only by
destroy-adapter!(Spec 006).
The contract for side-channel atoms keyed by frame-id: every such atom MUST register a cleanup hook with re-frame.late-bind and that hook MUST be invoked from frame/destroy-frame!. The SSR side-channel keys are :ssr/on-frame-destroyed and :epoch/on-frame-destroyed (the destroyed-frame cleanup callback verb). New artefacts that introduce per-frame side-channel state MUST publish such a hook — using whichever of the two normative verb forms fits the work: <feature>/on-frame-destroyed! for a side-table cleanup callback (the SSR shape here), or <feature>/teardown-on-frame-destroy! for an artefact-owned teardown recipe carrying lifecycle/registrar-consistency invariants (the machines/flows shape). The two verbs are a real semantic distinction, not a style choice — see 002 §Two destroy-hook verbs for the rule and 012 §Frame-destroy teardown for the per-artefact catalogue.
Verification: the load test at implementation/ssr/test/re_frame/ssr_teardown_load_test.clj drives the documented per-request SSR flow N times against the same host adapter, snapshots the JVM heap before and after a GC pause, and asserts the heap delta and the side-channel atom sizes return to baseline.
Open questions¶
SA-4 classification. Per SPEC-AUTHORING §SA-4: no blocking items outstanding at the 011-SSR tier. The streaming surface formerly listed here was classified
:resolvedwhen §Streaming SSR landed; the resolved entry lives at## Resolved decisionsbelow. The four items below are additive forward slots — each has a locked emit contract already documented in the body, and the deferred work is only the consumer flip that turns the slot on. They are demand-triggered:post-v1deferrals, not:still-blockingquestions; per SA-4 a:post-v1 trackeditem needs arf2-<id>tracking bead, and each item below is honestly marked untracked note because no dedicated tracking bead exists yet — the record is the concrete fires-when trigger that would justify filing one.
Additive SSR forward slots — demand triggers (SA-4 :post-v1, untracked notes)¶
Each slot's on-wire / emit contract is locked in the body; the flip is cheap once demanded. The trigger is the concrete condition under which the deferred work earns a tracking bead.
| Slot | Where the contract lives | Classification | Fires-when trigger |
|---|---|---|---|
Runtime-side head-mismatch attribution (the bundled runtime feeding :rf/head-hash through verify-hydration! with :failing-id :rf.ssr/head-mismatch) |
§Head/meta contract — Status (596), §Default flow step 6 (652), §Mismatch detection — head (677) | :post-v1 — untracked note (no dedicated build bead). NOTE (rf2-1oxjxk): the head-hash CHANNEL itself has SHIPPED — data-rf-head-hash wire attr + :rf/head-hash payload key are emitted over the canonical head model. What remains deferred is only the runtime emitting the head-vs-body-discriminated mismatch trace; a host attributes it today through the :failing-id seam. |
A host actually needing the bundled runtime to attribute head mismatches (rather than the host feeding the head hash through verify-hydration! itself via the :failing-id seam), or SEO/link-unfurl tooling wanting the runtime to surface head-vs-body divergence directly. Either turns runtime-side attribution from a nicety into a demanded capability. |
Streaming per-boundary :on-error hiccup override (:on-error [:hiccup-vec] on :rf/suspense-boundary) |
§Failure semantics — inline fallback (rationale at ~1004) | :post-v1 — untracked note (no dedicated bead; the inline-fallback default is :resolved and shipped) |
An app needing error-distinct fallback UI — a boundary whose hard-failure surface must differ from its loading fallback (e.g. "comments unavailable" vs the loading skeleton). The v1 default reuses the loading fallback on failure; a demanded error-distinct surface is the trigger. |
:writer-thread-factory opt-in (host-supplied virtual-thread / bounded-executor factory) |
§Writer concurrency model — Forward path (1069, non-normative) | :post-v1 — untracked note (no dedicated bead; the raw-daemon-thread-per-request baseline is :resolved and locked) |
Operator demand — a host requiring bounded executors or JDK 21+ virtual threads for its streaming-concurrency posture (the raw-java.lang.Thread-per-request model is the locked baseline; the factory is purely additive and must not disturb the no-leak teardown contract). |
:rf/sub-warmups payload key (pre-computed sub values on the wire) |
§Payload scope (73), §Resolved decisions — Sub-cache warmups out of scope (1119), Spec-Schemas :rf/hydration-payload |
:post-v1 — untracked note (no dedicated bead; the slot is :resolved as additive-and-absent-in-v1) |
A measured first-read hydration-cost — evidence that recomputing subs on the client's first read is a real latency cost worth the wire bytes + sub-graph-topology coupling. Absent that measurement the slot stays documented-but-empty ("the first read is the warmup"). |
No bd create performed (per this cluster's brief). Each row above is the identification: any row whose trigger fires warrants filing a rf2-<id> bead at that time, converting the row from untracked note to :post-v1 tracked per SA-4's cross-link rule.
Resolved decisions¶
Streaming SSR shipped as :rf/suspense-boundary¶
Per §Streaming SSR the framework now ships a hiccup-marker streaming primitive (:rf/suspense-boundary) plus a chunked-response host adapter (re-frame.ssr.ring.streaming/stream-handler). Earlier drafts of this Spec carried streaming under "Open questions" as a host-implementation concern; closed with Mike's (a) pick — declarative marker, walker-driven, no per-host streaming-render-mode API — plus three sub-recs (accepted): (1) inline-fallback failure semantics, (2) interleave-per-subtree hydration ordering, (3) the :rf/suspense-boundary name. SOTA parity with Next/Remix defer and Solid Suspense. The conformance fixture at spec/conformance/fixtures/ssr-streaming.edn pins chunk ordering plus final-payload hash equality; the worked example at examples/capabilities/ssr/ssr_streaming/ exercises the dashboard-with-slow-cards scenario.
:replace-frame-state is the locked hydration merge policy¶
Per §The :rf/hydrate event the client's :rf/hydrate handler replaces the frame-state (both partitions — app-db and the serializable runtime-db projection) with the server's serialised slices; earlier sketches considered a merge policy that would have preserved client-side pre-seeded state. The merge variant was rejected because the hydration contract is small and tractable only if the server is authoritative for the initial client frame-state — a merge policy makes "did the server's value win?" undecidable at every key. Apps that want client-only seeded state run it after the hydration event, not before.
Sub-cache warmups out of scope for v1 :rf/hydration-payload¶
Per §Hydration of non-state runtime artefacts, :rf/hydration-payload carries :rf/app-db plus the serializable :rf/runtime-db projection — no pre-computed sub values. Adding sub-cache warmups requires the client to know the same sub-graph topology the server used and adds wire bytes plus serialisation complexity. The first read is the warmup. :rf/sub-warmups remains an optional additive payload field in Spec-Schemas §:rf/hydration-payload; a future iteration can land it without breaking the contract.
Per-request frame teardown contract added¶
Per §Per-request frame teardown contract the framework now documents every per-frame allocation site that destroy-frame! MUST release, including three re-frame.ssr defonce side-channel atoms (HTTP response accumulator, pending error-trace buffer, per-frame HTTP request slot). All three are released via the :ssr/on-frame-destroyed re-frame.late-bind hook; the contract for side-channel atoms keyed by frame-id is locked. The load test at implementation/ssr/test/re_frame/ssr_teardown_load_test.clj (2000-request synthetic SSR loop) verifies the heap delta and side-channel atom sizes return to baseline.
Head/meta surface is live, not deferred¶
Earlier drafts of §Head/meta contract carried a deferral banner pointing to ; the contract was normative-looking prose but the impl was absent. The decision in landed the impl: reg-head registers under a :head registry kind; routes name a :head in route metadata; render-head computes the head model; active-head is sugar for the active route. The SSR emitter wraps body output with <head>...</head> from the model when a frame's active route declares :head. Head mismatch detection rides its own :rf/head-hash / data-rf-head-hash channel over the canonical head model, separate from the body's :rf/render-hash (rf2-1oxjxk — a unified body+head hash was reverted because the documented client boot hashes only the bare body render-tree). The head hash is client-reconstructible via active-head and omitted for explicit-:head-STRING requests. The :failing-id tag is a generic host-attribution seam (:rf.ssr/head-mismatch is host-suppliable now); runtime-side head-mismatch attribution is a post-v1 follow-on.
:rf.ssr/check-version and :rf.ssr/check-schema-digest are framework-registered¶
Per §The :rf/hydrate event the reference :rf/hydrate handler dispatches :rf.ssr/check-version and (when payload carries a digest) :rf.ssr/check-schema-digest. Both fxs are registered by re-frame.ssr at ns-load time with :platforms #{:client}; version-mismatch emits :rf.ssr/version-mismatch, schema-digest-mismatch emits :rf.ssr/schema-digest-mismatch. Earlier drafts named both events in normative prose but registered neither — a silent :rf.error/no-such-handler trace at hydration time was the visible symptom. The two-handler set is locked; apps that want app-specific checks register additional handlers, they don't replace these.
HTTP response accumulator stored in a side-channel atom, not in app-db¶
Per §Response storage substrate the per-request HTTP response accumulator MUST live in a framework-private side-channel atom keyed by frame-id (mirroring request-slots and pending-error-traces), NOT under any path in app-db. Earlier drafts pinned the accumulator at the [:rf/response] app-db path; the audit (parent) identified two failures of that placement: (a) the hydration payload at ssr-ring/build-payload ships the whole app-db by default, so the response accumulator — including Set-Cookie auth tokens and internal X-* headers — defaulted to riding the wire to the client; (b) every :rf.server/* fx swapped the whole app-db container (read → assoc → replace) to update the accumulator, allocating a fresh app-db value per fx call. Moving the substrate to a side-channel atom makes the privacy boundary self-enforcing (the accumulator cannot be misconfigured into the payload) and reduces per-fx writes to an O(small-map) atom CAS. The CLJS reference's re-frame.ssr/response-slots is ^:private; tests reach it via (resolve 're-frame.ssr/response-slots) for between-fixture reset.
:rf.server/safe-redirect ships alongside caller-trusted :rf.server/redirect¶
Per §HTTP response contract the runtime ships two redirect fxs: :rf.server/redirect is caller-trusted (arbitrary :location strings, no allowlist) and :rf.server/safe-redirect is the caller-untrusted variant (URL parse, scheme reject, relative-only / allowlist gating). The audit at 2026-05-14 §P3.2 identified the open-redirect class: an app that reads a ?next=… query parameter and dispatches [:rf.server/redirect {:location next-param}] against attacker-controlled input will happily redirect to a phishing site after auth. Three positions were on the table — (A) ship safe-redirect alongside, (B) doc-string warning + recipe in guide, (C) both — and resolved Option A: ship the primitive, the programmer chooses. Pro: discoverable, opt-in by API; the safe variant is first-class, not buried in a recipe. Con: adds one public fx-id surface; mitigated by the cross-reference in :rf.server/redirect's docstring. The four error categories (:rf.error/safe-redirect-invalid-url, :rf.error/safe-redirect-scheme-rejected, :rf.error/safe-redirect-host-disallowed — the latter discriminates :relative-only-violation vs :not-in-allowlist via the :reason tag) are catalogued in 009 §Error event catalogue.
Render-side validator failures unified under the projector¶
Per §View-time exceptions the JVM reference adapter (re-frame.ssr.ring) routes render-time throws through the SAME projector pipeline that catches drain-time fx / handler / sub exceptions. re-frame.ssr/project-render-exception! synthesises a :rf.error/ssr-render-failed trace event and applies the active projector; the host-adapter render call site wraps a try/catch that drives projection then emits a minimal HTML body from the projector's :message / :code. The outer :on-error hook is reserved for transport-layer failures (no server frame registered, projector pipeline catastrophically fails) where the fixed-body topology-leak rule applies. The category :rf.error/ssr-render-failed is catalogued in 009 §Error event catalogue and appears in the default projector table above (§Default projector).
resolve-head emits :rf.error/ssr-head-resolution-failed before fallback¶
Per §Head/meta contract the host-adapter helper that walks the active route's :head (the Ring adapter's re-frame.ssr.ring.lifecycle/resolve-head) wraps the resolution in try/catch and degrades to an empty fragment when the user's :head fn throws. Earlier drafts of the helper's docstring promised "the trace surface still carries the throw," but the impl just caught and returned "" with no emit — a silently-broken :head fn produced a visually-broken page with zero diagnostic. Two positions were on the table — (A) silent fallback (tighten the docstring to match the impl), (B) trace-emit (match the docstring's promise via :rf.error/ssr-head-resolution-failed) — and resolved Option B: the spec is the artefact, the impl drifted, and the always-on error-emit substrate carries the trace to user observability stacks. The category is catalogued in 009 §Error event catalogue; host adapters for non-Ring substrates (Express, Fastify, plain servlet) MUST emit the same category when their resolve-head equivalent's catch arm fires.
EP-0008 executes this ruling. "the always-on error-emit substrate carries the trace" had been only PARTIALLY true: resolve-head emitted via the dev-gated re-frame.trace/emit-error!, which DCEs / -Dre-frame.debug=false-elides — so an off-box shipper on a production JVM SSR host saw nothing. The category is now promoted to the always-on axis (Spec 009 §Error event catalogue marks it always-on): it rides re-frame.error-emit/dispatch-error-record! ALONGSIDE the dev trace, so a register-listener! (:errors stream) consumer (Sentry / Datadog) receives the structured record under -Dre-frame.debug=false. It stays NON-PROJECTING — re-frame.ssr.error-listener/non-projection-eligible-errors skips it, so promotion ships the off-box record but NEVER flips the deliberate degraded-200 outcome. The recoverable-degradation sibling :rf.error/ssr-ring-error-view-failed was promoted in the same wave on the same NON-PROJECTING terms.
Trusted shell hook contract — host-adapter convenience opts named as TRUSTED STRINGS¶
Per §Trusted shell hook contract the host adapter's default-shell convenience opts (:head, :body-end, :script-src, :app-element-id) are caller-trusted strings. The two content-position opts (:head, :body-end) are injected RAW into the rendered HTML envelope, no escaping; the two attribute-value-position opts (:script-src, :app-element-id) are escape-attr-escaped at the shell (position-correct attribute encoding, not a sandbox). The security audit (parent finding, closed; local-only doc) surfaced the documented-vs-undocumented gap: the four opts had always been trusted strings in practice, but the trust semantic was not normatively named, so apps wiring any of them from untrusted input (a CMS field, a tenant-admin form) had no spec-level signal that they were opting into an arbitrary-script-injection XSS vector. resolved by (a) NAMING the four opts as trusted-string surfaces at the spec level, (b) adding construction-time structural-shape validation (:rf.error/ssr-trusted-shell-opt-invalid rejects maps / vectors / symbols / numbers — the framework catches the structural mistake even though it does not gate the content), and (c) documenting the structured alternatives (reg-head for head fragments; reg-view* + :rf.server/* fx for body content; :rf.server/set-header for header-shaped customization) for untrusted-customization use cases. A later refinement split the two attribute-value-position opts off the raw-content treatment: a stray \" in an otherwise-benign id / bootstrap URL was breaking out of its attribute and emitting structurally-broken markup with no signal, so those two are now escape-attr-escaped (lossless, position-correct) while :head / :body-end (content positions, no single-correct escape) stay raw. The trust call itself remains the caller's — the framework names the boundary, validates the shape, encodes the attribute-value opts for their position, and points at the structured surfaces; it does not pretend to gate content the caller declares trusted.
Root Manifest v1 is a versioned superset, discovered by adjacency¶
Per §Root Manifest v1 the manifest extends the S1 root descriptor
rather than replacing it: :rf.root/schema-version stays 1, the six extension keys are
all optional, and an unmodified descriptor validates as a manifest unchanged. The
alternative — a distinct manifest schema with its own required fields and a
descriptor→manifest upgrade step — was rejected: it would need a migration mechanism and
a version-negotiation handshake to buy nothing, since the descriptor already carries
every static fact and the render only adds. Additive keys therefore never bump the
version integer, and there is no v2 slot designed in advance; v1 is the first version,
not a compatibility layer.
Two sub-decisions follow. The marker attribute is bare (data-rf-root, no value):
the root-id could have been spelled into it for a cheap querySelector lookup, but that
puts identity on the wire twice and invites readers to trust the copy over the content.
One spelling, in the content. Discovery is adjacency, not search: the manifest is
the container's immediately following element sibling and nothing else is scanned. A
document-wide query would have to re-derive which manifest belongs to which of a page's
N roots from the very identity it is trying to read; adjacency answers that
structurally, and the pair survives fragment reordering because it moves together.
The payload claim is transactional over the SEED, not over the root's boot¶
Per §Failed-root isolation. The obvious reading of "a
failed root must release its claim" is that the claim spans the root's whole
boot, released whenever the root dies. That was rejected, because it is wrong in
the common case: once :rf/hydrate commits, the payload genuinely is
installed, and a root can die afterwards — a failing verification, a throwing
host mount — with its frame correctly hydrated and sibling roots happily sharing
it. Releasing there would invite a sibling to re-seed and silently reset
everything that ran since, which is the exact harm the ledger was built to
prevent. So the transaction ends at the seed: a root that dies before the seed
commits releases; a root that dies after it does not.
The condition is checked against the frame's live incarnation, not against the absence of a throw. Dispatching into an absent or destroyed frame is a no-op rather than an exception, so a throw-only guard would miss the one case that actually poisons a payload id — a claim recorded for a seed that quietly never happened, whose successor then reads a legitimate already-installed verdict and skips its own install.
SSR ships in a separate Maven artefact (day8/re-frame2-ssr)¶
Per the abstract's CLJS-reference artefact statement, the SSR surface (re-frame.ssr namespace, the seven :rf.server/* per-request fxs, the reg-error-projector registry kind, the FNV-1a render-tree hash, the SSR error-projection trace listener) ships in a separate Maven artefact, not the core. The data-rf2-source-coord / data-rf-view view annotations are stamped at the core reg-view registration boundary (§Source-coord annotation under SSR) and are not part of this SSR artefact, though they appear in its serialised output. Apps that don't render server-side build an :advanced bundle clean of every re-frame.ssr / :rf.ssr/* / :rf.server/* symbol and trace string. The per-feature artefact split (under Strategy B) was chosen over a single-jar build with build-time elision because the optional-dependency boundary is cleaner to communicate and the static-classpath cost-of-presence is zero. See MIGRATION §M-32.
Cross-references¶
- 011-SSR.md — the goal-level statement and rationale.
- 002-Frames.md — frame lifecycle (per-request frames are the same shape as multi-instance / per-test).
- 004-Views.md — view contract; this Spec forces the
(state, props) → render-treepure-fn shape at the pattern level. - 008-Testing.md — JVM-runnable scope; SSR moves view rendering across that line.
- 009-Instrumentation.md — hydration-mismatch trace events.
-
Post-emit regex injection (matching the first
<tagopener of shape<[a-zA-Z][^\s>/]*in the emitted body string, with the letter-prefix skipping a<!DOCTYPE html>prefix) is a valid alternative for ports whose substrate lacks a hiccup-walk equivalent. It is not the canonical mechanism: it over-injects onto non-DOM roots (a tree that resolves to text or to a fragment placeholder gets a spurious attribute on whatever opener follows in the output) and cannot honour the:<>/:>exemption. Ports using the regex form MUST document the divergence and accept the edge-case mismatch with the CLJS reference. - Client renders the root view, computes the same hash on the client-side render-tree, and compares. ↩