v2.md was the design we set out to build. v2_1.md is the design we
are building, after a slice-0 implementation actually drove a browser.
The thesis is unchanged — the conversation is a value — and the model,
the kernel, and the data-shape sketches in v2 §3–§8 still hold. Only the
transport contract with Datastar and a small handful of kernel
protocol details moved.
This document is meant to be read alongside v2, not in place of it. It follows the same section numbering so you can flip between them; new sections are marked new and revised sections are marked revised.
Five Datastar facts had to be discovered the hard way. Each one would have silently broken the design as written.
| # | Fact | What v2 assumed | What's actually true |
|---|---|---|---|
| 1 | Bootstrap attribute | <body data-on:load="@get(...)" > | data-on:load doesn't fire — <body> has no load event after Datastar attaches. The right attribute is data-init, which Datastar fires once when it processes the element. |
| 2 | Event-listener attribute syntax | data-on:submit — data-on-submit shown as equivalent | The dash form data-on-<event> is reserved for built-in pseudo-events (data-on-intersect, data-on-resize, etc.). For an arbitrary DOM event you must use the colon form. The dash form is silently ignored. |
| 3 | Form submit default | (not addressed) | Datastar's data-on:submit already calls event.preventDefault() automatically. No __prevent needed for the form case. Other events still need __prevent if their default matters. |
| 4 | _meta signal convention | "set $_meta before posting; server reads :_meta from signals" | Signals whose names begin with _ are client-local: Datastar deliberately does not include them in the request payload. Anything we want the server to see must use a non-underscore name — or, better, not be a signal at all. |
| 5 | Morph-by-id and :call | "every patch the kernel emits is exactly the right thing to morph" | Morph-by-id only works when the target id is already in the DOM. When :call pushes a new instance with a fresh iid, the new HTML carries an id the page has never seen, so a default morph has nowhere to land. |
These are the only behavioural surprises in slice 0. None of them touch the kernel; all of them touch the wire.
Lifecycle hooks grew from a single :start into the smallest set needed
by history, persistence, and Tier-3 async demos.
;; Component definition, revised.
{:component/id :auth/login ; namespaced keyword, REQUIRED
:component/init (fn [args] state-map) ; optional, default (constantly {})
:component/render (fn [self] hiccup) ; optional, default hidden placeholder
:component/handle (fn [self event] [self' fx]) ; optional, default no-op
:component/keep #{:signal-keys} ; optional, default #{}
:start (fn [self] [self' fx]) ; OPTIONAL lifecycle hook (new)
:stop (fn [self] [self' fx]) ; OPTIONAL cleanup hook
:wakeup (fn [self] [self' fx]) ; OPTIONAL restore/resubscribe hook
:on-foo (fn [self answer-value] ; arbitrary resume keys
[self' fx])}
Two changes worth flagging:
:component/render and :component/handle are now optional. Tasks
(components that exist only to sequence :calls) had to grow a
trivial empty-renderer just to satisfy validation in v2; in v2.1 the
default is a hidden placeholder div with the instance id, and the
default handler is a no-op. The framework expects less boilerplate
from a task than from a UI component.
Lifecycle hooks are ordinary handler-shaped functions. :start
runs once, immediately after the kernel instantiates the instance;
:stop runs while an instance/subtree is still present but about to
be removed; :wakeup runs before a persisted/history-restored top
frame is rendered again. All return [self' effects] exactly like
:handle. Eager :children get the same per-instance lifecycle as
stack frames, so embedded widgets can subscribe or schedule timers as
soon as their parent is booted.
:start is what makes hand-rolled tasks read naturally:
(s/defcomponent :booking/wizard
:start (fn [self]
[self [[:call (s/embed :booking/dates) :resume :got-dates]]])
:on-got-dates (fn [self dates] …))
Without :start, a task would have to wait for a synthetic event to
fire its first :call, which is awkward and untrue to its purpose.
Slice 1's defflow macro uses :start as the entry point of the
compiled state machine. Tier 3 uses :stop / :wakeup for topic
cleanup and resubscription after resume.
Otherwise the model is exactly v2: instances are maps, conversations
are maps, history is a vector, behaviour is a registry lookup keyed by
:component/type.
The stack effects from v2 still hold, but the Tier-3 sweep added three small async/data-boundary effects. The shape of the resume mechanism also needed one clarification that v2 left ambiguous.
The resume key lives on the child frame, not the parent.
When :call pushes a child, it stamps the child's :instance/resume
with the key naming the function in the parent's component definition.
On :answer, the kernel:
:instance/resume,(get parent-cdef resume-key) in the new top,(parent-self answer-value).That sounds obvious now; v2's prose was imprecise enough that the first
implementation read the resume key from the parent's instance instead.
The resulting bug only manifested on the very first :answer flowing
into a parent, which is a perfectly avoidable rite of passage.
Tier 3 adds no framework-owned database or auth model; it only adds ways to route future/outside events back into a live cid/iid pair:
[:after delay-ms route-event] ; schedule `route-event` for this instance
[:subscribe topic route-event] ; deliver published messages as `route-event`
[:unsubscribe topic?] ; remove one/all subscriptions for this instance
stube.core exposes these as (s/after …), (s/subscribe …), and
(s/unsubscribe …). Publication itself is an async server operation,
(s/publish! topic msg), that dispatches the subscribed route-event
with msg in :payload. The kernel remains value-oriented: it calls
optional dynamic hooks while folding effects; the server supplies the
timer and topic registries.
Slice 0 added one small piece of kernel ergonomics that v2 didn't specify, because it became obvious the moment we wrote a real handler:
If a
:handleor resume function returns no element-producing effect, the kernel renders the affected frame on its own.
Concretely, after running effects through run-effects, both dispatch
and :answer check whether the resulting fragment list contains any
{:fragment/kind :elements}. If not — and the instance still exists,
because handlers can answer or end — the kernel renders the current
frame itself. This is what makes "update state, do nothing else" work
without callers having to remember to emit [:patch …].
The check has two clauses, both necessary:
(if (or (some #(= :elements (:fragment/kind %)) more-frags)
(nil? (conv/instance conv''' affected-id)))
[conv''' []] ; nothing to do
(let [[c f] (render-frame conv''' affected-id)]
[c [f]])) ; auto-render
The first clause avoids re-rendering a frame the user already rendered
via an explicit effect; the second avoids trying to render a frame the
handler just popped (e.g. via [:answer …]).
This section replaces v2 §9 wholesale. Every change is driven by §0.
The shape is unchanged in spirit; event routing lives in the URL path, and Tier 3 adds one non-SSE side route for browser-native multipart uploads.
| route | method | purpose |
|---|---|---|
<mount-path> | GET | mint a conversation, serve the shell HTML |
/conv/:cid/sse | GET | open the long-lived SSE stream |
/conv/:cid/back | POST | restore the previous conversation snapshot |
/stube/upload/:cid/:iid | POST | parse multipart upload, dispatch upload event |
/conv/:cid/:iid/:event | POST | dispatch one event into the named instance |
The shell:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>stube</title>
<script type="module" src="https://cdn.jsdelivr.net/.../datastar.js"></script>
</head>
<body data-init="@get('/conv/CID/sse')">
<div id="root"></div>
</body>
</html>
data-init instead of data-on:load — see §0 fact #1.
The two helpers stay, but produce different attribute keys and no longer touch signals.
(s/on self :submit) ; => {(keyword "data-on:submit") "@post('/conv/CID/IID/submit')"}
(s/bind :answer) ; => {(keyword "data-bind:answer") true}
Three deliberate changes from v2:
data-on:submit is
recognised by Datastar; data-on-submit is not (§0 fact #2)._meta signal (§0 fact #4). The expression is a clean
@post('/conv/cv-…/ix-…/event') and Datastar still ships every
other signal as the body, so two-way bindings still work.__prevent modifier on data-on:submit, because Datastar
already prevents the form's default submit (§0 fact #3). Other events
with surprising defaults can still take an explicit modifier.Every elements fragment the kernel emits in slice 0 carries
data: selector #root
data: mode inner
i.e. every render replaces the contents of the shell's
<div id="root">. This is the simple thing that always works for a
single-frame UI, regardless of how the iids change between renders
(§0 fact #5).
The downside: any client-side state inside #root (an <input>'s
focus, a half-completed text selection) is lost on each render. For
slice 0 — single-frame demos — that's acceptable; users don't notice
because the active component re-receives focus via autofocus. For
slice 2 (embedded children), this strategy will be replaced, not
extended; see §11.
Unchanged in spirit. The kernel emits generic fragments
{:fragment/kind :elements
:fragment/html "<form id=…>…</form>"
:fragment/opts {:selector "#root" :patch-mode :inner}}
and the http layer translates :fragment/opts into the Datastar SDK's
namespaced wire keys (:d*.elements/selector, :d*.elements/patch-mode,
etc.) at the moment of writing. This keeps the kernel pure of any
Datastar-specific knowledge — the SDK could be replaced wholesale and
the only file that would need to change is stube/http.clj.
Slice 0 makes one trade — replace #root every time — that won't
survive contact with embedding. Here's the staged plan.
slice 0 ╭──────────────────────────────────────────────╮
│ Always: selector "#root", mode "inner" │
│ Single top frame, no children │
│ Trivially correct, loses local DOM state │
╰──────────────────────────────────────────────╯
│ (slice 2 lands)
▼
slice 2 ╭──────────────────────────────────────────────╮
│ First top render: #root, inner │
│ Re-renders of same instance: morph by id │
│ Embedded children: rendered inline by │
│ parent; their fragment ids match what │
│ the parent's hiccup placed in the DOM │
╰──────────────────────────────────────────────╯
│ (slice 3 lands)
▼
slice 3+ ╭──────────────────────────────────────────────╮
│ `back!` re-issues a #root inner render of │
│ the restored top frame's hiccup; nested │
│ children re-mount on the next morph cycle │
╰──────────────────────────────────────────────╯
The contract that makes slice 2 work:
s/render-slot helper that looks
the child up in :instance/children and inlines its rendered HTML.This is the same contract HTMX, idiomorph, and Datastar's morph mode all assume — we just have to obey it.
The summary table from v2 stands; one row is worth promoting.
| concern | this design (v2.1) |
|---|---|
| event routing | URL path: /conv/:cid/:iid/:event |
| event payload | exactly the user signals — no framework metadata |
| transport contract | one bootstrap attribute (data-init), one event attribute pattern (data-on:event), one binding attribute pattern (data-bind:signal) |
The point: stube ships zero conventions on Datastar's signal namespace.
Anything in $signals is the application's, full stop. That gives us
back a lot of design space — for example, a future "optimistic UI"
slice can colonise the $ui.* namespace without colliding with the
kernel.
The macro-shape of the plan from v2 §14 is unchanged. Here is the plan as it actually shapes up after slice 0.
defcomponent.dispatch, step, run-effects.s/on and s/bind Hiccup helpers; URL-path event routing.:start lifecycle hook for hand-rolled tasks.#root mode inner.examples/stube/examples/guess.clj).defflow macro ✅ doneAdded cloroutine 13 and the stube.flow
namespace. defflow compiles a linear body into a component
definition; the body is wrapped in cloroutine.core/cr and the
continuation object is held on the instance map under ::flow/coro.
(s/defflow :booking/wizard []
(let [dates (s/await (s/embed :booking/dates))
room (s/await (s/embed :booking/room {:dates dates}))
receipt (s/await (s/embed :booking/pay {:price (price-for room)}))]
{:dates dates :room room :receipt receipt}))
Implementation as it actually shipped (small departures from the original plan, all towards simplicity):
:on-flow-resume rather than auto-generated
:on-step-N entries — there is one coroutine continuation per
instance, so one resume site is enough. The visible behaviour is the
same as the original "named per-step" plan; a future slice can re-add
per-step keys for debugging if it ever proves useful.:start runs the coroutine to its first suspend (or to completion for
zero-await flows).[:yield <embed-spec>]; the runtime turns it into
[:call <embed> :resume :on-flow-resume].[:done <value>]; the runtime turns it into
[:answer <value>]. For root flows the kernel's existing answer→end
rule turns that into :end.[] to ignore the embed
args, [<destructure>] for the usual case).await is :refer-imported into stube.core so s/await and
stube.flow/await resolve to the same var; cloroutine identifies
break points by var identity, so this is load-bearing.Resolved/observed during slice 1:
loop/recur with await inside the loop body works
(verified by loop-recur-across-awaits-works and the rewritten guess
demo). The cloroutine restriction is "no recur across a fn boundary"
— loop/recur itself is fine.defflow with zero awaits collapses to a
single :answer from :start; the kernel's :call step now handles
the case where :start immediately pops the just-pushed frame.Carried forward (not addressed yet):
try/catch across s/await — still an open spike.[:answer-error e] with :on-error resume
keys, but no use case has demanded them yet.Two compositional verbs landed.
Embedding. A component definition may declare children — either as a literal map or as a function of the freshly-initialised parent state:
;; Static
{:children {:slot/header (s/embed :ui/site-header)
:slot/cart (s/embed :cart/summary {:editable? true})}}
;; Dynamic
{:children (fn [self]
(into {} (for [[k v] (:items self)]
[(keyword "slot" (name k))
(s/embed :ui/row {:value v})])))}
The kernel materialises the whole subtree at instantiation time via
stube.conversation/instantiate-tree and stores {slot-key child-iid}
on the parent's :instance/children. Children live in
:conv/instances alongside their parent but never sit on
:conv/stack — embedding is structural, not modal. Inside the
parent's :render, (s/render-slot self :slot/header) returns the
child's hiccup directly; Chassis serialises everything in one pass, no
HTML escaping needed.
A child's own DOM events POST to the same /conv/:cid/:iid/:event
endpoint as any other instance. The kernel renders just that child
afterwards — see the patching switch below — so sibling DOM state in
the parent is preserved.
pop-top was extended to remove every embedded descendant when the
parent frame is popped, so :answer on a parent with children leaves
no orphans behind.
Patching-strategy switch (§11). render-frame now consults
:instance/rendered?:
{:selector "#root" :patch-mode :inner}
(the id isn't in the DOM yet — covers boot, :call, :replace).mark-rendered walks the children subtree, so when the parent's first
render places its children inline, those children are recognised as
"already in DOM" the next time their own handler triggers a re-render.
Decorations. No new runtime concept; just a thin helper:
(s/decorate base-cdef overrides)
overrides may be a map (replaces keys verbatim) or a function of the
base cdef returning such a map (so the override can call into the
original :render / :handle). Aesthetically equivalent to Seaside's
behavioural decorations; mechanically just merge.
Demo footnote: examples/.../breadcrumb.clj now exercises this
end-to-end. It registers a base page component, decorates the component
map with a breadcrumb trail (WAPath / WATrail style), and mounts the
decorated definition. The unit tests still cover both override styles.
Tier-2 Seaside sweep: the current example catalogue also includes
paginated_list.clj (WABatchedList), table_report.clj
(WATableReport), tree.clj (WATree), and example_browser.clj
(WAExampleBrowser). None required new kernel vocabulary; the main
design lesson was to keep user-supplied callback handles EDN-clean
(for example, a qualified symbol instead of a raw function in instance
state).
Carried forward:
[:answer …] is undefined today (their iid isn't on the stack).
Most parent↔child traffic should flow through shared signals; an
explicit [:notify-parent k value] effect is a candidate for slice
3 if a real use case demands it.:children is materialised eagerly at
instantiation. Conditional rendering is fine (just don't call
s/render-slot), but a slot whose embed-spec depends on later state
changes would need a :rebuild-children effect we haven't designed.Three things landed: a :back effect, a pluggable conversation store,
and crash-resume on SSE re-attach.
[:back] effect (re-exported as s/back). kernel/dispatch
already snapshotted the conversation onto :conv/history before every
event (slice 0), so the implementation is a one-liner conceptually:
pop the most recent history entry, install it as the current
conversation, mark every restored instance as not-yet-rendered, and
re-render the top frame. Marking instances as :instance/rendered? false is what makes the next render emit
{:selector "#root" :patch-mode :inner} — the same shell-replacing
strategy used on the very first SSE message of a fresh visit (§11).
This is correct because the previous DOM may not contain the ids the
restored frame wants to morph into.
A handler returns [self [s/back]] (s/back is the constant
[:back] effect). No-op when the history is empty, so the user can't
walk off the start. Tested against:
:call/:answer pair (the popped child reappears),Pluggable conversation store. A small three-method protocol in
stube.store:
(defprotocol ConversationStore
(load-all [this] "Eagerly load every persisted conv.")
(save! [this conv] "Atomically replace the persisted value.")
(delete! [this cid]))
Two implementations ship:
in-memory-store — every op is a no-op. This is the default
and preserves the slice-0 behaviour exactly: stube.server's
in-process atom is the source of truth.file-store — one EDN file per cid, written to a sibling temp
file and renamed atomically so a crashed write never leaves a
partial read on disk. java.time.Instant is round-tripped via a
print-method that emits #inst "…" and a matching EDN reader, so
conversations look like plain data on disk.stube.server/swap-conv! calls save! after every successful state
change; end-conversation! calls delete!. Both wrap the store call
in a try/catch — persistence failures are logged but never break the
live request.
A safe-printable? guard inspects each conversation before writing.
If pr-str produces a #object[…] marker (the canonical sign of a
non-EDN value, most commonly a defflow cloroutine continuation), the
file store skips the save and warns to *err*. The conversation
stays live in memory; only its on-disk copy is stale. Closing this
gap — making cloroutine continuations themselves persistable — is
left as work for a later slice; the framework still persists every
hand-rolled task component (slice 0 / slice 2 style) cleanly.
Crash-resume. start! accepts :store; when supplied, it calls
load-all before the http listener accepts requests and merges the
result into the in-memory atom. The SSE handler now has three
startup paths:
selector #root, mode inner strategy for the same reason :back
does.Demo: http://localhost:8080/wizard — a three-step name → colour →
summary wizard. Each step has a Back button wired to s/back; the
kernel rewinds the entire conversation (stack, embedded children,
all instance state) one snapshot at a time.
Carried forward:
popstate glue is wired
yet; s/back is fully usable from in-page Back buttons. Hooking
the browser button is a one-liner in the shell HTML
(data-on-popstate__window POSTing to /conv/:cid/back) but is
deferred until we have a clean cross-example pattern.defflow conversations are
in-memory-only for now; the file store skips them with a warning.The operational surface landed together with the Tier-3 Seaside sweep.
start! accepts :conversation-ttl and
:reaper-interval; the background loop calls end-conversation! for
stale conversations.(s/active-conversations), (s/end! cid),
(s/mounts), (s/inspect cid), and (s/replay root events) provide
the small REPL/admin surface needed by examples and tests.stube_sid owner cookie; event, back, SSE,
and upload handlers reject requests whose cookie does not own that cid.
This is framework ownership, not application authentication.[:after delay-ms route-event] schedules a cid/iid-scoped
event. Ending a conversation cancels outstanding timers; delivery to a
missing instance is a no-op. Demos use a generation payload when they
need stale tick suppression after wakeup/restart.[:subscribe topic event], [:unsubscribe topic],
and (s/publish! topic msg) deliver server-side publications back into
live conversations as normal events. Topic policy, durable storage,
fan-out limits, and authorization remain application concerns./stube/upload/:cid/:iid, (s/upload-attrs self),
and (s/upload-frame self) keep examples zero-JS while letting a
normal browser file form dispatch :upload-received. Handlers should
store EDN summaries, not raw temp-file objects; immediate cleanup can
happen via :io effects.Not in v2 at all, but obviously needed if anyone outside our heads is to use this. Several small pieces already landed during the example sweeps; hot-reload semantics remain the main open DX item.
defcomponent should
not crash live conversations whose instances are of that type. The
registry already replaces in-place; the open question is whether to
re-run the new :component/render against existing live instances
the next time their frame is touched, or only for new frames.(s/inspect cid) pretty-prints a live
conversation summary, and (s/replay baseline events) reduces a
conversation or root flow through pure events for tests.defcomponent accepts
:doc, the registry stores :component/doc, and (s/help id)
exposes it.Most of the original mechanics are now settled; the remaining questions are about optional ergonomics and policy boundaries.
| # | question | answer / status |
|---|---|---|
| v2.1 | Resume keys vs anonymous closures? | Resolved: named keys. Anonymous closures would lose serialisability for free; we choose to keep state EDN-clean. Slice 1's defflow auto-generates resume key names so users rarely write them by hand. |
| v2.2 | Where does the conv live during one event? | Resolved: in !conversations atom, mutated via swap-conv!. One writer per cid at a time. The volatile box pattern is retry-safe under CAS contention. |
| v2.3 | Signal naming under embedding | Resolved: explicit local signals. (s/local-bind self :answer) writes a signal name scoped by iid, and the conversation layer lifts it back onto :answer before the handler runs. Components can still choose global signals deliberately with s/bind. |
| v2.4 | Parent re-render frequency | Resolved: no by default. Child handlers render the child in place; the parent's hiccup is unchanged unless the parent explicitly receives an answer/notification or handles its own event. |
| v2.5 | Per-instance vs per-render lifecycle hooks | Resolved: per-instance lifecycle. :start, :stop, and :wakeup cover task launch, cleanup before removal, and resume/resubscribe without introducing per-render hooks. |
| v2.6 | What happens when an event arrives for an instance that no longer exists? | Resolved: 410 Gone for client POSTs; no-op for async delivery. A stale browser POST patches a reload message and ends the conversation. Timer/pubsub delivery to missing cid/iid pairs is dropped. |
| v2.7 | Should stube own shared app state, chat storage, or auth principals? | Resolved: no. The framework owns cid/iid delivery, owner-cookie binding, and EDN-clean upload summaries. Application state, durable pub/sub storage, topic authorization, and real principals remain host-app concerns. |
These are the non-functional things we want to be true at 1.0. They inform every slice.
defcomponent reads like a record definition. No magic, no
surprise side effects, no required imports beyond stube.core.defflow reads like the equivalent script. If the prose
description is "ask for dates, then ask for a room, then ask for
payment," the code says exactly that, with s/await as the only
syntactic oddity. No state-machine boilerplate.defcomponent macro is twenty lines.Carried over from v2 §15 and refined.
[:io <fn>] plus
[:patch …]. We won't add a first-class streaming primitive until
someone has a workload that demands one.A small dictionary so future contributors don't have to re-derive the nouns we ended up with.
| term | meaning |
|---|---|
| conversation | The entire server-side state of one user's session against one mounted flow. A plain map. |
| frame | One element on the conversation's stack. Same shape as an instance; "frame" is a vocabulary choice for "the live one we're operating on right now." |
| instance | A live, instantiated component. {:instance/id …, :instance/type …, …state…}. |
| component | A definition: the map under :component/id. Reusable across instances and across conversations. |
| flow / task | Component whose role is to sequence other components via :call. Often has no UI of its own. |
| resume key | A keyword that names the function in a parent's component definition that will receive the child's :answer value. |
| embed spec | {:embed/type :foo, :embed/args {…}} — the value s/embed returns and that effects pass around. |
| cid | Conversation id, also the URL handle. |
| iid | Instance id, also a URL handle for events. |
| fragment | One outgoing message produced by the kernel — HTML, signals, script, or close. |
| shell | The static HTML page Datastar bootstraps from; everything visible after page-load arrives over SSE. |
| topic | Application-chosen pub/sub key. stube only maps it to live cid/iid subscribers. |
| upload summary | EDN-safe map produced from a multipart file before user handlers store it. |
End — DESIGN-V2.1.
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 |