Big-rock changes to stube. Released versions may batch more than one development entry.
(No changes yet.)
:eager-scripts no longer HTML-escapes its body (bugfix). The
shell's eager-script-block wrapped the concatenated snippets in
[:script body] without chassis/raw, so any " inside a JSON
literal landed in the browser as " and the <script> tag
failed to parse with Uncaught SyntaxError: Unexpected token '&'.
Bodies are now emitted via chassis/raw, matching the documented
"emitted verbatim" contract. Pinned by kasten/stube_notes.md
after the 0.3.1 migration.Driven by the second wave of kasten post-migration notes
(kasten/stube_notes.md, §"Open rough edges against Stube 0.3.0").
Four rough edges land here; the fifth — a unified signal-naming /
binding toolkit — stays deferred in todo.md §2 until a second host
hits the same shape.
behaviors.js base-path resolution no longer requires a host
body attribute (bugfix). The shipped bridge is loaded as
<script type="module">, and inside a module IIFE
document.currentScript is always null — so the previous attempt
to read data-stube-base-path from the script tag silently fell
through to an empty base-path on every non-standalone mount, and
behavior modules 404'd unless the host stamped the attribute on
<body> itself. The resolver now consults, in order:
import.meta.url (always available in modules; the bridge is
served from <base>/behaviors.js, so stripping that suffix
recovers the base), then <html> / <body>
data-stube-base-path, then any element carrying the attribute
(the shell <div> does), then the empty string. The
data-stube-base-path attribute on the script tag and on the
shell <div> is retained for legacy fallback.
:base-css on make-kernel / start!. A vector of
stylesheet URLs that head-tags emits unconditionally, in order,
before component-derived stylesheets. Use this when host CSS has
to appear on routes that don't embed head-tags at all (e.g. a
static /about page that shares a layered stylesheet with the
stube-driven /). URLs are emitted verbatim; relative URLs
resolve against the host page, absolute URLs pass through.
:eager-scripts on make-kernel / start!. A vector of
inline JS snippets that head-tags emits as one synchronous
<script> block in <head> before any type="module" script.
This is the blessed seam for seeding window.<X> namespaces that
inline Datastar expressions
(data-on:input="window.X.bindSlugSync($value)") need available
from frame 1, before the deferred ESM module graph finishes
parsing. Snippets are emitted verbatim and concatenated; the host
owns the contents.
Behavior ctx grows real signal writes and a fetch helper.
ctx.signals now exposes get / set / patch against
Datastar's nested signal tree (dotted paths supported), and the
ctx surfaces ctx.setSignal(name, value) and
ctx.patchSignals(map) as direct aliases so behavior code does
not have to reach into ctx.signals for the common write paths.
ctx.fetch(eventUrl, opts?) posts to a stube event URL the same
shape s/on does — pair it with data-stube-arg-event-url (or
build the URL on the fly with event-url and pass it in) when a
behavior wants to drive server state without a sibling form. The
end result: behaviors that own a live widget (CodeMirror,
Chart.js, drag-and-drop) can mirror their internal state into
bound signals directly, retiring the hidden-input shim that
previously bridged the two worlds.
Carried forward to todo.md: the signal-naming / binding helper
toolkit (s/data-signals, s/$, s/signal lookup,
s/scoped-signal). Kasten still hand-rolls the registry; the right
API surface remains design-heavy enough that one datapoint is not
enough to commit to a shape.
Client-side seam: behaviors, component-scoped CSS, file-convention assets. Stube grows an opinion about where in-browser JS and CSS live and how they connect to a component. See ADR 0007 for the design rationale.
s/root-attrs now automatically emits
data-stube-component="ns/name" and class="stube-c-<ns>-<name>"
on every component root, derived from :instance/type. CSS
selectors and behaviors can address every instance by its
registered keyword; user-supplied :class is concatenated, never
replaced. Instance maps without :instance/type (hand-rolled in
tests) are unaffected.s/behavior self behavior-id args attaches a client-side
behavior to an element. Renders data-stube-behavior="ns/name"
plus one data-stube-arg-<k> per arg. The shell loads a tiny
behaviors.js bridge that sweeps the document on every
stube:patched, lazy-imports the matching module from
<base>/behaviors/<ns>/<name>.js, and drives a fixed lifecycle:
mount(el, ctx) once, patched(el, ctx) on every later morph
that left the element alive, unmount(el, ctx) on detach. ctx
is {el, args, basePath, signals}. Pairs with s/preserve when
the behavior owns DOM children outside the server's render tree.
Behaviors fetch the URLs produced by s/event-url to drive
server state — the canonical "client widget tells the server
something happened" pattern, demonstrated in the new /sketch
example.resources/stube_styles/<ns>/<name>.css, when present, is
auto-linked by head-tags. Selectors target the auto-emitted
[data-stube-component="ns/name"].:styles "..." on defcomponent. Colocated CSS for
small components: & is replaced with the component's
[data-stube-component] selector at head-emit time; all chunks
are concatenated into a single <style> block.:modules ["foo/bar"] on defcomponent declares eager JS
module dependencies served from
resources/stube_modules/foo/bar.js. head-tags emits one
deduped script per distinct entry across the whole registry./<base>/styles/<ns>/<name>.css, /<base>/modules/<id>.js,
/<base>/behaviors/<ns>/<name>.js, /<base>/behaviors.js. Asset
segments are restricted to [A-Za-z0-9_-]; traversal-shaped
requests return 400 and never touch io/resource./sketch example drives the whole seam end-to-end:
s/behavior for canvas drawing, s/preserve for the live pixels,
a per-component stylesheet, an inline :styles chip, a :modules
entry that registers a global c keyboard shortcut, an
s/execute-script snapshot button. Behavior posts back to a
server event URL on every pointerup; the server-owned stroke
counter morphs in the chip outside the preserved subtree.dev.zeko.stube.kit with ^:no-doc
so cljdoc-analyzer filters the namespace out before requiring it.
The kit adapter pulls in integrant.core, which stube intentionally
does not declare as a runtime dependency — without the marker the
0.2.0 cljdoc build failed on Could not locate integrant/core. The
kit-clj integration story still lives in the README; only the
generated API page for this one namespace is omitted from cljdoc.doc/cljdoc.edn so the API
reference, tutorial, internals, rationale, changelog and ADRs land
in the expected order on
cljdoc.org/d/dev.zeko/stube.
README picks up Clojars + cljdoc badges and a one-line pointer to
the rendered docs. The release script now POSTs to cljdoc's
request-build2 endpoint after the Clojars push so docs rebuild
without a manual step. No library code changed.Driven by the first big embedder's post-migration notes (kasten,
kasten/stube_notes.md). Four threads land here; the fifth — a
unified signal-naming/binding toolkit — is bigger and stays in
todo.md until a second host hits the same shape.
s/on / s/on-target. Both helpers accept
an optional trailing modifiers map (or vector of pairs) that
becomes Datastar event modifiers in the attribute name itself
(data-on:input__debounce.300ms, data-on:click__stop__prevent).
Values may be strings, numbers, keywords, or true for flag-only
modifiers. Map entries are sorted by key name for deterministic
output; vector form preserves caller order for cases like
__debounce.300ms.leading where one Datastar modifier takes
multiple positional parts. Previously hosts had to hand-roll the
attribute keyword to get debounce/throttle on search boxes,
typeaheads, and autosave fields.s/instance-id + s/on-target accepts an instance map.
Pure rendering helpers no longer have to reach for :instance/id
to call on-target. (s/instance-id self) is now the stable seam
that returns the iid or throws; s/on-target and s/event-url
accept either a bare iid string or an instance map. New
(s/on-parent self …) is shorthand for the recurring
child-renders-control-that-belongs-to-the-parent pattern —
equivalent to (s/on-target (:instance/parent self) …) but reads
as a stable API surface rather than reaching into the instance
map. (Picks the structural name on-parent over the semantic
on-owner to avoid collision with the existing
:conv/owner-token security vocabulary.)stube:patched browser lifecycle event. The shipped
preserve.js bridge now dispatches a CustomEvent on document
after every successful Datastar patch-elements morph. Hosts that
need to run after a patch lands (scroll/focus restoration, title
measurement, third-party widget reflow, optimistic UI cleanup) can
listen for stube:patched instead of inventing their own
MutationObserver. event.detail carries selector and mode
from the patch./stube/ segment (pre-1.0
breaking). With :base-path "/stube", asset routes now live at
/stube/ui.css, /stube/preserve.js, /stube/halos.js, and
/stube/halos/:cid/{panel,enable} instead of doubling the segment
(/stube/stube/ui.css). Standalone (empty base-path) gets the
symmetric /ui.css, /preserve.js, /halos.js. Conversation
endpoints (/sse, /event, /back, /upload) already lived at
the kernel's base-path; this aligns the asset routes with the same
rule. URL construction is centralised in render.clj and
adapter/ring.clj, so generated head-tags and shell-for pick
the new paths up automatically; hosts that hardcoded
/stube/stube/... in their own templates need a trivial rewrite.Carried forward to todo.md: a signal-naming/binding helper toolkit
(s/data-signals, s/$, s/signal lookup, s/scoped-signal) so
hosts don't rediscover Datastar's HTML-attribute case-collapse rules.
Tracked but not built: the kasten author flagged this as the biggest
remaining shared pattern, but the right API surface is design-heavy
enough to wait for a second concrete embedder.
:e2e deps alias and
make e2e target run a browser-driven sanity pass over the live
examples catalogue. Twenty tests pin one stube primitive each:
morph-by-id, s/keyed-children, :call-in-slot, :url projection,
s/decorate, s/preserve, :principal-fn gating, error-frame /
s/answer-error, defflow/s/await/wizard-back, dialogs,
recursive renderers, structured event payloads, table sort,
pagination, inactive-child preservation. The suite is gated to
make e2e and make release; make test stays Clojure-only and
fast. See test-e2e/dev/zeko/stube/e2e/ for the harness.mint-conversation! bugfix. A fresh shell visit minted a
session id for Set-Cookie, then ignored it and minted a second
id inside mint-conversation!. The conversation ended up owned by
the second id while the browser was told to send the first — every
subsequent SSE GET hit the cross-session check and returned 403.
Manual browser sessions papered over this by reusing a cookie from
an earlier tab, but a fully fresh BrowserContext (or any new
install) was broken. shell-handler now threads its sid through to
a new 5-arity mint-conversation!. Pinned by
shell-set-cookie-matches-conv-owner-token in http_test.examples/reading_list.clj Close button. The per-card Close
button used (s/on self :click :as [:close id]), where self is
the item — but :reading/item has no :handle, so the click was a
no-op and the desk's :close resume never ran. Switched to
s/on-target against (:instance/parent self) so the click POSTs
to the desk that owns the handler. The e2e test for URL-driven
restore + close now exercises the full round-trip.dev.zeko.stube.routes
namespace and the stray test that exercised its private fn.
The standalone server has long since used
dev.zeko.stube.adapter.ring/ring-handler; the internals
module map drops the line too.clj-kondo warnings (one in
src/, six in test/). The genuine false positive — kondo
can't see through runtime/cid-lock — gets an
#_:clj-kondo/ignore with a one-line explanation; the rest
are real cleanups.make lint target and made
make test depend on it. clj-kondo exits non-zero on any
warning, so the standard pre-PR check now catches lint
regressions before tests even run. AGENTS.md documents the
workflow and the #_:clj-kondo/ignore escape hatch.defalias macro in
dev.zeko.stube.core and rewrote every plain
(def ^{:doc "..."} foo target/foo) re-export with it.
:arglists now flows through, so (doc s/answer),
(doc s/patch), (doc s/end) — and CIDER eldoc — show the
target signature. clj-kondo learns the form via
:lint-as clj-kondo.lint-as/def-catch-all so dependent
namespaces still resolve s/... names.core_test/every-public-name-has-doc-and-arglists
sweeps ns-publics of dev.zeko.stube.core and fails if any
non-:no-doc var loses its docstring, or any function var
loses :arglists. Locks the R1-04 invariant against
regression.replay-event helper
out of core.clj and runtime.clj into a single public
conversation/replay-event. Both callers (core/replay and
runtime/replay-with) now share the event-shape normalisation
rule.conversation/snapshot-for-dispatch
from inside kernel/dispatch. The [:back] carve-out (a
handler walking history backwards must not have its own
pre-state pushed onto that history first) now lives next to
the conversation data it shapes; kernel/dispatch reads
the snapshot decision in one line instead of ten.registry/register! now throws ex-info
when a defcomponent form declares both :foo and
:component/foo for any lifecycle key (silent lift used to
overwrite the long-form value). The registry ns docstring and
docs/api.md document why lifecycle keys are a closed set
while resume keys (:on-foo, :on-error-foo) pass through
verbatim.subvec + apply hash-map
accessors in effects/call-resume and
effects/slot-call-resume with positional destructure
(Option A from the issue). The wire shape stays a vector and
the kernel multimethod is unchanged; the per-effect
allocation overhead in call/call-in-slot goes away.answer-error fallback warning's
once-per-pair dedup from a JVM-global atom in kernel.clj
onto the kernel value (:!answer-error-warned, reset on
halt!). Two embedded kernels in the same JVM now each emit
their own one-time message. Kernel-less paths (pure
s/dispatch / s/replay) skip the warning entirely.Dynamic bindings section in
docs/internals.md catalogues every ^:dynamic var (15 of
them across kernel/render/effects/errors/flow/dev) by where
it is bound, where it is read, and what nil means. Also
fixes the stale render/*conv* docstring (frame/render-frame
is the binder, not the kernel).load_direction_test codifies the
pure/impure split. Walks ns-aliases transitively from each
pure namespace (conversation, effects, fragments,
kernel, frame, lifecycle, registry, render, plus
the dev/errors/keyed helpers they pull) and fails if
any reach runtime, server, http, the adapters, or the
kit glue.AGENTS.md documents the convention.:url machinery
(S-11) is wired directly into kernel/dispatch via
maybe-emit-url; a hook list would generalise it, but a
hook list with one consumer is harder to read than the
straight call. Decision: not yet. docs/internals.md picks
up a one-paragraph note next to the dispatch-path diagram so
the seam is grep-able when a second consumer appears.requiring-resolve indirection
in dev.zeko.stube.embed. The namespace now contains only
the ten documented public fns
(make-kernel/mint-conversation!/shell-for/head-tags/
dispatch!/replay-with/halt!/shutting-down?/publish!),
each a thin direct delegate to dev.zeko.stube.runtime. The
~25 ^:no-doc plumbing fns it used to expose for adapters
have moved back to runtime; http, halos/http,
adapter/ring, and server :require runtime directly.dev.zeko.stube.server to
lifecycle (start!, stop!, mount!, unmount!, mounts,
reset-state!, the reaper) plus a small default-kernel
convenience surface (default-kernel, conversation,
active-conversations, end!, inspect, publish!). The
~20 wrapper fns that nothing outside the namespace needed
are gone; the two consumer test files (server_test,
http_test) switch to rt/foo (server/default-kernel) ….docs/decisions/0006-embed-as-direct-runtime-facade.md
records the decision behind R1-05 and R1-06. The original
requiring-resolve choice was never written down; this
ADR is the first written form, and the index table also
picks up the missing 0005 entry along the way.nix develop,
clojure -M:examples, the examples table, Datastar
Inspector and (s/inspect cid) hints). Content was reordered,
not rewritten.:example-ring deps.edn alias runs the plain-Ring embedded
example (examples/dev/zeko/stube/examples/embedded_ring.clj)
via clojure -M:example-ring instead of needing a REPL.Road to 1.0 (breaking, pre-1.0): trimmed pre-1.0 surface in preparation for stability.
:emit-on-mount colocated key; use :start
directly. :start already covers the effect-only case
((fn [self] [self effects])), so the sugar layer was net
cruft. Tutorial chapter and reading_list.clj updated. The
S-12 CHANGELOG note below describes the original sugar.:legacy standalone
paths (/conv/:cid/sse, /conv/:cid/:iid/:event, …) are gone;
every kernel now uses the :adapter paths (/sse/:cid,
/event/:cid/:iid/:event, …). :route-style is no longer
an option to s/start!, make-kernel, or the shell.s/with-app and s/with-principal macros so component
tests no longer reach into dev.zeko.stube.kernel/*current-*
directly. Docstrings, docs/api.md, docs/tutorial.md, and
ADR 0004 updated to use them.S-15: s/on-unmount Hiccup helper for preserved hosts. Mirrors
s/on-mount: returns a data-stube-on-unmount attribute carrying
a synchronous JS expression with el bound to the host element.
preserve.js grew a document-wide MutationObserver that fires the
expression once when the host genuinely detaches — queueMicrotask
defers the check so Idiomorph's detach+reattach swap dance can't
double-fire. Logs to console.error on throws; never blocks the
morph. CodeMirror/Chart.js/<video> integrations now have a real
teardown path. README and preserved_widget.clj updated.
S-14: (s/answer-error ex) + :on-error-<key> resume keys.
Symmetric child→parent failure routing — the child catches its
exception and emits (s/answer-error ex); the parent declares
:on-error-saved next to :on-saved and receives the exception
verbatim. Three-tier fallback: explicit :on-error-<key> →
:on-<key> with [:error ex] plus a one-time deprecation
warning per cdef → the default error-frame banner. New ADR
0005-answer-error-and-resume.md; new worked example
error_answer.clj; todo.md §2 entry removed.
S-13: New docs/api.md section "Reading dependencies — app
vs context vs principal" with a comparison table, decision
tree by question, three common mistakes (notably reading
(s/context self) from :init), and a worked migration moving
the DB choice from :app to :context-fn. Cross-refs from the
existing s/app / s/context / s/principal entries, from the
defcomponent :init row, and from make-kernel's opts list.
S-12: Shareable-URL bootstrap recipe and :emit-on-mount sugar.
Declaring :emit-on-mount (fn [self] effects) lifts to
:component/start at registration; declaring both is a register-time
error. New worked example reading_list.clj demonstrates the
three-piece pattern (:init-args-fn → :emit-on-mount →
:url) end-to-end. New tutorial chapter "Shareable views — URL as
durable state" walks through the same flow. docs/api.md
cross-refs from mount! and keyed-children.
S-11: Root-component :url key for declarative URL sync. Returns
nil, a string, or [:replace|:push url]; the kernel diffs against
:conv/last-url after every dispatch and auto-emits a [:history …]
effect on change. Explicit (s/history …) from the handler always
wins. Only the root frame's :url applies. :init-args-fn on
mount! pairs with this to read the URL back in on a fresh mount.
url_state_counter.clj refactored to use the new form;
url_state_counter_manual.clj preserves the hand-rolled version for
comparison.
:call-in-slot previous-chain leak surfaced by
kernel-property-test during the 0.1.1 sweep. New
conversation/subtree-ids walks :instance/previous chains
alongside :instance/children and :instance/keyed-slots; the
frame-destruction paths (pop-top, :replace, :end, root-frame
:answer, keyed-child removal, runtime halt!) use it so
previous-chain instances get their :stop hooks and are swept
from :conv/instances. The narrow descendant-ids survives for
paths where the previous gets restored (answer-from-slot,
mark-rendered, history wakeup). Pinned by a focused regression in
embed-test and by tightened structural assertions in
kernel-property-test.Road-to-1.0 sweep. See todo.md for the items deliberately deferred
past this release (composition spikes, popstate, extra HTTP routes,
the :call-in-slot previous-chain leak surfaced by the new property
test).
kernel_property_test uses test.check
to walk random event sequences through kernel/dispatch against a
stub registry, asserting after every step that (a) no throw,
(b) pr-str/read-string round-trip yields an equal conversation,
(c) every :elements/:error fragment that names an iid selector
refers to an instance that exists post-dispatch, and (d) the call
stack and :instance/children only reference live instances.
The test surfaced a real leak: :call-in-slot's
:instance/previous chain is orphaned when the parent frame is
replaced or ended before the slot child answers. Out of scope here;
named and bounded by a comment in the test so it does not get
forgotten.kernel, server, conversation, runtime,
render, frame, fragments, http, lifecycle, effects,
registry). embedded_ring.clj stays the documented exception
for dev.zeko.stube.embed as the host-embedder surface.docs/internals.md, with a sketched
Publisher protocol for the eventual seam if a real bus is ever
wanted, plus a working-today recipe using :app as a bring-your-own
bus. New docs/decisions/ folder with four short ADRs covering
resume-key naming, EDN-clean conversation state, the embed/call
split, and the :app + :principal-fn contract.stube-keepalive
event (an SSE event-type Datastar ignores) every
:sse-keepalive-ms milliseconds, default 15000. The heartbeat
stops when the SSE channel unregisters or the kernel halts. Pass
nil or 0 to disable. docs/internals.md carries an "SSE behind
a reverse proxy" section with nginx, ALB, Caddy, and HAProxy knobs.defflow durability is now documented as a deliberate property,
not a pending gap. A conversation containing a defflow is
in-memory only by design (its cloroutine continuation is not
EDN-serialisable). For long-running flows that must survive a
restart, write the same shape as a hand-rolled task component with
:start + named resume keys; the tutorial now shows the two side
by side. The store's skip-on-defflow warning is more pointed about
the workaround.:app option on make-kernel carries an opaque host value
(typically a map of long-lived dependencies) that component code
reads via (s/app). The value is not serialised with the
conversation; rebuild it from live JVM state on each
make-kernel call.:principal-fn option is invoked once when a conversation is
minted; its result is persisted on the conversation under
:conv/principal and surfaced through (s/principal). The
principal is fixed for the life of the conversation — end and
re-mint to change identity. There is no set-principal!
operation by design.s/start! for the standalone
server. The protected_counter example now reads its principal
via (s/principal) instead of carrying app-level login state on
the conversation.dev.zeko.stube.embed as the documented host-embedder
namespace. dev.zeko.stube.kernel is back to being just the pure
effect fold (step, run-effects, dispatch, boot,
resume-top, redraw-top). Internal callers, tests, examples, and
docs have been updated. The §15.4 line-count invariant has been
replaced by a structural one: the runtime stays organised around a
single effect multimethod.s/back is now a zero-arity function (s/back) returning the
[:back] effect, matching every other effect constructor. Call
sites that used the bare value need a pair of parens.dev.zeko.stube.kernel/replay is now kernel/replay-with so the
kernel-aware host helper no longer collides in name with the
kernel-less core/replay used by component-author tests.registry/register! now lifts every colocated author key —
:start, :stop, :wakeup, :children in addition to the
previous :init/:render/:handle/:keep/:doc/:state — to
its :component/<name> home. Component definitions registered via
any entry point (defcomponent, register-component!,
decorate!) are now uniform; the kernel reads them under a single
namespace.s/on-target
against the parent iid instead of synthesising a fake parent
instance map.204 no-ops instead of ending the
whole conversation; missing/ended conversations still surface the
stale-page response.s/publish! routes
to the active embedded kernel when called from component code.:io a runtime-interpreted effect. Pure dispatch/replay
keep it inert unless a runtime hook is bound, preserving the kernel's
value-oriented testability.s/on-target, with
s/event-url documented as the low-level escape hatch.s/call, s/become, and s/call-in-slot accept existing embed
specs directly, so stock helpers like (s/confirm "?") compose
without reaching into internal effect constructors.stube/head-tags now returns the stock CSS,
preserve bridge, Datastar, and optional halos assets for the current
kernel/base path; examples no longer reach into shell internals.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 |