| Field | Value |
|---|---|
| Status | Done |
| Priority | P2 |
| Created | 2026-02-19 |
| Completed | 2026-02-19 |
| Owner | Claude |
URL encoding is currently hardcoded in route_url.cljc. The encode path
(configuration->url / deep-configuration->url) always produces
/Seg1/Seg2?_p=<transit+percent-encoded>. The decode path
(url->route-target) always expects that exact format. This makes every URL
opaque — fine for apps, terrible for SEO, bookmarkability, and human
readability.
The goal is to make encode/decode pluggable so that:
= and + which are URI-significant, so the base64 string itself must be
uri-encoded)./users/42/posts?sort=date&page=3 by reading annotations on statechart
elements.configuration + data-model
│
├─ leaf-route-path-segments → ["AdminPanel" "UserDetail"]
│ walks :route/target up to :routing/root
│
└─ params-from-configuration → {:user-detail {:user-id 42}}
reads [:routing/parameters state-id] from data-model
╰── configuration->url joins them:
"/AdminPanel/UserDetail?_p=<transit+pct-encoded>"
"/AdminPanel/UserDetail?_p=..."
│
└─ url->route-target → {:leaf-name "UserDetail"
:segments ["AdminPanel" "UserDetail"]
:params {:user-detail {:user-id 42}}}
On rstate / istate elements:
:route/target — keyword (component registry key):route/segment — optional string override for the URL segment:route/params — set of keywords naming the route parameters:route/reachable — (istate only) set of keywords reachable through child chartA protocol with two functions: encode and decode. The encode function receives a rich context map (not just raw bytes) so the codec can make structural decisions. The decode function receives the URL string plus the same element metadata so it can reverse the encoding.
The encoder needs enough information to produce any reasonable URL shape. The
context map passed to encode should contain:
{;; State IDs of the active route path, in parent→leaf order.
;; The string segment for each is derivable: look up the ID in :route-elements,
;; then use :route/segment or (name :route/target).
:segments [:admin-panel :user-detail]
;; Parameters keyed by state-id, each value is a map of param-key → value.
;; These are the values the encoder must somehow embed in the URL.
:params {:user-detail {:user-id 42 :tab "settings"}}
;; Route elements from the active path, indexed by state ID for easy lookup.
;; Each value is the full element map from elements-by-id, so the encoder
;; can read any annotation it needs (including :route/encoding — see R4).
:route-elements {:admin-panel {:id :admin-panel :route/target :AdminPanel ...}
:user-detail {:id :user-detail :route/target :UserDetail
:route/params #{:user-id :tab}
:route/encoding {:path-params [:user-id]
:query-aliases {:tab "t"}} ...}}}
Why :route-elements? The encoder author may want to:
/users/42)tab → t)The elements give the encoder full access to the statechart author's intent without the framework needing to understand every possible annotation.
decode receives a URL string and the route-elements map (the same active
route elements the encoder saw). It must return:
{:leaf-id :user-detail ;; state ID of the leaf route target
:params {:user-detail {:user-id 42 :tab "settings"}}} ;; params keyed by state ID
The decoder is responsible for resolving the URL all the way to a leaf state
ID — it has route-elements to do the lookup. The framework then uses the
leaf ID directly to raise the routing event with the params. No intermediate
segment strings needed.
:route/encoding annotation on elementsArbitrary optional, opaque keys MAY be placed on rstate/istate elements. The framework
ignores these — but because the codec has the elements, it can use them.
I.e. The codec author defines them.
Example annotations an SEO codec might use:
;; Embed :user-id directly in the path: /users/42
(rstate {:route/target :UserDetail
:route/params #{:user-id :tab}
:route/encoding {:path-params [:user-id] ;; positional in path
:query-aliases {:tab "t"}}} ;; ?t=settings
...)
;; Custom segment pattern
(rstate {:route/target :BlogPost
:route/params #{:year :slug}
:route/encoding {:pattern "blog/:year/:slug"}} ;; /blog/2026/hello-world
...)
The :id of an rstate/istate is always (coerce-to-keyword target).
Passing an explicit :id to rstate or istate must be a compile-time error
(throw from the function itself during chart construction).
This eliminates the latent bug where auto-generated transitions use
:route/target as the SCXML transition target — since :id always equals
:route/target, they are guaranteed to match.
Runtime collision detection: When deep-configuration->url (or any
flattening step) collects route elements across the invocation tree, it must
check for duplicate state IDs and throw if any are found. This catches the
case where the same component appears as a leaf in multiple places in the
composed tree. The fix for the (rare) user who needs this is to create a
thin wrapper component with a distinct registry key.
Replace the current encode-params/decode-params with a default codec that:
+, /, = which are URI-significant)The URL shape stays /Seg1/Seg2?_p=<encoded>. This is a drop-in replacement
for the current encoding — just changes the wire format of _p.
The codec must be injectable at install-url-sync! time (or wherever the
URL sync machinery is initialized). When no codec is supplied, the default
transit+base64 codec is used.
Use parameter: :url-codec on the options map passed to install-url-sync!.
For any codec c and encoding context ctx:
(let [url (encode-url c ctx)
result (decode-url c url (:route-elements ctx))]
(= (:leaf-id result) (last (:segments ctx))) ;; resolves to same leaf
(= (:params result) (:params ctx))) ;; params survive round-trip
Define URLCodec protocol in route_url.cljc:
(defprotocol URLCodec
(encode-url [this context]
"Given an encoding context map, return a URL path string (with query if needed).")
(decode-url [this href route-elements]
"Given a URL string and route-elements map, return {:leaf-id <state-id> :params <map>}."))
Implement TransitBase64Codec as the default:
/,
append ?_p=<transit→base64→uri-encode> if params/, match segments to route-elements to find leaf ID,
extract _p, uri-decode→base64-decode→transitWire codec into the encode/decode call sites:
configuration->url and deep-configuration->url call encode-urlurl->route-target calls decode-urlModify configuration->url (and deep variant) to build the context map:
:route-elements by walking the same path as leaf-route-path-segmentselements-by-id):segments and :params as beforeAny custom keys the user puts on rstate/istate elements are already
preserved on the element map — no framework changes needed for annotations.
:url-codec option to install-url-sync!:route/target)(= params (-> (encode ...) (decode ...)))_p value is now base64+pct-encoded (not raw transit+pct-encoded):route-elementsroute_url_history_spec and url_sync_headless_spec tests pass:id to rstate/istate throws at chart construction timeEncoder receives only the active route elements (:route-elements map),
not the full elements-by-id. Same for decoder — it gets the route elements
only. This is sufficient since it covers the full active path.
One codec call per URL. deep-configuration->url flattens all segments
and params across the invocation tree first, then calls the codec once.
There is only one route/URL.
Custom element annotations are opaque. The framework does not define or
validate any annotation key (like :route/encoding). Codec authors put
whatever they want on elements — the framework just passes the elements
through.
State ID = route/target, always. Passing explicit :id to rstate or
istate is a compile-time error. Collisions across the composed invocation
tree are a runtime error. Users needing the same component in multiple tree
positions must create a thin wrapper with a distinct registry key (rare).
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |