Spec 012 — Routing¶
Routing is part of Goal #8 (Real SPA concerns are first-class) per 000-Vision.md.
Abstract¶
Routing is state plus events, not a separate subsystem. The URL is a derivable view of app-db; navigation is an event. Browser back/forward, deep links, and SSR all flow through this single contract.
The principle: routing does not get its own runtime. It uses the runtime that already exists — frames, events, subs, app-db. A route table is data; routes are registry entries; :rf.route/navigate is an event; (rf/sub :rf/route) derives the active route from app-db. Nothing new at the foundation level.
Normative surface inventory¶
The complete routing API surface, for quick audit. Each entry links to its normative definition below.
Registration¶
reg-route— registers a route. Reserved metadata keys::doc,:path(required),:params,:query,:query-defaults,:query-retain,:tags,:parent,:on-match,:on-error,:scroll,:can-leave. See §Reserved route-metadata keys and §Navigation blocking — pending-nav protocol for:can-leave. Returns itsidargument per the family-widereg-*return-value convention.- Path-pattern grammar — five productions (literal, named param, optional segment group, splat, root). See §Path-pattern grammar.
- Route ranking — six-rule cascade for resolving overlapping matches. See §Route ranking algorithm.
app-db slices¶
All routing state lives under [:rf/runtime :routing] (per Conventions §Reserved app-db keys):
- Route slice at
[:rf/runtime :routing :current]—{:id :params :query :fragment :transition :error :nav-token}. Schema:rf/route-slice. Consumer-facing sub-id:rf/route. See §The:rf/routeslice. - Pending-nav slot at
[:rf/runtime :routing :pending-navigation]— populated when a:can-leaveguard rejects. Schema:rf/pending-navigation. Sub-id:rf/pending-navigation. See §Navigation blocking — pending-nav protocol. - Scroll-position LRU at
[:rf/runtime :routing :scroll-positions]+[:rf/runtime :routing :scroll-positions-order]— see §Scroll restoration. - Routing counters at
[:rf/runtime :routing :nav-token-counter]+[:rf/runtime :routing :pending-nav-counter]— internal monotonic counters; not part of the consumer-facing sub surface.
Events¶
Audience column: user = an event apps dispatch or handle directly; runtime = an internal plumbing event the runtime fires at itself, sub-namespaced under :rf.route.internal/* so the user-facing :rf.route/* surface stays tidy. Apps and tools should never dispatch :rf.route.internal/* events. The same audience-split principle scopes :rf.route.nav-token/* and :rf.machine.internal/*.
| Event | Audience | Purpose | Source |
|---|---|---|---|
:rf.route/navigate |
user | Programmatic navigation. | §Navigation is an event |
:rf.route/handle-url-change |
user | Default handler for URL change (popstate / initial load / SSR). | §URL changes are events |
:rf.route/transitioned |
user | Fired by the runtime on every URL transition. | §Standard runtime events |
:rf/url-requested |
user | Fired by route-link and equivalent. Decides internal vs external navigation. |
§Standard runtime events |
:rf.route/navigation-blocked |
user | Dispatched by the runtime when a :can-leave guard rejects. |
§Navigation blocking — pending-nav protocol |
:rf.route/continue |
user | User-dispatched: confirm pending navigation. | §Navigation blocking — pending-nav protocol |
:rf.route/cancel |
user | User-dispatched: cancel pending navigation. | §Navigation blocking — pending-nav protocol |
:rf.route.internal/settle-transition |
runtime | Runtime-fired after the :on-match drain to land :transition at :idle. Nav-token-guarded. |
§Per-route data loading |
:rf.route.internal/on-match-error |
runtime | Runtime-fired by the corpus-wide error-emit listener when an :on-match event throws; flips :transition :error, populates :rf.route/error, chains :on-error. Nav-token-guarded. |
§Per-route error handling |
Effects (reg-fx)¶
| Fx | Purpose | Platform |
|---|---|---|
:rf.nav/push-url |
pushState for the URL. |
:client |
:rf.nav/replace-url |
replaceState for the URL. |
:client |
:rf.nav/scroll |
Apply a scroll strategy. Args carry {:strategy :from :to :saved-pos :fragment}. |
:client |
:rf.route/with-nav-token |
Threads :nav-token into a downstream dispatch for stale-result suppression. |
universal |
Subscriptions¶
| Sub | Returns |
|---|---|
:rf/route |
The current route slice (:id :params :query :fragment :transition :error :nav-token). |
:rf.route/id |
The active route id. |
:rf.route/params |
Path params. |
:rf.route/query |
Query params. |
:rf.route/fragment |
The URL #fragment or nil. |
:rf.route/transition |
:idle / :loading / :error. |
:rf.route/error |
Structured error map when :transition = :error. |
:rf.route/chain |
The :parent-chain of the active route. |
:rf/pending-navigation |
The pending-nav slot, or nil. |
Pure helpers¶
(rf/match-url url)→{:route-id :params :query :fragment :validation-failed?}ornil. Pure; JVM- and CLJS-runnable.(rf/route-url route-id path-params [query-params [fragment]])→ URL string. Pure; JVM- and CLJS-runnable.
Frame-level configuration¶
:url-bound?onreg-framemetadata (default true for:rf/defaultonly). Only one frame may own the URL. See §Multi-frame routing.
Schemas registered with the framework¶
:rf/route-pattern— path-pattern grammar (see Spec-Schemas.md).:rf/route-rank— structural rank tuple (see Spec-Schemas.md).:rf/route-slice— the:rf/routeslice shape (see Spec-Schemas.md).:rf/pending-navigation— the pending-nav slot shape (see Spec-Schemas.md §:rf/pending-navigation).
Trace events¶
Defined per the 009 Error contract:
:rf.route/registered— first-timereg-route. Re-registration rides the cross-kind:rf.registry/handler-replacedtrace; not re-emitted here. Mirrors the:rf.flow/registeredsymmetry.:rf.route/cleared— explicitunregister-route!. Mirrors the:rf.flow/clearedsymmetry.:rf.route/activated/:rf.route/deactivated— fire on every cross-route navigation commit, indeactivated → activatedorder. Same-id navigation (path/query change with no route-id shift) emits neither. First-ever navigation emits:rf.route/activatedonly (no prior route). Per.:rf.route.nav-token/allocated— fresh nav-token cascade begins.:rf.route.nav-token/stale-suppressed— async result carrying a now-superseded token.:rf.route/fragment-changed— fragment-only URL update (the URL changed only in its#fragment;:on-matchdid not re-fire). Distinct from the runtime event:rf.route/transitioned, which fires on every URL transition. The op-name says what fires it (only a#fragmentdiffered) and disambiguates from the runtime event.:rf.route/navigation-blocked—:can-leaveguard rejected a navigation.:rf.error/can-leave-non-boolean—:can-leavesub returned a non-boolean value; the runtime BLOCKED the navigation. Closed contract; see §Navigation blocking — pending-nav protocol.:rf.error/duplicate-url-binding— second frame attempted:url-bound? truewhile another already owns the URL.:rf.error/invalid-route-metadata—reg-routewas passed a bare metadata key outside the reserved set (a likely typo), or non-map metadata. Thrown at registration (caller bug; dev and prod). Names the offending:keysand the:reservedvocabulary. See §Authoring-boundary key validation.:rf.error/navigate-arity-misuse—[:rf.route/navigate target params opts]was dispatched with an opts-only key (:replace?/:scroll/:fragment/:bypass-leave-guard?) in the params slot that the target route does not declare as a path-param (the classic params/opts swap). Navigation rejected;:where :event. See §Arities — params is 2nd, opts is 3rd.:rf.warning/route-shadowed-by-equal-score— registration-time warning when ranking ties on rule 6.:rf.warning/no-not-found-route— runtime fell back to the built-in placeholder because:rf.route/not-foundis not registered (per §Route-not-found).
Pattern-level contract¶
The route table is data¶
A route is a (kind :route, id keyword) registry entry whose metadata describes its URL pattern, params, and any constraints. Routes register exactly like any other kind:
(rf/reg-route :route/home
{:doc "The landing page."
:path "/"})
(rf/reg-route :route/cart
{:doc "The cart page."
:path "/cart"})
(rf/reg-route :route/cart.item-detail
{:doc "Detail page for a single cart item."
:path "/cart/items/:id"
:params [:map [:id :uuid]]})
(rf/reg-route :route/article
{:doc "An article. Optional slug suffix."
:path "/articles/:id{/:slug}?"
:params [:map [:id :uuid] [:slug {:optional true} :string]]})
(rf/reg-route :route/files
{:doc "A files browser; matches /files and any sub-path."
:path "/files/*rest"
:params [:map [:rest :string]]})
Path-pattern grammar (canonical)¶
The :path value is a string in the canonical path-pattern grammar below. The grammar is part of the pattern contract, not implementation-specific. Every conforming implementation parses and emits this grammar; conformance fixtures (routing-match-url.edn, routing-navigate.edn) assume it.
The grammar is deliberately small. Five productions:
| Production | Syntax | Example | Captures |
|---|---|---|---|
| Literal segment | /text |
/articles |
nothing |
| Named param | /:name |
/articles/:id |
{:id "..."} (string by default; coerced via :params schema) |
| Optional segment group | {/:name}? or {/literal}? |
/articles/:id{/:slug}? |
param present only if matched |
| Catch-all (splat) | /*name |
/files/*rest |
{:rest "everything/after"} (string, includes embedded /) |
| Root | / |
/ |
{} |
Rules:
- Param names are unqualified keywords on the consumer side; in the pattern string they are bare identifiers (
:id, not::feature/id). A route's:paramsschema ([:map [:id :uuid]]) names the same key. - Optional groups wrap a slash-prefixed sub-pattern in
{...}?. They may contain literal segments, named params, or both. Nested optional groups are not part of the grammar. - Splats must be the final segment of the path. The captured value is a single string (slashes preserved). At most one splat per pattern.
- Trailing slashes are normalised away by
match-urlbefore matching:/cartand/cart/resolve to the same match.route-urlemits patterns without a trailing slash (except for the root pattern/). - Case is preserved as written; matching is case-sensitive by default. Implementations may offer a per-route
:case-insensitive? trueopt; the conformance corpus assumes case-sensitive matching. (The CLJS reference implementation deliberately does not implement the optional:case-insensitive?opt — match regexes are always built case-sensitively; themaykeeps the door open for hosts that need it.) - Reserved characters (
:,*,{,},?) inside literal segments must be percent-encoded in the path string;match-urlURL-decodes captured param values before they reach the handler. - The grammar does not encode query-string or fragment binding; see "Query strings and fragments" below for those.
The grammar is a small subset of common path-pattern syntaxes — straightforwardly implemented in any host (no parser-combinator library required). It is also a strict subset of RFC 6570 Level 1 plus a splat extension, which keeps it familiar to non-Clojure ecosystems. Other-language ports parse the same strings.
A canonical schema for path patterns is registered as :rf/route-pattern (see Spec-Schemas.md). Tooling can validate patterns at registration time.
Data-form path patterns (per host): the same grammar can be expressed as a vector of segment values —
[:files [:* :rest]]is the data-form of/files/*rest. This is the natural form in hosts without a string-parser library, and lines up with Principles.md §Data is code. Ports are required to support the string grammar above; hosts may additionally accept a data form whose semantics are equivalent.
Route ranking algorithm¶
When more than one registered route can match the same URL, match-url MUST resolve the conflict using the 6-rule ranking cascade below. The cascade is part of the pattern contract — every conforming implementation produces the same winner for the same registrations and URL. Without this lock, two implementations of match-url can both be "reasonable" and still disagree, defeating the cross-host conformance bar.
Ranking rules, evaluated in order. The first rule that distinguishes the candidates wins; later rules are only consulted on ties.
- More static segments beat fewer. Count the literal (non-param, non-splat) segments in each candidate's
:path. Higher count wins./users/me(2 statics) beats/users/:id(1 static) for/users/me. - Among equally-static-counted matches, longer paths beat shorter. Total segment count breaks the tie on equal static-count.
/users/:id/editbeats/users/:idfor/users/abc/edit. - Named params beat rest params. A
:namesegment is more specific than a*namesplat./files/:namebeats/files/*restfor/files/x. - Rest params beat catch-all/not-found. A
*restsegment is more specific than a top-level catch-all/*(or a registered:rf.route/not-found)./files/*restbeats/*for/files/x/y. - Exact routes beat optional-group routes. A pattern with no
{...}?group is more specific than a pattern that matches the same URL only by virtue of an optional group./aboutbeats/{:base}?/aboutfor/about. - Registration order is the final tiebreak only if every structural score is equal. When two routes are structurally indistinguishable (same statics, same length, same params/splats, same optional groups), the route registered first wins. This case is discouraged — implementations MUST emit a
:rf.warning/route-shadowed-by-equal-scorewarning at registration time when a new route is added and an existing route has an equal structural score on the same URL family. Tooling and AI scaffolds use the warning to flag potential conflicts.
The cascade is structural — the score is computable from each pattern's parsed shape; no URL is needed to compute it. Implementations may pre-compute every registered route's score at registration time and rank candidates by score on each match-url call.
;; Pseudocode
(defn route-rank [pattern]
(let [segments (parse-segments pattern)
static-count (count (filter literal? segments))
total-length (count segments)
has-splat? (some splat? segments)
has-optional? (some optional-group? segments)
is-catch-all? (= pattern "/*")]
;; Higher score = more specific. Tuple compares lexicographically.
[static-count ;; rule 1
total-length ;; rule 2
(if has-splat? 0 1) ;; rule 3 — named params beat splats
(if is-catch-all? 0 1) ;; rule 4 — rest beats catch-all
(if has-optional? 0 1) ;; rule 5 — exact beats optional-group
;; rule 6 — registration order — applied externally as a stable sort
]))
(defn match-url [url]
(->> (registered-routes)
(filter #(pattern-matches? % url))
(sort-by route-rank #(compare %2 %1)) ;; descending; rule 6 stable-sort
first
;; ... extract params, query, validate ...
))
:rf/route-rank is registered as a spec-internal schema (see Spec-Schemas.md) so tooling can read each route's rank vector via (rf/handler-meta :route route-id) (under a :rf.route/rank slot the registrar attaches at registration time).
Conformance. Fixture route-ranking-precedence.edn exercises every cascade rule with deliberately-overlapping registrations and asserts the same winner across implementations. Hosts that register routes in a different internal order MUST sort by registration time (the order user code called reg-route) for rule 6, not by hash-map iteration order.
Why this is correctness, not polish. Without a defined ranking, a CLJS implementation and a JS implementation of match-url can each be self-consistent and still disagree on which route wins for /users/me when both /users/me and /users/:id are registered. The conformance corpus depends on a single deterministic answer; ranking is the lock.
Other pattern-level requirements¶
- The path is parseable both ways: a path-pattern matched against an incoming URL produces a params map; a route-id + params map produces a URL.
- The params shape is described by the host's idiom (Malli for CLJS dynamic; types for static hosts; per 000-Vision.md on the schema/type duality).
- Routes are stably-id'd, queryable via
(rf/registrations :route), source-coordinated. - Route metadata is an open map. The pattern reserves a small set of keys (see "Reserved route-metadata keys" below); hosts and applications may add their own keys (e.g.
:myapp/analytics-id,:myapp/layout) under a chosen namespace. Interceptors, guards, layouts, and analytics tooling read those keys via(rf/handler-meta :route route-id).
Reserved route-metadata keys¶
The pattern reserves twelve keys on reg-route's metadata map. All are optional except :path. This is the largest registration shape in the v2 surface — for context, reg-flow carries six keys total (013 §The registration shape) and reg-event-fx reserves only the cross-kind registration metadata. The scale is justified by the cross-cutting concerns routing absorbs (URL ↔ params, query/path separation, lifecycle hooks at navigation boundaries, layout chains, scroll behaviour) but the keys do not cluster naturally as one flat list. The three axes below name the clusters so generators reading "what does reg-route accept?" can branch on intent rather than scan twelve docstrings.
The three axes¶
The twelve keys cluster into three axes by what each key controls:
| Axis | Keys | What it controls |
|---|---|---|
| Shape — URL ↔ params binding | :path, :params, :query, :query-defaults, :query-retain |
What URLs match this route and how their parts coerce into a params/query map. The contract surface that match-url and route-url agree on. |
| Lifecycle hooks — events the runtime dispatches at navigation boundaries | :on-match, :on-error, :can-leave |
Events the runtime fires on route activation (:on-match), on :on-match errors (:on-error), and a sub-id consulted before navigation away (:can-leave). These are the route's reactive surface — handlers run from app code, the runtime owns the dispatch points. |
| Layout — how the route fits with neighbours | :doc, :parent, :tags, :scroll |
How the route is described (:doc), composed with others (:parent chains layout shells; see §Nested layouts), grouped for interceptors (:tags), and visually transitioned (:scroll; see §Scroll restoration). |
The axes are documentation, not data structure — the keys remain flat on the metadata map. An earlier sketch (audit Finding 1) considered nesting lifecycle hooks under :hooks {...}; v1 keeps the flat shape because (a) the registration metadata is read by (rf/handler-meta :route id) and tools enumerate top-level keys; nesting would require every consumer to know the nesting; (b) the v1 surface is settled, a nested shape is a v2.x candidate at most. The cluster headings are the carry — a generator scaffolding a route picks the axis first, then the keys.
Authoring-boundary key validation¶
Because reg-route carries the largest shape in the surface, a typo'd key (:on-matched for :on-match, :querey for :query) would otherwise be silently accepted and fail later at nav-time, or never — a silent-swallow that costs a debugging session. reg-route therefore validates the metadata at the authoring boundary: a bare (unqualified) key outside the twelve reserved keys is rejected at registration with a thrown :rf.error/invalid-route-metadata (canonical thrown-error shape per 009 §The thrown-error shape; :where 'rf/reg-route), whose :keys slot names every offending key and :reserved slot carries the valid vocabulary. This is a caller bug, so it throws in dev and prod (it is not user input). Non-map metadata is rejected the same way, naming the route.
Namespaced keys are exempt. Route metadata is an open map for host/app extension keys, but only under a namespace (:myapp/analytics-id, :myapp/layout) — see §Other pattern-level requirements. Bare keys are the framework's reserved vocabulary; namespaced keys are the extension surface. The split makes the typo case (a bare key) distinguishable from the extension case (a namespaced key) without a registry of permitted host keys.
Cross-feature reserved keys. A small number of bare keys are reserved by other framework features that extend route metadata — currently :head, owned by SSR (011 §Head/meta contract: "routes name which head to use via :head route metadata"). These pass the guard because the framework owns them, even though they are not among the twelve routing-owned keys above. The accepted-key set is therefore the routing-owned twelve plus the enumerated cross-feature keys; a new framework feature that adds a bare route-metadata key adds it to that set.
Per-key table¶
| Key | Axis | Type | Purpose |
|---|---|---|---|
:doc |
layout | string | Human-readable description. |
:path |
shape | string (path-pattern grammar above) | The URL pattern. Required. |
:params |
shape | schema | Schema for path params (those captured by :name / *name segments in :path). |
:query |
shape | schema | Schema for search/query params (key-value pairs after ?). Distinct from :params. See "Query strings and fragments". |
:query-defaults |
shape | map | Default values for query-string keys when absent from the URL. Applied during match-url. See "Query strings and fragments". |
:query-retain |
shape | set of keywords | Query-string keys that should be carried through subsequent navigations even when the caller did not supply them. See "Query strings and fragments". |
:tags |
layout | set of keywords | User-defined tags (e.g. :requires-auth); read by interceptors. |
:parent |
layout | route-id | Parent route id (for the nested-layout convention; see "Nested layouts"). |
:on-match |
lifecycle | vector of event vectors | Events the runtime dispatches every time this route becomes the active route (server- and client-side). See "Per-route data loading". |
:on-error |
lifecycle | event vector | Event the runtime dispatches if any :on-match event errors. See "Per-route error handling". |
:can-leave |
lifecycle | sub-id | A subscription whose value (boolean) gates navigation away from this route. Strict contract: true allows, false blocks, any other value blocks AND emits :rf.error/can-leave-non-boolean. See §Navigation blocking — pending-nav protocol. |
:scroll |
layout | enum or map | Declarative scroll behaviour on entering this route. See "Scroll restoration". |
The :rf/route slice¶
The runtime maintains the route slice in app-db at [:rf/runtime :routing :current] (per Conventions §Reserved app-db keys):
{:rf/runtime
{:routing
{:current
{:id :route/article ;; current route id
:params {:id #uuid "..."} ;; path params (matches :params schema)
:query {:q "clojure" :page 2} ;; query/search params (matches :query schema)
:fragment "section-2" ;; URL #fragment, or nil; see "Fragments" below
:transition :idle ;; :idle | :loading | :error
:error nil ;; populated when :transition = :error
:nav-token "nav-42"}}}} ;; per-navigation epoch token; see "Navigation tokens"
The framework reg-sub [:rf/route] reads against [:rf/runtime :routing :current] and is the supported consumer surface (apps subscribe via (rf/sub :rf/route); the sub-id remains :rf/route for API stability).
:params and :query are separate maps. Path params come from segments captured by the :path grammar; query params come from the ?key=value portion of the URL. They are validated by separate schemas (:params and :query on reg-route). Consumers that prefer a single merged map can build one in a derived sub.
:fragment carries the URL #fragment part (per "Fragments" below). :nav-token is the runtime-allocated navigation epoch (per "Navigation tokens — stale-result suppression" below). Both are runtime-managed; user code reads them through subs.
:transition is a tiny FSM driven by the runtime: :idle when no navigation is in flight; :loading while the active route's :on-match events are draining; :error if any :on-match event errors. See "Per-route data loading" and "Per-route error handling" below.
A canonical schema for the slice is registered as :rf/route-slice (see Spec-Schemas.md).
Navigation is an event¶
(rf/reg-event-fx :rf.route/navigate
{:doc "Navigate to a registered route."
:schema [:cat [:= :rf.route/navigate] [:or :keyword [:map [:url :string]]]
[:? :map] ;; params
[:? :map]]} ;; opts
(fn handler-route-navigate [{:keys [db]} [_ target params opts]]
(let [{:keys [route-id path-params query-params fragment]} (resolve-target target params opts db)
route-meta (rf/handler-meta :route route-id)
url (rf/route-url route-id path-params query-params fragment)
push-fx-id (if (:replace? opts) :rf.nav/replace-url :rf.nav/push-url)
nav-token (rf/gen-nav-token)]
{:db (-> db
(assoc-in [:rf/runtime :routing :current]
{:id route-id
:params path-params
:query query-params
:fragment fragment
:transition (if (seq (:on-match route-meta)) :loading :idle)
:error nil
:nav-token nav-token}))
:fx (into [[push-fx-id url]
[:rf/trace [:rf.route.nav-token/allocated {:route-id route-id :nav-token nav-token}]]
(when-let [scroll (resolve-scroll route-meta opts fragment)]
[:rf.nav/scroll scroll])]
;; per-route :on-match dispatches (see "Per-route data loading")
(for [ev (:on-match route-meta)]
[:dispatch ev]))})))
Three effect categories flow:
1. app-db's :rf/route slice is updated (id, params, query, fragment, transition, nav-token).
2. The browser URL is pushed via :rf.nav/push-url (a registered fx; :platforms #{:client}), or replaced via :rf.nav/replace-url when opts has :replace? true.
3. The route's :on-match events (if any) are dispatched, and the route's :scroll strategy (if any) is emitted as a :rf.nav/scroll effect.
The order is locked: state changes first, URL update second, then :on-match dispatches and scroll effect. If the URL update fails (browser denies, user is offline) the state is still consistent.
The trailing opts map is open. The pattern recognises:
- :replace? (use replaceState rather than pushState — for redirects, search-as-you-type filters, login-flow returns where the back button should not return to the intermediate URL).
- :scroll (per-call override of the route's :scroll metadata; same enum/map shape, see "Scroll restoration").
- :fragment (target #fragment for the new URL; see "Fragments" below). May also be supplied as :fragment on the target map.
- :bypass-leave-guard? (skip the active route's :can-leave gate for this navigation; see §Navigation blocking).
- Hosts may add their own keys under a chosen namespace.
Arities — params is 2nd, opts is 3rd¶
The event vector has two trailing maps in fixed positions — params second, opts third — which are positionally ambiguous to a reader. The three arities are:
[:rf.route/navigate target] ;; no path-params, no opts
[:rf.route/navigate target params] ;; PATH-PARAMS in the 2nd slot; opts absent
[:rf.route/navigate target params opts] ;; path-params 2nd, OPTS THIRD
At a call site [:rf.route/navigate :route/x {...}] the {...} is path-params, not opts. The classic mistake is dropping an opts-shaped map into the params slot — [:rf.route/navigate :route/cart {:replace? true}] reads like "navigate with these opts" but is parsed as "navigate with path-param :replace?". The runtime rejects this swap: when an opts-only key (:replace?, :scroll, :fragment, :bypass-leave-guard?) appears in the params slot and is not a declared path-param of the target route, navigation is rejected (slice unchanged, no URL push) and :rf.error/navigate-arity-misuse (:where :event) is emitted naming the misplaced key. A route that legitimately captures a path segment named :fragment ("/anchor/:fragment") is not false-flagged — the key is a declared path-param, not a misplaced opt. To pass opts, use the explicit three-arity form with an empty (or real) params map: [:rf.route/navigate :route/cart {} {:replace? true}].
Target form — route-id vs URL-string¶
:rf.route/navigate's second arg is one of two forms (per the schema's [:or :keyword [:map [:url :string]]]):
- Route-id (canonical):
(rf/dispatch [:rf.route/navigate :route/cart {:cart-id 42}]). The runtime resolves the route, builds the URL viaroute-url, and pushes. This is the form Construction-Prompts and well-formed app code use. - URL-string (escape hatch):
(rf/dispatch [:rf.route/navigate {:url "/some/path"}]). For dynamic or user-supplied URLs the app didn't build itself — deep-link handlers, server-redirect targets, programmatic redirects from a string. The runtime callsmatch-urlon the string; if it resolves to a registered route, navigation proceeds normally. URL-strings that don't match any registered route resolve to:rf.route/not-foundwith the URL inparams.
The route-id form is preferred everywhere it can be used because the route-id is enumerable, refactorable, and queryable through the registrar. URL-strings are stringly-typed escape-hatchy by nature; tooling can flag them as candidates for migration to a registered route-id when the URL pattern is known.
URL changes are events¶
When the user clicks a link, presses Back/Forward, or arrives via a deep link, the runtime fires the canonical event :rf.route/transitioned (the pattern's onUrlChange analogue per Elm's Browser.application; see "Standard runtime events" below). The default handler is :rf.route/handle-url-change:
(rf/reg-event-fx :rf.route/handle-url-change
{:doc "Triggered by URL change (popstate or initial load). Sets app-db's route slice from the URL."
:platforms #{:client :server}} ;; same handler is used by SSR
(fn handler-route-handle-url-change [{:keys [db]} [_ url]]
(let [{:keys [route-id params query fragment validation-failed?]} (rf/match-url url)
route-meta (rf/handler-meta :route route-id)
prev-route (get-in db [:rf/runtime :routing :current])
fragment-only? (and prev-route
(= route-id (:id prev-route))
(= params (:params prev-route))
(= query (:query prev-route))
(not= fragment (:fragment prev-route)))
nav-token (if fragment-only?
(:nav-token prev-route) ;; fragment-only does not advance the epoch
(rf/gen-nav-token))] ;; fresh epoch
(cond
;; No match → 404 route
(nil? route-id)
{:db (assoc-in db [:rf/runtime :routing :current]
{:id :rf.route/not-found
:params {:url url}
:query {} :fragment fragment
:transition :idle :error nil
:nav-token nav-token})}
;; Validation failure → 404 (or, optionally, a configured error route)
validation-failed?
{:db (assoc-in db [:rf/runtime :routing :current]
{:id :rf.route/not-found
:params {:url url :reason :validation}
:query {} :fragment fragment
:transition :idle :error nil
:nav-token nav-token})}
;; Fragment-only change — update the slice; emit
;; :rf.route/fragment-changed trace; do NOT re-fire
;; :on-match. See "Fragments" below.
fragment-only?
{:db (assoc-in db [:rf/runtime :routing :current :fragment] fragment)
:fx [[:rf/trace [:rf.route/fragment-changed {:route-id route-id
:prev-fragment (:fragment prev-route)
:next-fragment fragment}]]]}
:else
{:db (assoc-in db [:rf/runtime :routing :current]
{:id route-id
:params params
:query query
:fragment fragment
:transition (if (seq (:on-match route-meta)) :loading :idle)
:error nil
:nav-token nav-token})
:fx (into [[:rf/trace [:rf.route.nav-token/allocated {:route-id route-id :nav-token nav-token}]]]
(for [ev (:on-match route-meta)]
[:dispatch ev]))}))))
The same handler runs on the server during SSR (no :platforms exclusion) — the request URL is fed in, the route slice is set, the view renders against it. The :on-match events also fire server-side, populating server-rendered data the same way they would client-side. No SSR-specific routing code.
Linking from views — plain-anchor semantics¶
Lock: the runtime does not auto-intercept
<a>clicks. Click interception is the host adapter's job.
| Form | Behaviour |
|---|---|
[rf/route-link {:to :route/cart} "Cart"] |
Renders <a href="..."> and intercepts plain primary-button clicks itself — its registered view body (per §Standard runtime events) calls .preventDefault and dispatches :rf/url-requested. Modifier keys (cmd-click, middle-click, shift-click) defer to the browser; the link follows the href natively. |
[:a {:href "..."} ...] (plain anchor in user view code) |
Browser-native navigation. The runtime does not intercept; clicking causes a full page load if the URL is on the same origin and an external navigation otherwise. Apps that want SPA-style interception on plain anchors install it at the host adapter layer (a top-level click listener on the document that consults match-url); the runtime's contract stops at route-link plus :rf/url-requested. |
Why the runtime doesn't auto-intercept. A global click listener that calls match-url on every link is a host concern (DOM-bound, browser-only, conflicts with non-routed <a> tags inside iframes / shadow DOM / third-party widgets). The host adapter has the context to install or skip it; the runtime stays portable.
Users who want plain anchors to be interceptable register their own delegating handler at the host layer, dispatching :rf/url-requested on match — this re-uses the same decision-point event the runtime already exposes, so the test surface and policy are unchanged.
Reading the route is a sub¶
The :rf/route sub projects the published slice keys via select-keys over the route slice at [:rf/runtime :routing :current]. Internal routing-runtime keys (:scroll-positions, :scroll-positions-order, :nav-token-counter, :pending-nav-counter) live alongside :current under :routing in app-db but do not surface through the :rf/route sub — consumers that deref the sub see only the slice and do not re-render on internal counter ticks.
(def route-slice-keys
[:id :params :query :transition :error :fragment :nav-token])
(rf/reg-sub :rf/route
{:doc "The current route slice: {:id :params :query :fragment :transition :error :nav-token}."}
(fn sub-route [db _] (select-keys (get-in db [:rf/runtime :routing :current]) route-slice-keys)))
(rf/reg-sub :rf.route/id
:<- [:rf/runtime :routing :current]
(fn [route _] (:id route)))
(rf/reg-sub :rf.route/params
:<- [:rf/runtime :routing :current]
(fn [route _] (:params route)))
(rf/reg-sub :rf.route/query
:<- [:rf/runtime :routing :current]
(fn [route _] (:query route)))
(rf/reg-sub :rf.route/fragment
:<- [:rf/runtime :routing :current]
(fn [route _] (:fragment route))) ;; URL #fragment string, or nil
(rf/reg-sub :rf.route/transition
:<- [:rf/runtime :routing :current]
(fn [route _] (:transition route))) ;; :idle | :loading | :error
(rf/reg-sub :rf.route/error
:<- [:rf/runtime :routing :current]
(fn [route _] (:error route)))
(rf/reg-sub :rf/pending-navigation
(fn [db _] (get-in db [:rf/runtime :routing :pending-navigation]))) ;; pending-nav slot when :can-leave guard rejects, else nil
Views derive UI from the route the same way they derive UI from any other state — no special routing API in views. A common pattern: a global progress bar reads :rf.route/transition and renders when the value is :loading; an error banner reads :rf.route/error.
The root view dispatches on :rf.route/id¶
(rf/reg-view app-root []
(case @(subscribe [:rf.route/id])
:route/home [home-page]
:route/cart [cart-page]
:route/cart.item-detail [cart-item-detail]
:route/article [article-page]
:rf.route/not-found [not-found-page]))
Pattern: a single case (or equivalent) over the route id at the top of the tree. Per-route views can subscribe to :rf.route/params for their own data needs.
Bidirectional URL ↔ params¶
Two pure helpers, both registered, both queryable:
(rf/match-url url)→{:route-id :keyword :params {...} :query {...} :fragment <string-or-nil> :validation-failed? boolean}ornil.- Returns
nilwhen no path-pattern matches the URL at all. - Returns the match map when some route's path-pattern matches. The
:paramsmap carries the captured path params (post-coercion against the route's:paramsschema, when one is present). The:querymap carries the parsed query-string params, with:query-defaultsfilled in for absent keys and the route's:queryschema applied for coercion (e.g."2"→2for an:intfield). The:fragmentfield carries the URL's#fragmentportion (string ornilif absent); see "Fragments" below. - If schema validation fails (path params don't conform to
:params, or query params don't conform to:query), the map carries:validation-failed? trueplus a:validation-errorfield with the schema-explanation (per Spec 010). The runtime's:rf.route/handle-url-changeevent treats validation-failure the same as no-match: it routes to:rf.route/not-foundwith the URL in params. - Pure; runs on JVM and CLJS.
(rf/route-url route-id path-params)/(rf/route-url route-id path-params query-params)/(rf/route-url route-id path-params query-params fragment)→ URL string. Pure; runs on JVM and CLJS.- Builds the URL from the
:pathtemplate, substituting path params, then appends?key=value&...for anyquery-params, then appends#fragmentif the 4-arityfragmentarg is supplied (and non-nil/non-empty). - Does not navigate. It is a string-builder; there is no side-effect on
app-db, nopushState, no dispatch. To navigate, dispatch:rf.route/navigate(which usesroute-urlinternally). - Does not read
app-db. Inputs are the registered route table (static) and the caller-supplied params/query/fragment. Same inputs always produce the same string output. :query-retainis NOT applied here. Carrying through retained keys (:theme,:locale, etc.) is anapp-db-aware concern —:rf.route/navigate's handler reads the current:rf.route/queryslice and merges retained keys into the outgoing query before callingroute-url. Callers that want the same merge behaviour without going through:rf.route/navigateperform the merge themselves (read:query-retainoff the route,select-keysfrom the current:rf.route/query, merge under their own values, then callroute-url). Keepingroute-urlpure is the lock — it is the function the conformance corpus and the SSR pipeline call without anapp-dbin hand.- Throws
:rf.error/route-url-validationifpath-paramsdoesn't conform to the route's:paramsschema, orquery-paramsdoesn't conform to the route's:queryschema (caller bug; not user input).
Both work against the same registered route table, so adding/removing a route updates both directions automatically.
route-url nil-policy: path params vs query params¶
route-url applies two different nil-policies to the path side and the query side — same function, opposite rules. The split is deliberate, but it is surprising enough to document explicitly (it otherwise costs a debugging session when {:page nil} mysteriously vanishes):
| Slot | nil (or absent) value |
present-but-falsy value (false, 0, "") |
|---|---|---|
Path param (a :name / *name segment) |
Hard error — throws :rf.error/missing-route-param. The URL cannot be built without the segment. |
Round-trips. A falsy-but-present value is a legitimate segment (/items/0). |
Query param (a key in query-params) |
Silently elided — {:page nil} omits the key entirely (no bare ?page=, no throw). |
Round-trips. A falsy-but-present value emits (?archived=false). |
The query-side elision is the useful default for absent optional query keys: a search form that conditionally adds ?sort= only when a sort is chosen passes {:sort nil} and gets a clean URL with no sort key. The path side cannot elide — a missing path segment has no URL to produce — so it throws. Both sides agree on the present-but-falsy case: a falsy value is real data and round-trips. Authors who need a query key to always be present must supply a non-nil value (use a sentinel string, not nil).
Param validation at the call site¶
The two boundaries where route params enter the runtime — programmatic navigation (route-url / :rf.route/navigate) and URL-driven navigation (match-url) — validate against the route's :params and :query schemas, with different failure modes on each side.
| Boundary | Source | Validation failure |
|---|---|---|
Programmatic — (route-url route-id path-params query-params) |
Caller supplies the params map directly. | Throws :rf.error/route-url-validation (caller bug; not user input). The schema-explanation is on the exception's data; the trace event is emitted at the same time. |
Programmatic — [:rf.route/navigate target params opts] |
Caller dispatches an event. | The event-boundary validation interceptor runs the route's :params / :query schema on the supplied maps before transitioning. Failure emits :rf.error/schema-validation-failure (per 009 §Error event catalogue, :where :event) and the navigation is rejected — the :rf/route slice does not change. |
URL-driven — (match-url url) |
Browser URL (popstate, link click, deep link). | :validation-failed? true in the result; :rf.route/handle-url-change routes to :rf.route/not-found with :reason :validation. |
The asymmetry is deliberate. Programmatic navigation is caller code — schema failures are bugs and should be surfaced loudly (throw / reject). URL-driven navigation is user input — schema failures are 404s, not exceptions. Both paths share the same :params / :query schemas (per Spec 010), so a route that compiles cleanly with one validates the same way against the other.
The event-boundary validation for :rf.route/navigate is a re-use of the standard schema-validation interceptor (the :schema slot on the reg-event-fx registration) — no routing-specific machinery.
Validation-error surfacing across the three paths¶
The three validation paths surface failures through three different error/no-error shapes. The table below names what an observer sees on each path so tools and handlers branch on the right surface. Audit Finding 3.
| Path | Error id | Trace :operation |
Cascade-level error fired? | Slice discriminator |
|---|---|---|---|---|
Programmatic — (route-url ...) |
:rf.error/route-url-validation |
none (synchronous throw) | thrown directly via ex-info; not on the trace bus |
n/a (the call throws; no slice write) |
Programmatic — [:rf.route/navigate ...] |
:rf.error/schema-validation-failure (with :where :event) |
:rf.error/schema-validation-failure per 009 §Error event catalogue |
yes — rides the always-on error-emit substrate (009 §Production builds) | n/a (navigation rejected; slice unchanged) |
URL-driven — (match-url ...) → :rf.route/handle-url-change |
no :rf.error/* — the failure becomes a not-found |
none (:rf.warning/malformed-url may fire for a separate sibling case) |
no | :rf/route slice writes {:id :rf.route/not-found :params {:url url :reason :validation}} |
The split is principled (per §Param validation at the call site above): caller-bug paths throw, event-boundary paths reject with a structured error, URL-driven paths route to the canonical not-found id. A consumer reading "the user tried to reach a route they can't parse" therefore branches differently per source: a caller-bug surfaces as an exception in dev (and as a substrate error in production); an event-boundary failure surfaces via the standard error substrate; a URL-driven failure surfaces via the not-found view's :reason :validation branch.
Asymmetry with flows. Flows' validation surface is flat by comparison — four explicit error ids that all fire at registration time, all under :rf.error/flow-*: :rf.error/flow-missing-id, :rf.error/flow-bad-inputs, :rf.error/flow-bad-output, :rf.error/flow-bad-path (per 013 §Failure semantics and implementation/flows/src/re_frame/flows/registry.cljc). Flows have a single validation time (registration) and a single surface (registration-throw); routing has three validation times (caller-fn invocation / event-boundary interceptor / URL-driven match) and three surfaces (synchronous throw / structured error / not-found route). The asymmetry is not a bug — it is principled per the table above — but it does mean that an AI scanning routing for "validation error ids" does not see one closed family, and a tool building an aggregate "show me all validation failures" surface needs to subscribe to two distinct error ids plus a slice-write predicate. The split is the cost of routing's caller-bug-vs-user-input distinction; flows have no such distinction (registration is always caller code).
Per-route data loading¶
A route may declare a vector of events the runtime dispatches whenever the route becomes active. This is the pattern's declarative loader. The mechanism is purely event-driven; no new effect substrate.
(rf/reg-route :route/cart
{:doc "The cart page."
:path "/cart"
:on-match [[:cart/load-items]
[:user/load-prefs]]})
Semantics:
- When
:rf.route/handle-url-change(URL-driven) or:rf.route/navigate(programmatic) makes this route the active route, the runtime dispatches each event in:on-match, in order, after writing the:rf/routeslice and before any view renders that depend on the loaded data. - The runtime sets
:rf.route/transitionto:loadingwhile these dispatches drain, and back to:idlewhen they complete (per the run-to-completion drain semantics, this is observable through trace events; see 009). - Same-route-id navigations with changed
:paramsor:querydo re-fire:on-match(the route is becoming active again under new inputs). Same-route-id navigations with identical params do not re-fire — the runtime compares the post-update:rf/routeslice against the pre-update slice and skips dispatch when nothing relevant changed. :on-matchevents run server- and client-side. SSR populates server-rendered data via the same vector. Hydration does not re-fire:on-matchevents — the seededapp-dbalready contains the data.- Each
:on-matchevent is an ordinary event vector. Handlers may emit any:fx(typically:http, etc.). The events are also enumerable:(rf/handler-meta :route :route/cart)returns the metadata, so tooling can render route-loading dependency graphs.
The :on-match list is the enumerable, machine-readable answer to "what loads when this route is active?" :on-match is the canonical surface.
Why not parameterise events explicitly with route params? Each
:on-matchevent runs with full access toapp-dbvia cofx, including the freshly-written route slice. Handlers read(get-in db [:rf/runtime :routing :current])for params/query as needed. Hard-wiring param substitution into the event vector would re-introduce a string-DSL where data already suffices.
Route-not-found — :rf.route/not-found (canonical)¶
:rf.route/not-found is a special-cased route id the runtime dispatches to whenever a URL fails to match any registered route. It is registered by the user, exactly like any other route — the runtime does not auto-register it; the framework's only special-casing is the target id it routes to on no-match. This keeps not-found rendering, head metadata, and :on-match events behaving identically to any other route.
(rf/reg-route :rf.route/not-found
{:doc "404 page."
:path "/404" ;; required, but rarely matched directly — the runtime
;; routes URL-driven misses here regardless of :path
:on-match [[:analytics/log-404]]
:scroll :top})
Semantics:
- Trigger. When
match-urlreturnsnil(no path-pattern matches), or when validation failure routes to "not found" (per §Param validation at the call site), the runtime sets:rf/routeto{:id :rf.route/not-found :params {:url <url>} ...}and proceeds with that route's:on-matchevents. - Same machinery.
:rf.route/not-foundis an ordinaryreg-route. It can declare:on-match,:on-error,:scroll,:head,:tags— all behave normally. The view tree'scaseover:rf.route/idrenders the not-found view from the leaf. - Required by contract. Apps must register a
:rf.route/not-foundroute. If no:rf.route/not-foundis registered when an unmatched URL arrives, the runtime emits a:rf.warning/no-not-found-routetrace event and falls back to a built-in placeholder view (a minimal<h1>Not Found</h1>page) so the request still produces a response. Test fixtures and the conformance corpus assume the user-registered shape. - Validation failures. A URL that matches a route's path but fails the route's
:params/:queryschema also routes to:rf.route/not-found, with:reason :validationin the:paramsslice (per §URL changes are events). - Malformed percent-encoding. A URL carrying malformed
%-sequences (%,%a,%XX, …) produces a route-miss, never an exception. Malformed encoding anywhere in the URL — captured path segments, query keys, query values, or the#fragmentportion — fails the whole match closed:match-urlreturns nil and:rf.route/transitionedwrites:rf.route/not-foundwith{:url url :reason :malformed-url}in the slice's:params. A:rf.warning/malformed-urltrace fires alongside the standard:rf.error/no-such-handler. The:reasondiscriminator distinguishes the malformed-URL case from a bare miss ({:url url}) and from a validation failure ({:url url :reason :validation}) Hostile URLs, partner integrations with broken escaping, and back-button to a malformed link must never crash a request handler on SSR. - Reserved id.
:rf.route/not-foundis the single locked id for this purpose. Implementations and tools depend on it; users do not redefine the meaning of the keyword. Hosts that want a different visual treatment per error kind branch inside the:rf.route/not-foundview (e.g., on:reason).
Tooling enumerates (rf/handler-meta :route :rf.route/not-found) to confirm the route is registered; the registrar emits the warning trace event at the first unmatched URL if it isn't.
Per-route error handling¶
If any event in :on-match errors (a handler throws, a registered fx errors, or a downstream handler errors during the drain — per 009's structured error contract), the runtime:
- Sets
:rf.route/transitionto:error. - Populates
:rf.route/errorwith the structured error map (schema::rf/errorper 009). - If the route declares an
:on-errorevent, dispatches it. The error map is available to the handler via(get-in db [:rf/runtime :routing :current :error]).
(rf/reg-route :route/cart
{:path "/cart"
:on-match [[:cart/load-items]]
:on-error [:route/cart-load-failed]})
(rf/reg-event-fx :route/cart-load-failed
(fn [{:keys [db]} _]
(let [error (get-in db [:rf/runtime :routing :current :error])]
;; surface a contextual error UI; toast; redirect; whatever the app needs.
{:db (assoc-in db [:cart :load-error] (:rf.error/message error))})))
:on-error is route-scoped error handling, layered over 009's structured error contract — it doesn't replace it. The structured error trace event still fires; :on-error is the route's response to it. Routes without an :on-error slot leave :rf.route/transition :error set; views may inspect :rf.route/error and render an error banner.
:on-match exception attribution¶
The error map written to :rf.route/error carries two routing-domain attribution slots in addition to the standard 009 shape:
| Slot | Value | Purpose |
|---|---|---|
:rf.route/on-match-id |
the failing event-id | Identifies the :on-match event vector whose handler threw. |
:rf.route/on-match-frame |
the dispatching frame-id | Identifies the frame whose :on-match cascade was mid-drain. |
These slots ride on the structured error so downstream consumers — Xray's event lens, an off-box Sentry/Honeybadger shipper, the SSR error projection per 011, an :on-error handler — can identify the throw as :on-match-attributed without re-running the corpus-wide register-error-listener! discrimination logic that the runtime uses to wire the trap. Mirrors the flow-attribution slots :rf.flow/failed-id / :rf.flow/failed-frame (per 013 §Failure semantics and). Tools that read :rf.error/handler-exception outside the routing listener's discrimination context get the same attribution the trap itself uses.
Navigation tokens — stale-result suppression¶
When a route is loading and the user navigates away before the load completes, the older load's result can land after the user has moved on, clobbering newer state. This is a real bug class — React Router and TanStack Router both explicitly handle it. Re-frame2's answer is the navigation-token (nav-token) epoch: a per-navigation token allocated when a route becomes active, carried by every async result, and validated on receipt. This is the same idiom used by :after timers per 005 §Epoch-based stale detection; see also the cross-cutting Pattern-StaleDetection.md for why the pattern recurs.
Mechanism¶
- Allocation. When
:rf.route/transitionedfires (URL-driven) or:rf.route/navigateruns (programmatic), the default handler allocates a fresh:nav-token(a gensym or monotonic counter) and writes it to the:rf/routeslice alongside the new id/params/query/fragment. - Capture. An
:on-match-reached handler declares the framework-supplied:nav-tokencofx via(inject-cofx :nav-token); the cofx injects the current token (read from[:rf/runtime :routing :current :nav-token]) under the key:nav-tokenin the handler's coeffects, so the handler captures the epoch live at scheduling time:
(rf/reg-event-fx :cart/load-items
[(rf/inject-cofx :nav-token)] ;; <-- declare the cofx
(fn [{:keys [db nav-token]} _] ;; <-- "nav-42", the token at scheduling time
...))
- Threading. Async completions either (a) carry the captured token in their follow-up event payload, or (b) use the framework-supplied
:rf.route/with-nav-tokenfx wrapper which threads the token into the dispatched continuation:
{:fx [[:rf.route/with-nav-token
{:do [:dispatch [:cart/items-loaded items]]
:nav-token nav-token}]]} ;; the token captured in step 2
- Validation. On receipt, the carried token is checked against the current
:rf/routeslice's:nav-token. Path (b) —:rf.route/with-nav-token— performs this check for you; path (a) hands the captured token to the receiving handler, which compares it against its own freshly-injected:nav-tokencofx and short-circuits on mismatch: - Match. The token is current; the result is committed normally.
- Mismatch. The token has been superseded; the runtime emits
:rf.route.nav-token/stale-suppressed(with:tags {:carried-token <t1> :current-token <t2> :event-id <id>}) and the wrapped:do(path b) or the receiving handler's commit (path a) does NOT run — no:dbwrite, no:fx, no transition.
The two halves are shared infrastructure: the :nav-token cofx supplies the capture-side token to any handler that declares (inject-cofx :nav-token), and :rf.route/with-nav-token performs the receipt-side check for any continuation routed through it. A handler can use both (inject to capture, wrap to validate) or compare the cofx-injected token directly.
What the slice looks like over time¶
;; All slice snapshots below are at [:rf/runtime :routing :current] in app-db.
;;
;; Step 1: User navigates to :route/article id="A". nav-token = "nav-1".
{:id :route/article :params {:id "A"} :transition :loading :nav-token "nav-1"}
;; Step 2: While the load is in flight, user navigates to :route/article id="B".
;; A fresh nav-token is allocated.
{:id :route/article :params {:id "B"} :transition :loading :nav-token "nav-2"}
;; Step 3: The "A" load completes; its dispatched [:article/loaded "A" payload] carries
;; nav-token "nav-1". Current is "nav-2". Mismatch → suppressed; trace fires; no commit.
;; Step 4: The "B" load completes; carries "nav-2". Match → commit.
{:id :route/article :params {:id "B"} :transition :idle :nav-token "nav-2"}
Cancellation as optimisation, not correctness¶
Suppression alone fixes the user-visible bug — the older load does complete and does dispatch its event, but its result is silently discarded at the validation cofx. Hosts that support abortable fetches (AbortController in JS, etc.) MAY additionally abort in-flight work for superseded tokens to save bandwidth — but the conformance contract only requires suppression, not cancellation. This matches the :after story per 005 §Epoch-based stale detection.
Trace events¶
Two trace events surround the nav-token lifecycle (added to the trace-op vocabulary per Spec-Schemas.md):
:rf.route.nav-token/allocated— emitted when a navigation cascade allocates a fresh token.:tags {:route-id <id> :nav-token <token>}.:rf.route.nav-token/stale-suppressed— emitted when an async result arrives carrying a now-superseded token.:tags {:carried-token <t1> :current-token <t2> :event-id <id>}. The handler does NOT run.
Naming follows the <feature>/<reason> convention used by :rf.machine.timer/stale-after. See Pattern-StaleDetection.md for the cross-cutting pattern.
Conformance¶
Fixture route-stale-nav-token-suppression.edn exercises the canonical race: load route A; navigate to route B before A finishes; A finishes; verify the late result is suppressed and the trace shows :rf.route.nav-token/stale-suppressed.
Standard runtime events¶
Two named events are part of the routing contract. Implementations register them; user code dispatches them; tests can fire them directly.
| Event | When it fires | Default handler |
|---|---|---|
:rf.route/transitioned |
The browser URL has changed (popstate, initial load, server-side request URL). The runtime dispatches this on every URL transition. | The default handler (the runtime registers it) is :rf.route/handle-url-change. Users can override by re-registering. |
:rf/url-requested |
The user clicked a link the framework owns (a route-link view, or any <a> whose href resolved to a registered route). The handler decides: navigate internally (dispatch :rf.route/navigate) or let the browser follow the link externally (dispatch :rf.nav/external or do nothing). |
The default handler classifies internal vs external by feeding the URL to match-url; matched URLs become :rf.route/navigate, unmatched become external. Users can override to enforce per-frame policy (auth-guard, modifier-key handling, etc.). |
These events are the decision points for navigation policy. The policy is enumerable and testable: dispatch [:rf/url-requested {:url "/cart"}] from a test, observe the resulting :rf.route/navigate, no DOM simulation required.
route-link ships in the routing artefact as a registered view at id :route/link. The body:
(rf/reg-view ^{:rf/id :route/link} route-link
[{:keys [to params query fragment on-click] :as props} & children]
(let [base-url (rf/route-url to (or params {}) (or query {}))
url (if (and fragment (not= "" fragment))
(str base-url "#" fragment)
base-url)
attrs (-> props
(dissoc :to :params :query :fragment :on-click)
(assoc :href url
:on-click
(fn [e]
(when on-click (on-click e))
(when (and (not (.-defaultPrevented e))
(plain-left-click? e)) ;; no modifier keys; primary button
(.preventDefault e)
(dispatch [:rf/url-requested
(cond-> {:url url :to to}
(seq params) (assoc :params params)
(seq query) (assoc :query query)
fragment (assoc :fragment fragment))])))))]
(into [:a attrs] children)))
The view exposes three behavioural seams: passthrough attributes (:class, :title, :id, :aria-label, …) flow through to the <a>; a caller-supplied :on-click runs before the framework's interception and can pre-empt it by calling .preventDefault; and modifier-key clicks defer to the browser so middle-click / cmd-click / shift-click keep their native open-in-new-tab affordances. route-url is the single point where the URL is synthesised, so route-rename and route-shape changes flow into every route-link site without per-link edits.
Scroll restoration¶
Browser-default behaviour on popstate restores scroll position. For SPA-controlled scroll (e.g., scroll to top on forward navigation, restore on back), declare a :scroll strategy on the route or pass :scroll in the :rf.route/navigate opts.
The :scroll value is one of:
| Value | Behaviour |
|---|---|
:top |
Scroll to top of page (window.scrollTo(0,0)). |
:restore |
Restore the saved scroll position for this URL (the runtime captures positions on every navigation; SSR-side: no-op). |
:preserve |
Do nothing (current scroll position stays as is). |
nil / absent |
Same as :preserve. |
| map | Hosts may supply additional shapes (e.g. {:to :element :selector "#article"}); see "Custom scroll strategies" below. |
Resolution order at navigation time:
1. :scroll key in :rf.route/navigate's opts map (per-call override). Wins.
2. :scroll key on the route's metadata.
3. Implicit default: :top for forward navigation, :restore for popstate-driven navigation.
When a :rf.nav/scroll effect is emitted, its args carry both the strategy and the from/to context: [:rf.nav/scroll {:strategy :top :from from-route :to to-route :saved-pos saved :fragment <s-or-nil>}]. The registered fx interprets the strategy. The :saved-pos field is captured by the runtime on every navigation (a small in-memory map URL → [x y]); on popstate, the runtime supplies the saved value. The :fragment field is the URL's #fragment, when present (per "Fragments" below); the standard strategies use it as described in "Fragments §:rf.nav/scroll integration".
(rf/reg-fx :rf.nav/scroll
{:platforms #{:client}}
(fn fx-nav-scroll [_m {:keys [strategy from to saved-pos fragment]}]
(case strategy
:top (if-let [el (and fragment (.getElementById js/document fragment))]
(.scrollIntoView el)
(.scrollTo js/window 0 0))
:restore (when saved-pos
(.scrollTo js/window (first saved-pos) (second saved-pos)))
:preserve nil)))
Custom scroll strategies: the
:scrollvalue may be a map, allowing applications to register named scroll-strategies (a small registry on the implementation side). The contract requires the three enum values:top,:restore,:preserve.
Query strings and fragments¶
The path syntax is the primary binding. Query strings are bound separately via the route's :query metadata key, which carries a schema for query-string coercion and validation (per Spec 010).
(rf/reg-route :route/search
{:path "/search"
:query [:map [:q :string] [:page {:optional true} :int]]
:query-defaults {:page 1}
:query-retain #{:theme :locale}})
;; URL: /search?q=clojure&page=2
;; match-url yields:
;; {:route-id :route/search :params {} :query {:q "clojure" :page 2}}
;;
;; URL: /search?q=clojure (page absent; default applied)
;; match-url yields:
;; {:route-id :route/search :params {} :query {:q "clojure" :page 1}}
Path params (:params) and query params (:query) are distinct concepts:
| Path params | Query params | |
|---|---|---|
| Source | :name / *name segments in :path |
?key=value&... after the path |
| Schema slot | :params |
:query |
| In route slice | (get-in db [:rf/runtime :routing :current :params]) |
(get-in db [:rf/runtime :routing :current :query]) |
| Required by URL? | Yes (URL doesn't match without them) | No (every key is optional from the URL's perspective) |
| Defaults | n/a (absence = no match) | :query-defaults map |
:query-defaults populates absent query keys at match time. :query-retain is a set of keys that should be carried through subsequent navigations even when the caller didn't supply them — useful for global state encoded in the URL (:theme, :locale, :debug). The merge is performed inside :rf.route/navigate's handler (which has access to app-db and reads the current :rf.route/query slice) before route-url is called; route-url itself is pure and does not consult app-db (per §Bidirectional URL ↔ params). The result: a [:rf.route/navigate :route/cart] from a search page preserves ?theme=dark.
:query-retain cross-route coercion class¶
Retained query values are pulled from the current route's :query slice and merged into the target route's outgoing query verbatim — they are not re-coerced against the target route's :query schema. This matters because the current route may have coerced a retained value into a specific class: a [:enum :light :dark]-typed key arrives in the slice as the keyword :dark (per §Keyword-interning cap), an :int-typed key as a number. Carrying verbatim means that keyword/number flows into the target unchanged.
The contract: keep a retain key's type consistent across every route that retains it. Because the merge is a pure select-keys (no re-coercion), an author who types :theme as [:enum :light :dark] on route A and as :string on route B will carry a keyword :dark into B's :string slot. This is caught, not silent: the target route's :query validator runs at the call site (in :rf.route/navigate's handler and in route-url), so a class mismatch surfaces as a validation failure (rejecting the navigation per §Param validation at the call site) rather than desyncing the slice.
Re-coercing retained values against the target schema was considered and rejected: retained values are already-coerced runtime values, not URL strings, so re-running the string→class coercion pipeline on them is ill-typed (the coercer's input contract is a raw URL string). Verbatim carry keeps the retain-merge a pure select-keys and keeps route-url pure (it never sees the merge). Consistency across routes is an author-named-intent invariant, the same trust class as the :query schema itself.
Coercion is data-shaped (the :query schema is the coercion specification — :int coerces "2" → 2); per-key middleware functions are not part of the contract — data over functions.
:int coercion is strict and host-identical. A value is coerced to a number only when the whole string is an integer literal — #"^-?\d+$" (an optional sign followed by ASCII digits); partial-numeric, radix-prefixed, or whitespace-padded input ("12abc", "0x10", " 12") is left as a string on every host. The route's :query schema then catches the type mismatch — a string in an :int-typed slot fails validation and surfaces :validation-failed? true (per Spec 010), identically server- and client-side. This rule exists because match-url runs on both the JVM (SSR) and CLJS (browser); a host-divergent integer parser (Long/parseLong is strict, js/parseInt is lenient) would yield a different :query slice for the same URL on each host — exactly the Spec 011 hydration-mismatch class, and a violation of the "same handler runs server- and client-side" contract and Goal 2's cross-host conformance bar. The strict regex makes the parse decision a pure function of the input string, host-independent. The conformance corpus pins ?page=12abc and ?page=12 across both harnesses in routing-query-string-coercion.edn.
+ is a literal¶
+ decodes to a literal + — not a space — on both the JVM (SSR) and CLJS (browser), in path captures and query values. %20 (and a real space) decodes to a space on both hosts. This is the same cross-host-symmetry rule as :int coercion above, applied to percent-decoding: match-url runs on both hosts, so a host-divergent decoder would yield a different :params / :query slice for the same URL on each host — the Spec 011 hydration-mismatch class again.
The rationale is threefold:
decodeURIComponentis the de-facto reference. The CLJS path decodes viajs/decodeURIComponent, which leaves+untouched. The browser is the canonical host, so the JVM matches it (the JVM'sjava.net.URLDecoder/decodeis theapplication/x-www-form-urlencodeddecoder, which turns a bare+into a space — the wrong decoder here; the implementation pre-escapes+→%2Bbefore handing the string toURLDecoderso the JVM reproducesdecodeURIComponentexactly).- RFC 3986 path semantics. In a URL path segment,
+is a literal — the+-means-space convention is specific toapplication/x-www-form-urlencodedform bodies and query strings of HTML formGETsubmissions, not to path captures. re-frame2 applies one rule to both path and query for a single, predictable decode contract. - re-frame2 never emits a bare
+.route-urlencodes a literal+(and a space) as%2B(and%20) viaurl-encode, so the round-trip (route-url→match-url) is exact and never depends on the+-as-space reading. A bare+only ever appears in a URL that re-frame2 did not author (a hand-typed or partner-supplied link), where the literal reading is the conservative, host-stable choice.
A match-url query string also skips empty pairs: a trailing ? (/x?), a leading & (/x?&a=1), or a doubled && (/x?a=1&&b=2) does not inject a spurious {"" ""} key into the :query slice. An explicit empty value (?foo=) is distinct — it keeps the key with an empty-string value ({"foo" ""}).
The conformance corpus pins +-literal (path capture + query value), the %20-is-space case, and the empty-pair filter across both harnesses in routing-plus-decode.edn.
Keyword-interning cap on query keys + values¶
URL query strings are an attacker-influenceable input — caller-controlled, often deep-linked from third parties (search results, partner sites, share links). JVM keywords intern into a process-global, never-GC'd table; a routing layer that turns every URL query key into a keyword permanently extends that table on every unique hostile key, eventually exhausting the host. Long-running SSR JVMs are the worst case. This is the routing-side analogue of the HTTP-side keyword-interning DoS (per Spec 014 §Keyword-interning cap).
Three layered defenses, all on by default:
-
URL-level cap on unique query keys.
match-urlenforces a per-URL cap on the number of unique query keys (default 10000, nameddefault-max-decoded-keysin the routing implementation — symmetric with:rf.http/max-decoded-keysfor managed HTTP). Overflow throws:rf.error/route-too-many-keyswith{:limit :count :url}ex-data, which propagates through the calling navigation event the same as any other parse failure. 10000 is generous — legitimate URLs typically carry tens of keys at most — and finite enough to bound an attacker-controlled payload. -
Selective keywording against the route's declared vocabulary. When the route declares a
:queryschema (or:query-defaults/:query-retain), only keys named by those slots are promoted to keyword keys. Unknown URL query keys retain their string form in the parsed:querymap. The route's declared vocabulary is the keyword universe; the framework refuses to permanently extend the JVM keyword table on behalf of URL keys the route did not name. -
:keyword-typed value gate. A bare:keywordquery-slot type-form is treated as an unbounded intern site (any URL value would intern as a keyword) and the value is preserved as a string. Authors who want keyword-typed values declare an[:enum :asc :desc ...]allowlist — the bounded keyword universe. Values matching one of the declared enum choices are interned; values outside the allowlist stay as strings.
Routes that declare no :query schema at all fall back to the legacy keyword-all behaviour for query keys — the URL-level cap (defense #1) is the DoS guard for that path. The selective-keywording rule (defense #2) and the :keyword-value gate (defense #3) only activate once the route opts in by declaring a :query schema, defaults, or retain set.
;; safe enum allowlist for a keyword-typed query value.
(rf/reg-route :route/sorted
{:path "/items"
:query [:map
[:sort [:enum :asc :desc]]]})
;; URL: /items?sort=desc → :query {:sort :desc} ;; declared enum value → interned
;; URL: /items?sort=hostile → :query {:sort "hostile"} ;; outside enum → stays as string
Cross-references: Security.md §DoS by input for the framework-wide stance (slot catalogue cross-ref TBD post-Security.md anchor stabilisation), and 014 §Keyword-interning cap for the symmetric HTTP-side cap.
Fragments¶
The URL #fragment is a first-class part of the routing contract — anchor navigation, scroll-to-section, settings-tab selection, and SSR-safe in-page navigation all depend on it being explicit data flowing through events rather than a window.location.hash read in view code.
Fragment in the slice¶
The route slice ([:rf/runtime :routing :current]) carries :fragment (string or nil):
;; at [:rf/runtime :routing :current]:
{:id :route/docs
:params {:page "routing"}
:query {}
:fragment "scroll-restoration"
...}
Read it via the :rf.route/fragment sub. Fragment is populated by match-url from the URL, written to the slice by :rf.route/handle-url-change, and emitted by route-url when the 4-arity form is used (or when :rf.route/navigate is called with a :fragment opt or target-map key).
Fragment-only changes do NOT re-fire :on-match¶
When the new URL differs from the current URL only in its fragment (same :route-id, same :params, same :query, but different :fragment), the runtime:
- Updates
:fragmentin the:rf/routeslice. - Emits a
:rf.route/fragment-changedtrace event with:tags {:route-id <id> :prev-fragment <s> :next-fragment <s>}. - Does NOT allocate a new
:nav-token. - Does NOT re-fire
:on-match.
The reason: :on-match exists to re-load route-scoped data when path or query changes. A fragment-only change does not change loaded data — only the in-page anchor target. Re-firing the loaders would re-fetch unchanged data on every #section jump, which is exactly the kind of thrash users complain about.
Views that need to react to fragment changes subscribe to :rf.route/fragment (or to :rf/route for the whole slice). The :rf.route/transitioned event still fires for fragment-only changes — the surface for "the URL is now different" — but :rf.route/handle-url-change's default behaviour distinguishes the cases.
:rf.nav/scroll integration¶
When a fragment is present and the resolved scroll strategy is one of the standard strategies (:top, :restore, :preserve), the :rf.nav/scroll fx receives the fragment in its args:
[:rf.nav/scroll {:strategy :top :from from-route :to to-route :saved-pos saved :fragment "section-2"}]
The fx's behaviour, when :fragment is present:
| Strategy | Behaviour |
|---|---|
:top |
Attempt getElementById(fragment) and scroll-into-view; on failure, fall back to window.scrollTo(0,0). |
:restore |
Restore saved scroll position; the fragment is ignored (the saved position trumps). |
:preserve |
Do nothing (fragment ignored). |
Hosts that ship a custom map-form scroll strategy may interpret :fragment per their own contract; the three enum strategies' fragment-handling is locked above.
Programmatic navigation with fragments¶
:rf.route/navigate accepts a :fragment key in opts or in the target map:
;; opts form
[:rf.route/navigate :route/docs {:page "routing"} {:fragment "scroll-restoration"}]
;; target-map form (URL escape hatch)
[:rf.route/navigate {:url "/docs/routing#scroll-restoration"}]
Either form ends up in the :rf/route slice's :fragment.
SSR¶
Browsers do not send #fragment to the server — window.location.hash is client-only. For browser-initiated SSR requests, the server-side :fragment is therefore typically nil, regardless of what the user typed in the address bar. The exceptions are static-site generators, server-side test harnesses, and crawlers that synthesise URLs with explicit fragments (e.g., for anchored documentation pages); when the host's request abstraction exposes a #fragment, SSR includes it in the seeded :rf/route slice. See 011 §Fragments under SSR for the full SSR-side contract.
The server does NOT scroll (no DOM); :rf.nav/scroll is :platforms #{:client} per 011 §Effect handling on the server. The first client render after hydration sees the same :fragment value the server seeded (typically nil for browser requests), so view code that reads :rf.route/fragment produces structurally-identical output on both sides. A subsequent :rf.nav/scroll (post-hydrate) is the host's choice — the contract leaves it to the host to decide whether to perform the initial scroll-to-fragment after hydration.
Conformance¶
Fixture route-fragment-change.edn exercises:
1. Navigate to /docs/routing#scroll-restoration. Verify the slice's :fragment is "scroll-restoration".
2. Navigate to /docs/routing#caching (same path/query, different fragment). Verify :on-match does NOT re-fire and :rf.route/fragment-changed trace event fires.
3. Navigate to /docs/instrumentation#scroll-restoration (different path, same fragment). Verify :on-match DOES re-fire (path changed; fragment-only rule does not apply).
Nested layouts¶
For nested layouts (e.g., /account/settings, /account/billing, /account/security all rendering inside an /account shell), the pattern is id namespacing plus an explicit :parent:
(rf/reg-route :route/account {:path "/account"})
(rf/reg-route :route/account.settings {:path "/account/settings" :parent :route/account})
(rf/reg-route :route/account.billing {:path "/account/billing" :parent :route/account})
(rf/reg-route :route/account.security {:path "/account/security" :parent :route/account})
The :parent key gives the rendering side an enumerable answer to "what's the layout chain for this route?" The runtime exposes a sub:
(rf/reg-sub :rf.route/chain
:<- [:rf.route/id]
(fn [id _]
;; Returns [parent-most ... current], following :parent links.
;; e.g. (:route/account :route/account.settings)
(chain-from-meta id)))
Views render the chain top-down:
A more elaborate router with native nested layouts — true <Outlet/> slot mechanics, parent-loader cascades, partial revalidation — is out of scope. The :parent + :rf.route/chain convention covers the common case (a parent shell wrapping leaf views), keeps the pattern data-only, and avoids introducing a new render substrate.
Future expansion may revisit:
- A :layout slot on reg-route (separate from :parent) so a route can declare which layout component wraps its leaf view.
- Parent-route :on-match events that cascade to children (today, child routes must duplicate the parent's :on-match if they need the same data).
- An <Outlet/>-equivalent primitive for the child render slot.
The :parent + chain-sub convention is sufficient for the common case and doesn't preclude a richer mechanism later.
Navigation blocking — pending-nav protocol¶
Real product needs — unsaved forms, interrupted checkouts, destructive multi-step workflows — require navigation to be blockable. Angular, Vue Router, and TanStack Router all support this. Re-frame2 makes navigation blocking a first-class named-event/state protocol instead of a magic component hook: pending-nav state lives in app-db; UI renders confirm dialogs from ordinary subscriptions; user choices are dispatched as standard events. All testable.
Mechanism¶
A standard pending-navigation slot in app-db, three named events, and an optional :can-leave route-metadata key.
Pending-nav slot at [:rf/runtime :routing :pending-navigation] (schema in Spec-Schemas.md §:rf/pending-navigation):
;; at [:rf/runtime :routing :pending-navigation]:
{:id "pn-7"
:requested-by-event [:rf/url-requested {:url "/editor/articles/42"}]
:requested-url "/editor/articles/42"
:reason "Form has unsaved changes"
:rejecting-route :editor/article
:rejecting-guard :editor/can-leave?}
nil/absent when no navigation is pending.
Three named events:
| Event | Dispatched by | Behaviour |
|---|---|---|
:rf.route/navigation-blocked |
The runtime, when a :can-leave guard rejects |
Sets :rf/pending-navigation (the runtime does this before dispatching the event); user code subscribes and renders the dialog. Event vector: [:rf.route/navigation-blocked pending-nav]. |
:rf.route/continue |
User code (typically a "Yes, leave" button) | Clears :rf/pending-navigation and re-dispatches the original navigation request without re-running the leave guard. Event vector: [:rf.route/continue pending-nav-id]. |
:rf.route/cancel |
User code (typically a "Stay" button) | Clears :rf/pending-navigation; the URL stays unchanged. Event vector: [:rf.route/cancel pending-nav-id]. |
Route-metadata extension — declare the leave-guard sub on a route:
(rf/reg-route :editor/article
{:doc "Editing an article."
:path "/editor/articles/:id"
:params [:map [:id :string]]
:can-leave [:editor/can-leave?]}) ;; sub-id; (subscribe [<sub-id>]) returns boolean
(rf/reg-sub :editor/can-leave?
:<- [:editor/dirty?]
(fn [dirty? _] (not dirty?))) ;; true means "OK to leave"
The sub returns true when the route is OK to leave; false to block. The convention: the sub's name describes the positive case (:can-leave), so false means "can NOT leave" — block.
Closed contract. The runtime accepts only the literals true and false from the guard sub. Any other value (42, a non-empty string, nil, a map) BLOCKS the navigation and emits the structured trace :rf.error/can-leave-non-boolean with :tags {:route-id :query :value :reason :recovery :blocked-navigation} The closed contract forces the route author to write (boolean ...) / (not ...) rather than warn-and-let-through. Pre-alpha posture: no shim, no soft transition; the warning slot is removed entirely.
Default flow¶
:rf/url-requestedfires with the new URL (link click,:rf.route/navigateprogrammatic call, popstate).- The runtime evaluates the current route's
:can-leavesub (if any). - No guard or guard returns
true→ proceed normally. The new URL becomes active; nav-token allocates;:on-matchruns. - Guard returns
false→ BLOCK: a. Generate apending-nav-id(gensym). b. Write:rf/pending-navigationwith{:id <id> :requested-by-event <ev> :requested-url <url> :rejecting-route <id> :rejecting-guard <sub-id>}. c. The URL does not change. NopushState, no:rf/routeslice update, no:on-match. d. Dispatch[:rf.route/navigation-blocked pending-nav]. Apps may register their own handler (default is a no-op trace; the value is in the slot, which a sub reads). e. Emit:rf.route/navigation-blockedtrace event. - UI renders the confirmation dialog by subscribing to
:rf/pending-navigation. - User chooses:
- Continue → dispatch
[:rf.route/continue pending-nav-id]. Runtime clears the slot and re-issues the original navigation, bypassing the leave-guard for this one shot. - Cancel → dispatch
[:rf.route/cancel pending-nav-id]. Runtime clears the slot. Nothing else changes.
Why this shape (not a hook-based router)¶
The hook-based version (e.g., React Router's useBlocker) is convenient but tied to component lifecycle. Re-frame2's strengths are explicit state and dispatched events; this design preserves them. Slightly more verbose at the call site; far more testable.
A test fires [:rf/url-requested {:url "/cart"}] against a frame whose :editor/can-leave? sub returns false, asserts :rf/pending-navigation is set, asserts :rf.nav/push-url did NOT fire, dispatches [:rf.route/continue pending-nav-id], asserts the navigation completes. No DOM, no event simulation, no hook-mock.
Interaction with other navigation features¶
- Nav-tokens. Navigation blocking happens before the new nav-token would be allocated; tokens are for committed navigations. The
:rf.route/continuere-issue allocates a fresh nav-token like any other navigation. The original (blocked) attempt never received one. - Fragments.
:can-leaveruns for any URL change, including fragment-only changes. The runtime DOES check:can-leavefor fragment-only changes — apps that want fragment changes to bypass the guard returntruefrom the sub when the only difference is the fragment (the sub reads the current:rf.route/fragmentand the requested fragment from the pending event). - Multiple guards. A route has at most one
:can-leavesub (it's a metadata key, single-valued). For frame-level cross-cutting policy (e.g., "always block when:auth/logging-out?"), use an interceptor on:rf/url-requested. Interceptors run before the leave-guard check — they can short-circuit by setting:rf/pending-navigationdirectly.
Conformance¶
Fixture route-navigation-blocked.edn exercises:
1. Register a route with :can-leave [:editor/can-leave?].
2. Set :editor/dirty? to true (sub returns false).
3. Dispatch [:rf/url-requested {:url "/cart"}].
4. Assert :rf/pending-navigation is set; :rf.nav/push-url did NOT fire; :rf.route/navigation-blocked trace event fired; :rf/route slice unchanged.
5. Dispatch [:rf.route/continue pending-nav-id].
6. Assert :rf/pending-navigation is nil; the URL is /cart; :route/cart is the active route.
Redirects and guards¶
A :rf.route/navigate event can be intercepted by an interceptor that decides whether the navigation proceeds, redirects elsewhere, or aborts:
(def auth-guard
{:id :rf.route/auth-guard
:before (fn before [ctx]
(let [event (get-in ctx [:coeffects :event])
target (second event)
route-meta (rf/handler-meta :route target)
needs-auth? (boolean (some #{:requires-auth} (:tags route-meta)))
logged-in? (some? (get-in ctx [:coeffects :db :auth :user]))]
(if (and needs-auth? (not logged-in?))
;; redirect to login
(assoc-in ctx [:coeffects :event] [:rf.route/navigate :route/login {:return-to target}])
ctx)))})
(rf/reg-route :route/account
{:path "/account"
:tags #{:requires-auth}})
Guards are interceptors, not a special routing mechanism. They compose; multiple guards can layer.
Server-side rendering integration (per 011)¶
The server-side flow:
- HTTP request arrives.
make-frameper request.:on-createfires[:rf/server-init request], which dispatches[:rf.route/handle-url-change (:uri request)].- Route slice is set from the URL; the same handler runs on server and client. Path params, query params, defaults, and
:query-retainkeys are populated. - The matched route's
:on-matchevents dispatch — the same vector that runs client-side. Server-side data loaders complete before the drain settles. - Drain settles; root view renders against the populated state.
- HTML + serialised state ship to the client.
On the client, hydration runs [:rf/hydrate state] which restores the route along with everything else. :on-match does not re-fire on hydration — the seeded app-db already contains the loaded data. The first client render produces the same HTML the server rendered (same :rf.route/id, same :params, same :query).
Tooling and AI-amenability¶
(rf/registrations :route)enumerates every registered route. Tools and agents enumerate them; AI scaffolding consults this before generating new routes to avoid collisions.(rf/handler-meta :route :route/cart)returns the route's metadata: path, params shape, query shape,:on-match,:on-error,:scroll,:parent, tags, source coords. The:on-matchslot is enumerable — tools render route-loading dependency graphs without parsing handler bodies.- The
:rf/routesub gives the entire route map;:rf.route/id,:rf.route/params,:rf.route/query,:rf.route/transition,:rf.route/errorare conveniences. :rf.route/navigate,:rf.route/handle-url-change,:rf.route/transitioned,:rf/url-requestedare stable, named events; trace events surface every navigation and every URL request.- A registered
:rf.route/not-foundis required (per §Route-not-found); tools surface the:rf.warning/no-not-found-routetrace event for apps missing the registration.
Frame-destroy teardown¶
Routing's per-frame state — the route slice, pending-nav slot, scroll-positions order/map, and the per-frame nav-token / pending-nav counters — lives entirely in app-db under [:rf/runtime :routing] (per §The :rf/route slice and §Scroll restoration). The destroy-frame! boundary therefore releases routing's per-frame state naturally via the frame's app-db going away. Routing publishes no :routing/teardown-on-frame-destroy! late-bind hook, by deliberate contrast with the per-feature artefacts that hold frame-scoped state outside app-db:
- Flows — publishes
:flows/teardown-on-frame-destroy!because the per-frame flow registry andlast-inputsdirty-check cache live in module-private atoms, not inapp-db. - Machines — the machine snapshots live at
[:rf/runtime :machines :snapshots <id>]insideapp-dbso they die naturally, but the artefact additionally publishes:machines/teardown-on-frame-destroy!for the per-frame timer registry and:afterepoch counters held outsideapp-db. - Schemas — publishes
:schemas/on-frame-destroyed!for the per-frame validator caches held in module-private atoms.
Routing fits the "all per-frame state in app-db" pattern in full — there is no module-private per-frame structure to clear, and so no hook to publish. Audit Finding 8.
Process-global slots are intentionally not per-frame¶
Routing holds three process-global resources that survive destroy-frame! and are intentionally cross-frame:
| Resource | Where | Why global |
|---|---|---|
The :route registrar map |
rf/registrar (per 001-Registration) |
Routes are a corpus-wide resource — every frame sees the same registered routes. A user's (rf/reg-route :route/cart ...) registers a route that frame :left and frame :right both match-URL against the same way. Per-frame route tables would multiply registrations and have no consumer use case. |
reg-counter (rule-6 tiebreak counter, monotonic) |
process-global defonce atom inside the routing artefact |
Rule 6 of the §Route ranking algorithm breaks structural ties on registration order. The counter monotonically increases over the process lifetime so a re-registered route lands "after" its siblings (per §Hot-reload semantics for routing). Per-frame counters would re-shuffle ranks on frame destroy in surprising ways; cross-frame correctness requires the counter to be global. reset-counters! is a test-only helper. |
route-table-cache (compiled-route lookup memo) |
process-global defonce atom inside the routing artefact |
A pre-sorted compiled-route table keyed on the registrar map's identity. Self-managing: rebuilds whenever (identical? @route-registrar last-key) is false. Per-frame caches would compute the same value redundantly for every frame; cross-frame caching is correct because the registrar is itself cross-frame (above). |
None of these clear on destroy-frame! and none should. A new feature artefact author scanning routing for the teardown shape MUST NOT publish a routing-style hook for module-private state they hold per-frame — they should follow Flows §Frame-destroy teardown or Machines §Teardown instead. The "publish a hook" rule applies when an artefact holds per-frame state outside app-db; routing's design choice (per-frame in app-db; process-global for corpus-wide concerns) is the contrast example.
What the slice teardown looks like¶
destroy-frame! calls (swap! frame-registry dissoc frame-id) which drops the whole frame's app-db along with everything else (per 002 §Destroy). The route slice ([:rf/runtime :routing :current]) and the pending-nav slot ([:rf/runtime :routing :pending-navigation]) live under the reserved app-db root :rf/runtime (per Conventions §Reserved app-db keys); all routing-runtime per-frame state — the scroll-positions structures ([:rf/runtime :routing :scroll-positions], [:rf/runtime :routing :scroll-positions-order]) and the per-frame counter slots ([:rf/runtime :routing :nav-token-counter], [:rf/runtime :routing :pending-nav-counter]) — is nested under [:rf/runtime :routing] so it releases in lockstep with the slice itself. There is no per-frame cache, no orphaned listener, no leaked timer to clear.
The corpus-wide :rf.error/handler-exception listener (on-match-error-listener, registered at routing-artefact load time per §Per-route error handling) is process-global and survives every destroy-frame!. It is defonce-protected and re-discriminates each handler-exception against the failing frame's current route slice ([:rf/runtime :routing :current]) — destroying frame :left simply means future exceptions thrown in :left's drain no longer trigger the listener (the frame is gone) and the listener continues to discriminate :right's exceptions normally.
Multi-frame routing¶
Each frame has its own [:rf/runtime :routing :current] slice. Only the default frame is URL-bound. Non-default frames have independent routes that don't push to the browser URL.
- Every frame's
app-dbmay have a:rf/routeslice (it's a regularapp-dbpath, not a special concept). - The default frame (
:rf/default) is URL-bound by default::rf.route/navigateevents on that frame fire:rf.nav/push-url, andpopstate(Back/Forward) drives it. The browser URL reflects the URL-owning frame's route. - Non-default frames are not URL-bound by default.
:rf.route/navigateupdates their:rf/routeslice (state changes) but does not fire:rf.nav/push-url. This is the right default for story-variant frames, devcards, per-test fixtures. - Opt-in URL binding for non-default frames via
(rf/reg-frame :my-frame {:url-bound? true}). The runtime enforces "only one frame can own the URL at a time" — re-registering a second:url-bound? trueframe is a:rf.error/duplicate-url-bindingtrace event. When the default frame opts out ({:url-bound? false}) and a single non-default frame opts in, that frame becomes the sole URL owner.
popstate drives the URL-owner frame, both directions¶
URL ownership is symmetric across the app↔browser boundary, and resolves to the same single owner in both directions:
- Outbound (app → browser).
:rf.nav/push-url/:rf.nav/replace-urlconsulturl-owner-frame-id(public) and only the owner mutates browser history. A non-owner's navigation updates its own:rf/routeslice but no-ops the history push. - Inbound (browser → app). A
popstatelistener fires[:rf.route/handle-url-change url] {:frame (url-owner-frame-id)}— targeted at the current owner resolved at pop time, NOT hard-coded to:rf/default. So Back/Forward restores the owner frame's:rf/routeslice (and the body rendered off it), whether the owner is:rf/defaultor a non-default:url-bound? trueframe.
The routing artefact ships install-history-listener! (CLJS) — re-exported as rf/install-history-listener! — which wires this popstate listener and does the initial URL → owner-slice sync. Apps boot it once after registering frames. It is idempotent (re-install replaces the listener, hot-reload safe) and is the inbound counterpart of the :rf.nav/push-url gate; rf/remove-history-listener! tears it down. Targeting url-owner-frame-id at pop time means default-owned apps and url-bound-non-default-frame apps behave identically through the same helper — a popstate dispatched at the old owner (:rf/default) after ownership transferred would update a frozen slice and leave Back/Forward broken (the bug.4 fixed).
The story / devcard / SSR cases all benefit:
- Stories / devcards: frame-per-variant; route within the variant is independent of the page URL.
- Per-test fixtures: each test frame has its own route; tests don't accidentally hit
pushState. - SSR per-request frames: the request URL is fed in via
:rf.route/handle-url-change; no client-sidepushState(which doesn't exist server-side anyway).
Open questions¶
SA-4 classification. Per SPEC-AUTHORING §SA-4: all five items classify as
:post-v1 tracked— additive design candidates that do not block v1.
Native nested layouts (post-v1)¶
Per §Nested layouts the v1 surface is :parent + the :rf.route/chain sub — the rendering side reads the layout chain as data and composes shells top-down. A richer mechanism — true <Outlet/>-style render slots, parent-loader cascades, and partial revalidation on child-only navigations (parent doesn't re-run when only the leaf changes) — is a substrate-shaped addition rather than a data convention. Deferred to until apps surface a real cost the chain-sub pattern can't carry; the :parent convention does not preclude a richer slot mechanism later.
Data-form path patterns (post-v1)¶
Per §The route table is data the v1 canonical wire form for :path is the string grammar ("/account/:id/orders/*rest"). A formally-specified vector-of-segments alternative (e.g. [:account [:id :int] "orders" [:rest :catchall]]) would carry per-segment schema inline and survive copy-paste better than embedded sigils. Deferred to — the string grammar is the v1 wire form and tools, conformance fixtures, and match-url all key off it; the data form would be an additive parser front-end.
Custom scroll-strategy registry (post-v1)¶
Per §Scroll restoration the v1 contract is the closed three-enum set (:top, :restore, :preserve) plus the map-form opt-in for host-specific shapes. A first-class registry (apps register-scroll-strategy! named entries; routes / nav opts name them by keyword) is an additive composition surface that keeps strategy registration enumerable for tools. Deferred to — the three enums cover the documented cases and locking them keeps tools' enumeration of scroll behaviour decidable.
URL-state-as-source-of-truth (post-v1)¶
Per §State-first, URL-second update order is locked the v1 model is app-db-canonical, URL-derived: navigation mutates state first, then syncs the URL. The inverse — URL canonical, app-db derived (the browser URL is the single source of truth; subscriptions parse it on demand) — is a substantial design change with downstream impact on SSR, multi-frame, stale-suppression, and the navigation cascade ordering. Deferred to ; v1's direction is locked because the URL update can fail (browser denies, offline) and state must remain consistent.
Declarative redirect rules in route metadata (post-v1)¶
Per §Redirects and guards v1 redirects compose as interceptors — guards are ordinary middleware over :rf.route/navigate, with full access to app-db and the event vector. A declarative metadata key (e.g. :redirect-to :route/login, optionally a fn of the route map) would let the simple "always redirect this route" cases skip interceptor boilerplate. Deferred to — the interceptor form is the universal carry; the declarative key is sugar over it once the common shapes have settled in real apps.
Resolved decisions¶
:rf.route.nav-token/* trace-operation namespace¶
The two nav-token trace operations — :rf.route.nav-token/allocated and :rf.route.nav-token/stale-suppressed (per §Navigation tokens — stale-result suppression) — live under :rf.route.nav-token/*. An earlier carve-out grandfathered the bare :route.nav-token/* prefix as the sole framework trace-operation namespace outside :rf.* (per the now-removed paragraph in Conventions); closed that single-bit-of-difference exception, mechanically renaming all 91 occurrences across spec, conformance fixtures, implementation, docs, skills, and tools. The Conventions single-root rule (every framework-owned keyword sits under :rf.*) now holds without exception.
Default frame is URL-bound; non-default frames opt in¶
Per §Multi-frame routing the default frame (:rf/default) is URL-bound by default; non-default frames are not. Non-default frames opt into URL binding via (rf/reg-frame :my-frame {:url-bound? true}); the runtime enforces "only one frame can own the URL at a time" (re-registering a second :url-bound? true frame emits :rf.error/duplicate-url-binding). This was chosen over a "first frame to dispatch :rf.route/navigate wins" rule because explicit declaration is auditable at registration time and matches the story / devcard / per-test-fixture / SSR-per-request use cases without surprise.
State-first, URL-second update order is locked¶
Per §Pattern-level contract, navigation runs state changes first, then the URL update, then :on-match dispatches and the scroll effect. The order is locked: if the URL update fails (browser denies, user is offline) the state is still consistent. An earlier alternative — URL-first — was rejected because the app-db becomes the source of truth for what URL the runtime intends; the :rf.nav/push-url fx is a downstream sync.
Three-enum scroll strategy (:top, :restore, :preserve)¶
Per §Scroll restoration the canonical scroll-strategy contract is the closed three-enum set. A custom scroll-strategy registry was considered but deferred to §Open questions — the three enums cover the documented cases (default-to-top on new navigation, restore on back/forward, preserve on intra-page transitions) and host-specific strategies layer additively via the map-form opt-in. Locking the enum keeps tools' enumeration of scroll behaviour decidable.
:rf.route/not-found is the single canonical reserved id¶
Per §Route-not-found — :rf.route/not-found (canonical) the framework names exactly one reserved route id for unmatched URLs. Earlier sketches considered per-host customisation of the reserved id; the single-id rule was chosen because tools, conformance fixtures, and the :rf.warning/no-not-found-route trace event all depend on it. Apps that want per-error-kind visual treatment branch inside the :rf.route/not-found view on :reason.
Run-to-completion enforced for navigation cascades¶
Per §Pattern-level contract the :rf.route/navigate cascade — state update, URL push, :on-match dispatches, scroll effect — settles inside a single drain. This matches Spec 002 §Run-to-completion dispatch and was chosen over a multi-drain cascade so that subscribers see a consistent post-navigation state in one render pass rather than visible intermediate states.
Cross-references¶
- 000-Vision §Working design implications — "routing is state plus events."
- 011-SSR.md — SSR-side route resolution.