Liking cljdoc? Tell your friends :D

DESIGN-V2.1 — corrections from slice 0, plan for the rest

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.


§0 (new) — What slice 0 actually taught us

Five Datastar facts had to be discovered the hard way. Each one would have silently broken the design as written.

#FactWhat v2 assumedWhat's actually true
1Bootstrap 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.
2Event-listener attribute syntaxdata-on:submitdata-on-submit shown as equivalentThe 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.
3Form 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.
5Morph-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.


§3 (revised) — The model

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:

  1. :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.

  2. 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.


§4 (revised) — The effect vocabulary

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:

  1. peeks at the leaving (top, popped) frame's :instance/resume,
  2. pops the frame,
  3. looks up (get parent-cdef resume-key) in the new top,
  4. invokes that with (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.


§5 (revised) — Auto-rendering at the kernel boundary

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 :handle or 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 …]).


§9 (revised) — Wiring to Datastar (the big one)

This section replaces v2 §9 wholesale. Every change is driven by §0.

9.1 The HTTP surface

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.

routemethodpurpose
<mount-path>GETmint a conversation, serve the shell HTML
/conv/:cid/sseGETopen the long-lived SSE stream
/conv/:cid/backPOSTrestore the previous conversation snapshot
/stube/upload/:cid/:iidPOSTparse multipart upload, dispatch upload event
/conv/:cid/:iid/:eventPOSTdispatch 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.

