Spec 006 — Reactive Substrate¶
Status: Drafting. v1-required (CLJS reference).
For where the adapter sits in relation to the rest of the runtime — the frame container, sub-cache, drain loop, and trace bus — see Runtime-Architecture.
Abstract¶
re-frame2 separates the dataflow core from the reactivity / rendering substrate — the abstract surface this spec defines. The substrate-agnostic core — registrar, frames, drain, dispatch envelope, subscription topology, sub computation, effect-map interpretation, trace stream — is JVM-runnable and has no dependency on Reagent, React, or DOM. A pluggable adapter (each implementation of the substrate contract) supplies the reactive container for app-db, the change-tracking that drives view re-renders, and the render-tree → surface step.
Substrate scope: React + VDOM. re-frame2 commits to React + VDOM as the rendering substrate. The adapter contract has two parts. The reactive-container half (entries 1-5 + 9 of §The adapter API contract:
make-state-container,read-container,replace-container!,subscribe-container,make-derived-value,dispose-adapter!) is substrate-agnostic in shape — its description does not mention React; it would generalise to any reactive primitive. The render-side half (entries 6-8:render,render-to-string,register-context-provider) is React-shaped:rendermounts viareact-dom/client.createRoot,render-to-stringwalks a hiccup-or-equivalent virtual-DOM tree to HTML (the contract for SSR (Spec 011)), andregister-context-providerreturns aReact.createContext-style provider. Ports are scoped to the eight JS-cross-compile-to-React-binding languages enumerated in 000 §The pattern. Non-React substrates (Vue, Solid, Svelte, vanilla DOM, Replicant, Lit) are out of scope; substrate-agnostic shape on the reactive-container side reflects "the contract generalises if we ever wanted it to," not "we ship adapters for them."Terminology. Throughout this spec, "substrate" names the abstract contract — the closed set of functions an adapter must implement. "Adapter" names each implementation: the Reagent adapter, the UIx adapter, the Helix adapter, the plain-atom adapter. Per rf2-0imy the implementation directory is
implementation/adapters/, the CLJS namespaces arere-frame.adapter.<name>, and the Maven artefacts areday8/re-frame2-<name>(unchanged from the earliersubstrates/directory naming).
This Spec defines:
- The boundary between core and adapter.
- The adapter API contract — the closed set of functions every adapter implements.
- Subscription cache invalidation semantics that adapters must respect.
The CLJS reference ships two adapters: a Reagent adapter (browser default) and a plain-atom adapter (JVM, used by SSR and headless tests). The same core runs against both; the observable behaviour of events, subs, and effects is identical across adapters given the same core inputs.
The boundary¶
re-frame2 splits into three layers:
┌─────────────────────────────────────────────────────────────────────┐
│ Application code (events, subs, views, fx, machines) │
│ ──────────────────────────────────────────────────────────────── │
│ Substrate-agnostic core (frame, registrar, drain, dispatch) │
│ - Pure data flow │
│ - JVM-runnable │
│ - No Reagent, no React, no DOM │
│ ──────────────────────────────────────────────────────────────── │
│ Substrate adapter (Reagent in CLJS reference; or others) │
│ - Reactivity primitives (atom-equivalent, derived-value-equivalent)│
│ - Render-tree → DOM (or to string for SSR) │
└─────────────────────────────────────────────────────────────────────┘
The substrate-agnostic core is what every implementation supplies. The adapter is where host-specific choices live.
What the core owns¶
The core is the substrate-agnostic part. It owns:
- The handler registrar.
(kind, id) → metadatalookup. Pure data. JVM-runnable. - The frame contract. Each frame holds an
app-dbvalue, a queue, a sub-cache, and an id. The "value" interface is what the core requires; the adapter provides the reactive container that holds it. - The dispatch envelope and event queue. Per 002 §Routing. Pure data, FIFO.
- The drain mechanism. Run-to-completion drain (per 002 §Run-to-completion). Pure logic over the queue.
- Subscription topology. The static
:<-graph derived fromreg-subchains. Pure data, JVM-runnable. - Subscription computation.
(compute-sub query-v db)— running a sub's body against anapp-dbvalue. Pure function. JVM-runnable. - Effect map interpretation. Walking
:fxand dispatching to registered fx handlers. Per Spec-Schemas §:rf/effect-map. - The trace event stream. Per 009. Pure data.
If you can plumb a runtime through these primitives, you have re-frame2's substrate-agnostic spine. None of it requires a reactivity library.
What the adapter owns¶
The adapter is the substrate-specific part. Per §Abstract, the adapter splits into a universal half (the reactive-container contract — would work over any reactive primitive) and a React-shaped half (the render side — explicitly assumes React + VDOM).
Universal half — the reactive container for app-db and its tracking.
- The reactive container for
app-db. In CLJS, this is a Reagent ratom. In CLJS-headless / SSR, aclojure.core/atom. In a TypeScript-React port, a tiny atom-shape overuseSyncExternalStore's snapshot store; same shape for the Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint ports atop their host's React binding. - Subscription tracking — the runtime side of reactivity. The view's
subscribecall returns a value, and when the underlyingapp-dbslice changes, the view re-renders. The view-render side is React's job; how the container's mutation feeds React's render scheduler is the adapter's call: in Reagent, Reagent's reaction graph + the React renderer; in UIx / Helix / TS-React / Fable.React / Feliz / ReasonReact / Halogen-React / kotlin-react,useSyncExternalStoreover the container'ssubscribe-containerwatch.
React-shaped half — render and frame-routing.
- Render-tree consumption. Walking the hiccup (or equivalent virtual-DOM tree) and producing DOM via React. In CLJS, Reagent does this through
react-dom/client. In every in-scope JS-cross-compile port, the host's React binding (Fable.React'screateRoot, kotlin-react'screateRoot, ReasonReact'screateRoot, etc.) calls into the samereact-domunderneath. SSR is a hiccup-or-equivalent → HTML pure walk on the JVM (per Spec 011); equivalent on the server-side runtime in any JS-cross-compile port. - Component lifecycle. Mount, update, unmount — React's lifecycle. Adapters wire into it via React's hooks or class-component machinery, host-binding-specific.
- Frame-routing for views. React context — per 002 §View ergonomics. The CLJS reference uses Reagent's
:contextType(class-component path) and a function-component_currentValueread; other ports' React bindings exposeuseContextas the standard mechanism. The contract is "context value carrying the current frame-id; views read via the host React binding's hooks-equivalent." See §Frame-provider via React context below for the per-port realisation.
Adapter behaviour is observably equivalent across the in-scope React-binding adapters given the same core: the same events produce the same state, the same subs return the same values. The adapter only changes how the view sees those values reactively and which React binding mounts the tree.
The adapter API contract¶
Every adapter implements the surface below. The contract is closed for v1 — the function set is fixed, signatures are fixed, dispose-after-use is fixed; new adapter capabilities ship post-v1 additively (a new fn with a feature predicate consumers can branch on).
The adapter contract is the canonical mechanism for bridging external reactive sources (timers, JS event streams, external pub/sub, signals from other libraries). The v1
reg-sub-rawescape hatch — which v1 users sometimes leaned on for non-app-db reactivity — is not shipped in v2 (per MIGRATION §M-18). A custom adapter brings the external source into the substrate; subs consume normally viareg-sub. State that needs to live across Goal 2 — Frame state revertibility must reachapp-dbthrough an event handler (Pattern-AsyncEffect plus a registered fx), not through an adapter-private side channel — see §What an adapter MUST NOT do.
The adapter surface is six required functions, three optional functions, and one lifecycle function — ten entries in total. The Normative contract section below specifies the call-shape for each; §Operational semantics covers cache-invalidation behaviour the adapter must respect; §CLJS reference: Reagent as default adapter covers reference-host implementation notes.
Normative contract¶
Required (6): every adapter must implement.
| Fn | Purpose |
|---|---|
make-state-container |
Create a reactive container holding an app-db value. |
read-container |
Read the current value (pure). |
replace-container! |
Mutate the container with a new value (the only mutation primitive). |
make-derived-value |
Construct a derived (memoised) container from one or more sources. |
render |
Render a render-tree onto the substrate's surface; return an unmount fn. |
render-to-string |
Pure render to an HTML string (JVM-runnable). |
Optional (3): adapters may omit; the core falls back (or no-ops) when an optional fn is absent.
| Fn | Purpose | Fallback when absent |
|---|---|---|
subscribe-container |
Register a change-listener for invalidation. | Core runs invalidation inline within replace-container!. |
register-context-provider |
Return a context-provider component that scopes a frame to a subtree. | Core falls back to explicit-frame-as-argument; the user's view code threads the frame. |
flush-render! |
Synchronously commit the substrate's pending renders to the surface — NOT scheduled on a requestAnimationFrame-style tick. |
Core no-ops (an adapter that renders without a live commit — plain-atom / SSR — has nothing to flush). |
Lifecycle (1): every adapter must implement.
| Fn | Purpose |
|---|---|
dispose-adapter! |
Tear down: release listeners, caches, host resources. |
(make-state-container initial-value) → container¶
Returns a container that holds an app-db value. The container is opaque to the core; the adapter exposes operations on it via the next three functions.
value is an immutable map (the initial app-db). The container's identity is stable — operations later in this section refer to the same container.
CLJS-Reagent: returns a Reagent r/atom.
CLJS-headless: returns a clojure.core/atom.
TS-React: returns a tiny atom-shape ({value, subscribers}) wired into React via useSyncExternalStore.
Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint: same atom-shape over the host's React binding's useSyncExternalStore equivalent.
(read-container container) → value and (replace-container! container new-value) → nil¶
The two basic operations on a container. read-container is pure; replace-container! is the only mutation primitive — partial updates aren't supported (the core always replaces the entire app-db after a drain).
(read-container container) ;; → current app-db value
(replace-container! container new-value) ;; → nil; container now holds new-value
Nil-container guard (defense-in-depth). The core's replace-container! wrapper guards against the destroy-race case where a write (router :db commit, drain rollback, flows recompute, epoch restore, SSR write) arrives after the owning frame has been destroyed and frame/app-db-container has started returning nil. When container is nil, the wrapper SKIPS the underlying adapter's replace-container! call and emits a :warning :rf.warning/write-after-destroy trace (per 009 §Where trace emission lives) with :recovery :no-recovery — the write is dropped, no exception is thrown. The guard centralises destroy-race handling on the one mutation primitive that every frame app-db write flows through. Adapter implementations may assume container is non-nil; the guard is in the core's wrapper, not in the adapter contract.
(subscribe-container container on-change) → unsubscribe-fn¶
Optional. Registers a callback that fires after replace-container! runs. The callback receives (prev-value, new-value).
(subscribe-container container on-change) ;; → unsubscribe-fn
;; on-change signature: (fn [prev-value new-value] ...)
;; unsubscribe-fn signature: (fn [] nil) — idempotent
If the adapter supports it, the core uses subscribe-container to wire reactive sub-cache invalidation. The CLJS reference adapters (Reagent, UIx, Helix, plain-atom) all supply it — the add-watch/remove-watch realisation is the lowest-common-denominator listener surface that every Clojure-host atom or atom-shape exposes for free. An adapter that genuinely cannot supply listeners (a host whose container primitive offers no observer hook) signals "unsupported" by either omitting the entry from its adapter spec map or returning nil from subscribe-container; in that case the core falls back to running invalidation inline within replace-container! itself (the adapter must, in that case, ensure replace-container! runs the core's invalidation hook before returning).
CLJS-Reagent: Reagent's reaction machinery handles this implicitly; subscribe-container returns a function that cancels the registration. The reference Reagent adapter additionally exposes the listener surface via add-watch on the underlying r/atom so the substrate contract is honoured uniformly across adapters — see §CLJS reference: Reagent as default adapter.
CLJS-headless (plain-atom adapter, JVM and Node): supported via add-watch on the clojure.core/atom container; the returned unsubscribe-fn calls remove-watch. This lets headless tests and SSR builders register change-listeners without resorting to polling — see §Plain-atom adapter (JVM, SSR, headless).
TS-React / Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint: returns a function that detaches the listener from the atom-shape's subscriber list (the same store useSyncExternalStore consumes).
(make-derived-value source-containers compute-fn) → container¶
Returns a derived container whose value is computed from one or more source containers. The derived container updates automatically when any source's value changes (transitively).
(make-derived-value source-containers compute-fn) ;; → container
;; source-containers: vector of containers
;; compute-fn signature: (fn [& source-values] ...) — pure; called with deref'd values
The returned container supports read-container; replace-container! is not supported on derived containers. A derived value is computed from its sources — there is no slot to write into — so writing to one is a programmer error. The core's replace-container! choke point (the single point every frame app-db write flows through, sibling to the nil-container guard in §read-container and replace-container!) detects the derived container, emits a :rf.error/derived-container-replaced trace (so error-listeners observe it), and throws the canonical thrown-error ex-info carrying :rf.error/id :rf.error/derived-container-replaced (per 009 §The thrown-error shape); the underlying adapter replace-container! is not invoked. subscribe-container works as on a base container.
Detecting a derived container is the adapter's responsibility, because no single host protocol separates the two shapes across every substrate: a Reagent Reaction reifies the host atom marker protocol (clojure.lang.IAtom) exactly as a base r/atom does, even though it is read-only. An adapter MAY therefore publish an optional :adapter/derived-container? late-bind hook (a 1-arg predicate); the choke point consults it first. The hook lives in the late-bind table rather than the adapter spec map so the 9-fn adapter contract shape is unchanged. The reference Reagent and reagent-slim adapters publish one keyed on the substrate's own disposal protocol (a Reaction is disposable; a base r/atom / RAtom is not). When no adapter publishes the hook, the choke point falls back to an atom-marker heuristic — a base container satisfies the host atom marker protocol (clojure.lang.IAtom on the JVM, cljs.core/IAtom on CLJS) while the adapter's derived value (an IDeref-only reify, or an IDeref+IWatchable+disposal reify) does not. Note the marker is IAtom, not ISwap/IReset: a ClojureScript cljs.core/Atom implements IAtom but not ISwap/IReset (swap! / reset! fast-path on the concrete Atom type), so only IAtom reliably marks a base atom on both hosts. That fall-back is sound for the plain-atom, test-react, UIx, and Helix reference adapters, whose derived values are not atom-shaped; it is the Reagent family alone that publishes the hook.
The derived container's caching responsibility is adapter discretion: an adapter MAY memoise the derived value (and is encouraged to where the host primitive makes it cheap — Reagent's Reaction does this for free), or MAY recompute on every read-container and rely on the per-frame sub-cache (§Subscription cache — contract and operational semantics) to enforce the =-equality invariant across recomputes. Either shape is conformant. What is NOT conformant: a derived container whose recompute fires for an input that did not change by = and whose downstream propagation does not collapse on =-equal new values — that would break the cascade rule in §Invalidation algorithm.
CLJS-Reagent: a Reagent reaction — memoising; re-runs only when an input deref changes by =. CLJS-headless (plain-atom adapter): an IDeref wrapper that recomputes on every read; no memoisation at the substrate layer because SSR runs each sub at most a handful of times per request and the sub-cache (when present) handles =-equality cascading. TS-React / UIx / Helix / other JS-cross-compile ports: an IDeref+IWatchable-shaped wrapper that recomputes on read and broadcasts change via the source containers' watch machinery (see §CLJS reference: UIx as alternative substrate and §CLJS reference: Helix as alternative substrate).
(render render-tree mount-point opts) → unmount-fn¶
Renders the render-tree onto the substrate's surface and returns a function that unmounts.
(render render-tree mount-point opts) ;; → unmount-fn
;; render-tree: a serialisable nested data structure (per Spec 004)
;; mount-point: implementation-specific (DOM element passed to react-dom/client.createRoot)
;; opts: open map; standard keys: :on-mismatch (per Spec 011), :hydrate? (boolean)
;; unmount-fn signature: (fn [] nil) — idempotent; releases all resources
CLJS-Reagent: wraps reagent.dom.client/create-root + reagent.dom.client/render (React 19 client-Root API; the same createRoot shape React 18 introduced); the unmount-fn closes over the Root and calls (rdc/unmount root). Hydrate path uses (rdc/hydrate-root mount-point render-tree) which returns its own Root.
SSR-on-JVM: this function isn't called server-side — render-to-string is used instead. The adapter may stub render to throw on the JVM.
(render-to-string render-tree opts) → string¶
Pure function. Renders the render-tree to an HTML string. JVM-runnable in the CLJS reference.
(render-to-string render-tree opts) ;; → string
;; opts: open map; standard keys: :doctype? (boolean), :frame (frame-id for resolving registered views)
The implementation is the per-host pure walk of the render-tree (per Spec 011 §The render-tree → HTML emitter).
(flush-render! [f]) → nil¶
Optional. Synchronously commits the substrate's pending renders to the surface. The 1-arity form runs f inside the substrate's synchronous-commit path so any state change f schedules — and any render already pending — is committed before the call returns; the 0-arity form flushes already-pending work with an empty callback.
(flush-render!) ;; → nil; flush already-pending work
(flush-render! f) ;; → nil; run f, then flush synchronously
;; f signature: (fn [] ...) — its return is ignored
Why this is a contract fn, not a test helper. The reference substrates schedule re-renders through a requestAnimationFrame-style tick that fires after an evaluated dispatch returns and is throttled to ~never in a backgrounded / unfocused tab. A tool that drives dispatch and then wants to observe the rendered result therefore cannot rely on the scheduled commit ever arriving. flush-render! runs through the host's synchronous-commit API (it is NOT rAF-scheduled), so it fires even headless and even when the tab is backgrounded — letting headless tooling drive a dispatch → flush-render! → observe-settled-DOM loop deterministically. This is the framework capability the Tool-Pair headless view-lifecycle driving depends on (see Tool-Pair §Driving the render, consumed by the pair MCP's dispatch-and-settle op).
This is distinct from a test-only flush. The CLJS reference also ships flush-views! on the React-hook adapters — a wrapper around React's act() intended for test code; flush-render! is the production-grade contract surface every adapter implements, callable from app or tooling code with no act() test-environment opt-in.
flush-render! must be no-op-safe: calling it when nothing is pending does no harm and returns nil. An adapter that renders without a live host commit (the plain-atom / SSR adapters render to a string, never to a live surface) ships no flush-render! at all; the core's delegation then no-ops.
CLJS-Reagent: (f) then reagent.core/flush — Reagent's render-queue drain forces every dirty component to re-render synchronously, bypassing its requestAnimationFrame next-tick scheduler, and (on React 19) commits via react-dom/flushSync.
CLJS-reagent-slim: (f) then reagent2.impl.batching/flush! — the rewrite's synchronous rea-queue + dirty-set drain (forceUpdate per dirty component), bypassing its microtask scheduler. Distinct from the goog.DEBUG-gated, act()-composing reagent2.dom.client/flush-views! test primitive.
CLJS-UIx / CLJS-Helix: react-dom/flushSync (the React-hook spine) — runs f inside flushSync so any useSyncExternalStore update commits before returning.
CLJS-headless (plain-atom) / SSR: not implemented — there is no live commit to flush; render-to-string is the only render path.
(register-context-provider frame-keyword) → component¶
Optional. For substrates with a context concept, returns a component that scopes a frame to a subtree.
CLJS-Reagent: returns the frame-provider Reagent component (a React Context Provider).
TS-React: returns an equivalent React.createContext-backed Provider component.
Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint: each returns the host React binding's createContext-backed Provider component (Feliz / Fable.React createContext, scalajs-react createContext, React.Basic.Hooks.createContext, kotlin-react createContext, ReasonReact React.createContext).
Headless / SSR (no React, no DOM): not supplied — the core falls back to explicit-frame-as-argument; the user's view code threads the frame.
Adapter disposal lifecycle¶
Every adapter exposes:
Called by the core when the runtime shuts down (process exit, test-frame teardown, or explicit (rf/shutdown-runtime!)). The adapter must:
- Cancel all in-flight reactive subscriptions.
- Release any host-specific resources (DOM event listeners, websocket subscribers, timers).
- Discard internal caches.
- Make subsequent calls to other adapter functions return
:rf.error/adapter-disposed(or throw, host-dependent).
The adapter is single-use after disposal; restart requires (install-adapter!) again.
In CLJS-Reagent: clears Reagent's reaction caches, unmounts any active root. In CLJS-headless: no-op (no resources held).
Revertibility constraints on adapters¶
Per 000 §Frame state revertibility, Goal 3 commits that a frame's complete runtime state is a single persistent value — reverting that value to any prior point fully reverts the frame. The adapter sits between the core and the host's reactivity layer, so its contract has to honour the goal explicitly: an adapter must not stash information that survives a revert of the frame value.
The rule:
An adapter may hold internal state if and only if that state is derivable from the frame's value. State that adds information not present in the frame value is prohibited.
"Derivable" means: dropping the adapter's internal state and recomputing it from the frame's current value yields equivalent observable behaviour. Memoisation caches, reaction caches, and listener-registration tables are derivable — they exist for performance and reattachment, not to hold information. State that adds information (an undo stack the adapter owns; a counter the adapter increments per render and reads back later; observer-side data that survives replace-container!) is not derivable and is therefore prohibited.
What this means per adapter primitive¶
make-state-container— the container holds the frame'sapp-dbvalue. The container's identity is stable but its value is the frame value; nothing else lives there. ✓read-container— pure read of the held value. No state. ✓replace-container!— single mutation primitive; after it returns, the container's value IS the supplied new value. The framework's revert path is(replace-container! container prior-value); this is the entire mechanism. ✓subscribe-container— registers a change-listener. The adapter's listener registry is transient infrastructure: dropping it and re-registering listeners is observably equivalent (modulo a tick of latency). The registry holds no information about the frame value. ✓make-derived-value— caches a derived value computed from sources. The cache is a pure memoisation of(compute-fn @source-1 @source-2 ...); if the cache were dropped, the next read would recompute and produce an equal value. Derivable. ✓render— produces DOM/UI as an external side effect. The DOM is outside the frame value entirely; reverting the frame value does NOT un-paint the DOM. This is the registered-fx seam Goal 3 names: external side effects need compensation, not reversal. The view layer re-renders on the next dispatch cycle and the UI follows. ✓register-context-provider— returns a stateless component (the host's context-provider). No state. ✓dispose-adapter!— tears down the adapter. After disposal,install-adapter!recreates a fresh one; no state survives. ✓
Reference-adapter compliance¶
- CLJS-Reagent. Reagent's
Reactionmachinery caches derived values (memoisation: derivable). The track-cache that Reagent maintains for reaction graphs is regenerable from the underlying ratoms (which hold the frame value) — drop the cache, the next deref rebuilds it. Reagent's listener registry is transient. No observer state outside the frame value. ✓ - CLJS plain-atom (headless). The container is a
clojure.core/atom. The adapter exposessubscribe-containerviaadd-watch/remove-watchandmake-derived-valueas anIDerefwrapper that recomputes on every read (no memoisation at the substrate layer — see §make-derived-value). The watch-key registry is transient — drop it, re-register, and observable behaviour is unchanged (modulo a tick); the derived-value wrapper holds no state beyond its source-container references. No reactivity graph and no value cache live outside the frame container. ✓ - TS-React / Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint adapters. Same constraint applies: each port's atom-shape subscriber registry, the
useSyncExternalStoresnapshot store React caches, and any derived-value memoisation must all be derivable from the frame's value. Ports verify the host React binding doesn't squirrel away non-derivable state outside the frame container.
What an adapter MUST NOT do¶
These would all violate revertibility and are prohibited by the adapter contract:
- Maintain a separate "previous values" history outside the frame's epoch buffer — any history-of-state lives in the framework's epoch-history (per Tool-Pair §Time-travel), not inside the adapter.
- Hold an adapter-private mutable cell that view code can read or write through a side channel — every view-visible value must come through
read-container(transitively, throughmake-derived-value/subscribe-container), so that reverting the container reverts what views see. - Cache derived values keyed on identity rather than value — caches must invalidate on
=-equality of inputs (per §Subscription cache invalidation) so that a revert to a prior=-equal state surfaces the prior derived values. - Persist any internal state across
dispose-adapter!/install-adapter!. Disposal is total.
Verifying compliance¶
The conformance corpus does not currently include an adapter-revertibility fixture, but the operational test for any adapter is:
- Create a frame; dispatch some events; capture the frame's value as
V1. - Run more events; the container now holds
V2. - Call
(replace-container! container V1). - Re-read everything that
subscribe-container/make-derived-value/ views can see. - The observable behaviour MUST equal what step 1 produced.
If any value differs, the adapter is holding state outside the frame value — a revertibility violation.
Cross-reference: 000 §Frame state revertibility names the goal; this section locks the adapter-contract obligation that follows from it.
Source-coord annotation (mandatory)¶
Every adapter MUST inject data-rf2-source-coord="<ns>:<sym>:<line>:<col>" on the rendered root DOM element of each registered view. The annotation is a normative entry on the adapter contract — devtools and pair-shaped tools (re-frame-pair, re-frame-10x, IDE jump-to-source per Tool-Pair §Source-mapping UI clicks back to code) consume it to map a clicked DOM node back to the reg-view call site. Without this annotation an adapter is non-conformant.
Capture mechanism¶
Source coordinates are captured at reg-view macro-expansion time from (meta &form) (:line, :column) and the compile-time *ns* / *file* (per Spec 001 §Source-coordinate capture). The macro stamps them onto the registry slot's metadata; the adapter reads them at render time when wiring the wrapper that produces the annotated DOM element. No runtime cost in the hot path: the coord string is computed once at registration time, then merged into attrs each render.
Attribute value format¶
The attribute value is a colon-separated four-segment string — the committed public contract :rf/source-coord-attr per Spec-Schemas:
<ns>is the keyword id's namespace — typically(namespace (registry-id)).<sym>is the keyword id's name —(name (registry-id)). Note this is the registry handler-id, not a file path.<line>is the integer source line;?when not captured.<col>is the integer source column;?when not captured.
A registration that bypassed the macro path (programmatic reg-view* with no captured coords) still annotates with <ns>:<sym>:?:? — degrading gracefully so pair tools can still resolve <ns>/<sym> via the registrar's :rf/id lookup. To recover the registration's full source-coord shape (including :file), pair tools follow up with (rf/handler-meta :view <handler-id>) which returns :rf/source-coord-meta per Spec-Schemas — :file is not encoded in the attribute string.
Production elision (mandatory)¶
The annotation site MUST sit inside (when interop/debug-enabled? ...) (the CLJS mirror of goog.DEBUG). Production builds (:advanced + goog.DEBUG=false) MUST NOT emit the attribute — the entire injection branch dead-code-eliminates so the literal data-rf2-source-coord string fragment does not appear in the bundle. Per Spec 009 §Production builds, the elision is verified by a grep against the production bundle (scripts/check-elision.cjs); the data-rf2-source-coord sentinel is part of the standard sentinel set.
Historical: JSX source-coord props (removed — never worked)¶
Status: removed in rf2-rohdn (Option A). An earlier version of this contract (rf2-fa4ly) called for the wrapper to ALSO inject the JSX-shaped source-coord props (
_jsxFileName/_jsxLineNumber/_jsxColumnNumber) per@babel/plugin-transform-react-jsx-source, with the intent of making React DevTools' "View source" gesture jump to thereg-viewdefinition.The feature never delivered. Two problems compounded:
- Reagent passes these props through as DOM attributes (it does not route them to React.createElement's
__sourceslot), so React's runtime emitted "does not recognize the_jsx*prop on a DOM element" console warnings for every annotated view's root.- React DevTools does not read "View source" from element props anyway — it reads
__sourceoffReact.createElement's third argument, which is set by the Babel plugin at JSX-compile time and is not reachable from hiccup. So the DevTools gesture never lit up for re-frame2-registered views.Net effect: dev-console noise with no DevTools benefit. rf2-rohdn dropped the injection cleanly. The
data-rf2-source-coordanddata-rf-viewDOM attributes (which DO work and are consumed by re-frame-pair, the view-walker, and IDE jump-to-source tooling) ride the same wrapper unchanged.If a future pass restores React DevTools "View source" integration, the correct path is to thread
__sourceinto the React element at element-creation time (cloneElement's third arg, or a substrate hook that participates in element construction) — not via element props.
Documented exemption: non-DOM roots¶
A registered view whose root element is one of:
- a React Fragment (
:<>), - a host-component head (
:>in Reagent — the React-interop marker), - a function/component head (e.g. another reg-view'd component),
…is exempt from the annotation. The adapter MUST emit a one-shot warning per id (so the developer learns the pair-tool footgun without spamming the console on re-render) and MUST NOT inject the attribute in these cases. Pair tools fall back to (rf/handler-meta :view id) for these nodes — the registry slot still carries the captured :ns / :line / :file; only the DOM-node-level mapping is skipped.
The exemption is principled: a Fragment has no DOM element to annotate, and a [:> Cmp …] interop call hands the props map straight through to React's component (which may not be a DOM-tag, may not accept arbitrary HTML attributes, and certainly should not have framework-derived strings inserted into it). Annotating these would either be a no-op (Fragment) or risk mutating semantics (interop).
Form-2 handling¶
When a registered view's render-fn returns a fn (Reagent's Form-2 closure shape per Spec 004 §Form-2), the adapter wraps the returned fn so the inner-fn's hiccup output is annotated on the next call. Annotation lands on the eventual rendered DOM root, not on the outer fn (which is not a DOM element).
Cross-host¶
Headless test adapters (no DOM) are exempt. Every in-scope React-binding adapter MUST honour this contract: the CLJS reference (Reagent, UIx, Helix) and every JS-cross-compile-language port (TypeScript-React, Feliz / Fable.React, scalajs-react / Slinky, React.Basic, kotlin-react, ReasonReact / Melange-React). The JVM SSR emitter is the server-side equivalent — it injects the same attribute when emitting HTML for a registered view, so server-rendered pages carry the annotation too.
Source-coord stamping for state machines¶
The view-side annotation above is one half of the tool-pair source-mapping contract. The other half is the spec-side stamping for state machines: per Spec 005 §Source-coord stamping, the reg-machine macro walks its literal spec form at expansion time and CO-LOCATES per-element source onto each guard / action entry (:guards {:form-valid? {:fn .. :source-coords .. :source-code ..}}), plus a reference-site :source-coords onto each :states-tree map node ({:states {:idle {:on {:submit {… :source-coords {…}}} :source-coords {…}}}}). Pair tools that surface a "click on a transition's call site" gesture read the co-located entry's :source-coords for a named guard/action, or the :source-coords off the state-node / transition map for a transition — symmetric to how they consume data-rf2-source-coord for views.
Both surfaces share the production-elision contract: the co-location dev arm is gated on interop/debug-enabled?, so under :advanced + goog.DEBUG=false the closure compiler folds it away — the co-located :source-code / :source-coords slots (on element entries AND on :states-tree map nodes) DCE. The scripts/check-elision.cjs sentinel set greps the co-located :source-code fn-body fragments (which ride the same dev arm as the state-node co-location), verified ABSENT in the production bundle and PRESENT in the control bundle.
View tagging contract (fallback)¶
Status: fallback safety-net only. The primary path for runtime view-hierarchy capture is the Fiber-walker documented in View-Hierarchy-Capture.md. This section pins the per-adapter fallback path that activates only if Fiber-reading breaks on a future React-version regression, or if a non-React substrate is ever wired in. Both paths can coexist; the fallback adds a single attribute per registered view and costs ~zero in production (elision-gated).
The same per-render wrapper that injects data-rf2-source-coord (§Source-coord annotation above) also injects data-rf-view="<id>" on the rendered root DOM element when interop/debug-enabled? is true. The two attributes ride the same wrapper, the same walk, and the same production-elision gate — there is no separate code path or separate elision contract.
Attribute value format¶
<id> is the registry id keyword stringified verbatim — (str id). For a namespaced keyword id :rf.foo/bar the attribute value is ":rf.foo/bar" (leading colon preserved). The walker reads it back via (keyword (subs s 1)) when the leading : is present, falling back to the raw string for non-keyword ids.
The committed public contract is :rf/view-id-attr (see Spec-Schemas); the on-attribute representation matches the registry handler-id, not the call-site symbol — symmetric to how data-rf2-source-coord carries the registry id portion.
Injection rules¶
The wrapper inspects the user render-fn's output and mutates the first concrete element's existing attribute map (the SAME element that carries data-rf2-source-coord). The injection rules:
[:tag {…attrs} & children]— merge:data-rf-viewinto the existing attrs map (alongside:data-rf2-source-coord).[:tag & children](no attrs map) — splice an attrs map in between head and children carrying both attributes.[fragment / interop-head / fn-component …]— SKIP (see §Documented edge cases below).- React-element output (UIx, Helix):
React.cloneElementwith{"data-rf-view": <id>}on the same call that addsdata-rf2-source-coord. - Form-2: when the render-fn returns a fn, the wrapper recurses on the inner fn's output (same machinery as the source-coord walk).
CRITICAL constraint: mutate, do not wrap¶
Adapters MUST mutate the existing first element's attribute map. Adapters MUST NOT wrap the rendered tree with a synthetic host element (e.g.
[:div {:data-rf-view …} <user-tree>]).
Wrapping is a non-starter — it breaks every layout idiom that depends on the DOM tree shape:
- Flexbox + CSS Grid —
display: flex/display: gridparents lay out their direct children. A synthetic wrapper would make every reg-view'd component a single grid/flex item regardless of what its render-fn produced. - Table layouts —
<table>/<tr>/<td>is a fixed DOM contract; an interposed<div>between<table>and<tr>is invalid HTML and breaks the browser's table-anonymous-box generation. :nth-childand sibling selectors —:nth-child(2n+1),+ sibling,~ general-siblingall count DOM positions. A wrapper would shift every child's index by one and break striping / first-row callouts / form-row separators.- Positioning ancestors —
position: absolutelooks for the nearestposition: !staticancestor. A wrapper that inadvertently inherits the user'sposition: relativewould silently capture every descendant's absolute positioning. - Stacking contexts —
z-indexresolves against the nearest stacking-context ancestor; a wrapper withopacity < 1ortransformwould create a new stacking context the user didn't author. - CSS containment —
contain: layout / paintboundaries depend on element identity; an interposed wrapper would either shift the boundary or invalidate the optimisation.
The mutate-existing-attrs strategy avoids every one of these failure modes — the rendered DOM tree is structurally identical to the un-instrumented version, modulo two extra attributes on the root element of each registered view.
Documented edge cases (fidelity gaps)¶
The fallback is a lossy approximation of the Fiber-walker's hierarchy capture. These shapes are exempt from data-rf-view annotation (the wrapper SKIPs with a one-shot warning per id, same as the source-coord exemption):
-
React Fragment root (
:<>/<Fragment>) — a fragment has no DOM element to annotate. The fallback walker treats the component as invisible to hierarchy capture (its children become orphans of the next-up tagged ancestor). The Fiber-walker primary path handles fragments correctly via thechildslot. -
Nil / conditional root (
(when cond …)returning nil) — when the render-fn returns nil, no DOM element exists. Same fidelity gap as fragments: the view is invisible on the render that returned nil; subsequent re-renders that produce a DOM element are tagged correctly. -
Component-returning-component head (
[other-view …]) — when a reg-view'd component's root is another reg-view'd component, the wrapper SKIPs (the head is a fn, not a DOM-tag keyword). The inner component will tag its own root; the outer view is invisible to the hierarchy capture and its children become orphans of the inner tagged element. Pair tools can chase the wrapping via(rf/handler-meta :view id). -
Portals (
React.createPortal) — portals teleport the rendered subtree to a different DOM location. The walker's DOM-containment inference will associate portal children with the portal target's ancestor chain, not with the portal-rendering component's ancestor chain. The Fiber-walker primary path handles portals correctly because Fiberreturnpointers follow the logical parent, not the DOM parent. -
display: nonesubtrees — elements withdisplay: noneare present in the DOM tree (and so are walkable byquerySelectorAll) but are not laid out. The walker reports them; consumers (Xray Views panel) may choose to filter them out. This is a known fidelity gap, not a correctness bug. -
Interop component head (
:>in Reagent) —[:> Cmp {…props}]hands the props map straight to React's component, which may not be a DOM-tag (and certainly should not have framework-derived strings inserted into its props). The wrapper SKIPs and emits the same warning as the source-coord exemption.
Production elision (mandatory)¶
data-rf-view MUST elide under :advanced + goog.DEBUG=false via the SAME (when interop/debug-enabled? …) gate that elides data-rf2-source-coord. The literal data-rf-view string fragment is part of the standard scripts/check-elision.cjs sentinel set.
Walker contract (fallback path)¶
When the fallback is consuming the tagged DOM, the walker:
- Calls
document.querySelectorAll('[data-rf-view]')to enumerate every tagged element in document order. - For each tagged element, reads
data-rf-viewanddata-rf2-source-coordoff the DOM node. - Infers parent-child by DOM containment: element B is a child of element A iff A is the nearest tagged ancestor of B (via
.contains()walks). - Produces the same output shape as the Fiber-walker (per View-Hierarchy-Capture.md §Output shape) so consumer code is path-agnostic.
The walker implementation lives at tools/xray/src/day8/re_frame2_xray/views/view_walker.cljs (alongside the Fiber-walker per the spec's Ownership table). Both walkers are bundle-isolated from production builds.
React DevTools support (zero-config, dev-only)¶
re-frame2 is Reagent-substrate-native (see §Reactive Substrate above). The framework MUST therefore make React DevTools — the industry-standard React-app inspection tool — work cleanly against any re-frame2 app. The two contracts below are framework-level; an app author opts into none of them, they fire by the same wrappers that handle the source-coord and view-tagging contracts.
-
Component display-name = registered view-id. Every adapter's
reg-viewwrapper MUST stamp the ReactdisplayNameof the wrapped component to(str view-id)so React DevTools' component tree shows<:cart/total-line>rather than the CLJS-munged function name or an anonymous Reagent wrapper. Reagent's class-component machinery reads.-displayNameoff the input fn and forwards it to the constructed component; React-hook substrates (UIx / Helix) set it directly on the wrapped function component. Gated oninterop/debug-enabled?so the per-view id-string literal elides in production builds. -
Frame-context display-name. The React Context object backing the frame-provider (per §Frame-provider via React context below) MUST carry a
displayNameof"rf2-frame"so React DevTools' Context inspector shows the entry asrf2-frame.Providerrather than the opaque defaultContext.Provider. The label is deliberately distinct from any keyword namespace to keep the elision-bundle sentinel unambiguous. The assignment site sits inside(when interop/debug-enabled? …)so the string literal elides in production. The per-frame value (:rf/default,:tenant/admin, etc.) is already inspectable as the Context value — DevTools renders it as the keyword'spr-str.
Both sites share the standard interop/debug-enabled? elision gate and are subject to the bundle-isolation gate (no displayName-assignment branches, no Context display-name string in the production bundle). React DevTools is a dev-time inspection tool; the framework pays nothing for these affordances in production.
A third contract — JSX source-coord props for the "View source" gesture — was attempted in rf2-fa4ly and removed in rf2-rohdn (see §Historical: JSX source-coord props (removed — never worked) above). The framework no longer emits _jsxFileName / _jsxLineNumber / _jsxColumnNumber.
Subscription cache — contract and operational semantics¶
A subscription's value lives in the per-frame sub-cache. This section defines the contract: the cache shape, the lookup algorithm, the invalidation algorithm, the ref-counting and disposal rules, the layer-1/2/3 sub semantics, and the lifetime contract that ties them together. The contract is host-agnostic; the Reagent reference adapter §Sub-cache wiring shows the CLJS realisation.
v1 reference. v1's
re-frame.subsnamespace already implements most of this — the invalidation algorithm, the cache de-duplication, the disposal-on-no-readers behaviour. What is new in re-frame2: the cache is per-frame (v1 has one global cache); disposal-on-frame-destroy is a contract, not an implementation detail; the layer-1/2/3 framing is named explicitly so non-CLJS implementors can satisfy the contract without leaning on Reagent's reaction machinery.
Cache shape¶
Each frame holds one sub-cache, keyed by [query-vector]:
;; Per-frame sub-cache, the entry shape the reference stores.
;; The entry wraps a substrate-specific *derived container* — in CLJS a
;; Reagent Reaction; on plain-atom hosts a thunk that recomputes on deref
;; (per [§make-derived-value]). The cached value is NOT a separate slot:
;; it lives ON the derived container and is read via deref. Disposal is
;; the derived container's own on-dispose hook (CLJS: interop/add-on-dispose!
;; on the Reaction), NOT an entry-level callback vector.
{[query-vector]
{:reaction r ;; the substrate-specific derived container
:inputs [[q1] [q2]] ;; resolved :<- chain (vector of input query-vectors)
:ref-count n}} ;; how many readers currently hold a reference
The cache is held inside the frame container (per 002 §What lives in a frame). Two frames running the same (rf/subscribe [:cart/total]) compute against their own app-dbs and cache against their own caches; isolation is automatic.
The canonical demo of this rule is the parallel-frames testbed at tools/xray/testbeds/parallel_frames/ — one app mounted in two frame-provider-rooted subtrees (:above and :below) on one page. Same view source, same registered handlers and subs, two fully isolated reactive contexts that diverge as the user interacts with each independently. There is no cross-frame sub, no cross-frame data routing, no "route data home" pattern — each frame is its own world.
Lookup algorithm¶
Lookup [query-v] in frame F:
k ← cache-key(query-v)
If F.sub-cache[k] exists:
F.sub-cache[k].ref-count += 1
return F.sub-cache[k].reaction ;; the derived container
Otherwise (cache miss):
meta ← registrar.lookup(:sub, first(query-v))
inputs ← resolve-inputs(meta, F) ;; recurses for :<- inputs
body ← meta.fn
derived ← substrate.make-derived-value(
inputs.map(c → c.reaction),
(in-vals) → body(in-vals, query-v))
F.sub-cache[k] ← {:reaction derived
:inputs inputs
:ref-count 1}
;; Wire disposal on the derived container itself — when its last
;; derefer drops, release input refs and dissoc the slot. The cache
;; holds NO entry-level dispose-fn vector; it relies on the container's
;; own on-dispose hook (CLJS: interop/add-on-dispose! on the Reaction).
on-dispose(derived, () → { for q in inputs: unsubscribe(F, q)
F.sub-cache.dissoc(k) })
trace! :sub/registered {:query-v query-v :frame F.id}
return derived
Two properties this guarantees:
- De-duplication. Concurrent equal subscriptions share one cached computation. The cache key is the query-vector itself. v2 has a single disposal algorithm (synchronous ref-counting; see §Reference counting and disposal).
- Layer-1/2/3 chaining. A layer-2 sub's
:<-inputs are themselves resolved via this same lookup, recursively. The recursion terminates at layer-1 subs whose inputs are not other subs but readers overapp-dbdirectly.
Invalidation algorithm¶
The contract:
A subscription's cached value is invalidated only when an input the subscription depends on changes value (by
=equality).
The algorithm, host-agnostic:
On replace-container!(F.app-db, new-db): ;; called from drain loop step 2
;; Phase 1: layer-1 subs (those whose inputs are app-db readers).
For each k → entry in F.sub-cache where entry is layer-1:
new-val ← (entry.body new-db query-v)
If new-val = entry.value: ;; value-equal: keep cache
no-op
Else:
entry.value ← new-val
mark-dirty entry
trace! :sub/recomputed {:query-v k :frame F.id}
;; Phase 2: layer-2+ subs cascade in topological order.
For each k → entry in F.sub-cache where entry is layer-2+:
If any input in entry.inputs is marked-dirty:
new-val ← (entry.body (read-inputs entry.inputs) query-v)
If new-val = entry.value:
no-op
Else:
entry.value ← new-val
mark-dirty entry
trace! :sub/recomputed {:query-v k :frame F.id}
;; Phase 3: notify subscribers (views, tools).
For each entry that is marked-dirty:
notify each registered subscriber
Three load-bearing properties:
- No path-overlap means no recompute. A
:cart/totalsub depending on[:cart :items]does not recompute when:user-profilechanges. (How the implementation knows:=-equality on the input value. If the input is value-equal, the sub stays cached.) - Value-equal means no propagation. A no-op
(assoc db :x (:x db))produces a=-equalapp-db; no sub recomputes; no view re-renders. - Topological cascade. Layer-2 subs see the new layer-1 values when they recompute. Layer-3 subs see new layer-2 values. The cascade respects the static
:<-topology recorded during registration.
Reagent realises this automatically: each Reaction re-runs only when its derefs change by =; the reactive graph is built from the :<- chain. Non-CLJS implementations (or the plain-atom adapter) must satisfy the contract explicitly — Phase 1 / Phase 2 / Phase 3 above is the fallback algorithm.
First-run discriminator on the cache-miss path. The cache lookup step above splits cleanly into two cases: a hit (existing slot, ref-count bump) and a miss (fresh slot, body's first run). The memo wrapper threads that discrimination through to the trace stream as a :rf.sub/first-run? boolean on every :rf.sub/run emit (true on the run that allocated the slot, false on every subsequent recompute). Consumers (Xray's SUBSCRIPTIONS leaf-scalar renderer per rf2-fyd8u) need the discriminator to render a fresh-cache-entry run (the sub is now alive — :added chrome, no "was") distinctly from a recompute whose prior value happened to be nil (the value really changed nil → X — ← was nil annotation). Both shapes report :rf.sub/value-changed? true and :rf.sub/prev-value nil; the :first-run? flag is the only signal that distinguishes them. See Spec 009 §:op-type vocabulary for the full :rf.sub/run tag-map shape.
Layer-1, layer-2, layer-3 sub semantics¶
The terminology comes from re-frame v1; the semantics carry over.
| Layer | Inputs | Example | Recompute trigger |
|---|---|---|---|
| Layer-1 | Reads app-db directly |
(reg-sub :user (fn [db _] (:user db))) |
The path it reads from app-db changes by =. |
| Layer-2 | Reads other subs via :<- |
(reg-sub :user-name :<- [:user] (fn [u _] (:name u))) |
Any input sub's value changes by =. |
| Layer-3 | Reads other subs via :<-, where one or more inputs are themselves layer-2 |
(reg-sub :user-greeting :<- [:user-name] :<- [:locale] (fn [...] ...)) |
Any input sub's value changes by =. |
Layers ≥ 3 are conventionally just "layer-2+" — the algorithm treats them all the same. The distinction matters for understanding the cascade order (layer-1 settles before layer-2, layer-2 before layer-3) but not for the implementation, which uses :<- chain depth implicitly via topological iteration.
Reference counting and disposal¶
The cache is not strong-referenced from the frame for the lifetime of the frame; entries dispose when their last reader goes away. The disposal algorithm is synchronous ref-counting on derefer-count → 0 (rf2-cmfln) — a single algorithm. There are no pluggable lifecycle policies; the v1 alpha namespace's :safe, :no-cache, :reactive, and :forever lifecycles are not part of v2, and v2 does NOT carry a deferred-grace-period timer either.
When the last subscriber drops, the cache slot is evicted in-tick: the reaction is disposed, the on-dispose callbacks fire (releasing input ref-counts on layer-2+ subs — see §Disposal cascades below), and the slot is dissoc'd. A :rf.sub/dispose trace event with :rf.sub/reason :no-more-derefers is emitted at the eviction site.
On subscriber detach (view unmounts, tool disconnects):
entry.ref-count -= 1
If entry.ref-count == 0:
dispose(entry.reaction) ;; fires the derived container's on-dispose
;; hook → releases input refs, dissocs the slot
trace! :rf.sub/dispose {:rf.sub/query-v k :frame F.id
:rf.sub/reason :no-more-derefers}
On subscriber attach (cache HIT; the slot already exists):
entry.ref-count += 1
Disposal is wired on the derived container, not an entry-level callback
vector: disposing entry.reaction runs the on-dispose hook that
compute-and-cache! registered (CLJS: interop/add-on-dispose!), which
both releases the input refs (layer-2+ cascade) and dissoc's the slot.
A subscribe arriving AFTER the disposal is treated as a fresh cache miss: compute-and-cache! builds a new reaction against the registered sub body. The recomputed value will = what was disposed (same body, same app-db) so the post-rebuild render observes no value change.
Disposal guarantees¶
- Zero-subscriber → disposed in-tick. When
ref-countreaches 0 the on-dispose callbacks fire, the cache slot is removed, and a:rf.sub/disposetrace event is emitted — all within the call that drove the 1 → 0 transition. No state change can land between the count reaching zero and the eviction; the reaction's watch onapp-dbis unwound before the next dispatch can observe it. - No wasted recompute before disposal. Because the dispose is synchronous on the 1 → 0 edge, the cache cannot hold a sub alive across a state change that lands after the last derefer has dropped. Pre-rf2-cmfln a 50ms deferred-grace timer left the reaction watching
app-dbfor the grace window — any state change in that window forced a recompute even though the result was about to be thrown away. The synchronous path closes that window entirely. - Hot-reload preserves the contract. Re-registering a sub disposes every cached slot for that query (regardless of ref-count) — the next subscribe builds afresh against the new body.
- Frame teardown preserves the contract. Destroying a frame disposes every cached slot; see §Lifetime contract — frame disposal.
On-dispose hooks¶
The on-dispose hook lets the adapter release substrate-specific resources (a Reagent reaction; a JS-cross-compile-port atom-shape's listener entry / derived-value memo) before the cache slot is removed. Hooks fire synchronously on the 1 → 0 transition. The CLJS reference uses interop/add-on-dispose! per the Reagent realisation in §Sub-cache wiring.
Three subtleties¶
- A sub can become live again after disposal. A view unmounts and its last subscription drops; the slot disposes. Later, the same view re-mounts (cache miss, fresh computation). This is correct — the cache is performance, not state. The recomputed value will
=what was disposed (same body, sameapp-db); no observable difference. Shared-component re-mount in the same cascade: when view A unmounts and view B (which subscribes to the samequery-v) mounts in the same React commit, the sub is disposed by A's cleanup then re-built by B's mount. The disposed reaction and the rebuilt reaction are distinct objects but compute the same value; the cost is one extracompute-and-cache!call (one reaction allocation, one body run) — accepted per the rf2-cmfln design phase as the "most honest" cost of closing the wasted-recompute window. - Eager subs. A future
:reg-sub-by-path(post-v1) might keep its cache slot live regardless of ref-count, for performance. v1 has no eager subs; if added, the contract surface isentry.eager? = trueand the disposal path skips the slot. - Disposal cascades. When a layer-2 sub disposes, its layer-1 inputs lose one reader each (the parent's
on-disposecallback callsunsubscribeon every:<-input symmetrically with the construction-time subscribes). If an input was held only by that layer-2 sub, it cascades to disposal in the same tick. The whole cascade — parent + every transitively-held input — completes within the call that drove the parent's 1 → 0 transition.
(subscribe-once query-v) → value / (subscribe-once frame-id query-v) → value¶
The one-shot, non-reactive read of a subscription's current value. subscribe-once is the canonical end-user surface for "give me the current value of this sub right now, and don't retain a reference on my behalf." It is the right call from event handlers, machine actions, REPL sessions, SSR builders, and any non-reactive consumer; views and tools that want to track future changes use subscribe instead.
(subscribe-once query-v) ;; → value (uses the resolved current frame)
(subscribe-once frame-id query-v) ;; → value (explicit-frame form)
Semantically, subscribe-once is subscribe + deref + immediate unsubscribe:
subscribe-once(frame-id, query-v):
r ← subscribe(frame-id, query-v) ;; cache hit OR miss; ref-count += 1
v ← deref r ;; current cached value
unsubscribe(frame-id, query-v) ;; ref-count -= 1; on 1→0, dispose synchronously
return v
Contract MUSTs.
- One-shot. Each call subscribes, derefs once, and unsubscribes. The caller does not receive a deref-able reaction; the returned value is a plain immutable value of whatever the sub computes.
- Non-reactive. The caller is not registered for re-render or change notification. A subsequent
app-dbmutation that would have invalidated the slot has no observable effect on the caller ofsubscribe-once— they got their value, they're done. - Synchronous teardown. Per §Reference counting and disposal the 1 → 0 transition disposes in-tick, so the one-shot read's whole lifetime — subscribe, deref, and (if this call drove the 1 → 0 transition) dispose — completes in the calling tick. A concurrent reactive subscriber (a view holding
subscribeon the samequery-v) keeps the slot alive via ref-count;subscribe-once's decrement only triggers disposal when it owned the last reference. - Frame-resolution. The 1-arg form resolves the current frame via the resolution chain (dynamic-var tier, React-context tier when an adapter has registered the
:adapter/current-framelate-bind hook per §Frame-provider via React context,:rf/defaultfallback). The 2-arg form is explicit and bypasses the chain. - Missing frame is not an error.
subscribe-onceagainst a destroyed or never-created frame returnsnil(and emits the same:rf.warning/unknown-frametracesubscribedoes); it does NOT throw. - Missing sub is not an error. Per §What happens when a sub references an unknown sub, an unregistered
query-vemits:rf.error/no-such-sub(recovery:replaced-with-default) and yieldsnil;subscribe-oncepropagates thenil. - JVM-runnable.
subscribe-onceis part of the substrate-agnostic call-site surface; it works against the plain-atom adapter (no Reagent dependency). On the JVM, the deref step reads the substrate's container directly; tests, SSR builders, and headless tools rely on this.
Where it differs from compute-sub. compute-sub (per 008 §compute-sub algorithm) is a pure function over an explicit app-db value — it bypasses the cache entirely and runs the sub's body fresh. subscribe-once is cache-aware: it materialises the cache entry (cache hit reuses; cache miss populates briefly), then immediately drops its reference (sync dispose on the 1 → 0 transition). Use compute-sub when you want to test a sub's body against a snapshot in isolation; use subscribe-once when you want what the running frame would see right now.
(unsubscribe query-v) → nil / (unsubscribe frame-id query-v) → nil¶
The explicit teardown of a subscribe call. unsubscribe decrements the cache entry's ref-count by 1; on the 1 → 0 transition, the cache slot is disposed synchronously (per §Reference counting and disposal). Reagent views auto-dispose via the reaction lifecycle and do not need to call unsubscribe explicitly; tests, REPL sessions, tools, and machine actions that subscribed imperatively are the call sites that need it.
(unsubscribe query-v) ;; → nil (uses the resolved current frame)
(unsubscribe frame-id query-v) ;; → nil (explicit-frame form)
Contract MUSTs.
- Decrement, then destroy on the 1 → 0 edge.
unsubscribedecrements the slot's ref-count by 1. The slot itself disposes synchronously when ref-count reaches 0 (per §Reference counting and disposal). A caller that holds N concurrent subscriptions to the samequery-vmust callunsubscribeN times to fully release; each call decrements one share, and the Nth (the one that drives 1 → 0) disposes. - Pair with
subscribe. Everysubscribe(including thesubscribehalf ofsubscribe-once) increments the slot's ref-count by 1; everyunsubscribedecrements by 1. Imperative subscribers are responsible for the pairing; views and tools that hold reactions through the reaction lifecycle get the decrement automatically when the reaction disposes. - Idempotent past zero. Calling
unsubscribeafter the slot has already been disposed is a no-op — the entry-lookup misses, and the call returnsnilwithout trace emission. A secondunsubscribefrom the same path (cleanup hook +finallyblock both running) is safe. - Missing frame is not an error.
unsubscribeagainst a destroyed or never-created frame returnsnil(and emits the same:rf.warning/unknown-frametracesubscribedoes); it does NOT throw. - Frame-resolution. The 1-arg form resolves the current frame via the resolution chain (dynamic-var tier, React-context tier when an adapter has registered the
:adapter/current-framelate-bind hook per §Frame-provider via React context,:rf/defaultfallback). The 2-arg form is explicit.
Composability with subscribe-once. subscribe-once internally invokes subscribe then unsubscribe — the teardown is synchronous on the 1 → 0 transition (per the unified disposal contract above). The user does NOT call unsubscribe for a subscribe-once call — the pairing is internal. Users only call unsubscribe for the subscribe calls they made themselves.
Why explicit teardown exists alongside auto-disposal. The reactive lifecycle handles the automatic case: a view unmounts, the reaction disposes, the underlying unsubscribe fires from the reaction's on-dispose hook, the slot drains in-tick. Explicit unsubscribe is the imperative-callers' equivalent: tools, REPL sessions, machine actions, and tests that took out a subscription without an enclosing reaction lifecycle to manage it. Both paths funnel into the same ref-count decrement and the same synchronous-on-zero dispose — one disposal algorithm, two arrival surfaces.
Lifetime contract — frame disposal¶
When a frame is destroyed (per 002 §Destroy):
On destroy-frame! F:
For each k → entry in F.sub-cache:
dispose(entry.reaction) ;; runs the container's on-dispose hook
F.sub-cache.clear()
trace! :sub-cache/cleared {:frame F.id}
Three contract guarantees this enforces:
- No leaks. Every cached substrate-specific resource (Reagent reaction; JS-cross-compile-port atom-shape's listener entry / derived-value memo) is released. Long-lived processes that create and destroy frames (test runs, SSR request handling) reach steady-state memory.
- No stale reads. After
destroy-frame!, attempts to subscribe toFraise:rf.error/frame-destroyed. There is no path that returns a value from a destroyed frame's cache. - Adapter symmetry. The adapter's
dispose-adapter!(§Adapter disposal lifecycle) is the per-process counterpart; it disposes every frame's sub-cache as part of process teardown.
Cross-spec interactions¶
- Drain-loop integration (002 §Drain-loop pseudocode): invalidation fires once per
process-event!, at the single deferred:dbinstall (step 2) — the flow transform has already rewritten the pending:dbeffect as the outermost:after(step 1, per 013 §Drain integration), so the value installed is the flow-augmented db. There is exactly one invalidation per event, at that install, and subscriptions observe the flow-augmented db on recompute. A handler can rely on subscriptions reflecting the newapp-dbfrom insidedo-fx(the:fxwalk at step 3, after the install). - Hot reload (001-Registration): re-registering a sub disposes the cache slot for that query (regardless of ref-count); next subscribe rebuilds with the new body. Tracked with the rest of hot-reload semantics in the bead-tracked work.
- Machine subscriptions (005 §Subscribing to machines via
sub-machine): a machine's snapshot lives at[:rf/runtime :machines :snapshots <id>]and is read like any other slice ofapp-db;sub-machineis a thin convenience overreg-sub. Sub-cache invalidation works the same. clear-subis a registry-only operation:(clear-sub id)and(clear-sub)remove:subregistrations but leave already-materialised per-frame cache slots in place. Caching is governed by the disposal contract above (synchronous ref-counting on derefer-count → 0, hot-reload eviction, frame-destroy eviction); cache eviction independent of those triggers isclear-sub-cache!'s job. This split preserves v1's documented contract — see theclear-subdocstring's note: "Depending on the usecase, it may be necessary to callclear-sub-cache!afterwards."
Per-host implementation notes¶
- CLJS-Reagent. Reagent's
Reactionhandles invalidation, ref-counting, and disposal automatically. Layer-1 reads viar/atomderef; layer-2+ build a graph of reactions; equality checks happen at each layer. The cache wraps Reagent's own machinery — see §Sub-cache wiring (Reagent realisation). - CLJS-headless / SSR. No caching.
compute-subis a pure function that runs the sub's body fresh every time it's called. Cheap because no SSR run does it twice. The contract above is satisfied trivially: no cached values means no invalidation question. - In-scope JS-cross-compile-language ports (TS-React / Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint). Must satisfy the algorithm above explicitly — the per-port adapter implements layer-1/2/3 invalidation atop its host's React binding. The atom-shape's watch/listener machinery and any derived-value memoisation cooperate with React's
useSyncExternalStore-driven render scheduling to surface invalidation to views. Tools relying on the trace stream's:sub/recomputedevents depend on the equality-check-on-invalidation rule.
What happens when a sub references an unknown sub¶
A sub registered via :<- referencing an undefined input is an error:
(rf/reg-sub :cart/total
:<- [:cart/items] ;; OK
:<- [:nonexistent/data] ;; ❌ no :nonexistent/data registered
(fn [...] ...))
The behaviour is environment-specific:
- At registration time (when the macro runs), the runtime cannot fully validate
:<-— the input might be registered later in the load order. - At first use (when something tries to subscribe to
:cart/total), the runtime resolves all inputs. If any input is unregistered, the runtime emits a:rf.error/no-such-subtrace event (per 009 §Error contract) and returnsnilfor that input. Recovery::replaced-with-default.
The subscription's body still runs with nil substituted for the unresolved input. This is intentional: it keeps the trace stream readable (the agent sees one error event rather than a chain of cascading throws) and lets the caller handle the missing data gracefully if it can.
A related case is subscribe itself naming an unregistered sub-id — most often a boot-order or lazy-load race where the consumer subscribes before the registering namespace has loaded. The runtime emits the same :rf.error/no-such-sub trace event, returns a nil-yielding reaction (recovery :replaced-with-default), and does not populate the per-frame sub-cache. Skipping the cache on miss preserves the v1 semantic that a later registration is observed by the next subscribe — no stale nil-reaction lingers.
CLJS reference: Reagent as default adapter¶
The CLJS reference ships two adapters across two Maven artefacts: the plain-atom (JVM/headless) adapter ships in the core artefact (day8/re-frame2); the Reagent adapter ships in its own sibling artefact (day8/re-frame2-reagent). Both implement the closed nine-fn contract above; the runtime picks per platform. UIx and Helix adapters ship as further sibling artefacts as they land. Per Conventions §Adapter shipping convention and rf2-0hxm.
This section is the bridging pseudocode for both. For each contract function, the pseudocode shows which Reagent (or, on the JVM, plain-Clojure) primitive realises it. An AI implementing the CLJS reference can lift this directly; non-CLJS implementors read it as one worked example of the contract.
Reading note. v1 of re-frame already implements most of these primitives (
re-frame.interop,re-frame.subs,re-frame.subs/cache-and-return,reagent.core/atom,reagent.ratom/make-reaction). The pseudocode below tracks v1's working code closely; what's new is the contract surface itself (the v1 code does not separate "core" from "adapter" — the substrate decoupling is the v2 work). Use v1 source as the implementation reference for everything below the contract line.
Per-contract-fn pseudocode¶
(ns re-frame.adapter.reagent
(:require [reagent.core :as r]
[reagent.ratom :as ratom]
[reagent.dom.client :as rdc]
[re-frame.frame-context :as fc] ;; the frame-keyword React Context
[re-frame.render.hiccup-to-html :as hiccup]
[re-frame.subs.cache :as sub-cache]))
;; -- 1. make-state-container ------------------------------------------------
;; A Reagent ratom holds the frame's app-db. r/atom is the only mutation point;
;; reagent.ratom captures all the change-tracking semantics for free.
(defn make-state-container [initial-value]
(r/atom initial-value)) ;; → IReactiveAtom
;; -- 2. read-container ------------------------------------------------------
;; Plain deref. Outside a reactive context this does not register a dependency;
;; inside one, Reagent automatically wires the dependency edge.
(defn read-container [container]
@container)
;; -- 3. replace-container! --------------------------------------------------
;; The single mutation primitive. Reagent's reset! schedules dependent
;; reactions; the core's invalidation hook runs synchronously *before* the
;; first :fx entry per [002 §:fx ordering] — Reagent's batching cooperates
;; because reactions only re-fire on next deref or the next animation frame.
(defn replace-container! [container new-value]
(reset! container new-value)
nil)
;; -- 4. subscribe-container -------------------------------------------------
;; Reagent itself drives invalidation through reactions; the explicit
;; subscribe-container surface exists for non-reactive substrates and tools
;; that want raw change events. Implemented via add-watch on the underlying
;; ratom — observably equivalent across substrates per [§Operational semantics].
(defn subscribe-container [container on-change]
(let [k (gensym "rf-sub-")]
(add-watch container k (fn [_ _ prev nu] (on-change prev nu)))
(fn unsubscribe [] (remove-watch container k))))
;; -- 5. make-derived-value --------------------------------------------------
;; reagent.ratom/make-reaction wraps a compute-fn in a Reaction that
;; (a) re-runs only when its derefs change by =, (b) caches the result,
;; (c) participates in the reactive graph so dependent views auto-rerender.
;; Equality-on-=-of-inputs is the rule the sub-cache invariant relies on.
(defn make-derived-value [source-containers compute-fn]
(ratom/make-reaction
(fn [] (apply compute-fn (map deref source-containers)))))
;; -- 6. render --------------------------------------------------------------
;; React 19 takes a `Root` (from `reagent.dom.client/create-root`) — NOT
;; a raw DOM element. The non-hydrate path creates the Root then renders
;; into it; the hydrate path's `hydrate-root` returns its own Root. The
;; returned unmount-fn closes over the Root so the runtime can release it
;; without re-consulting the DOM element. Idempotent: calling unmount
;; twice is a no-op.
(defn render [render-tree mount-point opts]
(let [hydrate? (boolean (:hydrate? opts))]
(if hydrate?
(let [root (rdc/hydrate-root mount-point render-tree)]
(fn unmount [] (rdc/unmount root)))
(let [root (rdc/create-root mount-point)]
(rdc/render root render-tree)
(fn unmount [] (rdc/unmount root))))))
;; -- 7. render-to-string ----------------------------------------------------
;; Pure JVM-runnable walk over the hiccup render-tree per [011-SSR
;; §The render-tree → HTML emitter (CLJS reference)]. No Reagent, no React;
;; the same pure emitter the plain-atom adapter uses.
(defn render-to-string [render-tree opts]
(hiccup/emit render-tree opts))
;; -- 8. register-context-provider -------------------------------------------
;; Returns the frame-provider component (a React Context Provider whose value
;; is the frame keyword, never the frame record — see [002 §Reading the frame
;; from React context]). Re-registering a frame is picked up on next render
;; because the context value is a keyword resolved against the registry.
(defn register-context-provider [frame-keyword]
(fc/provider frame-keyword))
;; -- 9. dispose-adapter! ----------------------------------------------------
;; Total disposal. Order matters: tear down sub-cache Reactions first (so
;; nothing observes a ratom going away), then unmount any active React
;; Roots, then clear adapter-private caches. Frame-providers are stateless
;; (a single zero-arity component services every frame keyword per
;;) so there is no provider-side cache to flush. Reagent's own
;; reaction-graph caches GC themselves once their last watcher drops, so
;; the explicit `(ratom/flush!)` step the v1-pseudocode named is not
;; needed — disposing the cached Reactions above is sufficient.
(defn dispose-adapter! []
;; Step 1 — cancel in-flight reactive subscriptions across every live
;; frame's per-frame sub-cache. Reaches each Reaction via
;; `interop/dispose!` (which routes through `:adapter/dispose!`).
(doseq [[_ frame-record] @frame/frames]
(when-let [cache (:sub-cache frame-record)]
(doseq [[_ entry] @cache]
(some-> (:reaction entry) interop/dispose!))
(reset! cache {})))
;; Step 2 — unmount any active React 19 Roots.
(doseq [root @active-roots]
(try (rdc/unmount root) (catch :default _ nil)))
(reset! active-roots #{})
;; Step 3 — clear adapter-private caches.
(reset! hiccup-emitter nil)
nil)
Sub-cache wiring (Reagent realisation)¶
The per-frame sub-cache (§Subscription cache invalidation) is the bridge between reg-sub and a Reagent reaction. v1's working algorithm in re-frame.subs is the reference. The CLJS-reference v2 wiring:
;; The cache is per-frame: keyed by [query-vector], stored on the frame.
;; Each entry is a map {:reaction r :inputs [...] :ref-count n} — the
;; Reagent Reaction wraps the sub's body; the cached value lives ON the
;; Reaction and is read via deref (no :value slot). See [§Cache shape].
(defn subscribe [frame query-v]
(let [k (cache-key query-v)
cache (:sub-cache frame)]
(if-let [entry (get @cache k)]
(do (swap! cache update-in [k :ref-count] inc) ;; cache hit: bump ref-count
(:reaction entry))
(compute-and-cache frame query-v)))) ;; cache miss: build chain
(defn- compute-and-cache [frame query-v]
(let [meta (registrar/lookup :sub (first query-v))
inputs (mapv (fn [input-q] (subscribe frame input-q)) ;; recurse for :<-
(:input-signals meta))
body-fn (:fn meta)
;; The Reaction wraps the sub body. Reagent re-runs body-fn only when
;; one of its derefs (the inputs) changes by =. This is the layer-1/2/3
;; sub semantics from v1 — same algorithm, now scoped per frame.
r (ratom/make-reaction
(fn [] (apply body-fn (conj (mapv deref inputs) query-v))))]
(swap! (:sub-cache frame) assoc k {:reaction r :inputs inputs :ref-count 1})
;; When this reaction's last reader disposes, release the input refs
;; symmetrically (layer-2+ cascade) then GC the cache slot.
(interop/add-on-dispose! r
(fn []
(doseq [input-q inputs] (unsubscribe frame input-q))
(swap! (:sub-cache frame)
(fn [cm] (if (identical? r (get-in cm [k :reaction])) (dissoc cm k) cm)))))
r))
(defn dispose-frame-subs! [frame]
(let [cache (:sub-cache frame)]
(doseq [[_ entry] @cache] (interop/dispose! (:reaction entry)))
(reset! cache {})))
What this gives:
- Hot reload (001-Registration, bead-tracked): re-registering a sub disposes the cache slot for that query; next subscribe rebuilds with the new body.
- Frame teardown (002 §Destroy):
dispose-frame-subs!fires from the frame's lifecycle hook; every reaction is disposed; no leaks. - Layer-1/2/3 semantics: the recursion in
compute-and-cachebuilds a chain. A layer-2 sub's reaction:<-s into a layer-1 sub's reaction; Reagent's tracking propagates=-equality up the chain.
Frame-provider via React context¶
register-context-provider returns the frame-provider component. The CLJS implementation lives in re-frame.frame-context; the design is owned by 002 §Reading the frame from React context — this section names the adapter-side hook into it.
;; The single React Context. Default value is :rf/default — the Spec
;; guarantees this frame always exists per [002 §:rf/default].
(defonce ^:private frame-context
(.createContext js/React :rf/default))
(defn provider []
;; Returns a Reagent component the user includes in their tree:
;; [provider :auth
;; [some-view ...]]
;; The Provider's value is the keyword, never the frame record;
;; consumers resolve the keyword against the global frame registry on
;; every read, so re-registering frames is picked up automatically.
;; 0-arity: a single built component services every frame —
;; the frame keyword lives in the Provider's :value at render time, not
;; in a build-time closure.
(fn [frame-kw & children]
;; `:r>` bypasses Reagent's `convert-prop-value` so the keyword's
;; namespace survives the React-context round trip — see Spec 002
;; §`frame-provider` for the prop-conversion rationale.
(into [:r> (.-Provider frame-context) #js {:value frame-kw}] children)))
The read-frame-from-context lookup chain (*current-frame* dynamic var → React context → :rf/default) is documented in 002 §Reading the frame from React context.
Frame propagation across React-binding ports¶
The CLJS-reference shape. The shared re-frame.adapter.context/frame-context primitive lives in the core artefact (day8/re-frame2) — a CLJS-only file that the JVM build does not load (per 000 §C2 Cross-platform). Every React-shaped CLJS adapter (Reagent, UIx, Helix) consumes it; mixed-substrate apps therefore compose providers across substrates rather than silos.
Per-language ports realise the same contract via the host React binding's own context primitive. The mechanism varies by binding; the contract — a context value carrying the current frame-id keyword; views read it via the host React binding's hooks-equivalent — does not. Per-port realisations:
| Port | React-context primitive | Hooks-equivalent read |
|---|---|---|
| TypeScript-React | React.createContext<FrameId>(":rf/default") |
useContext(FrameContext) |
| Fable (F#) — Feliz / Fable.React | React.createContext |
React.useContext |
| Scala.js — scalajs-react / Slinky | React.createContext (binding-shaped) |
useContext hook |
PureScript — React.Basic.Hooks |
Hooks.createContext |
Hooks.useContext |
| Kotlin/JS — kotlin-react | createContext |
useContext |
| Melange / ReScript / Reason — ReasonReact | React.createContext |
React.useContext |
| Squint | reuses the CLJS-Reagent shape (Squint preserves Clojure keywords) | same as CLJS |
The spec does not prescribe JS implementation details (_currentValue reads, class-component :contextType shapes, prop-stringification quirks) — those are port discretion. What the spec requires is the contract: the provider's value is a frame-id keyword (or the host's identity-primitive equivalent), and the views inside the provider's subtree resolve subscriptions / dispatches against that frame.
Adapter responsibility — :adapter/current-frame late-bind hook. Each React-shaped substrate adapter (Reagent, UIx, Helix) MUST register its React-context-aware current-frame-id impl through the :adapter/current-frame late-bind hook at namespace-load time. re-frame.subs/subscribe, re-frame.subs/subscribe-once, re-frame.subs/unsubscribe, and the dispatch envelope's :frame default consult the hook on CLJS so the React-context tier of the resolution chain is live rather than dead code. Without the registration the call sites fall back to re-frame.frame/current-frame (dynamic-var tier and :rf/default only); the React-context tier silently no-ops, so a (rf/subscribe ...) under a non-default frame-provider would route to :rf/default regardless of what the provider named. Hook signature: (fn frame-id-keyword).
The impl is substrate-specific:
- Reagent registers
re-frame.views/current-frame, which uses Reagent's class-component(.-context cmp)path. The path is intentionally narrow — it surfaces context only to components whose:contextTypematchesframe-context(i.e. components registered viareg-view*). Plain Reagent fns route to:rf/default, which is what the:rf.warning/plain-fn-under-non-default-frame-oncewarning targets. - UIx / Helix register
re-frame.adapter.context/function-component-current-frame, which reads_currentValuedirectly off the shared context object. Function components have no(.-context cmp)slot, so_currentValueis the substrate-portable path; UIx'suse-contextand Helix'suse-contextare sugar over the same read.
Both impls share the dynamic-var tier (re-frame.frame/*current-frame*, set by with-frame / frame-bound-fn) and the :rf/default tier; only the middle (React-context) tier differs. The canonical user-facing surface (rf/frame-provider) mounts the Provider via Reagent's :r> interop head so the props map bypasses reagent.impl.template/convert-prop-value — the :value keyword (namespace and all) reaches React unchanged. As defensive cover, both impls round-trip the prop-stringified shape via re-frame.adapter.context/coerce-context-value so a raw-hiccup [:> Provider {:value :tenant}] mount (not via rf/frame-provider) is still observed correctly by every substrate. The helper is lossy for namespaced keywords on raw-hiccup mounts under the classic adapter ((name :foo/bar) is "bar"); raw-hiccup mounts that need namespaced frame-ids should switch to rf/frame-provider or re-frame.adapter.context/provider-element.
Plain-fn-under-non-default-frame warning. A plain Reagent fn (not registered via reg-view) cannot subscribe to the closest enclosing frame-provider because plain fns lack the ^{:context-type frame-context} metadata reg-view attaches. When such a plain fn calls (rf/subscribe ...) and the React-context tier resolves to a non-default frame, the runtime emits :rf.warning/plain-fn-under-non-default-frame-once (per Conventions §Reserved namespaces) — once per (fn, frame) pair, not per render — pointing the user at reg-view or with-frame.
The detection sits in subscribe: if (reagent.core/current-component) returns a component whose contextType does not match frame-context, the dynamic-var tier is checked; if neither names a non-default frame, no warning fires; if the closest enclosing provider names a non-default frame and *current-frame* is unset, the warning fires.
Arity-gated. The check runs on the 1-arity (subscribe query-v) form only. The 2-arity (subscribe frame-id query-v) form skips the check by design — supplying an explicit frame-id IS the opt-out: the caller has told the runtime exactly which frame to target, so a fall-through-to-:rf/default diagnostic doesn't apply. Plain-Reagent-fn call-sites that need to subscribe against a known frame without triggering the warning surface should use the 2-arity form.
The sub-override subscribe seam (debug-gated; rf2-7pgiz)¶
re-frame.subs/subscribe carries one substitutive, debug-gated late-bind hook — :subs/resolve-sub-override — that lets a development tool replace a subscription's value at the view's deref point without touching app-db. It is the read-side of Story's :sub-overrides fidelity rung: a designer pins a view into an :error / :loading / :empty state by naming exact subscription query-vectors and the values they should surface — no events, no app-db seed.
Carriage — React context, not a dynamic var. The override map ({query-vector value}) must survive from the Story render-scope component into the descendant view's own, deferred React render — the view's @(rf/subscribe [:q]) runs later, in its own reaction, several component layers deep. A binding-bound dynamic var does NOT survive that boundary (the binding unwinds before the descendant renders). The carriage that does is a React context — React mutates a context's _currentValue as Provider boundaries are entered/exited during render, so a read from inside any descendant render sees the closest enclosing Provider's value. This is the exact mechanism the frame-id uses (§Frame-provider via React context above); the override carriage mirrors it in a sibling CLJS-only context object whose default value is nil (no overrides in scope). The tool wraps the variant view in that Provider; core reads the closest enclosing map at subscribe time.
Consult — :subs/resolve-sub-override. Inside the same (when interop/debug-enabled? …) envelope that gates the observational subscribe-time hooks (:views/record-view-deref!, the plain-fn warning), subscribe consults the hook with the query-vector. The hook returns a one-element vector [value] on an exact-query-vector HIT (a one-element vector — never a bare value — so a nil-valued override is still honoured as a hit) or nil on a miss / no Provider in scope / production. On a HIT, subscribe short-circuits build-and-cache and returns a constant reaction (a derived value with no inputs that always yields the pinned value): it never recomputes, is never cached, and is never invalidated. On a miss / unbound / production, subscribe is byte-for-byte unchanged. The whole block elides under :advanced + goog.DEBUG=false.
Bundle isolation. Core only declares the hook key and consults it; the resolver that reads the override-context Provider is published from the tool side (Story) via late-bind, so core never statically requires a tools namespace. The context-carriage object lives in core (CLJS-only) because core already depends on React — but it is read only on the dev consult path, which DCEs in production.
Honesty boundary (load-bearing). The override feeds only the constant reaction the view derefs. It NEVER writes app-db and NEVER reaches compute-sub. Because :rf.assert/sub-equals (and every subscription assertion) evaluates a sub through compute-sub against the real app-db, an override can never satisfy a subscription assertion. Subscription correctness is proven by real setup events / a schema-checked app-db seed / compute-sub — never by an override. This rung is, by construction, a picture for the eye, not proof.
Override schema-validation (rf2-7pgiz fold-in). When an override HIT targets a sub that declares an output :schema (per 010 §Validation order step 6), core validates the pinned value against that schema the SAME way :where :sub-return does — through the registered validator reached via the :schemas/validate-with-registered-fn late-bind hook, dev-only. A mismatch emits :rf.error/schema-validation-failure with a :where :sub-override discriminator and surfaces nil (mirroring :sub-return's :replaced-with-default recovery — observational; the failure is reported, the violating value is not surfaced). An override that violates the sub's own output contract is exactly the "pin a state the real derivation could never produce" anti-pattern; validating it closes that honesty gap. See 010 §Validation order.
Plain-atom adapter (JVM, SSR, headless)¶
The plain-atom adapter is the same nine-fn contract realised against clojure.core/atom instead of Reagent. It is what runs on the JVM (per 000 §C2. Cross-platform: JVM interop preserved) and what SSR and headless tests use (§SSR-specific behaviour, 008-Testing).
How it differs from the Reagent adapter:
(ns re-frame.substrate.plain-atom
(:require [re-frame.render.hiccup-to-html :as hiccup]))
(defn make-state-container [initial-value]
(atom initial-value)) ;; clojure.core/atom; reactivity via add-watch (see subscribe-container)
(defn read-container [container] @container)
(defn replace-container! [container nu] (reset! container nu) nil)
(defn subscribe-container [container on-change]
(let [k (gensym "rf-sub-")]
(add-watch container k (fn [_ _ prev nu] (on-change prev nu)))
(fn [] (remove-watch container k))))
;; No Reaction — derived values are computed on every read. SSR runs each
;; sub once, so caching wouldn't help. Tests that want caching swap in the
;; Reagent adapter via the reagent-cljs-jvm interop layer.
(defn make-derived-value [source-containers compute-fn]
(reify clojure.lang.IDeref
(deref [_] (apply compute-fn (map deref source-containers)))))
;; render is not used on the JVM — render-to-string is the only path.
(defn render [_ _ _]
(throw (ex-info "render not supported on plain-atom adapter; use render-to-string"
{:rf.error :rf.error/render-on-headless-adapter})))
(defn render-to-string [render-tree opts]
(hiccup/emit render-tree opts)) ;; same emitter as Reagent
;; No React, no context concept. The pattern's explicit-frame addressing
;; (per [002 §Routing]) handles frame routing without a context provider.
(defn register-context-provider [_frame-keyword]
nil) ;; optional fn, returning nil is the spec'd absence
(defn dispose-adapter! []
;; Watch handles are GC'd with their atoms; nothing else to clean up.
nil)
Three design decisions worth naming:
- No caching for derived values. SSR runs each sub at most a handful of times per request; caching would add complexity for negligible gain. Tests that want repeatable performance characteristics can swap in the Reagent adapter on the JVM.
renderthrows. SSR usesrender-to-stringexclusively; callingrenderon the JVM is a programmer error worth surfacing loudly. The conformance fixture for:rf.error/render-on-headless-adapterpins this.- No context provider. The pattern-level contract is explicit-frame addressing. Hosts without a context concept fall back to threading the frame as an argument; the headless adapter is the simplest such host.
The plain-atom adapter is trivially revertibility-compliant (§Reference-adapter compliance) because it holds no state outside the container.
Adapter selection at boot¶
Per (replaces; resolves) (rf/init! adapter-map) requires the consumer to pass an adapter spec map explicitly. Each adapter namespace exports an adapter Var (the spec map); the consumer requires the namespace and passes the Var:
;; Reagent (CLJS, day8/re-frame2-reagent):
(require '[re-frame.core :as rf]
'[re-frame.adapter.reagent :as reagent])
(rf/init! reagent/adapter)
;; UIx (CLJS, day8/re-frame2-uix):
(require '[re-frame.core :as rf]
'[re-frame.adapter.uix :as uix])
(rf/init! uix/adapter)
;; Helix (CLJS, day8/re-frame2-helix):
(require '[re-frame.core :as rf]
'[re-frame.adapter.helix :as helix])
(rf/init! helix/adapter)
;; SSR / JVM (day8/re-frame2-ssr):
(require '[re-frame.core :as rf]
'[re-frame.ssr :as ssr])
(rf/init! ssr/adapter)
;; Headless / plain-atom (re-frame.substrate.plain-atom in core):
(require '[re-frame.core :as rf]
'[re-frame.substrate.plain-atom :as plain-atom])
(rf/init! plain-atom/adapter)
(rf/init! …) accepts exactly one argument shape:
(rf/init! adapter-map)— install the literal adapter spec.
Calling (rf/init!) with no args raises a language-level ArityException at the call site (per — the no-arg arity was cut from the fn defn entirely, so the mistake surfaces at compile/load time rather than at runtime). Calling (rf/init! :reagent) (or any non-map value) and (rf/init! nil) raise :rf.error/no-adapter-specified at runtime — there is no default-adapter registry and no keyword-to-adapter lookup table. The runtime error message points the consumer at the adapter-ns + adapter-Var pattern.
No registry, no implicit defaults. The previous design shipped a default-adapter registry populated by adapter ns-load side-effects so that (rf/init!) with no args could resolve the only registered candidate. drops the registry entirely. Two reasons:
- Explicit > implicit at the call site. Reading any app's
runfunction tells you exactly which adapter is in use, with no need to chase ns-load side-effects through the require graph. - Bundle-size. A registry is bundle weight even when unused. Under, an app that requires only the adapter it needs ships only that adapter's code; the registry-and-resolver paths are gone.
A mixed-substrate app — say a build that imports both re-frame.adapter.reagent (for stories) and re-frame.adapter.uix (for production views) — picks the active adapter by passing the right Var to init!. There is no multi-adapter ambiguity to resolve at boot: only one adapter is ever installed.
install-adapter! is called once per process by init!'s implementation. Subsequent calls without an intervening dispose-adapter! raise :rf.error/adapter-already-installed (§Single adapter per process).
The CLJS adapter namespaces (Reagent, UIx, Helix) and the SSR namespace each export their adapter Var; the contract surface is the same nine-fn map (see §The adapter API contract above). The plain-atom adapter in re-frame.substrate.plain-atom is reachable on both JVM and CLJS — useful for headless tests on either platform.
CLJS reference: UIx as alternative substrate¶
The UIx adapter ships in day8/re-frame2-uix and implements the same nine-fn contract as the Reagent adapter — same observable behaviour for events, subs, effects; different rendering substrate for views.
Per the locked decisions (2026-05-09) are:
- Hook naming. The substrate's subscription surface is
use-subscribe, matching the React/UIx idiom. Symmetric ergonomics to Reagent's(rf/subscribe ...)deref shape; asymmetric naming because hooks live in hook-named space. - Frame propagation. Both the UIx and Reagent adapters read the same React Context object — factored out of
re-frame.viewsintore-frame.adapter.context(CLJS-only file in core). A future mixed-substrate app's frame-provider chain therefore composes across substrates rather than living in per-adapter silos. - Auto-injection. None for UIx. Components call
(use-subscribe [:foo])and(:dispatch (rf/frame-handle))directly — there is no UIx-side analogue toreg-view'sdispatch/subscribelexical bindings. The hook surface is the canonical UIx access path. reg-viewmacro scope.reg-viewstays Reagent-only (auto-defs the Var, auto-injects the lexicaldispatch/subscribe, threads source-coords through Reagent's:contextTypemachinery). UIx users register views viareg-view*(the plain-fn surface inre-frame.core); source-coord stamping for UIx-rendered roots happens at the adapter's render-time wrapper, not at registration time.- Source-coord DOM annotation. The UIx adapter wraps user components in a thin layer that calls
React.cloneElementto adddata-rf2-source-coord="<ns>:<sym>:<line>:<col>"on the rendered root DOM element wheninterop/debug-enabled?is true. Production-elision contract per rf2-z7f7: under:advanced+goog.DEBUG=falsethe entire wrapper branch DCEs and the literaldata-rf2-source-coordstring fragment is absent from the bundle. Fragments and non-DOM roots are exempt with the standard one-shot warning per id. - Render flush for tests. The adapter exposes
flush-views!wrapping React'sact(). Tests dispatching against a UIx-mounted tree call(flush-views!)after a dispatch to settle pending React effects before reading the DOM. The entry point is per-adapter-require —(uix-adapter/flush-views!), NOT centralised throughre-frame.test-support— per the adapter-dependency-direction rule in §What an adapter MUST NOT do; see Spec 008 §Adapter-aware test helpers for the test-author-facing rationale. - Smoke-test example set. counter + login (under
examples/uix/counter_uix/andexamples/uix/login_uix/). Realworld is skipped per Decision 7 — heavy with Reagent-flavoured idioms; deferred until a UIx user wants it. - UIx version target. UIx 2.x (hooks-based). UIx 1.x back-compat is explicitly out of scope.
The CLJS-reference code follows the same per-contract-fn shape as the Reagent adapter; the differences are at the React layer:
make-state-containerreturns aclojure.core/atomrather than a Reagentr/atom— UIx has no built-in reactive atom primitive. View-side reactivity flows throughuseSyncExternalStoreinuse-subscriberather than through Reagent reactions.make-derived-valuereturns anIDeref+IWatchablewrapper that recomputes on deref and broadcasts changes via the source containers' watch machinery. Equality-on-= invariants ride on the core's sub-cache (Spec 006 §Invalidation algorithm), not on the substrate's caching.renderwrapsreact-dom/client.createRoot+root.render; the unmount-fn callsroot.unmount().register-context-providerreturns a UIxdefuicomponent reading the sharedframe-contextviause-context.
Every other adapter primitive (read, replace, subscribe-container, dispose) is structurally identical to the Reagent adapter's — the contract is genuinely substrate-agnostic.
CLJS reference: Helix as alternative substrate¶
The Helix adapter ships in day8/re-frame2-helix and implements the same nine-fn contract as the Reagent and UIx adapters — same observable behaviour for events, subs, effects; different rendering substrate for views. Helix occupies the minimal-React-wrapper niche: it is structurally similar to UIx (React + hooks; no reactive-atom primitive) but ships a smaller surface and does not auto-instrument hooks.
Per the locked decisions (2026-05-10) transfer one-for-one from — the React + hooks substrate model is the same:
- Hook naming.
use-subscribe(matches the React/Helix idiom). - Frame propagation. Reads the same React Context object the Reagent and UIx adapters consume (
re-frame.adapter.context/frame-contextin core). - Auto-injection. None. Components call
(use-subscribe [:foo])and(:dispatch (rf/frame-handle))directly. reg-viewmacro scope. Stays Reagent-only; Helix users register registry-keyed views viareg-view*(the plain-fn surface) when they need it. Most Helix components are baredefncand don't need registry addressing.- Source-coord DOM annotation. The Helix adapter wraps user components in a thin layer that calls
React.cloneElementto adddata-rf2-source-coord="<ns>:<sym>:<line>:<col>"on the rendered root DOM element wheninterop/debug-enabled?is true. Production-elision contract per rf2-z7f7: under:advanced+goog.DEBUG=falsethe entire wrapper branch DCEs. Same Fragment / non-DOM-root exemption as the UIx adapter. - Render flush for tests.
flush-views!wrapping React'sact()— same surface as the UIx adapter. Per-adapter-require entry point ((helix-adapter/flush-views!)) per the adapter-dependency-direction rule in §What an adapter MUST NOT do; see Spec 008 §Adapter-aware test helpers for the test-author-facing rationale. - Smoke-test example set. counter + login (under
examples/helix/counter_helix/andexamples/helix/login_helix/). Realworld is skipped — same rationale as UIx (heavy with Reagent-flavoured idioms; deferred until a Helix user wants it). - Helix version target. Helix 0.2.x (the latest published Helix release line). Older Helix versions are explicitly out of scope.
Implementation notes:
make-state-containerreturns aclojure.core/atomrather than a Reagentr/atom— Helix has no built-in reactive atom primitive (same as UIx). View-side reactivity flows throughuseSyncExternalStoreinuse-subscribe.make-derived-valuereturns anIDeref+IWatchablewrapper that recomputes on deref and broadcasts changes via the source containers' watch machinery — structurally identical to the UIx adapter.renderwrapsreact-dom/client.createRoot+root.render(Helix doesn't ship ahelix.dom/render-rootwrapper of its own; the lower-level call is the cross-version-stable path).register-context-providerreturns a Helixdefnccomponent reading the sharedframe-contextviahelix.hooks/use-context.use-subscribecallsReact.useSyncExternalStoredirectly becausehelix.hooksdoesn't ship ause-syncExternalStorewrapper (Helix is the minimal-wrapper substrate); deps are wired throughhelix.hooks/use-memo*/use-callback*(the function-form hooks) so the adapter doesn't pull in Helix's macro layer.
Every other adapter primitive (read, replace, subscribe-container, dispose) is structurally identical to the Reagent and UIx adapters' — the contract is genuinely substrate-agnostic, and the Helix port surfaces no friction against the decision set.
Cross-substrate affordance summary¶
The nine-fn substrate contract is identical across adapters, but the three view-author-facing surfaces — read a subscription, scope a frame to a subtree, flush pending renders in a test — differ per substrate because each rides its host's idiom (Reagent's reactive deref vs the React-hooks model). A dev moving between substrates needs the one-glance map; this table is it. It documents the surfaces after the rf2-z7hfp restructure, in which each React-shaped adapter's frame-provider is a native substrate component (UIx defui, Helix defnc) rather than a re-exported plain fn handed to $.
| Affordance | Reagent (day8/re-frame2-reagent) |
UIx (day8/re-frame2-uix) |
Helix (day8/re-frame2-helix) |
|---|---|---|---|
| Read a subscription | @(rf/subscribe [:q …]) — reactive deref inside a reg-view/Form-2 render fn. |
(use-subscribe [:q …]) — React hook (re-renders on change via useSyncExternalStore). |
(use-subscribe [:q …]) — React hook (same surface as UIx). |
| Explicit-frame read | @(rf/subscribe frame-id [:q …]) (2-arg). |
(use-subscribe frame-id [:q …]) (2-arg). |
(use-subscribe frame-id [:q …]) (2-arg). |
| Frame resolution (1-arg form) | dynamic-var → React-context (surrounding frame-provider) → :rf/default. |
Same chain; React-context tier read via use-context. |
Same chain; React-context tier read via use-context. |
Scope a frame to a subtree (frame-provider) |
Native hiccup component; trailing-positional children: [rf/frame-provider {:frame :f} [header] [main]]. |
Native defui component, mounted via $; idiomatic $ trailing children: ($ frame-provider {:frame :f} ($ header) ($ main)). |
Native defnc component, mounted via $; idiomatic $ trailing children: ($ frame-provider {:frame :f} ($ header) ($ main)). |
Missing / nil :frame |
Falls through to :rf/default (deliberate default — matches the no-provider case; see §Frame-provider via React context). |
Same — :rf/default. |
Same — :rf/default. |
| Frame keyword fidelity under the mount idiom | :r> interop head bypasses Reagent prop conversion, so a namespaced frame keyword survives the React-context round trip. |
The native defui routes props through UIx's lossless argv channel (and folds native trailing children onto :children via glue-args) — keyword frame-ids survive intact by construction (rf2-z7hfp; was rf2-8svnm). |
The native defnc routes props through extract-cljs-props (keyword keys + preserved keyword values, native trailing children lifted onto :children) — survives by construction (rf2-z7hfp; was rf2-9ok1s). |
| Flush pending renders in a test | No spine flush-views!. Classic Reagent uses Reagent's own r/flush! / act harness; reagent-slim ships its own reagent2.dom.client/flush-views!. |
(uix-adapter/flush-views!) — wraps React's act() (per-adapter-require entry point). |
(helix-adapter/flush-views!) — wraps React's act() (same surface as UIx). |
reg-view macro |
Available (canonical view-registration surface). | reg-view* (plain-fn) when registry addressing is needed; most components are bare defui. |
reg-view* (plain-fn) when needed; most components are bare defnc. |
All three adapters read the same React Context object (re-frame.adapter.context/frame-context in core), so a mixed-substrate frame-provider chain composes — a UIx subtree under a Reagent provider (or vice versa) resolves the same frame.
Unified call shape (rf2-7kii2).
frame-provider's children-passing is now consistent across substrates. Reagent takes trailing-positional hiccup children; UIx and Helix take native$trailing children —($ frame-provider {:frame :f} ($ header) ($ main))— exactly the shape every other UIx/Helix component uses. The nativedefui/defncshells read children off the:childrenkey their element macro folds the trailing args onto (UIx'sglue-args, Helix'sextract-cljs-props), so there is no:children-in-props-map key for an author to forget — the former silent-drop footgun is eliminated by construction. (Previously UIx/Helix required($ frame-provider {:frame :f :children […]}); Mike-ruled A on rf2-7kii2 unified onto the trailing form.)
Subscription topology vs subscription tracking¶
A subtle distinction worth pulling out: the static topology of the sub graph is core; the runtime tracking is adapter.
The topology is "what depends on what" — the static :<- chain you can derive from registrations alone, without running any code. (rf/sub-topology) returns this graph as data, shaped {sub-id {:inputs [<input-sub-ids>] :doc :ns :line :file}} per 002 §The public registrar query API. :inputs is always present (empty for layer-1 / direct-app-db subs) and lists the upstream sub-ids in declaration order; :doc and the source-coord keys are present when the registration carries them. JVM-runnable. No adapter needed.
sub-topology is a literal projection of the registrar — it does not validate the resulting graph. Cycle detection, "this :<- references an unregistered sub", and similar diagnostics are debugger / tool-pair concerns that traverse the returned map; the topology query itself reports verbatim what was registered. (Cycles in :<- are not legal at runtime — the resolved sub will throw — but the topology query stays a static projection.)
The tracking is "when source X changes, recompute everyone who depends on X" — the runtime mechanism that makes views update reactively. This requires the adapter's make-derived-value and is substrate-specific.
In CLJS dev-mode tests, you often want sub computation without tracking: (compute-sub [:total] db-value) runs the sub's body against a static app-db value and returns the computed result. Pure function. No Reagent, no reactions. This is the "JVM-runnable" path that 008-Testing and 011-SSR use.
Lazy-seq deref tracking (Reagent adapter)¶
The Reagent adapter (and any React-shaped adapter whose render-time deref tracking uses a thread-local / dynamic-var reactive scope) only watches @(rf/subscribe …) derefs that fire while the parent reg-view's render-fn is on the stack. A (for [x xs] [child …]) form returns a lazy seq; if the seq is still unrealised at the moment the render-fn returns, every deref hiding in its body fires later — when React eventually walks the hiccup — at which point the reactive scope is gone and Reagent doesn't register the dependency. Symptom: the app-db slot flips, the sub recomputes, the view does NOT re-render until an external repaint forces a fresh render-pass. Reagent surfaces the case with a console warning at render time:
The fix is to realise the seq inside the render-fn so derefs reachable through it fire while the reactive scope is still live. Three idiomatic shapes, pick whichever reads cleanest at the call site:
;; (1) doall — minimum change, keeps the (for …) shape
(doall (for [row @some-sub] [row-view row]))
;; (2) mapv — eager vector; reads well when no :when / :let / :while
(mapv row-view @some-sub)
;; (3) into … with-transducer or fragment — eager, composes with siblings
(into [:<>] (map row-view) @some-sub)
Pure helpers called from inside the seq's body inherit the same rule: any @(rf/subscribe …) reachable transitively from a function call (not a [component args] Reagent component-vector — those get their own reactive scope when React mounts them) MUST be reachable through a realised seq. Reagent components ride their own reactive scopes; raw render helpers ride the parent's. The audit shape is "follow every plain-fn call inside a (for …) body; if any of them — directly or via further helpers — derefs a sub, the for MUST be realised".
This is a Reagent-substrate concern, not a core-framework one. Non-React substrates that wire reactivity through hooks (UIx, Helix) use use-subscribe per-call-site, which captures the dependency at hook-call time regardless of when the surrounding seq realises — they are immune to the lazy-seq trap by construction. Core's compute-sub is pure and orthogonal: no tracking, no scope.
SSR-specific behaviour¶
Per 011, the server-side render path doesn't use the adapter's reactivity machinery at all. The flow:
- Server creates a frame (per 002 §reg-frame).
- The frame's
app-dbis a plain atom (the core's plain-atom adapter, not the Reagent adapter). :on-createevents run; the drain settles.- The view fn is called as a plain function against the now-stable
app-dbvalue. - The hiccup output is rendered to a string by
render-to-string.
No Reagent. No React. No reactivity. Pure data → pure data → string.
The adapter that the core uses on the server is the plain-atom adapter (or "headless adapter"). The CLJS reference ships this alongside the Reagent adapter; the runtime picks based on platform.
CLJS reference scope¶
The CLJS reference ships across multiple Maven artefacts (per Conventions §Adapter shipping convention):
day8/re-frame2— the substrate-agnostic core (the registrar, the drain, the dispatch envelope, the trace stream, sub topology, sub computation, effect-map interpretation) plus the adapter API contract, the plain-atom (headless) adapter used by SSR and headless tests, and (per rf2-3yij Decision 2) the shared React frame Context object atre-frame.adapter.contextthat every React-shaped adapter consumes.day8/re-frame2-reagent— the Reagent adapter (browser default).day8/re-frame2-uix— the UIx adapter (rf2-3yij). Targets UIx 2.x; ships theuse-subscribehook (Decision 1), theflush-views!test-flush helper (Decision 6), a source-coord wrapping component (Decision 5), and aframe-providerconsuming the shared React context (Decision 2). Apps written for UIx callreg-view*(plain-fn) directly — thereg-viewmacro stays Reagent-flavoured per Decision 4.day8/re-frame2-helix— the Helix adapter (rf2-2qit). Targets Helix 0.2.x; ships the sameuse-subscribehook,flush-views!test-flush helper, source-coord wrapping component, and shared-contextframe-provideras the UIx adapter. Apps written for Helix callreg-view*(plain-fn) directly — thereg-viewmacro stays Reagent-flavoured per Decision 4. The eight UIx decisions transferred unchanged because Helix and UIx share the React + hooks substrate model.
In the CLJS reference repository the three adapter sources live under implementation/adapters/<name>/ — implementation/adapters/reagent/, implementation/adapters/uix/, implementation/adapters/helix/. Per-feature artefacts (schemas, machines, routing, flows, http, ssr, epoch) stay flat under implementation/<name>/. The directory split surfaces the adapter-vs-per-feature distinction in the layout — adapters implement the §adapter API contract; per-feature artefacts plug into core via the late-bind hook table per Conventions §Independence rule. Maven artefact names are unchanged across the move per; per the directory is adapters/, not substrates/ — "substrate" names the abstract contract, "adapter" names each implementation.
Per-host adapters for non-CLJS implementations ship as separate packages, implementing the same contract — the per-adapter-artefact pattern is JS-cross-compile-language-agnostic across the eight in-scope hosts (TypeScript-React, Fable.React / Feliz, scalajs-react / Slinky, React.Basic, kotlin-react, ReasonReact, Melange-React, Squint-with-React). All ship a React-binding adapter; non-React substrates are out of scope per §Abstract.
Open questions¶
SA-4 classification. Per SPEC-AUTHORING §SA-4: "Cooperative rendering substrate" classifies as
:post-v1 tracked(tracked at — deferred to a later cycle's benefits-vs-cost evaluation); "Multi-adapter coexistence" classifies as:post-v1 tracked(tracked at — additive on the v1 single-adapter contract once a concrete use case emerges).
Cooperative rendering substrate (post-v1)¶
A cooperative rendering substrate — a rendering layer designed natively to cooperate with re-frame, instead of re-frame wrapping Reagent — is on the horizon. Substrate-agnostic decoupling (this Spec) is the prerequisite. Whether the cooperative variant ships depends on a benefits-vs-cost evaluation in a later cycle. Deferred to.
Post-v1 Tracking¶
- Foundation in v1. The adapter contract (per §The adapter API contract) is the substrate-decoupling primitive — any cooperative variant ships as another adapter, no core change required.
- Scope deferred. The evaluation itself: identifying the cooperation primitives a native substrate could expose (e.g., scheduler-aware re-render coalescing, subscription-graph-driven scheduling, batched view updates aligned to drain boundaries), and the benefits-vs-cost ledger against staying with Reagent / UIx / Helix adapters.
- Reconsideration trigger. Either (a) measured re-render overhead in the Reagent path becomes the dominant cost on a real workload, or (b) a tool (xray / re-frame2-pair / story) needs scheduling hooks the React substrates can't surface.
- Out of scope for the bead. Building the cooperative substrate itself — the bead tracks the decision, not the implementation. A separate bead is filed if the evaluation lands "yes".
Multi-adapter coexistence (post-v1)¶
The current contract is single-adapter-per-process. If a concrete use case for per-frame adapter selection emerges, multi-adapter support can be added additively without breaking the single-adapter contract. Deferred to.
Post-v1 Tracking¶
- Foundation in v1. The single-adapter contract (per §Single adapter per process) is locked; per-frame adapter selection is an extension, not a replacement — the install slot becomes a map keyed by frame-id rather than a singleton.
- Scope deferred. The lifting itself: dispatch envelope carrying the in-scope adapter, registrar / tool branching on which adapter a frame uses, error categories for cross-frame view mounts that span adapters.
- Reconsideration trigger. A concrete app use case — e.g., a single process embedding a Reagent host alongside a UIx subtree, both backed by re-frame, where running them as separate processes is infeasible.
- Out of scope for the bead. Multi-adapter within a single frame (one view tree mixing adapters) — that path is rejected per §Single adapter per process's reasoning and is not on the post-v1 ledger.
Resolved decisions¶
Adapter selection¶
Per (replaces; resolves) the consumer passes an adapter spec map explicitly to (rf/init! adapter-map). There is no default-adapter registry. Each adapter namespace exports an adapter Var; consumers require the namespace and pass the Var.
See §Adapter selection at boot above for the boot-time wiring, the legal call shapes, and the rationale (explicit > implicit; bundle-size; no implicit cross-adapter coupling).
Re-installing after frames exist is an error (:rf.error/adapter-already-installed trace event; recovery: :no-recovery, the call is rejected).
Other-language ports follow the same pattern: each adapter package exports a public adapter spec; the consumer requires the package and passes the spec to the language's init! equivalent.
Adapter introspection¶
Two complementary accessors:
-
(rf/current-adapter)returns a discriminator keyword identifying the active adapter (the:kindslot of the installed adapter spec map), ornilif no adapter is installed. Canonical values live under the:rf.adapter/*reserved namespace (per Conventions §Reserved namespaces,) so third-party adapters can publish their own unqualified:kindkeywords without collision risk: -
:rf.adapter/reagent— CLJS browser default (bridge adapter) :rf.adapter/reagent-slim— CLJS browser, slim adapter (no stock-Reagent dep):rf.adapter/uix— CLJS browser, UIx substrate:rf.adapter/helix— CLJS browser, Helix substrate:rf.adapter/plain-atom— CLJS JVM headless / tests / Node-based CLJS:rf.adapter/ssr— CLJS JVM SSR (re-frame.ssr adapter)-
:custom— user installed a custom adapter that didn't pick one of the canonical kinds -
(rf/current-adapter-spec)returns the installed adapter spec map (the value passed to(rf/init! ...)), ornilif no adapter is installed. This is the map carrying the contract fns (:make-state-container,:replace-container!,:make-derived-value, …) plus the:kinddiscriminator.
Use current-adapter for predicate / branch code ("what substrate am I on?"); use current-adapter-spec for tool code that needs the adapter fn handles, or for identity checks across the install/dispose lifecycle.
Tools (10x, re-frame-pair) use the keyword to branch on host capabilities — for instance, the time-travel UI is meaningful in browser-Reagent but not in plain-atom.
The keyword is informational. Behaviour-affecting decisions should be based on :platforms metadata (per 011 §S-3) or on explicit configuration, not on which adapter is loaded.
Disposed-vs-never-installed¶
Runtime delegation calls (make-state-container, read-container, replace-container!, make-derived-value, render, render-to-string, subscribe-container, register-context-provider, flush-render!) raise a structured ex-info when no adapter is installed. The throw shape distinguishes two states:
:rf.error/no-adapter-installed— fresh process, no(rf/init! …)has fired yet. Recovery: install an adapter.:rf.error/adapter-disposed— an adapter was previously installed and torn down by(rf/destroy-adapter!)without a subsequent install. Recovery: install a fresh adapter. Common in test fixtures and hot-reload flows.
A disposed-breadcrumb (boolean) is set by destroy-adapter! and cleared by the next successful install-adapter!. Both states leave the install slot empty so a fresh adapter can install without a slot collision.
(rf/adapter-disposed?) returns the breadcrumb's value as a read-only predicate for tools and test harnesses that want to assert the lifecycle state without provoking a throw.
Single adapter per process¶
One adapter per process. Frames within a process all use the same adapter.
Reasons:
- Per-frame adapter selection adds complexity in the runtime, the registry, and the dispatch envelope (which adapter's reactivity is in scope?).
- The use cases people propose for multi-adapter (headless tests inside a browser app; mixed Reagent and UIx) are better served by separate processes (test JVMs, separate apps) or by the existing
compute-subheadless path (no reactivity at all).
Re-installing an adapter after frames exist is rejected (per Adapter selection above).
Cross-references¶
- 000 §Substrate decoupling — the framework-level commitment to substrate decoupling.
- 011-SSR.md — SSR uses the plain-atom adapter on the JVM.
- 008-Testing.md — the headless-test path uses the plain-atom adapter.
- 002-Frames.md — frames are the core's primary structure; the adapter holds their
app-dbcontainers. - 004-Views.md — view rendering is the adapter's job.