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. |
:rf/route (carried inside :rf/app-db under the :rf/route slice) |
Active route, populated by :rf.route/handle-url-change server-side. |
:rf/machines (carried inside :rf/app-db) |
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.
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 (get-frame-db 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 rf2-agql 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}
:spec 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 rf2-asmj1 Q5 / cluster rf2-sljs1.
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 is out of initial scope — see §Streaming SSR under Open questions.
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. |
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 (per rf2-lxwse): :<> 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. Hosts that want to suppress the annotation in production builds branch on the resolved frame's :ssr config (or on a host-supplied flag); the default is to emit, matching the dev-time semantics that the annotation exists to support.
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/hydration :server-hash]. Alongside replacing :db, the reference handler stashes the payload's :rf/render-hash value under [:rf/hydration :server-hash] in the post-hydration app-db. 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 SHOULD NOT write to it. The companion :version key under :rf/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/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 (per rf2-69ad2): 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.
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 (:strict-hydration config) escalates to a hard error for production 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 (rf2-jbcmt).
- 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 (rf2-fcj33) — 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). |
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 :spec boundary check (per 010 §Validation timing).
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.
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/handlers :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:rf/route). - 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 :rf/route); 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 rf2-cegm7 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/handlers :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-args) |
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 |
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.
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.
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-trace-cb!) see the full structured trace event with :exception-message, :exception-data, 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:rf/routeslice inapp-db. 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:rf/routeand 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 :rf/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/machines <id>with the current:stateand:data(including:rf/after-epoch); 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, :invoke, 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.
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 (rf2-jbcmt) |
the :ssr/on-frame-destroyed hook (rf2-fcj33) drops the slot |
| pending error-trace buffer | re-frame.ssr |
defonce atom keyed by frame-id, side-channel |
the :ssr/on-frame-destroyed hook (rf2-fcj33) 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 (rf2-fcj33) 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 (rf2-d656) 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 (rf2-isdwf) |
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/trace-buffer-state, default depth 200) — a process-wide, capacity-bounded buffer; the size is fixed by configuration regardless of request count. - 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¶
Streaming SSR¶
The pattern provides the primitives for streaming — frames per request, deterministic drain, :on-create events, the pure hiccup → string emitter, run-to-completion semantics — but does not ship a streaming render mode in the initial scope. Building streaming on top is a host-implementation concern.
What an implementation would do (sketch):
- Render the shell (everything that doesn't need data) via
render-to-stringand flush. - For each suspense-boundary subtree, register a "pending" placeholder; dispatch the data-loading event; render the placeholder HTML and flush.
- As each pending event resolves (drain settles for that piece), render the resolved subtree and stream its HTML.
- Once all pending events settle, ship the final
:rf/hydration-payload.
Implementation costs: chunked HTTP response handling, framework integration with the host's streaming response API, ordering of payload chunks vs. body chunks. None of these change the pattern's primitives.
An implementation that wants streaming can layer it on the existing emitter without framework changes.
Resolved decisions¶
: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 (rf2-fcj33)¶
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 per rf2-jbcmt, 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 (rf2-4dra9)¶
Earlier drafts of §Head/meta contract carried a deferral banner pointing to rf2-gr0n; the contract was normative-looking prose but the impl was absent. The decision in rf2-4dra9 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 (rf2-69ad2)¶
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 (rf2-jbcmt)¶
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 rf2-jbcmt audit (parent rf2-asmj1) 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.
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. ↩