9.2 The Hiccup → Datastar bridge

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:

  • Colon, not dash, in the attribute name. data-on:submit is recognised by Datastar; data-on-submit is not (§0 fact #2).
  • The instance id and event name live in the URL path, not in a _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.
  • No __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.

9.3 Patching strategy in slice 0

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.

9.4 Pushing fragments

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.


§11 (new) — Patching strategy roadmap

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:

  • A parent that embeds children includes each child's expected id in its own rendered hiccup, via an s/render-slot helper that looks the child up in :instance/children and inlines its rendered HTML.
  • Embedded children are stored once and morph-preserved across the parent's re-renders. The kernel only emits a child render when the child itself changes (a handler on the child fires); when the parent re-renders, Datastar's morph sees that the child subtree hasn't changed and leaves it alone.

This is the same contract HTMX, idiomorph, and Datastar's morph mode all assume — we just have to obey it.


§12 (revised) — Comparison to the originals

The summary table from v2 stands; one row is worth promoting.

concernthis design (v2.1)
event routingURL path: /conv/:cid/:iid/:event
event payloadexactly the user signals — no framework metadata
transport contractone 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.


§13 (revised) — Implementation plan

The macro-shape of the plan from v2 §14 is unchanged. Here is the plan as it actually shapes up after slice 0.

Slice 0 — primitives, no flow macro ✅ done

  • Component registry, defcomponent.
  • Conversation atom, dispatch, step, run-effects.
  • HTTP wiring (start, sse, event) on http-kit + reitit.
  • s/on and s/bind Hiccup helpers; URL-path event routing.
  • :start lifecycle hook for hand-rolled tasks.
  • Slice-0 patch strategy: every render targets #root mode inner.
  • One demo (examples/stube/examples/guess.clj).

Slice 1 — defflow macro ✅ done

Added 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):

  • A single fixed resume key :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).
  • Each suspend yields [:yield <embed-spec>]; the runtime turns it into [:call <embed> :resume :on-flow-resume].
  • Body completion yields [:done <value>]; the runtime turns it into [:answer <value>]. For root flows the kernel's existing answer→end rule turns that into :end.
  • Bindings read like a one-arg fn arglist ([] 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:

  • Loops. 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.
  • Empty bodies. A 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.
  • Error answers. Likely [:answer-error e] with :on-error resume keys, but no use case has demanded them yet.
  • Persistence of cloroutine continuations. Slice 3 will need to decide between (a) snapshotting the body's state machine vs. (b) declaring flows non-persistable and recommending hand-rolled tasks for crash-resume scenarios.

Slice 2 — embedding & decorations ✅ done

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?:

  • first render of a frame{:selector "#root" :patch-mode :inner} (the id isn't in the DOM yet — covers boot, :call, :replace).
  • subsequent renders → no opts, Datastar morphs by element id.

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:

  • Children answering their parent. Embedded children calling [: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.
  • Lazy / conditional slots. :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.

Slice 3 — history & persistence ✅ done

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:

  • a counter (state restored bit-for-bit),
  • a :call/:answer pair (the popped child reappears),
  • an embedding parent (children re-inline correctly).

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:

  1. Fresh shell visit — pending flow on the baton; boot it.
  2. Restored conversation — cid exists in memory but has no pending flow; rerender the current top frame into the freshly attached browser shell. Restored renders use the selector #root, mode inner strategy for the same reason :back does.
  3. Unknown cid — gone (ended, expired); the channel stays empty.

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:

  • Browser back-button integration. No 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.
  • Cloroutine persistence. As above — defflow conversations are in-memory-only for now; the file store skips them with a warning.

Slice 4 — operations & Tier-3 async ✅ done

The operational surface landed together with the Tier-3 Seaside sweep.

  • Reaper. start! accepts :conversation-ttl and :reaper-interval; the background loop calls end-conversation! for stale conversations.
  • Admin ops. (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.
  • Session ownership. A cid is a routing handle, not a capability. The shell response stamps a 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.
  • Logging. Optional slf4j MDC integration keys request dispatch by cid/iid when slf4j is on the classpath.
  • Timers. [: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.
  • Topic delivery. [: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.
  • Multipart upload. /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.

Slice 5 (new) — DX polish ⏳ partial

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.

  • Hot-reload friendliness. Re-evaluating a 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.
  • REPL helpers. Done: (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.
  • Datastar Inspector tip in the README. Done.
  • Component documentation conventions. Done: defcomponent accepts :doc, the registry stores :component/doc, and (s/help id) exposes it.

§14 (new) — Open questions, refreshed

Most of the original mechanics are now settled; the remaining questions are about optional ergonomics and policy boundaries.

#questionanswer / status
v2.1Resume 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.2Where 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.3Signal naming under embeddingResolved: 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.4Parent re-render frequencyResolved: 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.5Per-instance vs per-render lifecycle hooksResolved: per-instance lifecycle. :start, :stop, and :wakeup cover task launch, cleanup before removal, and resume/resubscribe without introducing per-render hooks.
v2.6What 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.7Should 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.

§15 (new) — Aesthetic & usability bar

These are the non-functional things we want to be true at 1.0. They inform every slice.

  1. A defcomponent reads like a record definition. No magic, no surprise side effects, no required imports beyond stube.core.
  2. A 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.
  3. Errors are local. A bad component breaks its own frame, not the page. Datastar exceptions surface in the browser console with enough context to identify the failing instance.
  4. The whole library fits in your head. The kernel is one file, under 350 lines, with one multimethod. The transport is one file. The defcomponent macro is twenty lines.
  5. Zero client code. A user's project never imports JavaScript beyond the Datastar bundle. CSS is a separate concern; component styles are class names the user manages.
  6. The data is the program. Anything you can do with the framework you can also do by directly editing a conversation value in the REPL — including time-travel, replay, and "what if I had answered differently." That property is what justifies all of this; if we ever lose it, we've taken a wrong turn.

§16 (new) — Things still deliberately out of scope

Carried over from v2 §15 and refined.

  • Time-travel UI. History exists, the back button is one line, but a UI for browsing the history vector is an application's job, not the framework's.
  • Optimistic updates. Datastar handles them client-side via signals. The framework still won't model them server-side; that would re-introduce the cache-coherence problems persistent data structures buy us out of.
  • Streaming flows. Achievable today via [:io <fn>] plus [:patch …]. We won't add a first-class streaming primitive until someone has a workload that demands one.
  • Per-component CSS scoping. Hiccup is global. Tailwind, CSS modules, or PostCSS at the build layer is the intended path.
  • WebSocket transport. SSE is the right primitive here. WebSockets introduce ordering and reconnection complexity we don't need.
  • Framework-owned shared database / durable chat. Tier-3 pub/sub is live delivery only. Persistent rooms, replay, moderation, retention, and topic authorization belong at the host app boundary.
  • Application auth model. stube protects cids with an owner cookie; it does not model users, roles, sessions, or login flows for the app.

§17 (new) — Glossary, for the record

A small dictionary so future contributors don't have to re-derive the nouns we ended up with.

termmeaning
conversationThe entire server-side state of one user's session against one mounted flow. A plain map.
frameOne 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."
instanceA live, instantiated component. {:instance/id …, :instance/type …, …state…}.
componentA definition: the map under :component/id. Reusable across instances and across conversations.
flow / taskComponent whose role is to sequence other components via :call. Often has no UI of its own.
resume keyA 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.
cidConversation id, also the URL handle.
iidInstance id, also a URL handle for events.
fragmentOne outgoing message produced by the kernel — HTML, signals, script, or close.
shellThe static HTML page Datastar bootstraps from; everything visible after page-load arrives over SSE.
topicApplication-chosen pub/sub key. stube only maps it to live cid/iid subscribers.
upload summaryEDN-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

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close