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 eight properties (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 rf2-uo7v (the sixth per-feature artefact split per rf2-5vjj Strategy B), the CLJS reference's SSR & hydration surface ships in the separate Maven artefact day8/re-frame2-ssr — re-frame.ssr namespace, the pure hiccup → HTML emitter (render-to-string), the FNV-1a structural render-tree hash (render-tree-hash), the :rf/hydrate event with :replace-app-db 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 at [:rf/response], the reg-error-projector registry kind plus the built-in :rf.ssr/default-error-projector, the SSR error-projection trace listener, and the data-rf2-source-coord annotation on registered-view roots. The core artefact (day8/re-frame2) no longer carries 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 (:on-create and any per-request init), renders, serialises the resulting state, destroys the frame. The frame contract is unchanged from Spec 002 — frames are isolated runtime boundaries; "per-request" is just one more use case alongside multi-instance / per-test / per-session.
The override seam is id-based¶
The dispatch envelope's :fx-overrides and :interceptor-overrides cannot be raw functions — functions don't serialize across the wire. Overrides are {registered-fx-id → registered-fx-id} maps, looked up at consumption time. The CLJS reference may keep function-valued overrides as a client-only convenience, but the pattern's contract is id-based.
Hydration is a defined protocol¶
Not magic:
- Server creates a frame for the request.
- Server dispatches setup events (events that resolve via JVM-runnable handlers).
- Server serialises the resulting
app-db(and any other frame state needed for hydration). - Server renders the view to a string and ships both the HTML and the serialised state to the client.
- Client creates a frame, dispatches a
:rf/hydrateevent with the serialised state as payload. - Client renders against the now-seeded state.
Hydration equivalence rule (canonical)¶
Equivalence is structural, not textual. The contract is: the client, given the hydrated payload, computes the same view the server rendered. Operationally this is verified by hashing the canonical-EDN serialisation of the render-tree on both sides and comparing the hashes (per §Hydration-mismatch detection below).
This rule is the lock. Byte-for-byte HTML equality is not required — different HTML serialisers may emit semantically-equivalent strings that differ in attribute order, whitespace, or boolean-attribute spelling. Tools and tests assert structural equivalence (the canonical-EDN hash) and may also compare HTML strings as a stricter check, but the contract requires only the structural form. The single equivalence rule applies to body, head (§Mismatch detection — head), and any other render-tree fragment the runtime hashes.
Mismatches at step 6 are detectable and surface as structured trace events per 009.
Payload scope (canonical boundary)¶
The :rf/hydration-payload is bounded — it carries the minimum data the client needs to recompute the server's view. The schema is in Spec-Schemas §:rf/hydration-payload; the canonical scope at this layer is:
| In payload | Purpose |
|---|---|
:rf/version |
Runtime version stamp; mismatches emit :rf.ssr/version-mismatch. Server-side source-of-truth (host adapter): the host adapter resolves the stamp in this order — (1) explicit :version opt passed to the payload builder; (2) the framework-global :rf2/runtime-version late-bind hook (the same source the client-side :rf.ssr/check-version fx reads, per §The :rf/hydrate event — both sides of the wire pin the same value when the host registers the hook at boot); (3) terminal fallback 1 (the v1 pattern-protocol stamp per Spec-Schemas §:rf/hydration-payload). Hosts that don't register :rf2/runtime-version (or pass :version explicitly) ship the terminal 1 — the check-version fx still no-ops cleanly because the client's lookup hits the same fallback. |
:rf/frame-id |
The frame to hydrate into. |
:rf/app-db |
The serialised app-db slice — server's authoritative 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. |
Route slice (carried inside :rf/app-db at [:rf/runtime :routing :current]) |
Active route, populated by :rf.route/handle-url-change server-side. |
Machine snapshots (carried inside :rf/app-db at [:rf/runtime :machines :snapshots]) |
State-machine snapshots; survive the round-trip per 011 §:after is no-op under SSR. |
:rf/sub-warmups (optional, future-additive) |
Pre-computed sub values; absent in v1 (see §Hydration of non-state runtime artefacts). |
Out of payload scope (explicitly not carried):
- The trace stream and trace ring buffer (dev-only; see 009 §Production builds).
- Server-side handler closures, fx implementations, and any function-valued state (overrides are id-based per §The override seam is id-based).
- In-flight HTTP request continuations (host-side concern; not part of v1).
- Sub-cache contents beyond the optional
:rf/sub-warmupsslot. - Internal trace-event detail (the security boundary in §Server error projection — error pages carry only the locked
:rf/public-errorshape).
The bounded payload is the lock: implementations may emit additional optional keys per the additive-fields rule, but never alter the required keys, and the boundary above is what consumers (clients, tests, host adapters) rely on.
:rf/app-db projection — explicit fail-closed policy¶
The :rf/app-db slice is projected from the request frame's app-db per an explicit, fail-closed policy. The host adapter MUST receive a single declarative opt — :payload — at construction time. It carries the policy in one of two shapes:
:payload [<top-level-app-db-keys>](a non-empty vector) — an allowlist. Only the listed keys ride the wire; everything else is dropped, including any keys added later as the app evolves. This is the recommended primary mechanism — a denylist would silently leak each new server-only key as the app evolves; an allowlist forces a deliberate edit per new wire-bound key.:payload :rf.ssr.payload/whole-app-db(the policy keyword) — explicit opt-in to ship the wholeapp-dbverbatim. Use only when the app'sapp-dbis structurally safe to expose end-to-end (e.g. small SPAs where every server-set key is intended for the client).
Absence of :payload is a structural error — the host adapter throws :rf.error/ssr-missing-payload-policy at handler-construction time so misconfigured deployments fail at boot, not at first request. The contract makes the privacy decision explicit at every host site (no fail-OPEN default at a security boundary). The CLJS reference's projection helper lives in re-frame.ssr.payload-policy/apply-policy (consumed by both re-frame.ssr.ring.payload/build-payload for non-streaming and re-frame.ssr.streaming/build-final-payload for streaming SSR — the policy contract is shared).
The single-opt shape is deliberate: a single value holds exactly one policy, so there is nothing to arbitrate. The allowlist-vs-whole-app-db choice is the value's SHAPE (vector vs keyword), not a contest between two opts — which removes the prior surface's precedence rule and silent-ignore branch. An empty :payload ([]) is treated as no-allowlist — shipping zero keys is almost certainly a programmer error, not intent — and falls into the missing-policy bucket. An unrecognised :payload keyword surfaces as the distinct :rf.error/ssr-unknown-payload-policy so the developer can tell the failure modes apart.
SSR flow¶
Server flow (per request)¶
HTTP request arrives
│
▼
make-frame { :on-create [:rf/hydrate-init request-context] }
│
▼
:on-create 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!
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] — seeds the frame's app-db
│
▼
client renders root view; first render-tree should match the server's HTML
│
▼
react/reagent attaches event listeners to the existing DOM
│
▼
hydration-mismatch detector compares first client render-tree against server-supplied marker (if any) and emits a trace event on mismatch
│
▼
app is interactive
Detailed design¶
Server-side init flow¶
Per the SSR namespace exports an adapter Var of the same nine-fn shape Spec 006 §The reactive-substrate adapter contract specifies. Server-side bootstrap is one explicit call:
The SSR adapter is plain-atom-shaped — make-state-container is clojure.core/atom, read-container is deref, replace-container! is reset!, make-derived-value is a recompute-on-deref IDeref reify; the JVM has no React reactivity layer. The adapter binds re-frame.ssr/render-to-string directly into the :render-to-string slot, so callers using rf/render-to-string (which delegates through the installed adapter) get the SSR emitter without any late-bind wiring at the call site. The :render slot deliberately throws (:rf.error/render-on-headless-adapter) — SSR uses render-to-string exclusively; calling render on a server-side process is a programmer error worth surfacing loudly.
CLJS hosts that ship Reagent on the browser AND need SSR on the JVM use the appropriate adapter per platform branch:
;; .cljc shared between JVM (server) and CLJS (browser):
#?(:cljs
(defn ^:export run []
(rf/init! reagent-adapter/adapter)
(rdc/render react-root [(rf/view :app/root)])))
#?(:clj
(defn ssr-handler [request]
(rf/init! ssr/adapter)
...))
Per Spec 006 §Adapter selection at boot the init! call is idempotent — re-calling it after the adapter is installed is a no-op (modulo the :rf/default frame ensure step).
:platforms metadata on reg-fx¶
Every registered effect handler declares which platforms it runs on:
(rf/reg-fx :http
{:doc "HTTP request — runs on both server and client"
:platforms #{:server :client}
:schema HttpFxSchema}
(fn [m args] ...))
(rf/reg-fx :localstorage
{:doc "Browser localStorage — client only"
:platforms #{:client}}
(fn [m args] ...))
(rf/reg-fx :rf.server/set-status
{:doc "Set HTTP response status — server only"
:platforms #{:server}}
(fn [m args] ...))
Default if absent: #{:server :client} (universal). Fx run wherever they are dispatched, including JVM headless tests. Fx that cannot run server-side (:localstorage/set, browser-DOM mutations, things that require js/window) declare :platforms #{:client} explicitly.
The fx resolver consults the active platform on dispatch (a runtime-static value: :server on the JVM-side server, :client in the browser). If an effect's :platforms set doesn't include the active platform, the resolver:
- Emits a
:rf.fx/skipped-on-platformtrace event with{:fx-id :localstorage, :platform :server}. - Treats the effect as a no-op for that invocation.
This gives a clean, deterministic story for SSR: every effect declares its compatible platforms; the resolver enforces. No runtime (when (browser?) ...) checks scattered through handler bodies.
The render-tree → HTML emitter (CLJS reference)¶
A pure function (hiccup-form, opts) → string. Pattern-level: implementations supply equivalent. CLJS reference shape:
(rf/render-to-string
view-or-hiccup ;; a hiccup form (including [:view-id arg…] refs)
{:doctype? true ;; prepend "<!DOCTYPE html>"
:emit-hash? true}) ;; embed data-rf-render-hash on the root element
The active frame is the one bound by the surrounding (rf/with-frame frame-id …) call (host adapters wrap their per-request frame). View arguments travel as inline hiccup positions — [:view-id arg1 arg2] — not via a separate :props opt. Per Q5 / cluster.
Return shape — locked to STRING. render-to-string always returns one shape: an HTML string. It does NOT return a map of {:html :hash :status :headers ...}. Callers that need the structural hash use the separate render-tree-hash fn (or read the data-rf-render-hash attribute the emitter embeds when :emit-hash? is set). Callers that need the HTTP response triple read the per-request response accumulator at [:rf/response] (per §HTTP response contract) — that slot is the carrier for :status / :headers / :cookies / :redirect. Hosts that want the bundled {:html :payload :response} request-result shape (per §Request-handler return shape) build it from these three primitives — render-to-string is the string-yielding piece, get-response reads the resolved response, and the host builds the hydration payload.
The emitter:
- Walks the hiccup tree.
- Resolves DOM tags into HTML strings; void elements (
<br>,<img>, ...) self-close per HTML5 rules. - Escapes text content per the position (attribute values, text nodes, raw inside
<script>/<style>). - Calls registered views inline (looking each up via the registrar; same path as client-side).
- 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.
Streaming/chunked emission ships as the :rf/suspense-boundary primitive — see §Streaming SSR under Detailed design.
XSS at output boundaries¶
The emitter renders a host render-tree to a string that crosses the trust boundary into a browser. Three emission positions have different escaping rules — text nodes, attribute keys/values, and raw-script bodies (<script> for JSON-LD, <style> for inline CSS) — and the emitter MUST apply the position-appropriate escape at every leaf. Mixing positions or under-escaping any one of them is the XSS vector. The CLJS reference's escapes are catalogued below; other-language ports re-bind the same three rules to their HTML emitter.
- JSON-LD
<script>body — escape<as<. String values inlined into a<script type="application/ld+json">body MUST have every<re-encoded so an attacker-supplied substring (a product title, an article summary, a partner-supplied payload) cannot close the script context with</script>and pivot into HTML. The escape applies to every string leaf in the JSON-LD payload; other characters inside the script body (&,>,",') are JSON-string-safe and need no additional encoding at the script-body layer. Per and Security.md §XSS at output boundaries. - Body-position raw
<script>/<style>author content is fail-loud, not silently escaped. A<script>or<style>element appearing 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 MUST raise:rf.error/ssr-raw-text-in-bodyrather than route the string through the text-node escape. There is no single correct escape for raw script/style content in the body position: JSON-LD needs<→<(round-trips throughJSON.parse), but raw inline JS/CSS must NOT be<-escaped (a browser does not decode</<outside a JS string literal, soif (a < b)would break). Applying the text-node escape is XSS-safe but silently corrupts legitimate content; guessing per-content escapes risks an XSS or a broken script. The structured channels arereg-head(JSON-LD / structured head content, whose emitter applies the JSON-LD<escape) and the host shell's trusted:body-end/:head-extraopts (caller-trusted inline JS/CSS, not author data). 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 and Security.md §XSS at output boundaries. on*event-handler prop filter + reserved-prop-keys gate. SSR static-markup emission MUST stripon*event-handler props (:on-click,:on-mouse-down, …) and function-valued props at attribute-emit time, matching react-dom/server behaviour. The client-side substrate adapters (Reagent, UIx, Helix) wire event handlers at hydration; the server-side rendered string MUST NOT carry them inline. Additionally, the emitter MUST drop reserved prototype-pollution keys (__proto__,constructor,prototype) from the props map before they reach the underlying host'screateElement-equivalent. Closes both the event-handler-injection vector and the prototype-pollution path on the client. Per and Security.md §XSS at output boundaries.
The three escapes compose at the emitter walk: per-element attribute-key check → per-attribute prop-name filter (on* / reserved keys) → per-attribute value escape → per-text-node escape → per-script-body JSON-LD escape. The composition order is locked — relaxing any one position breaks the closed-set guarantee. Other-language ports MUST mirror all three rules against their host's render-tree shape; the rule numbers are the contract, not the CLJS function names.
Cross-reference: see Security.md §XSS at output boundaries for the framework-wide threat-model entry and rationale.
Source-coord annotation under SSR¶
Per Spec 006 §Source-coord annotation (rf2-z7f7 / rf2-z9n1) every substrate adapter MUST inject data-rf2-source-coord="<ns>:<sym>:<line>:<col>" on the rendered root DOM element of each registered view. The JVM SSR emitter mirrors that contract string-side: when emitting HTML for a registered view (the (registrar/lookup :view head) branch in emit-element), the emitter walks the view's hiccup output, merges :data-rf2-source-coord into the root element's attrs map, and then proceeds with HTML emission as usual.
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. The same documented exemption applies — fragments / non-DOM roots are skipped, and the JVM emitter resolves to the registry's :rf/id for those cases.
Exempt-keyword enumeration. The "non-DOM root" exemption is closed-set: a hiccup vector is exempt from :data-rf2-source-coord (and from the data-rf-render-hash root injection per §Hydration-mismatch detection) when its head keyword is one of:
| Exempt head | Meaning |
|---|---|
:<> |
Fragment shorthand — no DOM element emitted; children are spliced into the parent. |
:> |
Reagent-native interop head — children pass through to a React component, not a DOM tag. Not statically renderable server-side: the JVM emitter raises :rf.error/ssr-reagent-native-head — there is no React on the JVM, so the author must wrap the component in a reg-view (which the emitter resolves) or render it client-only. The exemption row remains for ports/substrates that can render a native head server-side. |
A hiccup vector whose head is a Var-ref ((fn? head) — fn-headed component), a registered-view keyword (resolved via (registrar/lookup :view head)), 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 registered view that returns [:<> ...] resolves to the fragment, so the source-coord injection no-ops (consistent with the CLJS-side wrapper).
The same enumeration governs the data-rf-render-hash root-attrs injection: :<> and :> are the two skip-list heads; everything else either receives the injection directly or threads it through to the resolved root. Other-language ports MUST mirror the exemption against their substrate's analogous "no-DOM-element-emitted" heads — the contract is the closed-set above for the CLJS reference and (no-DOM-element-emitted) semantically for ports.
Production-elision differs from CLJS: the JVM has no goog.DEBUG constant-fold concept and no Closure DCE. The JVM half of the interop layer provides the runtime counterpart — re-frame.interop/debug-enabled? is a def read ONCE at ns-load from the system property -Dre-frame.debug=false or the env var RE_FRAME_DEBUG=false (false-y vocabulary: false, 0, no, off, empty string; system property wins on conflict). The annotation site is gated on the same interop/debug-enabled? as CLJS, so an SSR / long-running JVM that sets the flag at process startup gets equivalent suppression — a runtime short-circuit rather than compile-time DCE. The default is true (dev parity); SSR / webhook receivers / long-running JVMs facing untrusted input MUST set the gate false explicitly per 009 §JVM builds and Security §Production gates. Hosts that want finer-grained per-request control can still branch on the resolved frame's :ssr config (or a host-supplied flag) on top of the gate.
The :rf/hydrate event¶
Pattern-level standard event:
The hydration-payload is the canonical :rf/hydration-payload shape (per Spec-Schemas). The reference handler is registered automatically by the runtime:
(rf/reg-event-fx :rf/hydrate
{:doc "Seed the frame's app-db from the server-supplied payload."
:platforms #{:client}} ;; hydration is client-side only
(fn [_ [_ {:rf/keys [version frame-id app-db schema-digest] :as payload}]]
;; Replace policy: server is authoritative for the initial client app-db.
{:db app-db
:fx [(when schema-digest
[:rf.ssr/check-schema-digest schema-digest])
[:rf.ssr/check-version version]]}))
Merge policy is :replace-app-db. Server is authoritative for the initial client app-db: the handler sets :db to the server's serialised slice, replacing whatever the client bootstrap had pre-seeded. This is locked.
Server-hash slot at [:rf/runtime :ssr :hydration :server-hash]. Alongside replacing :db, the reference handler stashes the payload's :rf/render-hash value under [:rf/runtime :ssr :hydration :server-hash] in the post-hydration app-db — the path lives under the framework's single reserved :rf/runtime root per Conventions §Reserved app-db keys. This slot is the carrier verify-hydration! reads later (after the first client render) to compare against the client-side render-tree hash — see §Hydration-mismatch detection. The slot is a runtime-managed app-db path; user code MUST NOT write to it. The companion :version key under [:rf/runtime :ssr :hydration] carries the payload's :rf/version value when present (consumed by :rf.ssr/check-version). Implementations that override the reference :rf/hydrate handler with their own merge policy MUST preserve the [:rf/runtime :ssr :hydration :server-hash] write (or pass a :server-hash opt to verify-hydration! per the fn's docstring) — otherwise verify-hydration! has nothing to compare against and silently no-ops.
If the user wants client-only transient state to survive hydration: the customisation point is re-registering :rf/hydrate with a custom handler that performs an explicit merge in the user's intended order. The default is replace; opt-in merge is the user's choice and they own the semantics.
Mismatch detection between server and client schemas runs as part of :rf/hydrate's :fx:
:rf.ssr/check-versioncompares the payload's:rf/versionagainst the runtime's. A mismatch emits:rf.ssr/version-mismatch(a structured trace event) and the handler still applies (best-effort).:rf.ssr/check-schema-digest(when the payload includes one) hashes the client's currently-registeredapp-schemaset and compares to the server's digest. Mismatch emits:rf.ssr/schema-digest-mismatch. Useful for catching deploy drift where the server is rendering against a newer/older schema set than the client's bundle.
fx-input shape: each fx accepts either a scalar — [:rf.ssr/check-version <server-value>] (the form the reference handler dispatches) — or an explicit map — [:rf.ssr/check-version {:expected <server-value> :actual <client-value>}]. The scalar form treats the value as the server-supplied "expected" and looks up the client-side "actual" via a published hook (:rf2/runtime-version for version, :schemas/app-schemas-digest for schema-digest); when no hook is registered the fx emits :rf.ssr/compatibility-check-skipped (warning) and no-ops the comparison rather than crashing. Both fxs gate on :platforms #{:client} — server-side dispatches no-op via the standard fx-gating contract. The fxs NEVER throw — degraded-but-running is the locked posture.
The three compatibility-check trace categories — :rf.ssr/version-mismatch, :rf.ssr/schema-digest-mismatch, :rf.ssr/compatibility-check-skipped — are catalogued in 009 §Error event catalogue (the single source of truth for every :rf.ssr/* category, per Ownership).
Hash-based render-tree mismatch (a separate concern) lives in §Hydration-mismatch detection.
The payload shape is fixed; implementations may emit additive optional keys (per Spec-Schemas §:rf/hydration-payload) but never alter the required keys.
Client-side hydration boot helper¶
The server side ships ONE explicit handler-constructor (ssr-handler — §HTTP response contract) that renders, builds the __rf_payload <script>, and writes the wire response. The client side ships its symmetric counterpart — ONE explicit boot call that reads __rf_payload, dispatches :rf/hydrate, and verifies. The pair reads as one contract; a host should not have to re-derive the read → dispatch → render → verify ordering by hand at every boot site.
The CLJS reference ships it as re-frame.ssr/hydrate! (re-exported from the façade; implemented in re-frame.ssr.boot):
#?(:cljs
(defn ^:export run []
(rf/init! reagent-adapter/adapter)
(let [payload (ssr/hydrate! {:render-tree-fn #((rf/view :app/root))})]
(rdc/render react-root [(rf/view :app/root)])
payload)))
hydrate! performs the three steps in the order the §Client flow mandates:
- READ — the payload. Supplied explicitly via
:payload, or read from the DOM's__rf_payload<script>(viaread-server-payload, which reads the id pinned in the CLJS reference'sre-frame.ssr.constants/payload-script-id— the same id the host shell stamps) when:payloadis omitted. Returnsnilon a client-only first load (no payload script) — the host renders against the empty app-db. - HYDRATE —
dispatch-sync [:rf/hydrate payload]against the target frame (default:rf/default) BEFORE the first render, so the frame's app-db is the server's authoritative slice when the view first evaluates (the locked:replace-app-dbpolicy above). - VERIFY — after the first render,
verify-hydration!compares the client render-tree hash against the server hash stashed at[:rf/runtime :ssr :hydration :server-hash](see §Hydration-mismatch detection). The render itself is the host substrate's job (render); the helper cannot mount the DOM, so the verify step takes a:render-tree-fn— a 0-arity fn returning the render-tree the host just mounted (typically#((rf/view :app/root))). Omit:render-tree-fnto skip verification (the host opts out of hash-mismatch detection, or runsverify-hydration!itself at its own render site).
hydrate! returns the applied payload (or nil) so the caller can branch on "was this server-rendered?" without re-reading the DOM. It is the convenience that fuses the common ordering; hosts that must interleave their own render between hydrate and verify (async mount) call dispatch-sync [:rf/hydrate …] and verify-hydration! directly at their own sites. 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-mismatch detection¶
After the first client render, a comparison pass:
- Server emits the rendered string AND a structural marker (a hash of the render-tree, computed before stringification) on the root element. Placement: a
data-rf-render-hash="<hex-string>"attribute on the root view's outermost element. Encoding: lowercase hex of the raw hash bytes; no prefix. - Injection point — structural injection on the hiccup root. When
render-to-stringis called with:emit-hash? true, the attribute is injected structurally on the first DOM-tag element of the hiccup tree before stringification — not as a post-emit regex pass on the output string. The implementation threads aroot-attrsmap down through the hiccup walk: view-refs ((registrar/lookup :view head)), fn-headed components ((fn? head)), and the resolved bodies they return all passroot-attrsthrough to the eventual DOM root; the first DOM-tag emission merges and consumes it. Existing user-supplieddata-rf-render-hashon the root wins (the merge is non-overwriting). The exempt-keyword enumeration above (§Source-coord annotation —:<>and:>) governs the skip-list: a hiccup vector whose head is one of those fragment / Reagent-native heads dropsroot-attrsrather than emitting a wrapper DOM element, matching the source-coord exemption. The two opts:doctype?and:emit-hash?compose::doctype?prepends<!DOCTYPE html>to the already-injected body string. Non-DOM-rooted trees silently no-op on the injection — the structural mechanism cannot over-inject onto a doctype or text node the way a string-level regex would. The on-wire shape (data-rf-render-hash="<hex>"on the outermost rendered DOM element) is the contract; other-language ports MUST mirror the structural-walk semantics against their substrate's hiccup-equivalent tree.1
Hash algorithm (CLJS reference): the FNV-1a 32-bit hash over a canonical EDN serialisation of the render-tree (depth-first traversal; attribute maps in sorted-key order; nil pruned). FNV-1a is chosen because it is fast, has no platform dependencies (no crypto/SubtleCrypto), and produces an 8-character hex string that fits comfortably in a <meta> / data- attribute. Cryptographic strength is not needed — the hash is a tamper-evident structural marker, not a security primitive.
Other-language ports may pick a different hash as long as the canonical-EDN traversal is the same; the hash value crosses the wire only between one server and one client of the same implementation, so the algorithm choice is a per-host commitment rather than a pattern-level one. The traversal IS pattern-level: render-tree shape, sorted attribute keys, nil pruning. Conformance fixtures pin the canonical-traversal output for a small render-tree corpus so a port can verify its serialiser. - On mismatch, the runtime emits a trace event:
{:id (gensym)
:operation :rf.ssr/hydration-mismatch
:op-type :error
:tags {:server-hash "abc123"
:client-hash "def456"
:frame :rf/default
:failing-id :rf/hydrate ;; body mismatch (see §Mismatch detection — head for the head case)
:first-diff-path [...]} ;; optional: path into the render tree where divergence first occurs
:start (...)
:end (...)}
The :failing-id discriminator routes the two hydration-mismatch shapes the runtime emits under one :operation: :rf/hydrate for body mismatch (the case above) and :rf.ssr/head-mismatch for head mismatch (per §Mismatch detection — head). Consumers branch on :failing-id rather than maintaining a parallel category keyword per case.
Recovery is implementation-policy: the CLJS reference defaults to warn-and-replace — log the trace event, then re-render client-side, replacing the server's HTML. Strict mode (the frame's :ssr {:on-mismatch :hard-error} config — see §Mismatch recovery and configuration) escalates to a hard error for dev/CI builds that want to fail fast on misalignment.
Server-only reg-cofx for request context¶
A standard cofx for accessing the current request:
(rf/reg-cofx :rf.server/request
{:doc "The active HTTP request. Server only."
:platforms #{:server}}
(fn [coeffects _]
(assoc coeffects :rf.server/request *current-request*)))
Setup events use (inject-cofx :rf.server/request) to read the URL, headers, session, etc. The :platforms metadata mirrors reg-fx.
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(inject-cofx :rf.server/request)to resolvenil. - Per-frame isolation under load. Two concurrent per-request frames MUST observe their own request maps and only their own. The substrate MUST NOT use a single dynamic
Var/ thread-local / module-level binding that could bleed across frames sharing a thread (e.g., async drain steps, ForkJoin work-stealing). Frame-id keying is the canonical implementation; any equivalent isolation primitive that preserves per-frame separation under concurrent drains satisfies the contract.
The :platforms #{:server} gate on the cofx itself is orthogonal to substrate choice — it ensures client-side dispatches no-op via :rf.cofx/skipped-on-platform per the standard cofx-gating contract. Substrate isolation is the privacy guarantee; the platform gate is the dispatch guarantee.
HTTP response contract¶
SSR is not just HTML + state — it is a full HTTP response. The runtime owns a per-request response accumulator keyed on the request frame's frame-id; the canonical shape is registered as :rf/response in Spec-Schemas. Standard server-only fx populate the slot during the drain; the host adapter consumes the resolved value (via re-frame.ssr/get-response) to build the wire response.
The accumulator's default shape:
{:status 200 ;; default if no fx sets one
:headers {"content-type" "text/html; charset=utf-8"} ;; default content-type for HTML
:cookies []
:redirect nil}
Response storage substrate¶
The response accumulator's storage substrate is normative for the same reasons as the request slot's (§Request storage substrate) — getting it wrong has direct privacy and performance consequences.
- MUST NOT ride
app-db. The response accumulator MUST NOT be stored under any key inapp-db— neither the request frame's nor any other frame's.app-dbis the hydration payload's source (§Payload scope,:rf/app-db): every value in it is a candidate to ship to the client on bootstrap. The response accumulator routinely carries server-only data —Set-Cookieheaders (auth tokens, session ids), internalX-*headers, redirect URLs that may encode internal hostnames — whose leakage to the client is a security incident. The hydration boundary must remain shippable-without-redaction; storing the accumulator inapp-dbwould default-leak that surface onto the wire and force every host adapter to remember a defensive(dissoc :rf/response)before serialising the payload. A privacy boundary that's a constant caller-vigilance burden is a leak waiting to happen; side-channel storage makes the boundary self-enforcing. - MUST use a framework-private side-channel keyed on frame-id. The substrate is per-frame so two simultaneous per-request frames (the canonical SSR shape under concurrent load) carry independent accumulators that cannot bleed into each other. The slot is framework-private — not a public app-db key, not a registered subscription — read exclusively by the runtime (via
re-frame.ssr/get-response) and written exclusively by the 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-dbpaid a full app-db replacement on every:rf.server/*fx (read-container → assoc → replace-container!); for a 7-fx response shape (typical login flow:set-status+ 2×set-cookie+ 3×set-header+redirect), that's seven full-app-db replacements per request. The side-channel substrate's swap is O(small-map): one atom CAS against a{frame-id → response-map}table. The contract is the algorithmic class — per-fx response writes scale with the response shape, not with app-db size. - Runtime MUST clear the slot on frame teardown. Per §Per-request frame teardown contract the slot MUST be released when the request frame is destroyed. The CLJS reference clears via the
:ssr/on-frame-destroyedlate-bind hook — the same hook that drops the request slot and the pending-error-trace buffer.
Standard fx¶
All six are :platforms #{:server} — registered, schema-validated, and silently no-op'd by :rf.fx/skipped-on-platform if dispatched client-side.
| Fx | Args | Notes |
|---|---|---|
:rf.server/set-status |
<int> (e.g., 404) |
Set the response status code. Last-write-wins (per §Multiple-status policy below). |
:rf.server/set-header |
{:name "X-Foo" :value "bar"} |
Replaces an existing header (case-insensitive name match). Wire format is host-adapter business. |
:rf.server/append-header |
{:name "X-Foo" :value "bar"} |
Appends another instance — required for Set-Cookie-style multi-valued headers. Deduplication is host-adapter policy. |
:rf.server/set-cookie |
a :rf.server/cookie map (:name, :value, :max-age, :secure, :http-only, :same-site, :path, :domain, :expires) |
Adds a structured cookie to :cookies; the host adapter serialises to wire form (avoids cookie-attribute quoting bugs). |
:rf.server/delete-cookie |
{:name "session" :path "/"} |
Adds a delete-cookie marker (set-cookie with :max-age 0); semantics are host-adapter business. |
:rf.server/redirect |
{:status 302 :location "/login"} (or :url/:to; :status defaults to 302) |
Sets :redirect on the accumulator and short-circuits HTML rendering (per §Redirect precedence below). Caller-trusted :location — accepts arbitrary URL strings without allowlist or relative-only gating. For caller-untrusted strings (e.g. a ?next= query param), use :rf.server/safe-redirect (below). |
:rf.server/safe-redirect |
{:location "/dashboard" :relative-only? true} or {:location "https://app.example.com/..." :allow ["app.example.com" "alt.example.com"]} |
Validates :location before populating :redirect. Validation order (per 009 §Error event catalogue): (1) URL must parse — :rf.error/safe-redirect-invalid-url on failure; (2) reject javascript: / data: / vbscript: schemes — :rf.error/safe-redirect-scheme-rejected; (3) :relative-only? true and the URL has a host — :rf.error/safe-redirect-host-disallowed (:reason :relative-only-violation); (4) :allow [...] allowlist mismatch — :rf.error/safe-redirect-host-disallowed (:reason :not-in-allowlist); (5) on pass, sets :redirect (same shape as :rf.server/redirect). Mitigation for the open-redirect class — an attacker-controlled ?next=… URL parameter cannot redirect off-origin. |
The fx-args schemas (:rf.fx.server/set-status-args, etc.) are registered per Spec-Schemas §Standard fx args schemas. Args validation runs as part of the standard :schema boundary check (per 010 §Validation timing).
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 and Security.md §CRLF injection at HTTP-response boundaries.:rf.server/redirectMUST reject CRLF in:location. TheLocation:header is a header value subject to the same CRLF check, plus a structural URL-shape check; a CRLF (or any of the other structural-malformity classes) in the redirect target surfaces:rf.error/redirect-invalid-location. The fail-fast posture applies whether:locationcame from a constant, a route binding, or a?next=query parameter forwarded through:rf.server/redirect. (Caller-untrusted strings should route through:rf.server/safe-redirectabove for open-redirect mitigation; the CRLF check applies to both.) Per.: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. Per and 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. The fail-fast posture composes cleanly with the structured-fx-args schemas above — args validation surfaces the bug at the dispatch site, not at the wire boundary.
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 :on-create event, an :on-match route handler, a downstream cascade), the runtime:
- Sets
:redirect {:status N :location "..."}on the accumulator. - Skips the HTML render step entirely —
:htmlis absent from the request result. - Skips the hydration-payload serialisation —
:payloadis also absent (no client to hydrate). - The host adapter sees
{:response {:redirect {:status 302 :location "/login"} ...}}and emits a status-and-Location-header response with no body.
Multiple redirects: last-write-wins on the :redirect slot itself, with a :rf.warning/multiple-redirects trace (same shape as the multiple-status warning below).
Multiple-status policy¶
Lock: last-write-wins, with a structured warning. If two handlers in the drain both emit :rf.server/set-status, the runtime:
- Records each write to the
:statusslot. - After drain, if more than one distinct write occurred, emits a
:rf.warning/multiple-status-settrace event (per 009 §Error event catalogue). - The final
:statusis the last write — same default:db-style semantics re-frame uses elsewhere.
The warning is advisory: production response is the last-write value. Tools (10x, error monitors) surface the warning so authors can find the conflicting handlers.
Header replacement vs append¶
Lock: :rf.server/set-header replaces; :rf.server/append-header adds another instance. Both are documented; choosing the wrong one is a contract bug, not a runtime error.
The runtime stores headers internally as an ordered vector of [name value] pairs (case-insensitive name match). On serialisation, the host adapter chooses how to wire the multi-valued case (most frameworks accept [name value] arrays; Ring uses string-or-vector values).
Deduplication is host-adapter policy. The runtime does not deduplicate — if user code emits two :rf.server/append-header calls with the same value, both go on the wire. The adapter may collapse them or pass them through.
Cookie shape¶
Lock: structured maps, not raw header strings. The :rf.server/cookie schema (registered in Spec-Schemas) names the canonical attributes:
{:name "session"
:value "abc123"
:max-age 3600
:secure true
:http-only true
:same-site :lax ;; one of :strict :lax :none
:path "/"
:domain "example.com"
:expires <int-ms> ;; optional; either :max-age or :expires (or neither)
}
The host adapter serialises this to a Set-Cookie: header value per RFC 6265. User code never builds the wire string. This intentionally avoids the per-attribute quoting / encoding bugs that plague raw-string cookie APIs.
: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
:status(typically500) and a defaulttext/html; charset=utf-8content-type unless user code has set a different one.
These defaults are the runtime's; user fx can override any of them. The runtime never interferes with explicit user-supplied values.
Trusted shell hook contract¶
The host adapter's default HTML envelope (CLJS reference: re-frame.ssr.ring/default-html-shell for non-streaming, default-streaming-prefix + default-streaming-suffix for streaming SSR) exposes four convenience opts the handler-constructor surface accepts as TRUSTED STRINGS — values the shell injects RAW into the rendered HTML response, with no escaping, no validation, and no sandbox. They are the parallel of the caller-trusted :rf.server/redirect surface (§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 / asset URL / doctype-extension / head fragment to inject); the framework names the trust boundary; the content trust itself remains the caller's.
The four trusted-string opts:
| Opt | Wire position | Trust semantic |
|---|---|---|
:head |
Verbatim HTML inside <head>...</head> (overrides the route-resolved head fragment when supplied as a string) |
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> |
Caller-trusted string. The escape hatch for analytics / third-party scripts / chat widgets the app boot decides to inject. The CLJS reference also runs a dev-mode :csp-script-src-allowlist scan (Security.md §Decisions log §) that emits :rf.ssr/csp-allowlist-violation for <script src> hosts outside the allowlist — defence-in-depth signalling, not a block. |
:script-src |
Written into <script src=\"...\"></script> (the client-side bootstrap script URL) |
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 |
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 four opts are TRUSTED STRINGS. The framework MUST accept them as strings (or nil — meaning "no override, use the default"); the shell MUST inject the string verbatim into the rendered HTML envelope. No framework-level escaping is applied — the trust call is the caller's. Apps that wire any of the four 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.
- Construction-time structural-shape validation. The framework MUST validate at handler-construction time that each of the four, if supplied, is a string (or nil). Non-string non-nil values (a map, a vector, a symbol, a number) surface
:rf.error/ssr-trusted-shell-opt-invalidat boot — the structural mistake is caught before the first request rather than as aClassCastExceptiondeep in the rendering path. The error's ex-data carries:opt-key(the offending opt's keyword),:got(the rejected value),:got-type(its type), and:recovery :supply-string-or-nil. The check is contains?-aware so absent opts pass; nil opts pass. - Documented structured alternative for untrusted-customization use cases. Apps offering admin- or tenant-configurable shell customization (a "customize site head" admin form; per-tenant analytics blocks driven by tenant settings; user-driven body-end widget configuration) MUST NOT wire the raw input through these four opts. The structured alternative is:
reg-headfor head fragments. The head/meta registry (§Head/meta contract below) carries{:title :meta :link :script}shape; the SSR emitter renders the structured head through the standard hiccup → HTML walker, which applies position-appropriate escaping (text-node, attribute-value, raw-script-body — per §XSS at output boundaries) at every leaf. An admin-editable title / meta-description / OG-tag flows through structured data throughreg-head— never as a raw:headstring.reg-view*+:rf.server/*fx for body content. Body content the admin / tenant customizes belongs in a registered view that takes the customization as a sub'd value off app-db. The view's hiccup goes through the same SSR emitter — string content lands as text-node children (HTML-escaped); attribute values land as escaped attribute pairs; the trust boundary is the schema-validated app-db slice, not the rendered string.:rf.server/set-headerfor header-shaped customization. Custom response headers go through the structured fx — CRLF-checked, schema-validated.- 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 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 — rf2-4dra9. The
reg-head/render-head/active-headsurface described in this section ships in theday8/re-frame2-ssrartefact (re-frame.ssr.head). The hash-on-wire path (per §Hydration-mismatch detection) covers head as part of the unified render-tree hash; head-mismatch detection discriminates via:failing-id :rf.ssr/head-mismatch. A dedicateddata-rf-head-hashpayload key + wire attribute remains reserved for a post-v1 head-only-hash extension.
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.
The standard head model (registered as :rf/head-model in Spec-Schemas):
{:title "Article: re-frame2 SSR"
:meta [{:name "description" :content "..."}
{:property "og:title" :content "..."}
{:property "og:image" :content "https://..."}]
:link [{:rel "canonical" :href "https://example.com/articles/123"}]
:script [{:src "https://..." :async true}]
:json-ld [{"@context" "https://schema.org" "@type" "Article" ...}]
:html-attrs {:lang "en"}
:body-attrs {:class "page-article"}}
Mechanism — registered head function + route metadata¶
Lock: head logic is registered with reg-head; routes name which head to use via :head route metadata.
(rf/reg-head :head/article
{:doc "Article-page head model — derives title/meta/og from the article."}
(fn [db {:keys [params] :as route}]
(let [{:keys [title summary image]} (get-in db [:articles (:id params)])]
{:title (str title " — Example")
:meta [{:name "description" :content summary}
{:property "og:title" :content title}
{:property "og:image" :content image}]
:link [{:rel "canonical" :href (route-url :route/article params)}]
:json-ld [{"@context" "https://schema.org"
"@type" "Article"
"headline" title}]})))
(rf/reg-route :route/article
{:path "/articles/:id"
:head :head/article}) ;; route declares which head to use
reg-head adds a new registry kind :head (per 001 §Registry model). The query API surfaces it: (rf/registrations :head) returns id → metadata; tools can enumerate registered heads.
reg-head returns its id argument per the family-wide reg-* return-value convention.
The function signature is (fn [db route] head-model) — pure, deterministic, no side-effects. Same shape and discipline as a sub. Subscriptions inside head functions are evaluated against the static app-db value (same path as views; per compute-sub).
Default flow¶
- SSR request renders the body view.
- The runtime resolves the active route's
:headmetadata → a registered head id. (rf/render-head head-id frame-id)(or equivalently(compute-head head-id db route)) returns the head model.- The runtime emits
<head>...</head>from the model, in canonical order:<title>first, then<meta>in declaration order, then<link>, then<script>, then JSON-LD<script type="application/ld+json">.:html-attrspopulate<html>;:body-attrspopulate<body>. - Client on hydration recomputes the head model from the now-seeded state (the hydrated payload's
:rf/app-dbplus the carried route slice at[:rf/runtime :routing :current]). - Mismatch detection: client compares its computed head to the server-supplied head; on mismatch, the client re-renders the head and emits
:rf.ssr/hydration-mismatchwith:failing-id :rf.ssr/head-mismatch. The server head is replaced (consistency with body-mismatch handling).
Head-mismatch detection in v1 piggy-backs on the unified :rf/render-hash channel: the server-rendered HTML carries a single structural hash that covers both head and body; the runtime emits one :rf.ssr/hydration-mismatch trace on disagreement and discriminates head-vs-body via the :failing-id tag (per §Hydration-mismatch detection). A dedicated :rf/head model on the wire (and its companion :rf/head-hash) is reserved for a post-v1 reg-head payload extension — when reg-head lands as a non-deferred surface those keys will be defined under :rf/hydration-payload-postv1 in Spec-Schemas. The v1 client recomputes the head from the hydrated :rf/app-db (plus the carried route slice); SPAs that route post-load see the same graceful path because no separate head channel is required.
render-head¶
(rf/render-head head-id
{:frame :rf/default ;; required
:route active-route}) ;; optional; defaults to (subscribe [:route])
Returns the head model map. Pure, JVM-runnable, used by the SSR pipeline to materialise <head>...</head> and by tooling to inspect the active head without re-rendering the body.
(rf/active-head) is sugar — looks up the active route's :head, calls render-head, returns the model. Useful in dev tools.
Single :head per route in v1¶
Lock: one registered :head per route. No composition (parent + child route head fragments) in v1 — that's a follow-up if real cases emerge. Routes that want to share head logic do so by referencing the same registered :head id, or by registering a head fn that calls a helper.
Mismatch detection — head¶
Same shape as body-mismatch (§Hydration-mismatch detection above):
- The render-tree's structural hash (per §Hydration-mismatch detection) covers head as well as body in v1 — head and body share the unified
:rf/render-hashchannel. A dedicateddata-rf-head-hashattribute is reserved for the post-v1reg-headextension; v1 implementations emit onlydata-rf-render-hashon the root element. - Client computes the same hash on the recomputed render-tree (head + body) and compares against the server-supplied hash.
- On mismatch where the divergence is localised to head:
:rf.ssr/hydration-mismatchtrace event with:tags {:server-hash "..." :client-hash "..." :failing-id :rf.ssr/head-mismatch}. The:failing-iddiscriminator distinguishes head-mismatch from body-mismatch (which carries:failing-id :rf/hydrate) so consumers branch on a single operation key plus the discriminator rather than two parallel operation keys. Recovery is:warned-and-replaced— the client renders its computed head, replacing the server's. In v1, head and body share the unified:rf/render-hashchannel, so the runtime cannot distinguish head-only divergence from body-only divergence and always emits:failing-id :rf/hydrateon mismatch. The:rf.ssr/head-mismatchdiscriminator is reserved for the post-v1 head-only-hash channel; the public contract is locked so consumers can branch on it once the discriminator becomes live. (Audit S5.)
Default head when no route declares :head¶
Sensible default (from frame metadata + the runtime's HTML defaults):
{:title (or (:doc (frame-meta frame-id)) "")
:meta [{:charset "utf-8"}
{:name "viewport" :content "width=device-width, initial-scale=1"}]}
No registered head is required. The default is silent — no warning. Routes that want explicit head data declare :head.
Server error projection¶
The trace surface (009 §Error contract) carries internal error detail — stack traces, exception data, internal codes — for monitoring and debugging. The HTTP response carries a public projection — a sanitised, client-safe shape that crawlers, browsers, and unauthenticated users may see. The two surfaces have different audiences and different security profiles.
The standard public error shape (registered as :rf/public-error in Spec-Schemas):
{:status 500
:code :internal-error ;; stable category keyword for response-page templates
:message "Something went wrong" ;; one-sentence human-facing
:retryable? false}
Mechanism — registered projector + per-frame :ssr metadata¶
Lock: both a registry-first projector and a per-frame :ssr metadata map naming which projector is active. The :ssr config sits on the frame's metadata (per Conventions §Configuration surfaces bucket 3) — so a single process can run a server-rendering frame with one projector and a dev tooling frame with another.
(rf/reg-error-projector :myapp/public-error
{:doc "Project internal error trace events to public response shapes."}
(fn [trace-event]
(case (:operation trace-event)
:rf.error/no-such-handler {:status 404 :code :not-found
:message "Page not found" :retryable? false}
:rf.error/schema-validation-failure {:status 400 :code :bad-request
:message "Invalid input" :retryable? false}
;; default — generic 500 in prod
{:status 500 :code :internal-error
:message "Something went wrong" :retryable? false})))
;; Wire the projector at frame-creation time — server frames opt in
;; via `:ssr` metadata; the runtime reads it through `frame-meta`.
(rf/make-frame {:platform :server
:ssr {:public-error-id :myapp/public-error
:dev-error-detail? true}}) ;; dev: include :details with full trace
reg-error-projector adds a new registry kind :error-projector (per 001 §Registry model). The query API surfaces it: (rf/registrations :error-projector) returns id → metadata. The runtime consults exactly one projector per response — the one named in the frame's :ssr {:public-error-id ...} metadata. If unset, the runtime uses its default projector (below).
reg-error-projector returns its id argument per the family-wide reg-* return-value convention.
Default projector¶
The runtime ships a default projector (:rf.ssr/default-error-projector) implementing the canonical mapping:
Internal :operation |
Public :status |
Public :code |
|---|---|---|
:rf.error/no-such-handler (in routing context) |
404 |
:not-found |
:rf.error/no-such-route (route-id not in registrar — per 009 §Error event catalogue) |
404 |
:not-found |
:rf.error/schema-validation-failure (:where :event or :cofx) |
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 |
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. - Runtime renders an error page — a registered view (or the host's default error template) — receiving the public-error map as its prop. The error-page view sees only the sanitised projection; it cannot accidentally leak the internal trace.
- The host adapter serialises the response.
The HTML response is the public projection — error pages read the public shape, the rendered HTML never contains internal detail. This is the security boundary.
If the projector itself throws (or returns a non-conforming shape), the runtime emits :rf.error/sanitised-on-projection (per 009 §Error event catalogue) and falls back to the locked generic-500 shape {:status 500 :code :internal-error :message "Something went wrong" :retryable? false}. The fallback ensures the boundary cannot be bypassed by a bug in the projector.
Substrate — projector rides the always-on error-emit listener¶
the SSR error-projection pipeline installs onto the always-on register-error-listener! substrate (per 009 §What IS available in production §Error-emit listener) — NOT the dev-only register-listener! surface. The error-emit substrate is documented as production-survivable; it fires under :advanced + goog.DEBUG=false builds and under the JVM -Dre-frame.debug=false production-hardening gate. Server error projection is a production-required surface, not a dev-only one: SSR / webhook receivers / long-running JVMs facing untrusted input MUST set re-frame.debug=false per 009 §JVM builds, and the projector's status-stamping contract must hold under that posture.
The substrate boundary maps the always-on error-emit record ({:error :event :event-id :frame :time :exception :elapsed-ms :source-coord}) onto the projector's trace-event-shaped envelope ({:operation :op-type :tags}) before invoking the active projector. Custom projectors that case on (:operation event) or read (get-in event [:tags :exception]) work unchanged across both substrates.
Five :rf.error/* categories do NOT have an always-on emission path — they ride the dev-only trace/emit-error! and DCE under production hardening: :rf.error/no-such-handler, :rf.error/no-such-route, :rf.error/schema-validation-failure, :rf.error/sub-exception, :rf.error/drain-depth-exceeded. For dev parity these are also routed through the projector via a secondary register-listener! install. Under production hardening these categories silently elide along with the trace surface — apps that need the 404/400 stamping in production deployments must explicitly route them through a :rf.error/* category that fires on the always-on substrate (typically by rethrowing inside the failing handler so the cascade lands on :rf.error/handler-exception).
View-time exceptions¶
A view that throws during render-to-string (e.g., a missing key on an attempted (get-in db [...])) flows through the same projector. The hiccup walker catches the exception, emits :rf.error/sub-exception (or :rf.error/handler-exception for view-fn exceptions), invokes the projector, and renders the error page. The user does not write a separate "view exception" path.
Hosts that prefer eager exceptions during dev (to surface bugs early) can opt in via the frame's :ssr {:on-view-exception :throw} metadata — dev convenience; production should always project. The CLJS reference reads this knob at the render-time projection entry point (re-frame.ssr/project-render-exception!,.10): when set to :throw, the original throwable is re-thrown unchanged to the host's outer handler instead of being projected to a sanitised public-error.
The JVM reference adapter (re-frame.ssr.ring) unifies the two render-side failure surfaces under one pipeline. Render-time throws are caught at the host-adapter render call site and routed through ssr/project-render-exception! (synthesises a :rf.error/ssr-render-failed trace event and applies the active projector); the wire body is the projector's :message / :code. The outer :on-error hook is reserved for transport-layer / projector-undeliverable failures (no server frame, projector pipeline catastrophically fails) where the fixed-body contract applies.
:on-error vs :error-view — the error-handling division¶
The host adapter exposes TWO error opts that handle TWO different failures. They are not alternatives — a robust deployment usually wires both. The division (the CLJS reference's ssr-handler docstring carries the same decision table verbatim):
:error-view |
:on-error |
|
|---|---|---|
| Which failure | An exception INSIDE the drain or render walk — something the error PROJECTOR catches (steps 1–5 above). | A transport / Ring-layer failure the projector CANNOT see — per-request frame setup throw, a render-time host exception outside the walker, a header/cookie materialise throw, a thrown :on-create. |
| What it produces | The PROJECTED error-page body (hiccup) — a registered-view keyword or a (public-error) → hiccup fn, rendered through the SSR emitter. |
A raw HTTP response map {:status … :headers … :body …} returned verbatim to the server. |
| Its input | The SANITISED :rf/public-error map — safe to render. |
The raw (request throwable). The locked default NEVER reads the throwable (the .getMessage topology-leak boundary). |
| HTTP path | Normal response, projector's status (typically 500) + the rendered page. | Last-resort net OUTSIDE the normal pipeline (the projector never ran / can't run). |
| Default when omitted | Minimal default error template. | Minimal locked 500 (topology-leak-safe generic body). |
Mnemonic: :error-view is the projected page (drain-time, sanitised, hiccup); :on-error is the transport net (Ring-layer, raw throwable, outside the projector). Both are bug-contained: a buggy :error-view falls back to the default error template; 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, configurable; defaults totruewhen the build'sgoog.DEBUG/ equivalent is on) — public shape carries:details(the original trace event); the error page can render full detail for the developer. - Prod (
:dev-error-detail? false, the production-build default) — public shape is the locked four keys only;:detailsis absent; no internal detail leaks regardless of projector implementation.
Implementations may key the dev/prod default off the build flag (e.g., goog.DEBUG); the configuration API is the same for both. The default is safe by default in prod — leaking detail requires explicit opt-in.
Internal trace events are not leaked¶
The internal trace stream remains unchanged by projection. Monitoring listeners (register-listener! — dev-only; register-error-listener! — always-on; the latter is the production-survivable channel per 009 §What IS available in production) see the full structured error record with :exception-message, :exception, stack traces, and any other detail the runtime captured. Projection only governs what crosses the HTTP boundary.
This separation is the operational benefit: monitoring stays rich; the wire stays clean.
Operational rules¶
These are normative rules implementations must follow — active contract, not historical notes. Each subsection below is part of the load-bearing SSR surface: platform gating, JVM-runnable rendering, the server/client routing handshake, fragment behaviour, auth/session flow, the
:aftercarve-out for machines, and hydration-payload scope. Read this section as a continuation of §Detailed design. Implementations that want a working SSR surface must satisfy every rule below.
Mismatch recovery and configuration¶
The detection mechanism is in §Hydration-mismatch detection above. Recovery and configuration:
- Default recovery:
:warned-and-replaced— the runtime renders the client's view, replacing the server's HTML. The page becomes interactive. - Strict mode (frame's
:ssr {:on-mismatch :hard-error}metadata): the runtime throws a structured exception with the same payload. Used in dev/CI to surface mismatches loudly. - External monitoring integrations register a trace listener and ship mismatch events to their backend.
- Mismatch detection is mandatory in dev builds; production builds can disable the hash-comparison work via the frame's
:ssr {:detect-mismatch? false}metadata for a small first-render perf win, at the cost of silent mismatches. Default: detection on in all builds.
Effect handling on the server¶
:platforms metadata on every reg-fx and reg-cofx. Server-side fx resolver filters by platform; absence of the key defaults to #{:server :client} (universal).
The full rule:
reg-fx(andreg-cofx) takes optional:platformsmetadata: a set containing:server,:client, or both.- The runtime tracks the active platform — the CLJS reference sets this at startup based on
cljs.core/*target*for client builds, or via an explicit(rf/init-platform :server)for server-side bootstraps. - When the fx resolver encounters an effect whose
:platformsdoesn't include the active platform, it skips the fx and emits a:rf.fx/skipped-on-platformtrace event (not an error;:op-type :warning) with:fx-id,:platform,:registered-platforms. Recovery::skipped(per 009 §Error contract). The fx silently no-ops with the trace. SSR does not get stricter than this; the trace event is sufficient observability without aborting render. Tools that want strict-mode behaviour register a trace listener on:rf.fx/skipped-on-platformand escalate. The same rule applies symmetrically to cofx: wheninject-cofxresolves a cofx whose:platformsexcludes the active platform, the cofx's handler-fn is NOT invoked, no value is injected into:coeffects, and the runtime emits:rf.cofx/skipped-on-platform(same shape;:cofx-idinstead of:fx-id). The event handler still runs — only the injection is skipped. - Default if
:platformsabsent:#{:server :client}(universal). SSR-shared fx and headless-test fx are universal by default. Explicit:platforms #{:client}is required for fx that genuinely cannot run server-side (browser-only). - Setup events that only matter on the server (
:rf/server-init, request-cofx injection) carry:platforms #{:server}themselves so they don't run client-side after hydration.
This is the single mechanism for platform-gating; no per-fx branching inside handler bodies. Same handler dispatches the same effects on both platforms; the runtime decides which actually fire.
JVM-runnable view rendering¶
Pure hiccup → string emission as a JVM-runnable function. No JVM React. No component lifecycle on the server.
Concrete contract:
- The CLJS reference ships
re-frame.render/render-to-stringas a pure function over hiccup data. Implementation is a walk: tag → HTML element, attrs → escaped attribute pairs, children recursed, void elements (<br>,<img>,<input>, etc.) self-closed per HTML5, text content escaped per XSS rules. - The function is in
.cljc. No Reagent, no React, no DOM dependencies. JVM-runnable. - Registered views are resolved by id at emission time; the registry is just data, queryable from the JVM.
- Subscriptions inside view bodies use
compute-sub(not the reactivesubscribe) — pure derivations against the staticapp-dbvalue. - Component lifecycle hooks (
:component-did-mount, etc.) do NOT fire on the server. Form-3 components render their:reagent-renderonly; lifecycle is client-side after hydration.
The JVM-runnable scope in 008's table reflects this: hiccup → string is JVM-runnable; React mount/commit is CLJS-only.
Routing and SSR¶
The request URL is fed in via :rf/server-init, which dispatches :rf.route/handle-url-change with the URL. Same handler runs server- and client-side (:platforms absent, so default-universal).
Concrete handshake:
- Server's
handle-requestcallsmake-framewith:on-create [:rf/server-init request]. :rf/server-init(:platforms #{:server}) reads the request's URI and dispatches[:rf.route/handle-url-change uri].:rf.route/handle-url-change(universal — runs on both platforms) calls(rf/match-url uri), sets the route slice inapp-dbat[:rf/runtime :routing :current]. Per 012-Routing.- Per-route data fetches happen via the route's matched-state side-effects: a registered fx like
[:route/fetch-data route-id params]is dispatched as part of:rf.route/handle-url-change's effects. - Drain settles. The frame's
app-dbhas the route slice ([:rf/runtime :routing :current]) and any route-specific data in place. The view renders.
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 cofx injection at :rf/server-init time. No SSR-specific auth surface.
Concrete:
- Server middleware (Ring/Pedestal/etc.) extracts the session from the request — cookie-based, JWT-based, whatever the host uses.
- The session is attached to the request map as
:session(or whatever key the host's middleware uses). :rf/server-initreads the session via cofx:[:dispatch [:auth/server-init (:session request)]].:auth/server-init(:platforms #{:server}) sets the relevantapp-dbslice:{:auth/user (or session-user nil) :auth/state (if session :authed :idle)}.- The client side is hydrated with this slice; the user's authed-state survives the round-trip.
The framework provides no auth-specific machinery. The pattern's primitives — events, cofx, frames per request — are sufficient.
:after is no-op under SSR¶
State machines that declare :after (per 005 §Delayed :after transitions) do not schedule timers in SSR mode. The state node's entry action skips the re-frame.interop/schedule-after! call when the active platform is :server; the synthetic timer-elapsed event is never queued; the request frame is destroyed before any timer could fire anyway.
The rule:
- The server renders the machine's current
:statestatically. Whatever timer-driven transitions might be pending have not happened — they don't exist on the server. - The serialised
app-dbsnapshot includes[:rf/runtime :machines :snapshots <id>]with the current:stateand:data(including the per-decl-path:rf/after-epochmap); the client hydrates that snapshot. - After hydration, the client's first render of the relevant view is what triggers the machine to "enter" the state for client-side purposes — the implementation may re-fire entry actions on hydration to begin scheduling, or may treat hydration as a special case that schedules
:aftertimers without re-running other entry effects. The exact handoff is a host-implementation choice; the contract is that the snapshot value is preserved across the round-trip and that:aftertimers begin running on the client (per the snapshot's epoch) rather than on the server.
Symptoms of getting this wrong: the server schedules a 5-second :dispatch-later; the request frame is destroyed; the timer fires against a destroyed frame (CLJS) or a freed channel (host-equivalent); a stray trace event surfaces or, worse, the timer's effect lands in some other in-flight frame's app-db.
Why it's safe to elide:
:aftertimers are state-entry-relative. They have no semantic meaning until the user is interacting with the page — i.e., until after hydration. There's nothing to lose by deferring scheduling to the client.- Epoch-based stale detection makes the round-trip idempotent: even if a server-scheduled timer somehow leaked, the client's epoch would be different and the timer would be ignored at expiry. Eliding scheduling outright avoids the leak.
- Per
:platformsgating (§Effect handling on the server), the implementation can register the timer-scheduling fx with:platforms #{:client}— the server-side fx resolver silently no-ops it without further machine-handler awareness.
This is the only SSR-specific carve-out the state-machine substrate needs; all other machine semantics (transitions, :always, :spawn, hierarchical entry/exit cascading) run identically on both platforms.
Hydration of non-state runtime artefacts¶
:rf/hydration-payload carries only :rf/app-db (the canonical state). Sub-cache warmups, in-flight request continuations, and other 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-dband replaces itsapp-dbwith it (per the locked:replace-app-dbpolicy in §The:rf/hydrateevent). Server is authoritative for the initial client app-db. - Client's reactive subscriptions, on first read, compute against the now-seeded state. Same values the server saw.
- There is intentionally no special-case for "warm" subs vs "cold" subs. The first read is the warmup.
Streaming SSR¶
Status: shipped — rf2-ojakd / rf2-olb64 (a). The
:rf/suspense-boundaryhiccup marker described below ships in theday8/re-frame2-ssrartefact (re-frame.ssr.streamingns); the chunked-response wiring ships inday8/re-frame2-ssr-ring(re-frame.ssr.ring.streamingns +stream-handler).
Streaming SSR lets the server flush a usable shell on first byte, then stream subtrees as their data resolves. Crawlers, low-latency rendering, and SOTA parity with Next/Remix defer and Solid Suspense all benefit. The primitive is one hiccup marker — declarative, walker-driven, no per-host streaming-render-mode API.
The :rf/suspense-boundary hiccup marker¶
[:rf/suspense-boundary
{:id :news/comments ;; required, namespaced keyword or string
:fallback [:p "Loading comments…"]} ;; required, hiccup
[:comments/section]] ;; subtree — the deferred body
Operational semantics (the emitter contract):
- The streaming emitter walks the hiccup tree top-down.
- On a
:rf/suspense-boundarynode, the emitter: - emits the rendered
:fallbackwrapped in<template data-rf2-suspense-id="<id>" data-rf2-suspense-fallback="1">…</template>, - records a continuation entry
{:id <id> :subtree <body-hiccup>}in the per-request streaming-continuations registry, - continues walking sibling nodes (the shell is single-pass; nested boundaries are recursed into the continuation's body when the continuation later renders).
- After the shell HTML is materialised, the host adapter drains continuations one by one (order is FIFO over registration order; nested boundaries inside a continuation register their own entries during the continuation render). For each continuation:
- render the subtree to HTML via
render-to-string(same emitter, recursion-friendly — a nested:rf/suspense-boundaryre-recurses through this same drain), - build the per-subtree hydration delta (the subset of
app-dbkeys touched between the start of the continuation render and its end; see §Hydration interleaving below for the partitioning rule), - emit one chunk carrying
<template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1">…subtree-html…</template>followed by<script data-rf2-suspense-hydrate="<id>" type="application/edn">…delta-edn…</script>, - flush the chunk.
- After the last continuation drains, the host adapter emits the final-hydration-payload chunk (
<script id="__rf_payload" type="application/edn">…full-payload…</script>) and closes the response. The final payload carries the canonical:rf/hydration-payloadshape — it is the source of truth on hydration; the per-subtree deltas are speculative chunks the client may apply progressively.
The boundary :id is stable per render — the hiccup author picks it. The runtime does not autogenerate boundary ids; that would defeat hydration matching when the client re-walks the tree.
Client-side hydration semantics:
- On first chunk: the client sees the shell HTML with
<template>fallbacks. The shell hydrates against whatever payload is already inlined (none, in the streaming case) — the streaming bootstrap waits for the__rf_payloadscript, which arrives last. - As resolved-subtree chunks stream in: the client's streaming-runtime swaps each
<template>fallback for the resolved subtree's HTML in-place, and applies the per-subtree hydration delta toapp-db. - After the final chunk: the client dispatches
:rf/hydratewith the full payload — this is the consistency moment; the deltas were speculative, the final payload is canonical.
The streaming runtime is a 30-line CLJS shim (re-frame.ssr.streaming.client/install!) consumed by the bootstrap; non-streaming pages skip the require entirely (it's behind a host-opt-in).
Failure semantics — inline fallback¶
Per (a) sub-rec: when a continuation's render throws, the runtime does NOT fail the whole response. Instead:
- The throwable is caught at the continuation drain step.
- A
:rf.ssr/suspense-boundary-failedtrace event fires (per 009 §Error event catalogue) with{:id <boundary-id> :exception t :recovery :inline-fallback}. - The runtime emits the chunk as
<template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1" data-rf2-suspense-failed="1">…fallback-html…</template>— the fallback's HTML, materialised against the original fallback hiccup, with thedata-rf2-suspense-failedmarker for client-side observability. - The per-subtree hydration delta is omitted for failed boundaries (there is no resolved state to ship; the client keeps its pre-failure delta).
- The response continues — sibling boundaries, the final payload, and the close all proceed normally.
Rationale: streaming SSR's whole point is partial-render robustness. A failed sub-subtree (a flaky comments service, a slow third-party dependency) should not 500 a page whose shell, head, and other subtrees rendered successfully. The fallback is already the author's declared "things-are-loading" surface; reusing it on hard failure is a defensible default and the simplest opt-in alternative (:on-error [:hiccup-vec]) is additive future work.
The failure boundary stops at the continuation. An exception inside the shell walk (before the first chunk has flushed) is NOT covered by this contract — the shell is the request's structural foundation and a shell-render throw escalates to :rf.error/ssr-render-failed per the standard error-projection path. The streaming contract only covers continuations.
Hydration interleaving — per-subtree deltas¶
Per (a) sub-rec: the hydration payload is interleaved per subtree, not shipped last. Each resolved-subtree chunk carries a delta of the app-db keys touched by that continuation's render path; the final payload carries the canonical complete state.
The partitioning rule for what each delta carries:
- The streaming runtime snapshots
app-dbat the start of each continuation render (before-db) and again at the end (after-db). - The delta carries the top-level keys present-or-changed in
after-db, and for each such key its fullafter-dbvalue. The changed-or-new key set is(clojure.data/diff before-db after-db)'s second return slot (the keys only-in / changed-inafter-db); implementations may use any equivalent structural-diff strategy that yields the same key set. The delta ships the full value for each of those keys — notdata/diff's partial second-slot value, which for a changed nested map returns only the changed sub-portion. Shipping the full value is what keeps the client's top-level(into existing delta)merge below lossless: a changed nested top-level key is replaced wholesale with its complete new value rather than having its untouched sub-keys silently dropped. Unchanged top-level keys are omitted (the client already holds them). - The per-subtree delta is shipped as
{:rf/app-db-delta <delta-map> :rf/boundary-id <id>}in the<script data-rf2-suspense-hydrate="<id>">chunk; the client merges it intoapp-dbvia(into existing delta)over the top-level keys. Because each delta value is a completeafter-dbvalue, this top-level merge is lossless even for changed nested keys.
Boundary ordering (registration → drain → chunk emit) is FIFO over the shell's walk order (depth-first, document order). A nested :rf/suspense-boundary inside a continuation's body registers DURING that continuation's render — the inner entry lands at the tail of the registry and drains after all originally-registered entries. This preserves the document-order intuition: each chunk hydrates a strictly-later DOM region than the previous chunk.
Final-payload precedence: the __rf_payload chunk arrives last and carries the canonical app-db. Implementations may detect drift between the accumulated deltas and the final payload (a non-empty diff after applying both); the v1 contract is the final payload wins — :rf/hydrate runs :replace-app-db semantics, the same lock as non-streaming SSR. Deltas are the progressive-rendering speed prop; the final payload is the correctness lock.
Chunk-ordering contract (the wire shape)¶
The host adapter MUST emit chunks in this order:
- Shell chunk —
<!DOCTYPE html><html>…<body>…<div id="app"><shell-html-with-template-fallbacks/></div>— flushed immediately after the shell walk completes. - N resolved-subtree chunks — one per boundary, in registration-order — each chunk is
<template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1">…</template><script data-rf2-suspense-hydrate="<id>" type="application/edn">…</script>. - Final-payload chunk —
<script id="__rf_payload" type="application/edn">…full-payload…</script>. - Closing chunk —
</body></html>.
The chunk content uses HTTP Transfer-Encoding: chunked framing; the application-layer ordering above is what the conformance fixture pins.
Boundary nesting and recursion¶
:rf/suspense-boundary nodes nest. When a continuation's render walks into another :rf/suspense-boundary, the inner boundary registers a new continuation at the tail of the drain queue. The inner subtree's resolved chunk arrives after all originally-registered continuations — the order is strictly registration-order (FIFO).
Edge case — the same :id appearing twice (e.g. a buggy author using :id :news/comments on two separate boundaries): the runtime emits :rf.error/suspense-boundary-duplicate-id (per 009 §Error event catalogue) and the second registration overwrites the first in the drain queue. The wire ships only the last-registered continuation's chunk; the earlier <template data-rf2-suspense-id="<id>" data-rf2-suspense-fallback="1"> placeholder is left in place (the client-side runtime never finds a matching resolved chunk and keeps the fallback rendered). Duplicate ids are a programmer error; the contract is fail-soft (no 500), with a visible trace.
Late-bind hook surface¶
The streaming surface composes from three late-bind hooks (per Conventions §Late-bind hook key grammar):
| Hook | Producer | Consumer | Purpose |
|---|---|---|---|
:ssr.streaming/render-shell! |
re-frame.ssr.streaming |
host adapters | Walk the root hiccup, emit the shell HTML with <template> fallbacks, return {:shell-html "…" :continuations [{:id … :subtree …} …]}. |
:ssr.streaming/render-continuation! |
re-frame.ssr.streaming |
host adapters | Render one continuation's subtree to {:html "…" :delta {…} :failed? false} (or fallback-html + :failed? true on throw). |
:ssr.streaming/build-final-payload |
re-frame.ssr.streaming |
host adapters | Build the final __rf_payload chunk's payload map after all continuations have drained — the canonical :rf/hydration-payload shape, including the post-drain app-db. |
Host adapters call these three hooks in order; the streaming runtime owns the walker, the registry, and the delta computation. The Ring adapter wires them via re-frame.ssr.ring.streaming/stream-handler (per §Cross-references).
Per-request frame teardown contract¶
Per §Server flow every per-request server frame ends with destroy-frame!. The destroy step is load-bearing for memory hygiene on a long-running server process — leaks here compound at request-rate, and a slow leak under prod load is the kind of bug that ships SSR-broken.
The framework owns the following per-frame allocation sites; all MUST be released by destroy-frame!:
| Slot | Owning ns | Storage | Released by |
|---|---|---|---|
app-db (the frame's state container) |
re-frame.frame |
per-frame, on the frame record | the frame record is dropped (dissoc-frame!) |
| router queue + drain-lock | re-frame.frame |
per-frame, on the frame record | the frame record is dropped |
| sub-cache | re-frame.subs |
per-frame, on the frame record | tear-down-sub-cache! disposes every cached reaction; the cache atom is reset to {} |
| HTTP response accumulator | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the :ssr/on-frame-destroyed hook drops the slot |
| pending error-trace buffer | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the :ssr/on-frame-destroyed hook drops the slot |
| per-frame HTTP request slot | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the :ssr/on-frame-destroyed hook drops the slot; host adapters MAY also clear inline via clear-request! |
| epoch ring buffer | re-frame.epoch |
defonce atom keyed by frame-id |
the :epoch/on-frame-destroyed hook drops the slot |
| privacy warn-once suppression cache | re-frame.privacy |
process-wide defonce, not per-frame |
the :privacy/clear-suppression-cache! hook resets the cache so a re-registered frame re-emits the warning if mis-configured |
What deliberately survives a per-request frame's destruction (these are NOT leaks — they are process-wide registries that mirror handler registration shape):
- The global registrar (
re-frame.registrar/kind->id->metadata) — event / sub / fx / cofx / view / route / error-projector / flow registrations are process-wide; they exist independently of any frame and do not leak per-request. - The retain-N trace ring buffer (
re-frame.trace.tooling/trace-buffer-state, default depth 200) — a process-wide, capacity-bounded buffer; the size is fixed by configuration regardless of request count. (Per the buffer state lives in the tooling sibling ns split off fromre-frame.tracefor production DCE; tools and tests reach it throughre-frame.trace/trace-buffer's late-bind wrapper.) - The substrate adapter slot (
re-frame.substrate.adapter/installed-adapter) — set once at boot.
The contract for side-channel atoms keyed by frame-id: every such atom MUST register a cleanup hook with re-frame.late-bind and that hook MUST be invoked from frame/destroy-frame!. The two existing keys are :ssr/on-frame-destroyed and :epoch/on-frame-destroyed; new artefacts that introduce per-frame side-channel state MUST follow the same pattern.
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 items outstanding at the 011-SSR tier. The streaming surface formerly listed here was classified
:resolvedwhen §Streaming SSR landed; the resolved entry lives at## Resolved decisionsbelow.
(None outstanding — add new open questions inline as decisions land or are deferred.)
Resolved decisions¶
Streaming SSR shipped as :rf/suspense-boundary¶
Per §Streaming SSR the framework now ships a hiccup-marker streaming primitive (:rf/suspense-boundary) plus a chunked-response host adapter (re-frame.ssr.ring.streaming/stream-handler). Earlier drafts of this Spec carried streaming under "Open questions" as a host-implementation concern; closed with Mike's (a) pick — declarative marker, walker-driven, no per-host streaming-render-mode API — plus three sub-recs (accepted): (1) inline-fallback failure semantics, (2) interleave-per-subtree hydration ordering, (3) the :rf/suspense-boundary name. SOTA parity with Next/Remix defer and Solid Suspense. The conformance fixture at spec/conformance/fixtures/ssr-streaming.edn pins chunk ordering plus final-payload hash equality; the worked example at examples/reagent/ssr-streaming/ exercises the dashboard-with-slow-cards scenario.
:replace-app-db is the locked hydration merge policy¶
Per §The :rf/hydrate event the client's :rf/hydrate handler replaces app-db with the server's serialised slice; earlier sketches considered a merge policy that would have preserved client-side pre-seeded state. The merge variant was rejected because the hydration contract is small and tractable only if the server is authoritative for the initial client app-db — 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 only. Adding sub-cache warmups requires the client to know the same sub-graph topology the server used and adds wire bytes plus serialisation complexity. The first read is the warmup. :rf/sub-warmups remains an optional additive payload field in Spec-Schemas §:rf/hydration-payload; a future iteration can land it without breaking the contract.
Per-request frame teardown contract added¶
Per §Per-request frame teardown contract the framework now documents every per-frame allocation site that destroy-frame! MUST release, including three re-frame.ssr defonce side-channel atoms (HTTP response accumulator, pending error-trace buffer, per-frame HTTP request slot). All three are released via the :ssr/on-frame-destroyed re-frame.late-bind hook; the contract for side-channel atoms keyed by frame-id is locked. The load test at implementation/ssr/test/re_frame/ssr_teardown_load_test.clj (2000-request synthetic SSR loop) verifies the heap delta and side-channel atom sizes return to baseline.
Head/meta surface is live, not deferred¶
Earlier drafts of §Head/meta contract carried a deferral banner pointing to ; the contract was normative-looking prose but the impl was absent. The decision in landed the impl: reg-head registers under a :head registry kind; routes name a :head in route metadata; render-head computes the head model; active-head is sugar for the active route. The SSR emitter wraps body output with <head>...</head> from the model when a frame's active route declares :head. Head-vs-body mismatch detection piggy-backs on the unified :rf/render-hash via the :failing-id discriminator.
:rf.ssr/check-version and :rf.ssr/check-schema-digest are framework-registered¶
Per §The :rf/hydrate event the reference :rf/hydrate handler dispatches :rf.ssr/check-version and (when payload carries a digest) :rf.ssr/check-schema-digest. Both fxs are registered by re-frame.ssr at ns-load time with :platforms #{:client}; version-mismatch emits :rf.ssr/version-mismatch, schema-digest-mismatch emits :rf.ssr/schema-digest-mismatch. Earlier drafts named both events in normative prose but registered neither — a silent :rf.error/no-such-handler trace at hydration time was the visible symptom. The two-handler set is locked; apps that want app-specific checks register additional handlers, they don't replace these.
HTTP response accumulator stored in a side-channel atom, not in app-db¶
Per §Response storage substrate the per-request HTTP response accumulator MUST live in a framework-private side-channel atom keyed by frame-id (mirroring request-slots and pending-error-traces), NOT under any path in app-db. Earlier drafts pinned the accumulator at the [:rf/response] app-db path; the audit (parent) identified two failures of that placement: (a) the hydration payload at ssr-ring/build-payload ships the whole app-db by default, so the response accumulator — including Set-Cookie auth tokens and internal X-* headers — defaulted to riding the wire to the client; (b) every :rf.server/* fx swapped the whole app-db container (read → assoc → replace) to update the accumulator, allocating a fresh app-db value per fx call. Moving the substrate to a side-channel atom makes the privacy boundary self-enforcing (the accumulator cannot be misconfigured into the payload) and reduces per-fx writes to an O(small-map) atom CAS. The CLJS reference's re-frame.ssr/response-slots is ^:private; tests reach it via (resolve 're-frame.ssr/response-slots) for between-fixture reset.
:rf.server/safe-redirect ships alongside caller-trusted :rf.server/redirect¶
Per §HTTP response contract the runtime ships two redirect fxs: :rf.server/redirect is caller-trusted (arbitrary :location strings, no allowlist) and :rf.server/safe-redirect is the caller-untrusted variant (URL parse, scheme reject, relative-only / allowlist gating). The audit at 2026-05-14 §P3.2 identified the open-redirect class: an app that reads a ?next=… query parameter and dispatches [:rf.server/redirect {:location next-param}] against attacker-controlled input will happily redirect to a phishing site after auth. Three positions were on the table — (A) ship safe-redirect alongside, (B) doc-string warning + recipe in guide, (C) both — and resolved Option A: ship the primitive, the programmer chooses. Pro: discoverable, opt-in by API; the safe variant is first-class, not buried in a recipe. Con: adds one public fx-id surface; mitigated by the cross-reference in :rf.server/redirect's docstring. The four error categories (:rf.error/safe-redirect-invalid-url, :rf.error/safe-redirect-scheme-rejected, :rf.error/safe-redirect-host-disallowed — the latter discriminates :relative-only-violation vs :not-in-allowlist via the :reason tag) are catalogued in 009 §Error event catalogue.
Render-side validator failures unified under the projector¶
Per §View-time exceptions the JVM reference adapter (re-frame.ssr.ring) routes render-time throws through the SAME projector pipeline that catches drain-time fx / handler / sub exceptions. re-frame.ssr/project-render-exception! synthesises a :rf.error/ssr-render-failed trace event and applies the active projector; the host-adapter render call site wraps a try/catch that drives projection then emits a minimal HTML body from the projector's :message / :code. The outer :on-error hook is reserved for transport-layer failures (no server frame registered, projector pipeline catastrophically fails) where the fixed-body topology-leak rule applies. The category :rf.error/ssr-render-failed is catalogued in 009 §Error event catalogue and appears in the default projector table above (§Default projector).
resolve-head emits :rf.error/ssr-head-resolution-failed before fallback¶
Per §Head/meta contract the host-adapter helper that walks the active route's :head (the Ring adapter's re-frame.ssr.ring.lifecycle/resolve-head) wraps the resolution in try/catch and degrades to an empty fragment when the user's :head fn throws. Earlier drafts of the helper's docstring promised "the trace surface still carries the throw," but the impl just caught and returned "" with no emit — a silently-broken :head fn produced a visually-broken page with zero diagnostic. Two positions were on the table — (A) silent fallback (tighten the docstring to match the impl), (B) trace-emit (match the docstring's promise via :rf.error/ssr-head-resolution-failed) — and resolved Option B: the spec is the artefact, the impl drifted, and the always-on error-emit substrate carries the trace to user observability stacks. The category is catalogued in 009 §Error event catalogue; host adapters for non-Ring substrates (Express, Fastify, plain servlet) MUST emit the same category when their resolve-head equivalent's catch arm fires.
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 TRUSTED STRINGS — injected RAW into the rendered HTML envelope, no escaping. The security audit (parent finding, closed; local-only doc) surfaced the documented-vs-undocumented gap: the four opts had always been trusted strings in practice, but the trust semantic was not normatively named, so apps wiring any of them from untrusted input (a CMS field, a tenant-admin form) had no spec-level signal that they were opting into an arbitrary-script-injection XSS vector. resolved by (a) NAMING the four opts as trusted-string surfaces at the spec level, (b) adding construction-time structural-shape validation (:rf.error/ssr-trusted-shell-opt-invalid rejects maps / vectors / symbols / numbers — the framework catches the structural mistake even though it does not gate the content), and (c) documenting the structured alternatives (reg-head for head fragments; reg-view* + :rf.server/* fx for body content; :rf.server/set-header for header-shaped customization) for untrusted-customization use cases. The trust call itself remains the caller's — the framework names the boundary, validates the shape, and points at the structured surfaces; it does not pretend to gate content the caller declares trusted.
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 FNV-1a render-tree hash, the data-rf2-source-coord annotation, the SSR error-projection trace listener) ships in a separate Maven artefact, not the core. 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 (rf2-uo7v under Strategy B from rf2-5vjj) was chosen over a single-jar build with build-time elision because the optional-dependency boundary is cleaner to communicate and the static-classpath cost-of-presence is zero. See MIGRATION §M-32.
Cross-references¶
- 011-SSR.md — the goal-level statement and rationale.
- 002-Frames.md — frame lifecycle (per-request frames are the same shape as multi-instance / per-test).
- 004-Views.md — view contract; this Spec forces the
(state, props) → render-treepure-fn shape at the pattern level. - 008-Testing.md — JVM-runnable scope; SSR moves view rendering across that line.
- 009-Instrumentation.md — hydration-mismatch trace events.
-
Post-emit regex injection (matching the first
<tagopener of shape<[a-zA-Z][^\s>/]*in the emitted body string, with the letter-prefix skipping a<!DOCTYPE html>prefix) is a valid alternative for ports whose substrate lacks a hiccup-walk equivalent. It is not the canonical mechanism: it over-injects onto non-DOM roots (a tree that resolves to text or to a fragment placeholder gets a spurious attribute on whatever opener follows in the output) and cannot honour the:<>/:>exemption. Ports using the regex form MUST document the divergence and accept the edge-case mismatch with the CLJS reference. - Client renders the root view, computes the same hash on the client-side render-tree, and compares. ↩