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 :rf/hydrate event with :replace-frame-state semantics, the six :rf.server/* server-only fxs (set-status, set-header, append-header, set-cookie, delete-cookie, 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 capture hooks. (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 Spec 002's — 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 React adoption plus deterministic rendering: a view is a pure function of the hydrated state, so the client's first render is the tree the server rendered, and the hydrating root adopts the server's DOM against it, reporting each divergence React recovers from (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. React compares the DOM the server's markup parses into, not its bytes. Tests may compare HTML strings as a check of deterministic server output, but the contract requires only the structural form. The head lies outside the hydrating root; its model is data a host can recompute and compare (§Head/meta contract).
A divergence React recovers from at step 6 surfaces as a structured trace event per 009 and as an always-on record; an attribute-only divergence does not (§Hydration-mismatch detection).
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 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: the payload builder always stamps the constant, and no host opt overrides it — the check exists to catch a cached client bundle meeting a newer server, and any other stamp could only manufacture a mismatch. On a matching build the client-side check compares the constant against its own copy of the same constant, so the check-version fx stays silent rather than skipping. |
:rf/frame-id |
The stable client frame id both sides agree on in advance (ssr-handler's :client-frame-id), never the per-request server frame's — 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 a direct dispatch-sync [:rf/hydrate …] (a custom host's own boot) 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, SSR metadata. No classification rides it: the client resolves the same declarations from the same app image. 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/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.
On a JVM host the payload obeys §The numeric crossing rule, except for ##NaN. The payload is printed as EDN and read back by the browser's reader, and for a handful of JVM number types that read succeeds with a different value: a Long or BigInt past ±(2^53−1) lands on a neighbouring integer, a BigDecimal or a Ratio becomes a double, and a Float is not the double it prints as. An order id then addresses a different order and money becomes a double, while the server reads its own value back perfectly and the hydrated page agrees whenever the view prints the value identically or carries it only into an event. So both partitions are walked — map keys and set members included — and a number outside the rule's type and range half fails closed with :rf.error/ssr-hydration-payload-invalid, naming the partition, the path and the class. Each map and set is then checked as a whole: two keys or members the JVM holds apart but the browser reads as one value — 1 beside 1.0, 0 beside -0.0 — fail closed with the same id, naming the partition, the path, the colliding values and the value they become, because the browser's reader rejects the duplicate and hydration fails. The check is always on: the failure is data-dependent, so a development-only check would leave it open exactly in production. ##NaN and the infinities ride, because they read back as what they were (the rule refuses NaN only because its property is round-trip equality), and so do #inst and #uuid. The author narrows the value deliberately — an id to a string, money to a string or integer cents — or leaves the key off the allowlist. A refusal is projected like any render throw. On a CLJS host (Fresco's Node door included) every number is already a double and crosses unchanged, so the check does not exist there.
The numeric crossing rule¶
A value the server prints as EDN for the browser crosses only if the reader on the other host reconstructs it equal. The acceptance predicate is the round trip itself, and four consequences are easy to get wrong:
- 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 the client. - Both halves of a map 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. 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. - A collection can fail where every number in it crosses. The browser holds one double per number and a single zero, so
1and1.0, or0and-0.0, are two map keys or set members on the server and one on the client, whose reader then rejects the duplicate; composite keys whose numbers collapse ([1]beside[1.0]) fail the same way. So each map and set is checked as a whole, at any depth, by the value the browser reads.
Rejecting rather than coercing is the rule in all four 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.
Artefact (CLJS reference). re-frame.ssr.wire — edn-carryable? (the whole round-trip predicate), portable-number? (its numeric type and range half) and numeric-collision (its collection half). The hydration payload's check above applies portable-number? and numeric-collision, and the non-local renderer's :render-state wire applies edn-carryable?.
: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 [<entries>](a sequential — a vector, the canonical spelling, or a list / lazy-seq, e.g. a computed(filterv …)/(keep …)result) — an allowlist. Each entry is a keyword top-levelapp-dbkey or a path, a non-empty vector of map keys:[:articles [:session :csrf]]. A keyword ships that key's value; a path ships the value it reaches, inside its containing maps. 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 is accepted (a sequential can never be confused with the whole-app-db keyword); a set is rejected — the allowlist is an ordered selection. An entry that is neither a keyword nor a path — a string typo for a keyword (["public/articles"]or'("public/articles")), a straynil, a list, an empty vector — is a malformed allowlist and fails loud at construction time with:rf.error/ssr-malformed-payload-allowlistrather than silently shipping a wrong or empty slice.: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 re-frame.ssr.ring.payload/build-payload and by Fresco's server render — 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 vs keyword), not a contest between two opts — so there is no precedence rule and no silent-ignore branch. An empty :payload ([] / '()) is an allowlist that names no keys, so it projects nothing: :rf/app-db is {} however full app-db is. That is the explicit way to ship nothing, and what an allowlist computed to nothing means; an absent or nil :payload is still a missing policy. An empty slot of a non-local renderer's :render-state projects its partition to {} the same way. An unrecognised :payload keyword surfaces as the distinct :rf.error/ssr-unknown-payload-policy, and a sequential allowlist with an entry that is neither a keyword nor a path 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.
After the allowlist, the slice is projected, and a path permits the classified leaf it names. The surviving slice runs through the :rf.egress/ssr-hydration egress projection under the request frame (015 §SSR and hydration). A path the frame classifies :sensitive arrives as :rf/redacted. No size elision applies: :rf/hydrate installs the payload as live client state, so a :large value rides whole — machine snapshot :data and the route :current slice included. Classification says what stays out of logs and tools. Whether this user's browser may hold a classified value is a different question, and only the host can answer it, so the answer is spelled where the host already says what the browser receives: a :payload path. :payload [:session [:session :csrf]] ships the session with its CSRF synchronizer token raw, for a page that sends the token back. The rules:
- A path permits exactly the classified leaf it names. After the projection, the raw value at each path is restored from the selected slice, never from the frame, when it is a leaf — a value that is not a map, vector, set or sequence (a present
nilrestoresnil). A classified sibling stays redacted. A keyword permits nothing, and neither does:rf.ssr.payload/whole-app-db. - A named ancestor still redacts its classified descendants. A keyword or a path naming a map ships it with every classified value inside redacted, and a path naming a classified map leaves it redacted whole: selecting a subtree never authorises its secrets. Classify at the leaves the host may permit.
- No piercing. A path never reaches through a classified ancestor. If
[:session]is itself:sensitive, the path[:session :csrf]leaves the session redacted. - Maps only. A path selects, and permits, only while each coordinate steps into a map holding it. Through a vector, a scalar or an absent key it selects nothing and permits nothing, so a path never creates a key or a container.
- Fail-closed survives. A dead or unresolvable request frame still redacts the whole slice to
:rf/redacted, and every permit is then inert.
The same permit reaches every place state ships: the payload builder, a Fresco server render, and the app-db partition of a non-local renderer's :render-state, where the handler's :payload paths permit inside that policy's own allowlist and select nothing. So the rendered HTML and the payload agree on a permitted value. A restored value still obeys the numeric crossing rule above. The permit has one spelling, because a second permit list would be a second way to misdeclare one boundary: a :payload-include-sensitive opt is refused at construction — and by a Fresco server render, which has no construction step — with :rf.error/ssr-malformed-payload-allowlist, carrying :opt :payload-include-sensitive and naming the path form. Permit only values the user's own browser is entitled to hold, such as a CSRF token or the user's own email. Never permit a long-lived bearer credential or a server secret.
A withheld value that a view renders mismatches honestly: the server renders the live frame and the client renders :rf/redacted, a text divergence the hydrating root reports as a recovery. That mismatch is the signal either to permit the value or to stop rendering it. A client-originated classified value, such as a draft password the user is typing, is not permitted. It is re-seeded on the client after hydration, per the resolved decision :replace-frame-state is the locked hydration merge policy. Re-seed after hydrate! returns and before the hydrating mount, so the first client render reads the re-seeded state.
SSR flow¶
Server flow (per request)¶
HTTP request arrives
│
▼
ssr-handler: rf/make-frame :frame { :initial-events [[:app/server-init]] } ;; the request fills the frame's request slot first, read via the :rf.server/request coeffect
│
▼
: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!
The app's frame config, whole. 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 one is an unknown key, whichmake-framewarns about in dev. Thessr-ringadapter'sssr-handlertakes the app's frame config as ONE:frameoption (re-frame.ssr.ring.lifecycle), the map the browser boot mounts with, so a host passes the var the client uses rather than splitting it into options. Each request frame is made from that map whole —:initial-events,:fx-overrides,:ssr,:url-strategyand every other frame-config key — under the handler's own:idand:platform :server; the handler refuses either key inside:frame, and refuses a frame-config key at its own top level, naming:frame. The handler fills the request slot beforemake-frame, so the first initial event already reads the request. Inside:frame,:initial-eventsalso accepts a(fn [request] → initial-events-vector), which the handler resolves once per request and lowers verbatim into the per-request frame's:initial-events— 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
│
▼
the root's adoption reporter surfaces each divergence React recovers from as :rf.ssr/hydration-mismatch
│
▼
app is interactive
Detailed design¶
Server-side init flow¶
The SSR namespace exports an adapter Var: an adapter spec map of the shape Spec 006 §The adapter API contract specifies, carrying the functions and whichever capability keys it supports, which install-adapter! publishes into the :adapter/* slots (006 §Capability publication). 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 plain-atom's recompute-on-deref derived value; the JVM has no React reactivity layer. ssr/render-to-string is the SSR emitter itself, not a lookup through the installed adapter, so a caller needs no late-bind wiring at the call site. The adapter binds that same emitter into its :render-to-string slot, so a render through the installed adapter's slot gets it too. 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 (defonce app-root (rf.adapter.reagent/client-root)))
#?(:cljs
(defn ^:export run []
(rf/init! rf.adapter.reagent/adapter)
(rf.adapter.reagent/render! app-root
[(rf/view :app/root)]
(js/document.getElementById "app"))))
#?(: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:
(ssr/render-to-string
view-or-hiccup ;; a hiccup form (including [:view-id arg…] refs)
{:doctype? true}) ;; prepend "<!DOCTYPE html>"
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.
Return shape — locked to STRING. render-to-string always returns one shape: an HTML string. It does NOT return a map of {:html :status :headers ...}. 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 hydration-payload builds the hydration payload under the same :payload policy and egress projections ssr-handler applies, from a live frame: one never made or already destroyed is refused with :rf.error/frame-destroyed, since its payload could only redact whole and the client would refuse it.
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>). - Serialises a boolean attribute value by the attribute's class, never by the value alone (below).
- Converts each DOM element's attribute names and values the way the hydrating Reagent-tier client does, and applies react-dom's form-control special forms (below).
- 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.
Boolean attribute values follow 004B's classes. 004B §Booleans and their neighbours carries the react-dom 19.2.0 table — row-by-row probed — and it is the one authority: aria-*, data-* and the booleanish family (contentEditable / draggable / spellCheck) stringify true and false; true boolean attributes and the overloaded booleans keep presence semantics (true → present, false → omitted); a boolean on any other attribute reaches markup as nothing at all.
Why the hiccup emitter cannot carry its own rule. ARIA attributes are not boolean HTML: aria-expanded="false" is a different state from the attribute being absent, so a value-only rule that reads false as "omit" ships markup asserting the opposite of what the author wrote, and an assistive technology reads a different UI than the client render shows. The mistake does not surface downstream either: React adoption is the only check that compares server and client output, and §Hydration-mismatch detection records — under The boundary — that React neither patches nor reports an attribute-only divergence. The other direction is equally load-bearing and pulls the opposite way: for a genuine boolean attribute disabled="false" is still truthy to a browser, so emitting the false value there would disable the control. One rule cannot serve both, which is why the class — not the value — decides.
The leading-LF compensation has one authority the same way. 004B §Leading-newline compensation is the one authority here too: a <pre> / <listing> / <textarea> whose body is a single string beginning with LF gets one compensating LF immediately after the start tag, because the HTML parser drops the first LF there. The hiccup emitter, render-to-string, applies it to a lone string child, so [:pre "\ncode"] reaches the DOM as the string the author wrote rather than one character short; to a <textarea>'s :value, which it writes as the element's body (see Form controls take react-dom's special forms, below); and to the raw-HTML body a <pre> or <listing> takes from :dangerouslySetInnerHTML when its :__html is a string, as react-dom/server does. react-dom refuses that prop on a <textarea>, so a textarea's raw-HTML body is not compensated. Everything else — multiple children, an element child, a body not beginning with LF — is left untouched. The parallel with the boolean-attribute paragraph above is the authority, not what catches a miss. An attribute-only divergence is invisible to React adoption — §Hydration-mismatch detection records, under The boundary, that React neither patches nor reports it. A text-content divergence is not in that set: the same section lists a text-content mismatch among the divergences React recovers from and reports through onRecoverableError. So a lone string child left uncompensated would not pass silently: hydration would report :rf.ssr/hydration-mismatch, and React's warn-and-replace recovery would re-render that subtree on the client, discarding the server's markup for it. The fix is the same in both cases — keep the emitter from producing the divergence at all: one rule, one place.
The rosters are pinned against react-dom itself, because nothing that reads them can check them. One roster per class is right — it keeps every attribute render-to-string emits on one rule — but no check built from re-frame's own side can validate it: a test whose expectation is derived from the roster, or from anything else that reads it, agrees with the emitter whatever the roster says. A roster can be internally consistent, agree with every such test, and still miss names that react-dom 19.2 classifies (disablePictureInPicture, disableRemotePlayback, scoped, seamless as presence; autoReverse, externalResourcesRequired, focusable, preserveAlpha as stringifying), with every test green. The reference implementation therefore holds a committed record of react-dom's own output — measured from the installed package, candidate names and boolean-ness both taken from react-dom rather than restated — and fails when the classifier disagrees in either direction: a name react-dom classifies that the roster omits, and a name the roster carries that react-dom does not. A conformant implementation is expected to pin its rosters against the React version it targets in the same way; pinning them against anything of its own that reads the same rosters is not equivalent.
Two attributes are deliberately not what react-dom does, and both are decisions rather than omissions. value — react-dom stringifies a boolean value because value shares its serialiser branch with the booleanish names, not because it is one; a string or number value, the only kinds a form control carries, serialises identically under the ordinary rule, so the classification bites only for a boolean, which on a form control is an author error rather than a state. value is also a 004B form-control special form whose meaning already varies by element, so it stays ordinary here rather than being reclassified from the SSR side. ismap — react-dom 19.2 accepts no boolean ismap, in any spelling (the name is absent from its possibleStandardNames; a boolean warns and emits nothing). ismap is a genuine HTML boolean attribute on <img>, so presence is the HTML-correct rendering, and 004B §Booleans and their neighbours specifies that this grammar tracks HTML where the two part on a real HTML boolean attribute — the rosters follow that row, which carries the divergence and its reason explicitly.
Attribute names and values are the ones the hydrating client paints. A hiccup attribute map is Reagent-tier authoring, and the client that hydrates it does not paint it verbatim: the adapter converts each prop, and react-dom writes the DOM attribute for it. The hiccup body emitter, render-to-string, therefore converts the same way, through one shared function, before any attribute is serialised:
- Names, on an ordinary element. A keyword or symbol name takes Reagent's kebab → camel rule — split on
-; adataorariafirst segment keeps the name verbatim; otherwise each later segment is capitalised, withclass/for/charsetseeded toclassName/htmlFor/charSet— and then react-dom's prop → attribute alias table. So:read-onlypaintsreadOnly,:tab-indexpaintstabindex,:html-forand:htmlForpaintfor,:view-boxpaintsviewBox, and:stroke-widthgoes out and back tostroke-width. A string name skips Reagent's step, as the adapters do, and takes the alias table only:"className"paintsclass,"hx-post"stayshx-post. - Names, on a custom element (a hyphenated tag): kept verbatim. The two supported adapters already disagree with each other there, and web components conventionally read kebab attributes.
- Values. A keyword or symbol scalar is written with
nameon every DOM tag:[:button {:type :button}]paintstype="button". - Class. A
:classNamefolds into:class, and a class collection is joined with a space,nilandfalsemembers dropped:[:div.x {:class [:a nil :b]}]paintsclass="x a b".
Stripping, the boolean class and the attribute-name grammar then judge the converted name, which is the name that reaches the browser. That order is load-bearing: :onc-lick converts to oncLick, which the HTML parser reads as onclick, and it is stripped because the strip reads the converted name. The <head> emitter's attributes and the Ring host shell's :html-attrs / :body-attrs bags are re-rendered by no adapter and stay verbatim. The emitter has to carry this rather than leave it to hydration for the boolean paragraph's reason: React neither patches nor reports an attribute-only divergence, so a [:input {:read-only true}] painted verbatim stays editable, and a [:button {:type :button}] stays a submit button, until the prop next changes.
Form controls take react-dom's special forms. On top of the conversion, the hiccup emitter does what react-dom/server does with four form-control props. :default-value / :default-checked on an <input> write value / checked when the controlled prop is absent. A <textarea>'s :value (else :default-value) is its text body, with the leading-LF compensation above. react-dom/server refuses a textarea given both a value and a child; this emitter writes the value, which is what the client paints. A <select>'s :value (else :default-value) writes no attribute: it marks selected on each descendant <option> whose value — its :value, else its text — it names, and a collection names several. Both default props are dropped on any other element. The cost of getting these wrong is narrower than the attribute conversion's, because React writes form-control state at hydration; but until then the first paint shows the wrong control state, and that paint is all a slow-JS, no-JS or crawler visitor ever sees.
javascript: URLs are blocked where the client blocks them. The client paints through react-dom, which swaps a javascript: URL for a URL that throws before the value reaches the DOM. It does this in href, src, action, formAction and xlinkHref on any element it does not treat as custom, and in data on an <object>. The hiccup emitter applies react-dom's rule, keyed on the React prop name the adapter produces: the same scheme test (case-insensitive, skipping leading C0 controls and spaces, allowing a tab, LF or CR between the letters) and the same substituted URL. The emitter has to carry this for the reason the attribute paragraph above gives: React does not patch an attribute at hydration. A server-written javascript: URL would therefore stay live on the hydrated page even though the client's own render blocks it.
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.
Conventions §Render-tree shape vs runtime lookup owns the head grammar: a bare [:keyword args] head is an HTML element that the runtime does not intercept. Spec 011 does not extend that grammar, so neither JVM emitter resolves a keyword head through the view registrar.
Why a keyword head is never a view here. A server that resolved [:dashboard/card :revenue] as a view would render <div class="card"> subtrees where stock Reagent + react-dom/server renders phantom <card> elements. 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 — could then not write a keyword head that meant one thing.
The failure mode is what would make it expensive: the server looks right and the client fails silent. A keyword head resolved server-side would render correctly there, so it would survive every server-side test; only a client render reveals it, and it reveals it as wrong pixels rather than an error.
A server-only tree references a view the same way a hydratable one does: [(rf/view :id) …].
Scalar children are spelled by name¶
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 JVM emitter follows the client behaviour, not the reverse: every client substrate
routes a named child through (name x) before React sees it, whereas a generic
stringify would preserve the colon (:revenue) and the namespace (a/b). Namespaced
symbols follow 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 colon-preserving 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 — :rf/suspense-boundry, say — would otherwise sail through the element branch and paint a phantom <suspense-boundry>, reintroducing the same silent mis-render. The recognised reserved heads are :<> and :>; every head 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.
No client substrate enforces this grammar. Stock Reagent is an external dependency whose element dispatch is not ours to extend, so a reserved-head typo rendered only on the client is not caught, and that diagnostic asymmetry is accepted.
The server renders the whole page in one response and does not stream regions — 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\u003c. String values inlined into a<script type="application/ld+json">body MUST have every<re-encoded as the JSON string escape\u003cso 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.JSON.parsereads the escape back as<, so the data arrives unchanged; an HTML entity such as<would not, because the HTML parser decodes no character reference inside a raw-text<script>andJSON.parsewould receive the literal text. 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 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. - Prop-drop roster at static-markup emission. SSR static-markup emission MUST drop, at attribute-emit time, every prop class in the roster below, matching react-dom/server behaviour. The roster is a closed set, closed in both directions: an emitter that drops fewer leaves a live vector, and one that drops more strips a name the client paints, which is a hydration divergence rather than extra hardening. Per Security.md §XSS at output boundaries.
on*event-handler props (:on-click,:on-mouse-down,:onMouseDown,:ONLOAD,:onbegin, …) and function-valued props. An event-handler prop is react-dom's own rule, on its client and its server alike: any attribute-key-valid name longer than two characters whose first two characters areon, matched case-insensitively. A vendor or future event (onbeginon SVG<animate>,onwebkitanimationend) therefore drops with no list to keep current, whileonanddata-onemit as the client paints them; anon-led key carrying breakout characters is not dropped but fails the attribute-key rule above. The client-side substrate adapters (Reagent, UIx) wire event handlers at hydration; the server-side rendered string MUST NOT carry them inline. Closes the event-handler-injection vector.- Reserved prototype-pollution keys (
__proto__,constructor,prototype), matched case-insensitively and dropped from the props map before they reach the underlying host'screateElement-equivalent. Closes the prototype-pollution path on the client. - JSX source-coord props (
:_jsxFileName,:_jsxLineNumber,:_jsxColumnNumber), matched case-sensitively against exactly those three names. The framework itself does not emit them (per 006 §Historical: JSX source-coord props), but hiccup reaching the emitter from application code, from JSX-compiled third-party libraries, or from a hand-stamped DevTools shim may still carry them. They have no HTML wire representation, and their leading underscore fails the conservative HTML5 attribute-name grammar, so this drop sits upstream of the grammar gate: a user-carried source-coord prop passes through cleanly instead of surfacing as a grammar error. Pinning exactly those three documented names rather than a broad underscore prefix leaves an app's own underscore-prefixed prop on the grammar-error path where it belongs. - React's structural slots,
:keyand:ref— reconciliation identity and an instance handle. React consumes both before the host sees them and react-dom/server serialises neither, so emitting them server-side is precisely a hydration divergence: the client paints no such attribute, and a custom element cannot obtain akeyattribute out of React either, so refusing it costs nothing that was reachable on the client. Matched case-sensitively, because React extracts these slots by exact property name —:Keyreaches the host as an ordinary unknown prop that the client does paint, so a loose match would make the server strip a name the client keeps, which is this same divergence pointed the other way. - React's content channels,
:childrenand:dangerouslySetInnerHTML— the two slots React takes an element's content from, likewise consumed before the host sees them and likewise serialised by react-dom/server as neither. Matched case-sensitively, for the same reason as the structural slots. A separate class from those slots deliberately::keyand:refare identity, these two are content, and they fail on the wire for different reasons. Without the drop the raw-HTML channel would reach the wire as the escaped EDN print of its{:__html …}map, which no browser renders as anything. The content is the children path's, as it is on the client: the hiccup body emitter writes:dangerouslySetInnerHTML's__htmlas the element's body, raw and unescaped — making that markup safe is the caller's responsibility, exactly as it is when React renders the same prop. On a void element the prop is ignored, and when an element carries both the prop and children the raw body wins and the children are not rendered; React throws in both cases, so the author meets the mistake on the client.:childrenstays dropped and renders nothing, because an element's own children are its content. Trusted markup has its own spelling per tier: the hiccup tier's is:dangerouslySetInnerHTML, as above, and Fresco's is React'sdangerouslySetInnerHTML, which Fresco passes through to React with its other content props. 004B's conversion table names the same spelling for both emitters (004B §Attribute names, the reserved-props row).
Dropped here, recorded in the structural tree. The roster above is the hiccup attribute-emit path, whose contract is narrower and is stated there: it recognises exactly the props that are safe to silently drop. 004B's structural tree refuses none of these names — it records an attribute-map key that canonicalises onto :children, dangerouslySetInnerHTML, :key or :ref as the data the author wrote (004B §Attribute names). Malformed keys — breakout characters in the name — are not that roster's concern; they surface as a structured emission error at the attribute-key rule above.
One roster, read by every surface that emits an attribute. The enumeration above is the contract, and it is what an other-language port implements. The CLJS reference realises it at a single point — strip-prop? in implementation/ssr/src/re_frame/ssr/html_helpers.cljc — through which the hiccup body emitter, the head emitter and the Ring host shell all serialise; a second stripping roster would be a drift surface, and a repair local to one emitter would leave the others divergent from it. That fn is downstream of this list, and the two standing apart is a defect in whichever moved without the other.
The three escapes compose at the emitter walk: per-element attribute-key check → per-attribute prop-name filter (the drop roster above) → 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-id capture surface). 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. 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.
Locating annotation at registration rather than in the emitter is deliberate. The emitter has no view branch to stamp in — a keyword head is a DOM element on every host — and a hydratable page composes through callable heads ((rf/view :id) / Var heads) that only resolve to a view's output once called. 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. 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 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 serves ports/substrates that can render a native head server-side. |
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.
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?, a def read ONCE at ns-load (§The debug gate on an SSR JVM). The annotation site is gated on the same interop/debug-enabled? as CLJS, so a JVM with the gate off gets equivalent suppression — a runtime short-circuit rather than compile-time DCE. An SSR JVM defaults the gate off, so its renders carry no source coords unless the deployment opts dev in with -Dre-frame.debug=true; webhook receivers and other long-running JVMs facing untrusted input default on and 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."}
(fn [_ [_ {:rf/keys [version frame-id app-db runtime-db 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 (cond-> runtime-db ;; serializable runtime-db projection
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, 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, the SSR hydration metadata). Classification is NOT among them: it is declared statically, so the client resolves the same declarations from the same app image (Spec 015 §SSR and hydration). It MUST NOT carry transient runtime state — server-only request/response accumulators, head snapshots, 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 a direct dispatch-sync (a custom host's own boot) cannot bypass it. An absent :rf/frame-id is no conflict — the dispatch target stands.
Hydration metadata at [:rf.runtime/ssr :hydration]. As shown above, the reference handler writes the payload's :rf/version value at [:rf.runtime/ssr :hydration :version] 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), consumed by :rf.ssr/check-version. The slot is a runtime-managed runtime-db path; user code MUST NOT write to it.
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.
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).
Server-vs-client render divergence (a separate concern) is reported by the hydrating root — §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> (handed to the page shell finished, as :payload-tag — §Trusted shell hook contract), and writes the wire response. The client side ships its symmetric counterpart — ONE explicit boot call that reads __rf_payload and dispatches :rf/hydrate before the first render. The pair reads as one contract; a host should not have to re-derive the read → dispatch → render ordering by hand at every boot site.
That constructor renders through the render-body seam: one construction opt, :renderer, a plain fn (fn [{:keys [frame-id request opts]}] → {:body-html <string>}) — no protocol, registry or multimethod. The handler calls it once per request inside the request frame's scope, after the boot-event drain and the blocking-resource settle and before head resolution and the payload build consume its result, with the live post-drain frame (by id), the host request and the handler opts — never a hiccup value. The renderer owns body markup and nothing else; the server keeps the request frame, head, __rf_payload, shell, status, headers, cookies, redirects, error projection and frame teardown. The default renderer is the local one (the CLJS reference's re-frame.ssr.ring.pipeline/local-renderer: resolve :root-view → render-to-string), whose bytes and errors the seam does not alter; :root-view is required exactly when the renderer is the default. A renderer throw is projected like any render-time throw (§Server error projection), and so is a result that is not {:body-html <string>} — the markup returned bare, say — rather than shipping a 200 around an empty app root.
The CLJS reference ships ONE non-local renderer, re-frame.ssr.ring.node/renderer — the JVM→Node adapter over the bounded sidecar implementation/ssr-node ships (the ssr-node crossing). Its construction opts, validated at construction so a misconfigured deployment fails at boot: :module (the server bundle's path, which makes the renderer OWN the sidecar: day8/re-frame2-ssr-ring carries the sidecar's runtime as classpath resources, and construction copies it to a temp directory, launches it with Node on that bundle on a loopback port the OS picks, and waits for its ready line, refusing a launcher whose protocol is not 1 and a bundle whose buildId is not :build-id; the sidecar is handed :timeout-ms, as both its deadline and its ceiling, and :admission-ms, is relaunched with a capped backoff whenever it exits, and is stopped by the renderer's close — the renderer is a java.io.Closeable — or else by a JVM shutdown hook; beside it, :node names the Node executable and :isolates the sidecar's worker threads) or :endpoint (a sidecar the operator runs, never both: an absolute http(s) URL; default http://127.0.0.1:8148, the launcher's default bind — a non-loopback sidecar is accepted, not refused, but render state may carry server-only values, so it is the operator's network and transport to secure), :entry (the bundle's entry id), :build-id (the deployed bundle identity), :render-state (required — the fail-closed per-partition allowlist, or the (fn [frame-id] → partitions) projector, that re-frame.ssr.render-state validates; a policy distinct from :payload, with no whole-partition spelling), :args (optional root arguments, EDN the sidecar's safe reader reads back equal), :timeout-ms (the render deadline the sidecar is asked to honour; default 1000) and :admission-ms (the sidecar's admission budget; default 250). Transport: one reused JDK HttpClient per renderer, and every request carries an explicit timeout of :timeout-ms + :admission-ms + a fixed wire margin, so the sidecar refuses before the JVM gives up. Per request it projects the live post-drain frame, serialises both partitions per key as EDN text, POSTs protocol 1 with entry, args, state, runtime, buildId, timeoutMs and requestId, and returns the sidecar's body bytes as :body-html. Every failure throws at the render call site with a live frame and so is projected through project-render-exception! as a 5xx — the sidecar's own status is never copied to the browser, and no partial page is possible — under four distinct ids (009 §Error event catalogue): :rf.error/ssr-node-unreachable (no HTTP answer at all, an owned sidecar that is down while it is relaunched included), :rf.error/ssr-node-deadline (the deadline passed, observed by the sidecar's 504 or by the JVM's own timeout), :rf.error/ssr-node-refused (any other non-200; :refusal carries the sidecar's code as an opaque keyword — the adapter classifies by the transport's status contract and never spells that vocabulary) and :rf.error/ssr-node-build-skew (a 200 whose x-rf-ssr-build is not :build-id). A malformed construction opt throws :rf.error/ssr-node-renderer-opt-invalid; a missing or malformed :render-state throws the payload family's ids with :opt :render-state. Under :module, construction also throws :rf.error/ssr-node-unreachable for a sidecar that cannot start and :rf.error/ssr-node-build-skew for a bundle that names another build. The CI witness for the crossing is the jvm-node-crossing job, which runs the reference's :crossing-test alias against a plain fixture render module.
The end-to-end recipe for that renderer — both builds, the render module and its entry table, the two state policies and why they differ, the serve command and its ready line, hydration's identifier-prefix rule, build-id skew and the deployment posture — is the guide page Render on Node. The sidecar's own guarantees, wire protocol and refusal vocabulary are implementation/ssr-node/README.md. The crossing is witnessed in two complementary halves rather than by one runnable application: the jvm-node-crossing job drives a real JVM → Node → JVM request over a real socket against a plain fixture render module, and the CLJS product witness drives the real Fresco login example — its views, its registrations, its published entry table and the sidecar's own request validator — simulating only the transport. The example's server bundle at examples/substrates/fresco/login is real and exercised, and so is its JVM host: the shared login.model is .cljc, so the JVM loads the application's own registrations, and host.clj publishes init! / make-handler / make-app and is driven over a real socket against a spawned sidecar by re-frame.ssr.ring.login-host-crossing-test on that same jvm-node-crossing job. That witness deliberately does not re-render the Fresco views on Node — the CLJS product witness above is what holds that half — which is what keeps the two complementary rather than redundant.
Two full-document server pipelines ship — one per substrate — and the seam above is what makes them one contract rather than two designs. The JVM pipeline is re-frame.ssr.ring/ssr-handler, a Ring handler whose default body renderer is this spec's own hiccup emitter (§The render-tree → HTML emitter) — the pipeline for hiccup views, serving Reagent, whose views are pure data. It owns the whole response around that body: head resolution, __rf_payload, the :html-shell envelope, status, headers, cookies, redirects, error projection and per-request frame teardown. The Node pipeline is re-frame.fresco.server/render, the door the Fresco guide teaches (SSR and hydration): a Fresco view resolves to a React element rather than to a data tree, so its body can only come from react-dom/server's renderToString, and the page it returns is its own small envelope — a fixed <head> of charset and title, the app-root element, __rf_payload, then the bootstrap <script src> when one is given — carrying no status, headers, cookies, redirects or error projection, because it is a renderer and not an HTTP host: that response contract stays ssr-ring's. What they share is the contract; what differs is what the substrate forces. Both mint one :platform :server frame per request and destroy it in a finally, and both project :rf/app-db through the same fail-closed :payload policy (§:rf/app-db projection) and stamp the result under the same pinned __rf_payload script id. Neither puts a fingerprint of the page on the wire: both hydrate by React adoption (§Hydration-mismatch detection). One further difference is the door's scope rather than the substrate's: the Node door seeds from an app-db :snapshot and stamps only that partition, so its payload carries no :rf/runtime-db, which the JVM pipeline projects. Neither pipeline is the ssr-node crossing described above, which is the JVM one with a remote body: there ssr-handler keeps the entire envelope and only the body crosses to Node — through the :renderer seam on the JVM side and, for a Fresco application, re-frame.fresco.server/render-body on the other.
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! rf.adapter.reagent/adapter)
;; hydrate! seeds app-db BEFORE the host mounts; the hydrating render
;; then adopts the server's markup. `: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 :rf/default})]
(rf.adapter.reagent/render! app-root
[rf/frame-provider {:frame :rf/default}
[(rf/view :app/root)]]
(js/document.getElementById "app")
{:hydrate? (some? payload)})
payload)))
hydrate! performs the two 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 handler's prebuilt payload tag carries into the page shell) 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, resource preload, and Xray. An absent:frameraises:rf.error/no-frame-context; the runtime never synthesises:rf/default. A:framethat is not live — never made, or destroyed — takes no seed: the dispatch reports it through the router's always-on:rf.error/frame-destroyedrecord andhydrate!returnsnil, since a payload handed back would tell the host the page hydrated. 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.
The host then mounts the root through its substrate's hydrating render — re-frame.substrate.adapter/render (the adapter render! for Reagent and UIx) or Fresco's h/render!, each with {:hydrate? true} — and that root reports the recoveries React makes (§Hydration-mismatch detection). hydrate! compares nothing and does not own the DOM mount.
hydrate! returns the applied payload (or nil) so the caller can branch on "was this server-rendered?" without re-reading the DOM. A host may instead seed with dispatch-sync [:rf/hydrate …] directly; the handler enforces the same frame-id guard (§The :rf/hydrate event). 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.
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.
Preflight is everything that must be settled before any frame-state moves: the
payload's frame id, then the install decision. The root's identity throughout is the
:root-id its host passes
(004C §3),
which the ledger records with each claim and a conflict names.
Step 1 — the frame id¶
The payload's :rf/frame-id is validated against the explicit :frame target
(§Payload scope): a present-and-different value
raises :rf.error/hydration-frame-id-mismatch. It runs first, so a payload rendered for
a different frame never reaches the ledger and cannot claim the target's payload id.
Step 2 — the install decision¶
Payload install is idempotent and order-independent (per 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: the payload a root references is the one for the frame it hydrates into, so the two 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 idempotent case; do not re-seed |
| entry, differing digest | :rf.error/frame-payload-conflict, thrown |
The third arm is the content-digest :rf.error/frame-payload-conflict that
004C §7
defines, 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.
Release. A payload id's claim is released when its frame is destroyed (the same per-frame teardown that clears the request, response and pending-error 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! (step 2, returning
the verdict), payload-install-decision! (the atomic claim),
payload-content-digest, installed-payload / release-payload!. re-frame.ssr.boot/hydrate!
runs step 1, then preflight, and performs the remaining hydrate step only on the
install verdict; it takes a :root-id opt for the multi-root path. The
digest is the canonical-data structural hash (re-frame.ssr.hash/data-hash), whose
JVM/CLJS byte-parity comes from an FNV-1a step over UTF-8 bytes and the cross-runtime numeric
print form the HTML emitter also uses — two roots reading the same script agree on
either host.
The digest preserves nil. A hydration payload is app data, where {:x nil} is not
{}, [nil 7] is not [7], and #{nil 1} is not #{1}. A digest that pruned nil would
alias those pairs deterministically, so the middle row of the table above would swallow a
genuinely different server slice as the idempotent no-op instead of reaching the third row.
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.
Individual failures are already scoped to one root — a bad frame payload "affects exactly the roots referencing it" (004C §7). This section states the contract that scoping cites, 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.
The boundary is synchronous. A root reports :hydrated when its hydrate and the
host's mount both return without throwing — the boot was submitted. Adoption
work React does asynchronously after that call returns is outside the boundary.
What a failed root leaves behind¶
Nothing that can harm a sibling. Precisely, by where it died:
| Died at | Leaves behind |
|---|---|
| preflight — a frame-id mismatch or 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 — 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. Liveness is
not acceptance, though: a live frame proves only that the dispatch was
delivered, and the handler can still refuse the payload and leave the frame
untouched (a malformed partition, per §The :rf/hydrate event).
So a payload the handler will refuse is screened with the handler's own
predicate before the claim, and never claims at all. 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 record is
structural: the throwable and its message ride back to the caller as part of that
root's outcome, and on the dev trace, never on the record — a boot exception's
message can carry application data, and the record reaches production sinks.
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.
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 correlates each root positionally.
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;
three independent levers — a payload naming another frame, a payload conflict and a
throwing mount — each failed at every position on a
three-root page, with every sibling asserted hydrated and interactive) and
re-frame.ssr.failed-root-isolation-dom-cljs-test (a real multi-root document whose
healthy roots adopt their server markup through the Reagent adapter's render! with
{:hydrate? true}, while a root whose mount throws keeps its server markup).
Hydration-mismatch detection¶
Hydration mismatches are reported by React adoption alone: the runtime reports hydration recoveries, not every divergence. Every React-family root hydrates the client's first render against the server's DOM — Reagent through the ratom spine's mount, UIx through the React-hook spine's re-frame.substrate.spine/make-render, Fresco through its hydrating door re-frame.fresco.impl.mount/hydrate-root!. React reports the divergences it recovers from — a text-content mismatch, or a missing / extra / wrong-type element, at any depth of the tree — through the root's onRecoverableError, and the framework surfaces each one as :rf.ssr/hydration-mismatch, :where naming the root door. Nothing else compares server and client output: the payload carries state, not a fingerprint of the page.
The reporter. The framework installs an onRecoverableError on the hydrate path only — on every hydrating root, in every build — composed over any host-supplied :on-recoverable-error (framework report first, then delegate — never clobber; with no host callback it delegates to React's default report). Inside the adoption window each recoverable error yields:
- an always-on structural record through
:error-emit/dispatch-error-record—{:rf.error/id :rf.ssr/hydration-mismatch :where … :recovery :warned-and-replaced :time …}, plus:framewhen the root door knows it — so a production build's error sinks see the recovery. React's message is not in it: in development it carries app text, and the record reaches the sinks unprojected. - in development, the
:warningtrace:rf.ssr/hydration-mismatch, tags{:error <React's message> :where … :recovery :warned-and-replaced}. It is debug-gated and DCE'd in production; non-hydrating mounts pay nothing.
Recovery is React's own warn-and-replace: React has already re-rendered the divergent subtree on the client by the time the callback fires, so there is no framework strict mode and no :hard-error escalation. CI fails a build by watching the public callback (§Mismatch recovery and configuration).
The adoption window. React holds onRecoverableError for the root's whole lifetime and invokes it for post-hydration recoverable errors too, so reporting on every call would mislabel a later recovery as a hydration mismatch. A root-local #js {:adopting true} flag gates the report: an adoption-window-closer mounted into the hydrating tree clears it on the hydration commit, after which the reporter still delegates to the host / React-default handler but fans no record and emits no trace. No root has a :server → :client phase flip to close the window on, so a dedicated closer shuts the window on the first (hydration) commit.
Canonical entry. The root door's mount path is the only route that installs the reporter, so a hydrating root routes through the Spec 006 client mount entry (re-frame.substrate.adapter/render with {:hydrate? true} — the adapter :render slot — or the first render! through a client root), or through Fresco's h/render! with {:hydrate? true}. Hydrating via the substrate-native renderer directly (uix.dom/hydrate-root, reagent.dom.client/hydrate-root, react-dom hydrateRoot) bypasses the reporter and falls back to React's default handling, which re-frame never sees.
The boundary — an attribute-only divergence is not reported. onRecoverableError is, per React's own contract, the "callback called when React automatically recovers from errors", and an attribute-only mismatch — a stale class, style, or ARIA value on an element whose tag and text still match, anywhere in the tree, the root view's own markup included — is deliberately not in that set: React documents that "there are no guarantees that attribute differences will be patched up in case of mismatches". Such a divergence gets no re-frame trace, no production record and no strict throw. React's development-only console.error (with a diff) is the only signal, and React leaves the server's attribute in the DOM. A stale attribute is a real, deterministic bug; the framework does not promise to report it, and a development build or test that must catch it watches console.error for React's attribute warning.
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}
:recordable? false}
(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 (:recordable? false): 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 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{: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 :rf/response, specified in Spec-Schemas and not registered at runtime. 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"]] ;; ordered vector of [name value] pairs
: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 six: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-dbwould pay 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 it in the SSR artefact's post-dead teardown (002 §Lifecycle contributions) — the same teardown that drops the request slot and the pending-error-trace buffer.
Standard fx¶
All six 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 |
{:location "/login"}, {:location "https://app.example.com/…" :allow ["app.example.com"]} or {:location trusted-url :allow :any} (:status defaults to 302) |
Sets :redirect on the accumulator and short-circuits HTML rendering (per §Redirect precedence below) once :location passes the URL gate. Relative-only by default: :allow names the hosts permitted beyond the request's own origin, and :allow :any is the explicit trusted spelling, which waives the host check and nothing else. :allow is the whole policy language. 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). There is no alias: a map spelling the target :url / :to carries no :location, so it is a target-less redirect (below). A :relative-only? key, which would only name the default, is refused as a malformed argument. Validation order (per 009 §Error event catalogue), after a malformed call has thrown: (1) reject a javascript: / data: / vbscript: scheme prefix, before parsing, because a data: body can fail to parse and must still be rejected as a scheme — :rf.error/redirect-scheme-rejected; (2) URL must parse — :rf.error/redirect-invalid-url on failure; (3) a parsed scheme other than http / https — :rf.error/redirect-scheme-rejected, and a scheme whose host cannot be extracted — :rf.error/redirect-invalid-url; (4) with no :allow, a URL that is not a relative reference — :rf.error/redirect-host-disallowed (:reason :relative-only-violation); (5) with :allow hosts, a non-relative URL whose host is not among them — :rf.error/redirect-host-disallowed (:reason :not-in-allowlist); (6) on pass, sets :redirect, without :allow. Mitigation for the open-redirect class — an attacker-controlled ?next=… URL parameter cannot redirect off-origin, and the safe spelling is the short one. 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. :location is optional: a target-less redirect skips the gate and takes the host adapter's no-target path. |
The fx-args schemas (:rf.fx.server/set-status-args, etc.) are registered per Spec-Schemas §Standard fx args schemas as the published description of each args shape, for docs and tools. None of the six registrations carries one as its :schema: the acceptance check is the guard below, and it is the only one, in every build.
The reserved fx guard their own args¶
The step-5 fx-args boundary a reg-fx :schema hangs off (per 010 §Validation timing) is dev-posture. validate-fx! reads re-frame.interop/debug-enabled? once at namespace load, so a JVM with the gate off — an SSR host's default, per §The debug gate on an SSR JVM — 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 six 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, an :allow that is :any or a sequence of host strings, and no :relative-only?. Args maps stay open: an unrecognised key is not a violation.
Two readings of "the published type" are settled here, and the published schemas describe the same readings:
- 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. The guard reads it as absent and does not refuse 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 a host materialiser may tolerate one. It is refused here anyway, 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.
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.
Nothing differs between builds, the diagnostic included. With no :schema on the six registrations step 5 never speaks for them, in dev or in release: the guard is the check, and the record is the always-on :rf.error/fx-handler-exception. The request takes the error arm: the projector answers it with the sanitised 500 (§Server error projection), and the sibling effects still run. A wire-adjacent programmer error 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/redirect's URL gate. 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 non-projecting no-op (:rf.error/redirect-invalid-url, -scheme-rejected and -host-disallowed, per the table above). A blank :location is still a string, so it takes the parse-failure arm — a rejected redirect must not become a denial of service. :location itself is optional, and the documented no-target path applies.
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 — 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 work: 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 the wire format, the error categories they emit, and the fail-closed behaviours this document 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. The check runs before the URL gate of §Standard fx, and no:allowwaives it::allow :anystill refuses a header-splitting character (RFC 7230 §3.2.4). The fail-fast CRLF/NUL posture applies whether:locationcame from a constant, a route binding, or a?next=query parameter.: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.
A redirect the URL gate refuses (§Standard fx) writes nothing, so it truncates nothing: the page renders as it would have without it.
Multiple redirects: last-write-wins on the :redirect slot itself, as for the status below.
Multiple-status policy¶
Lock: last-write-wins. If more than one :rf.server/set-status fires in the drain, the final :status is the last write — the same :db-style semantics re-frame uses elsewhere. A later write replacing an earlier one is ordinary, not a conflict.
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 six 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 redirect gate 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. The record is what makes the rewrite observable: a 500 shipped in silence would leave no trace of the malformed status. 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 CLJS reference keeps the envelope pieces its hosts share in the SSR artefact's re-frame.ssr.html-helpers (a .cljc namespace): html-document (the page, from parts each host resolves under its own defaults), payload-script-tag (the id-pinned, EDN-escaped payload <script>, the one definition every host writes through) and html-attr-bag (the <html> attributes with their :lang fallback). The Ring shell (re-frame.ssr.ring/default-html-shell) and Fresco's re-frame.fresco.server/document both compose over them.
Every page shell is handed the payload tag finished. The handler constructor's :html-shell opt, (body-html payload-edn opts) → string, replaces the whole envelope. The handler builds the payload <script> once, through payload-script-tag, and passes it to whichever shell renders the page as :payload-tag in its opts. A shell ships the payload by splicing that string where it belongs, and the default shell splices the same part, so the id and the escaping have one builder. A custom shell is a trusted surface like the four opts below: the framework hands it parts and validates none of its output.
The host adapter's default HTML envelope (CLJS reference: re-frame.ssr.ring/default-html-shell) 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"); an explicit nil MUST render exactly as an absent key.:script-srcalso acceptsfalse: emit no bootstrap<script src>at all (the hydration payload script and any:body-endcontent are unaffected), for an app that boots from:body-end— for example atype="module"tag. 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), or — for
:script-srcalone —false. Any other non-string non-nil value (a map, a vector, a symbol, a number,falseon the other three) surfaces: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 declares:boundary? trueon the handler that admits the customization: that handler's own:schemathen 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/head-modelsurface described in this section ships in theday8/re-frame2-ssrartefact (re-frame.ssr.head, withhead-modelandhead-model->htmlalso re-exported onre-frame.ssr). The head model is data the client can reconstruct —head-modelrecomputes the identical model against the just-hydrated state (§Default flow step 5) — but the bundled client does not: it never callshead-modeland never touches the DOM<head>. The hydrating root covers the body only, so a head divergence is outside the adoption channel (§Hydration-mismatch detection); a host that wants the head checked recomputes the model and compares it itself.
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 standard head model (:rf/head-model, specified in Spec-Schemas and not registered at runtime):
{: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 registers under the registry kind :head (per 001 §Registry model). The query API surfaces it: (rf/registrations {:kind :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. (ssr/head-model frame-id)returns the head model — the effective route's:headselects the registration.(ssr/head-model frame-id {:head-id head-id})is the same read against a NAMED head instead of the route's.- 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 two bags are ordinary attribute maps and carry no shell-specific serialisation rule — the host shell stamps them through the same serialiser the hiccup emitter uses, sonilomits an attribute and a boolean is rendered by its attribute's class exactly as §The render-tree → HTML emitter states:aria-*/data-*/ booleanish stringifytrueandfalse, true boolean attributes keep presence semantics.falseis a value here, not an omission spelling —<html data-js="false">and a bare<html>are different states to a CSS attribute selector — andnilis what omits. - The model stays reconstructible on the client: the same
head-modelcall 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 one a host that compares the head runs. The recompute is available to the client (head-modelover the hydrated:rf/app-dbplus 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.
head-model¶
(ssr/head-model frame-id) ;; frame required (EP-0002 — carried, not defaulted)
(ssr/head-model frame-id
{:head-id :head/article ;; optional; else the effective route's :head
:route active-route}) ;; optional; else the frame's [:rf.runtime/routing :current]
Returns the head model map. Pure, JVM-runnable, used by the SSR pipeline to materialise <head>...</head> and by tooling to inspect a head without re-rendering the body. One read answers the whole question, and it resolves in one pass:
frame-idis carried: head rendering is a frame-scoped read (it reads the frame's app-db + the runtime-db route slice), so anilstamp raises:rf.error/no-frame-context. There is no:rf/default-from-absence floor.- The effective route is resolved once —
:routewhen the key is present (an explicit{:route nil}means no route), else the frame's[:rf.runtime/routing :current]slice. - The head is selected —
:head-idwhen supplied, else the effective route's:headmetadata, elsedefault-head. A selected-but-unregistered id raises:rf.error/no-such-head; a route declaring no:headat all falls back silently. - The head fn is evaluated against that same effective route, so
(ssr/head-model f {:route r})selectsr's head and runs it againstr— a hypothetical-route preview is coherent end to end.
The model is the return value and is recorded nowhere: no side channel, no HTML mode, no cache. Pair it with head-model->html to emit the fragment.
The carried frame selects the REGISTRATIONS as well as the data. The :head lookup, the :route metadata lookup the head id is read from, and the head fn's own invocation all resolve through the target frame's image generation (002 §Frame target resolution) — the same resolution a dispatch or a subscribe into that frame would get. These are explicit-target reads made outside any ambient frame binding, so resolving the app-db per-frame while resolving the registrations against the process store would let one image's head body run against another image's data and return a plausible model for the wrong page. A frame carrying no image generation resolves against the process store.
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.
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 (:rf/public-error, specified in Spec-Schemas and not registered at runtime):
{: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]
;; The app's own conventions. nil for everything else defers to the
;; default projector, keeping its condition-gated 404 / 400 arms.
(case (some-> trace-event :tags :exception ex-data :app/error)
:unauthorised {:status 401 :code :unauthorised
:message "Please sign in." :retryable? false}
:forbidden {:status 403 :code :forbidden
:message "Not allowed." :retryable? false}
nil)))
;; 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 registers under the registry kind :error-projector (per 001 §Registry model). The query API surfaces it: (rf/registrations {:kind :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).
A projector that returns nil defers. The runtime applies the default projector to the same trace event and emits nothing, so a custom projector handles only its own cases and inherits the default's condition-gated 404 and 400 arms for every other. nil is therefore not a projector failure: only a throw or a non-nil non-conforming return takes the sanitised fallback below.
reg-error-projector returns its id argument per the family-wide reg-* return-value convention.
The frame that names the projector resolves it. project-error takes an explicit frame and is called outside that frame's binding, so — exactly as for head-model — the :error-projector lookup and the projector's invocation run through that frame's image generation, or the process store for a frame carrying none. Reading the frame's :ssr {:public-error-id …} per-frame and then resolving the id it names against the process store would let another image's projector decide this response's status and public body.
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. The arm answers for an event dispatched against the live request frame after construction, such as a host that dispatches its boot event with its:rf.cofxrather than passing it as:initial-events. Under the Ring handler a request-supplied value enters through:initial-events, where a rejected:rf.cofxvalue fails the setup step: construction aborts, the request answers through:on-error, and this arm is never consulted.: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_payloador app-db is 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). - The worst buffered failure decides. One request can buffer several failures — a handler exception, then a routing miss. The settle projects every one and keeps the highest projected status, the later one among equals, so a server fault anywhere in the drain takes the error arm whatever
4xxfollows it: app-db is in the same partial state either way. This orders failures; the app's own:rf.server/set-statuswrites keep §Multiple-status policy's last-write-wins. Reading the response is not settling it: in the CLJS reference onlyflush-response-result!drains, andpeek-response/get-responseare pure reads, so an inspection before the settle cannot steal the classification. - 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). - 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 other than nil, which defers to the default projector), 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 substrate through a late-bind hook¶
The SSR error-projection pipeline rides the always-on error-emit substrate (per 009 §What IS available in production §Error-emit listener) through a late-bind hook, :ssr/capture-error-record, which the substrate's fan-out calls beside its listener registry for every promoted record. It is never a listener, on either registry: a test's listener clear removes a listener, and a listener can take a dev console print — an error listener owns each record it claims by returning exactly true, and any registered trace listener stands down an unobserved warning's print — while the projection is a permanent framework consumer that must survive the clear and own neither print. The fan-out ignores a hook's return value, so the projection never claims a record. The dev capture likewise rides a hook, :ssr/capture-error-trace, which the dev trace delivery calls beside its listener fan-out. 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: an SSR JVM runs with the gate off by default (§The debug gate on an SSR JVM), webhook receivers and other 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 :boundary? true 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 ({:rf.error/id :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).
:rf.error/no-such-route reaches the projection pipeline only on the dev trace. It is the route-url caller-misuse category — a route-id that is not in the registrar — and route-url throws it synchronously at the call site rather than emitting a record or a trace. 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. A miss inside an event handler therefore projects as that handler's :rf.error/handler-exception, and one during render as :rf.error/ssr-render-failed. :rf.route/navigate is the one door that catches the throw: it rejects the navigation and reports the throw's own id through trace/emit-error! (012 §Param validation at the call site), so in a dev build the dev capture projects a server frame's [:rf.route/navigate {:to :route/typo}] through the table's :rf.error/no-such-route row as a 404, and a missing or uncaptured path param (:rf.error/missing-route-param, :rf.error/route-url-validation) through the unenumerated tail row as a 500. None of these ids is promoted, so a release build projects nothing for the rejected navigation. The row also serves a caller that hands such an event to project-error directly. It is not the URL-driven miss (that is :rf.error/no-such-handler :kind :route, always-on above).
: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 :boundary? true arm is the exception. A handler carrying that flag has its own :schema checked ungated, on every build, at the router's step-1 site — on the original dispatched event vector, before the interceptor chain runs, so dev and production validate the same value at the same point — and a boundary rejection under re-frame.debug=false is therefore real — the handler is skipped and the payload never reaches app-db — and the report survives alongside the refusal (009 §Error event catalogue). 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 a silent 200 — 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 needs no machinery of its own for that status: the always-on record is 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 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 check 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. In an :initial-events step, where the Ring handler runs the events a request dispatches, either one fails construction instead, and the request answers through :on-error.
Why the URL miss and the boundary arm are always-on, and why both rows are gated. Without an always-on path a production server would answer
200for an unroutable URL — a soft 404 that search engines drop — even though the framework has computed the miss and committed the:rf.route/not-foundslice, and200over a payload it has already refused. Because:rf.error/no-such-handlerreaches the projector in production, an ungated arm would answer404for an unregistered event id, which is why the404row above is:kind-gated. The400row's:wheregate carries the same weight for the boundary arm: 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/redirect gate rejections are the inverse case: always-on, and deliberately not projection-eligible. :rf.error/redirect-invalid-url, :rf.error/redirect-scheme-rejected and :rf.error/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 capture 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. The skip sits at the shared projection chokepoint, which is what makes the two postures agree. Without it they would not: a refused redirect would stamp 500 in a dev build through the trace-buffering path while production answered 200 — a dev/prod wire asymmetry on a security surface. 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!): 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 (a failed :initial-events step, 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 re-frame.ssr.ring API reference carries the same table):
:error-view |
:on-error |
|
|---|---|---|
| Which failure | A projected 5xx the error PROJECTOR catches (an effect that throws or an unregistered event during the drain, a subscription or view that throws while rendering, the walker, a custom :renderer, or an unrenderable root/shell). 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 failed :initial-events setup step, a header/cookie materialise throw, a failure inside the error projection itself (no server frame). A setup step fails when its event handler, an interceptor, a coeffect or a flow throws, or when a :rf.cofx value is rejected: strict construction tears the frame down and raises :rf.error/initial-events-step-failed (009 §Error event catalogue), so the projector never sees it. No render-time throw reaches it: the host routes every one through the projector. |
| 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¶
Projection leaves the internal trace stream untouched. The dev-only monitoring listeners (register-listener!) and the always-on error-emit substrate (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 SSR JVM's debug gate, 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— React re-renders the divergent subtree on the client, replacing the server's HTML there. The page becomes interactive. - External monitoring integrations register a trace listener, or an error sink for the always-on record, and ship mismatch events to their backend.
- There is no framework strict mode and no per-frame detection knob: the reporter is installed on every hydrating root, in every build. Dev and CI fail fast by watching the root's public
:on-recoverable-errorcallback —{:hydrate? true :on-recoverable-error (fn [error info] …)}— which React calls after it has recovered. A development build that must also catch an attribute-only divergence watchesconsole.errorfor React's attribute warning (§Hydration-mismatch detection, the boundary).
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 resolves the active platform PER FRAME — the frame's own
:platformkey over a per-host constant default (:clienton CLJS including CLJS-on-Node,:serveron the JVM). A server-side bootstrap tags its request frame{:platform :server}; there is no process-wide setter. - 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). :platformsgates effects and coeffects only. An event registration accepts the key and stores it as metadata, but the router never reads it, so it gates nothing. Setup events that only matter on the server (a per-request setup event such as:app/server-init, handlers declaring the:rf.server/requestcoeffect) stay server-side by being dispatched only there — from the per-request frame's:initial-events— and the server-only reads and writes inside them are coeffects and effects carrying:platforms #{:server}.
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.ssr/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.
The debug gate on an SSR JVM¶
A JVM that hosts SSR runs in production posture unless the deployment opts dev in. The JVM's dev/prod gate, re-frame.interop/debug-enabled? (the counterpart to CLJS goog.DEBUG), is a def read ONCE when re-frame.interop loads, from the system property re-frame.debug or, when that is unset, the environment variable RE_FRAME_DEBUG:
| Setting (case-insensitive, trimmed) | The gate reads |
|---|---|
true, 1, yes, on |
on |
false, 0, no, off, empty |
off |
| unset, or any other value | the JVM's default |
The default is off on a JVM that hosts SSR and on everywhere else. An explicit setting wins both ways: development on an SSR JVM opts in with -Dre-frame.debug=true (or RE_FRAME_DEBUG=true), and any other JVM still turns the gate off with -Dre-frame.debug=false. A value outside both vocabularies takes the default, so a misspelt setting on an SSR host leaves the gate off.
A host artefact marks its JVM as an SSR host by shipping the classpath resource re_frame/ssr_host.edn. In the CLJS reference the Ring adapter (day8/re-frame2-ssr-ring) ships it, so a JVM with that artefact on its classpath defaults off. The signal is a resource because the gate is read before any host namespace loads, and readers rely on it never changing afterwards — validate-fx! reads it once at its own namespace load (§The reserved fx guard their own args) — so a host MUST NOT flip the gate after load. CLJS builds are unaffected; their gate is goog.DEBUG.
Off elides what Closure DCE removes from a CLJS :advanced + goog.DEBUG=false build — trace emission and the per-frame trace rings, epoch-history capture with restore-epoch! and replace-app-db!, source-coord enrichment — as a runtime short-circuit rather than dead-code elimination (009 §JVM builds, Security §Production gates). The always-on error-emit substrate and the server error projection run either way (§Server error projection).
Routing and SSR¶
The request URL is fed in via the app's per-request setup event (:app/server-init below), which dispatches :rf.route/handle-url-change with the URL. The same handler runs server- and client-side; an event carries no platform gate.
Concrete handshake:
- The server's
ssr-handlercreates the per-request frame with:initial-events [[:app/server-init]](a record-config key onmake-frame) and fills that frame's request slot;:app/server-initreads the request through the:rf.server/requestcoeffect, never off the event. :app/server-init(dispatched only on the server, from that frame's:initial-events) reads the request target, its path plus any query (for Ring,:uri, then?and:query-stringwhen present), and dispatches[:rf.route/handle-url-change url].:rf.route/handle-url-change(universal — runs on both platforms) calls(rf.routing/match-url url), 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) on the app's per-request setup event (:app/server-init above). 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(dispatched only on the server, from that frame's:initial-events) 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:state,:dataand:rf/runtime(including the per-decl-path:after-epochmap); the client hydrates that snapshot. -
After hydration the client arms the timers, and three things about that are normative. On successful hydration the client installs the snapshot and then reconciles each active
:afterdeclaration in it to exactly one armed timer:- Duration — the full delay, measured from the client arm. Each timer is armed for its full client-resolved delay, measured from the moment the client seam arms it — not from the server render, and not from when the page became interactive. Client-resolved is load-bearing: a literal delay is its own value, and a fn-form delay resolves against the hydrated snapshot. No remainder is computed and none is subtracted, because there is no remainder to compute —
:afternever armed on the server, so there is no running countdown to resume and nothing durable records a schedule instant (per 005 §Clock abstraction). - Epoch — retained, never bumped. The hydrated snapshot's
[:rf/runtime :after-epoch](and[:rf/runtime :after-epoch-by-region]) is carried through unchanged. Hydration re-establishes host work for a visit that already happened; it is not a fresh state entry, and bumping would strand the very snapshot being restored. - No replay. No
:entry,:action,:raise,:spawnor:alwaysruns, and no snapshot is written. The server already ran the entry cascade for this configuration exactly once — the re-arm reconstructs host work, it does not replay history. A declaration the hydrated snapshot no longer carries is reconciled away rather than left armed.
Which host seam performs the arm is unspecified. A hydration-time reconcile pass, a first-render hook, or any other mechanism conforms — the duration, the epoch and the no-replay rule are the contract.
- Duration — the full delay, measured from the client arm. Each timer is armed for its full client-resolved delay, measured from the moment the client seam arms it — not from the server render, and not from when the page became interactive. Client-resolved is load-bearing: a literal delay is its own value, and a fn-form delay resolves against the hydrated snapshot. No remainder is computed and none is subtracted, because there is no remainder to compute —
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.- Eliding is what avoids the leak — the epoch is not a backstop for it. Epoch-based stale detection catches a timer whose bearing node has since re-entered; it does not catch a leaked server-armed one, because hydration retains the snapshot's epoch rather than bumping it (bullet 3 above), so a leaked handle would carry the very epoch the client considers current. That is precisely why the server arms nothing: the symptoms above have no downstream filter to fall back on, so not scheduling is the guarantee rather than a convenience.
- 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, 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¶
The server renders the whole page in one response. The frame's blocking route resources settle, the root view renders once, and the body, the head and __rf_payload go out together; there is no streamed shell and no per-region chunk.
A slow region is answered by a route resource declared :blocking? false. The server does not wait for it, so the page ships at once and the region renders the resource's :loading state as live DOM, which paints without JavaScript; the client fetches the data after hydration (016 §SSR and hydration). The region therefore fills in once the bundle has booted and the fetch has returned, and a client without JavaScript keeps the loading state.
:rf/suspense-boundary is not a recognised hiccup head: like every head under :rf/*, it raises :rf.error/invalid-hiccup-head (§The head grammar is not Spec 011's to extend). A design for per-boundary resource deferral is recorded under §Open questions.
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 artefact's frame teardown drops the slot |
| pending error-trace buffer | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the SSR artefact's frame teardown drops the slot |
| per-frame HTTP request slot | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the SSR artefact's frame teardown drops the slot; host adapters MAY also clear inline via clear-request! |
| epoch ring buffer | re-frame.epoch |
defonce atom keyed by frame-id |
epoch's frame teardown 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). A production SSR JVM runs with the gate off, its default (§The debug gate on an SSR JVM), 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 registration table (
re-frame.source-store/kind->id->ns->descriptor) — event / sub / fx / cofx / view / route / error-projector / flow registrations are process-wide; they exist independently of any frame and do not leak per-request. (A per-request frame's flow evaluation memo is per-frame state the flows artefact's frame teardown releases — see 013 §Frame-destroy teardown.) - 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 be released by its artefact's frame teardown, a member of the artefact's lifecycle contribution that frame/destroy-frame! runs (002 §Lifecycle contributions). The SSR artefact's and epoch's contributions release the side channels above. A new artefact that introduces per-frame side-channel state MUST release it the same way; 012 §Frame-destroy teardown catalogues the per-artefact teardowns.
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 whole-page response (§Streaming SSR) is
:resolved; its entry lives at## Resolved decisionsbelow. One of the two items below is an additive forward slot — its contract is already documented in the body, and the deferred work is only the consumer flip that turns the slot on; the other records the direction of a future streaming design. Both 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)¶
The additive slot has its on-wire contract locked in the body, so the flip is cheap once demanded; the streaming row records a direction, not a contract. The trigger is the concrete condition under which the deferred work earns a tracking bead.
| Slot | Where the contract lives | Classification | Fires-when trigger |
|---|---|---|---|
:rf/sub-warmups payload key (pre-computed sub values on the wire) |
§Payload scope, §Resolved decisions — Sub-cache warmups out of scope, 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"). |
| Streaming SSR (per-boundary resource deferral) — a boundary declares the resources it waits on; the shell waits only for its own blocking set; every fetch starts at once; each region streams, out of order, when its own resources settle, and a small inline script swaps it into place so it paints without the application bundle; the final payload and ordinary hydration follow | §Streaming SSR — a direction only; no contract is locked | :post-v1 — untracked note (no dedicated bead; the whole-page response is :resolved) |
An application page has a slow region that must be server-rendered for first paint, and the client-side fetch-after-hydration of a :blocking? false resource is measured as too slow. Acceptance for the design: one page with two independently delayed resources, the first-declared boundary deliberately the slower one, served through the real handler and the normal bootstrap with a deliberately slow application bundle. The shell and both fallbacks are visible before either resource settles; the faster region is visible while the slower region and the final payload are still withheld; ordinary hydration follows with no mismatch. Compare against React's documented behaviour (server fallbacks and resolved HTML visible before the bundle loads), not against internal marker shapes. |
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¶
The server does not stream regions¶
The server renders the whole page in one response (§Streaming SSR). There is no per-boundary data wait to stream: the blocking route resources settle before the render, and a region rendered after the shell renders against the same settled state. Plain SSR plus a :blocking? false route resource answers the slow region — the page ships at once with the region's loading state, and the client fetches the data after hydration. Real streaming needs per-boundary resource waits, a design recorded as a demand-triggered row under §Open questions.
: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. A merge policy that preserved client-side pre-seeded state is 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 is 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 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 by the SSR artefact's frame teardown; 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¶
§Head/meta contract is shipped: reg-head registers under a :head registry kind; routes name a :head in route metadata; head-model computes the head model in one read. The SSR emitter wraps body output with <head>...</head> from the model when a frame's active route declares :head. The head model is client-reconstructible via head-model; the bundled client neither recomputes nor compares it, and the hydrating root covers the body only.
: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. Leaving either unregistered would surface only as a silent :rf.error/no-such-handler trace at hydration time. 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. An app-db path such as [:rf/response] fails twice: (a) the hydration payload at ssr-ring/build-payload can ship the whole app-db (:rf.ssr.payload/whole-app-db), so the response accumulator — including Set-Cookie auth tokens and internal X-* headers — would ride the wire to the client; (b) every :rf.server/* fx would swap the whole app-db container (read → assoc → replace) to update the accumulator, allocating a fresh app-db value per fx call. 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/redirect is safe by default¶
Per §HTTP response contract the runtime ships one redirect fx, and it is relative-only by default: :allow names the hosts permitted beyond the request's own origin, and :allow :any is the explicit trusted spelling. The threat is the open-redirect class: an app that reads a ?next=… query parameter and redirects to it will happily send the browser to a phishing site after auth. A trusted redirect beside a validated sibling makes the obvious name the dangerous one; with one fx the short spelling is the safe one, and trusting an absolute target is an :allow visible at the call site. :allow is the whole policy language — no scheme allowlists and no host patterns — because every richer policy is one more way to misconfigure a security boundary. :allow :any waives the host check only: even a target the app built itself must parse and carry an http(s) scheme or none, and no redirect target has a use for the rest. The three error categories (:rf.error/redirect-invalid-url, :rf.error/redirect-scheme-rejected, :rf.error/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. A silent fallback would leave a broken :head fn producing a visually-broken page with zero diagnostic, so the catch arm emits :rf.error/ssr-head-resolution-failed before degrading, and the always-on error-emit substrate carries it 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.
The category is always-on (Spec 009 §Error event catalogue marks it always-on, per EP-0008): it rides re-frame.error-emit/dispatch-error-record! ALONGSIDE the dev trace, so an :observability :errors sink (Sentry / Datadog; per 015 §Frame-owned observability sink policy) receives the projected record under -Dre-frame.debug=false. The dev-gated re-frame.trace/emit-error! alone DCEs / -Dre-frame.debug=false-elides, which would leave an off-box shipper on a production JVM SSR host with nothing. It is NON-PROJECTING — re-frame.ssr.error-listener/non-projection-eligible-errors skips it, so the off-box record NEVER flips the deliberate degraded-200 outcome. The recoverable-degradation sibling :rf.error/ssr-ring-error-view-failed is always-on 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). Naming the trust semantic normatively is what gives an app wiring any of them from untrusted input (a CMS field, a tenant-admin form) a spec-level signal that it is opting into an arbitrary-script-injection XSS vector. The contract (a) NAMES the four opts as trusted-string surfaces at the spec level, (b) validates their structural shape at construction time (: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) documents 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. The two attribute-value-position opts are escape-attr-escaped (lossless, position-correct) because a stray \" in an otherwise-benign id / bootstrap URL would break out of its attribute and emit structurally-broken markup with no signal, while :head / :body-end (content positions, no single-correct escape) stay raw. The trust call itself is 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.
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 reading is 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 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 exists 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 six :rf.server/* per-request fxs, the reg-error-projector registry kind, the SSR error-projection capture hooks) 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 beats 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).
- 008-Testing.md — JVM-runnable scope; SSR moves view rendering across that line.
- 009-Instrumentation.md — hydration-mismatch trace events.