Builds the client-side expressions that invoke :on handlers.
1, which recorded this as "unresolved, and currently unsound". This namespace resolves it. The problem was not ergonomics — it was that the hand-written form was wrong:
;; before
[:button {:data-on:click (str "@post('/act?event=remove&id=" (:id i) "')")}]
Three defects, all verified against a real render rather than reasoned about:
' closes datastar's string literal early — o'brien became
@post('...id=o'brien'), a syntax error. An arg containing & split into two
query params. = and + corrupted the parse.(parse-long (get params "id")), hardcoded. A string arg silently became
nil; a boolean could not round-trip at all.Datastar's fetch actions take a payload option that replaces the signal set
and is sent as the request body:
@post('/live/act', {payload: {"event":"remove","id":42}})
This kills all three defects structurally rather than by escaping more carefully:
' and & are unremarkable. The
nested-quoting problem does not arise, because there is no nested quoting.42, "42", true and null stay distinguishable and
the server never guesses.Escaping does not disappear entirely — it moves somewhere tractable. The JSON is
embedded in an HTML attribute inside a JS expression, so a ' or \ within a
string value still has to be escaped for the JS literal. That is what
write-json handles, and it is the one case worth testing hardest, since "just
use JSON" is only 90% of the answer.
liveId has to be merged inNot anticipated by payload replaces the signals datastar would
otherwise send, and liveId is a server-pushed signal — so a bare payload
drops it and every action fails with "no live context". The expression therefore
merges the live-id signal back in explicitly with datastar's $ syntax.
The dispatch path belongs to whatever mounted the route,
so hardcoding /live/act here would reintroduce exactly the drift that reading
ids from the render removed
for element ids. Callers pass it; adapters supply it from their own config.
Whether :on handlers should declare arg schemas is deliberately unsettled.
They do not, for now. Types survive the wire on their own once args are JSON, which was the
actual complaint. Adding an optional :args predicate later is additive — it
slots in front of the handler call without changing this wire format or any
existing handler — so the cheap version is not a dead end. Tracked in
Dependency-free, like the rest of the core: the JSON writer below is a few
lines rather than a reason to put charred on the core's classpath. It follows
snapshot, which hand-rolls its own encoding for the same reason.
Builds the client-side expressions that invoke `:on` handlers.
1, which recorded this as "unresolved, and currently unsound".
This namespace resolves it. The problem was not ergonomics — it was that the
hand-written form was *wrong*:
;; before
[:button {:data-on:click (str "@post('/act?event=remove&id=" (:id i) "')")}]
Three defects, all verified against a real render rather than reasoned about:
1. **Escaping.** Chassis HTML-escapes the attribute, so the markup is valid, but
the browser un-escapes it before the expression is parsed. An arg containing
`'` closes datastar's string literal early — `o'brien` became
`@post('...id=o'brien')`, a syntax error. An arg containing `&` split into two
query params. `=` and `+` corrupted the parse.
2. **Args were int-only by accident.** The receiving handler did
`(parse-long (get params "id"))`, hardcoded. A string arg silently became
nil; a boolean could not round-trip at all.
3. **Types were lost.** Query strings carry only strings, so every handler
needed per-arg coercion it never declared.
## The fix: a JSON payload, not a query string
Datastar's fetch actions take a `payload` option that **replaces** the signal set
and is sent as the request body:
@post('/live/act', {payload: {"event":"remove","id":42}})
This kills all three defects structurally rather than by escaping more
carefully:
- The arg sits inside a **JSON string**, where `'` and `&` are unremarkable. The
nested-quoting problem does not arise, because there is no nested quoting.
- JSON has types, so `42`, `"42"`, `true` and `null` stay distinguishable and
the server never guesses.
Escaping does not disappear entirely — it moves somewhere tractable. The JSON is
embedded in an HTML attribute inside a JS expression, so a `'` or `\` *within a
string value* still has to be escaped for the JS literal. That is what
`write-json` handles, and it is the one case worth testing hardest, since "just
use JSON" is only 90% of the answer.
## `liveId` has to be merged in
Not anticipated by `payload` **replaces** the signals datastar would
otherwise send, and `liveId` is a server-pushed *signal* — so a bare payload
drops it and every action fails with "no live context". The expression therefore
merges the live-id signal back in explicitly with datastar's `$` syntax.
## Why the path is a parameter
The dispatch path belongs to whatever mounted the route,
so hardcoding `/live/act` here would reintroduce exactly the drift that reading
ids from the render removed
for element ids. Callers pass it; adapters supply it from their own config.
## No arg schemas, deliberately
Whether `:on` handlers should declare arg schemas is deliberately unsettled.
They do not, for now. Types survive the wire on their own once args are JSON, which was the
actual complaint. Adding an optional `:args` predicate later is additive — it
slots in front of the handler call without changing this wire format or any
existing handler — so the cheap version is not a dead end. Tracked in
Dependency-free, like the rest of the core: the JSON writer below is a few
lines rather than a reason to put charred on the core's classpath. It follows
`snapshot`, which hand-rolls its own encoding for the same reason.Development-mode checks that turn watch's silent failures into loud ones.
Five real bugs were found by building two applications on watch, and all five
were silent — wrong output or frozen output, never an exception:
| # | mistake | symptom |
|---|---|---|
| 1 | two sibling fragments read one topic; only one was patched | one goes stale |
| 2 | a fragment read one topic twice; the second read overwrote the first | pruning suppresses a real change |
| 3 | a reader rebuilt its collection every call | pruning never fires; slow, not wrong |
| 4 | a value was passed in as an argument instead of read via watch | fragment subscribes to nothing, freezes |
| 5 | a topic name was invented at the read site that nothing publishes | fragment never updates |
1 and 2 were fixed in the engine and are now covered by tests. 3, 4 and 5 are application mistakes the engine cannot fix, because the application is free to write whatever readers it likes. So they are detected here instead.
Every check needs work the hot path should not do: calling readers a second time and
keeping a registry of published topics. So these are not wired into the engine — they
are called explicitly, from a test or a REPL, via check-component.
A render-fn wrapper was tried first and removed: by the time a render-fn runs the
tree is already built and the recording is gone, so it could see nothing worth
checking. Detection has to happen where the recording is, which is why the entry
point takes a component rather than wrapping the renderer.
Plain hiccup that is not wrapped in fragment. The two styles mix — a component may
hold watch-managed fragments alongside markup the application pushes itself — so an
element with an id but no fragment is a legitimate choice, not a mistake.
Development-mode checks that turn `watch`'s silent failures into loud ones. ## Why this exists Five real bugs were found by building two applications on `watch`, and **all five were silent** — wrong output or frozen output, never an exception: | # | mistake | symptom | |---|---|---| | 1 | two sibling fragments read one topic; only one was patched | one goes stale | | 2 | a fragment read one topic twice; the second read overwrote the first | pruning suppresses a real change | | 3 | a reader rebuilt its collection every call | pruning never fires; slow, not wrong | | 4 | a value was passed in as an argument instead of read via `watch` | fragment subscribes to nothing, freezes | | 5 | a topic name was invented at the read site that nothing publishes | fragment never updates | 1 and 2 were fixed in the engine and are now covered by tests. 3, 4 and 5 are **application** mistakes the engine cannot fix, because the application is free to write whatever readers it likes. So they are detected here instead. ## Cost, and why this is opt-in Every check needs work the hot path should not do: calling readers a second time and keeping a registry of published topics. So these are not wired into the engine — they are called explicitly, from a test or a REPL, via `check-component`. A `render-fn` wrapper was tried first and removed: by the time a `render-fn` runs the tree is already built and the recording is gone, so it could see nothing worth checking. Detection has to happen where the recording is, which is why the entry point takes a component rather than wrapping the renderer. ## What is deliberately NOT checked Plain hiccup that is not wrapped in `fragment`. The two styles mix — a component may hold `watch`-managed fragments alongside markup the application pushes itself — so an element with an id but no fragment is a legitimate choice, not a mistake.
Connections, and the fragments each one is showing.
This is the runtime half of darkstar.watch: watch records which fragments read
which topics, and this holds one such recording per connection so that a hint can be
turned into the narrowest set of patches for that viewer.
It began as an experiment beside an older view-and-diff engine. That engine is gone — it addressed regions by view path and resolved paths to element ids, and the path-to-id translation was where five of seven live-children bugs lived — so this is no longer an alternative to anything.
A component is one function of params returning a tree, reading through
watch. There is no view map, no :mount, no :subscribe, no diff.
(defn row [{:keys [channel-id username]}]
(fragment (member-id username)
(fn []
(let [online? (watch [:presence channel-id username]
#(presence/online? channel-id username))]
[:li {:id (member-id username)} …]))))
Connect renders it once, recording which fragments read which topics. A hint then re-renders only the fragments that read that topic, and each fragment's id is its own patch target — so nothing derives an id and nothing can derive one wrongly.
Verified against two real browser tabs, checking computed styles rather than server-side values: a member joining appears in the other viewer immediately, and a member whose heartbeat lapses turns grey in the other viewer without a reload — patched one row at a time, not as a whole list. That last case is the bug that took nine fixes in the framework version.
It did not work first time. Two defects, both worth recording because neither was visible headlessly:
refresh!.:async? true, :async-timeout 0), or an SSE
response is torn down when on-open returns, and later writes to it report
success while the client receives nothing.The second is a transport property with nothing to do with this namespace, but it cost an hour of suspecting this namespace, so it is written down here.
No state tiers and no recovery snapshot. A reconnect re-renders from scratch, which is correct because these components read their state at render time rather than holding a projection of it.
An earlier design had both: tiers declaring how each field of a view survived a reconnect, and a signed client-held snapshot for state the server could not re-derive. Both were removed after measurement — the snapshot recovered a reload or a returning visitor rather than a dropped connection, and a dropped connection is already handled by the transport re-issuing its original request.
Connections, and the fragments each one is showing.
This is the runtime half of `darkstar.watch`: `watch` records which fragments read
which topics, and this holds one such recording per connection so that a hint can be
turned into the narrowest set of patches for that viewer.
It began as an experiment beside an older view-and-diff engine. That engine is gone —
it addressed regions by *view path* and resolved paths to element ids, and the
path-to-id translation was where five of seven live-children bugs lived — so this is
no longer an alternative to anything.
## The whole model
A component is **one function** of params returning a tree, reading through
`watch`. There is no view map, no `:mount`, no `:subscribe`, no diff.
(defn row [{:keys [channel-id username]}]
(fragment (member-id username)
(fn []
(let [online? (watch [:presence channel-id username]
#(presence/online? channel-id username))]
[:li {:id (member-id username)} …]))))
Connect renders it once, recording which fragments read which topics. A hint then
re-renders only the fragments that read that topic, and each fragment's id is its
own patch target — so nothing derives an id and nothing can derive one wrongly.
## What the browser said
Verified against two real browser tabs, checking computed styles rather than
server-side values: a member joining appears in the other viewer immediately, and a
member whose heartbeat lapses turns grey in the other viewer without a reload —
patched one row at a time, not as a whole list. That last case is the bug that took
nine fixes in the framework version.
It did not work first time. Two defects, both worth recording because neither was
visible headlessly:
1. **A dependency set is data, so it changes.** Subscribing only at mount meant a
viewer never heard about a member who joined later. See `refresh!`.
2. **Jetty must run in async mode** (`:async? true`, `:async-timeout 0`), or an SSE
response is torn down when `on-open` returns, and later writes to it report
success while the client receives nothing.
The second is a transport property with nothing to do with this namespace, but it
cost an hour of suspecting this namespace, so it is written down here.
## What this does not do
No state tiers and no recovery snapshot. A reconnect re-renders from scratch, which
is correct because these components read their state at render time rather than
holding a projection of it.
An earlier design had both: tiers declaring how each field of a view survived a
reconnect, and a signed client-held snapshot for state the server could not
re-derive. Both were removed after measurement — the snapshot recovered a reload or a
returning visitor rather than a dropped connection, and a dropped connection is
already handled by the transport re-issuing its original request.Subscriptions, invalidation hints, and coalescing.
This is what makes a component pushable — able to update without the user doing anything.
A hint says "topic X changed, re-derive". It carries no payload, and there is deliberately no event handler: a hint triggers the same rebuild a reconnect uses. That is not a simplification, it is the correctness argument. A data-carrying event is wrong under every bus failure mode — dropped leaves the view permanently wrong, reordered applies stale data last, duplicated breaks any non-idempotent handler — and is correct only on an ordered exactly-once bus. Redis pub/sub is fire-and-forget; NATS core is at-most-once. Since "bring your own bus" is a goal, the engine has to be correct on the weakest plausible one.
With no handler, a drifting handler cannot be written, so the convergence invariant holds structurally rather than by discipline.
Because hints carry nothing, N hints for a topic are indistinguishable from one.
So they collapse into a single rebuild with no information lost — a property
data-carrying events could never have. flush-dirty! drains a dirty-topic set;
a burst of 100 hints on a hot topic produces one rebuild per affected context
rather than 100.
Measured on 1000 subscribed contexts: 100 hints without coalescing meant 100,000 re-renders; with coalescing, 1,000. The remaining amplification is one render per viewer, which is inherent — each viewer may be looking at something different.
PubSub is a two-method protocol. The engine never implements it — Redis, NATS,
core.async, or a bare atom for a single process are all the caller's choice. The
engine only needs "deliver this topic to this process"; ordering, delivery
guarantees and clustering are the bus's business, and the design above means none
of them affect correctness.
Subscriptions, invalidation hints, and coalescing. This is what makes a component *pushable* — able to update without the user doing anything. ## Hints, never data A hint says "topic X changed, re-derive". It carries no payload, and there is deliberately **no event handler**: a hint triggers the same rebuild a reconnect uses. That is not a simplification, it is the correctness argument. A data-carrying event is wrong under every bus failure mode — dropped leaves the view permanently wrong, reordered applies stale data last, duplicated breaks any non-idempotent handler — and is correct only on an ordered exactly-once bus. Redis pub/sub is fire-and-forget; NATS core is at-most-once. Since "bring your own bus" is a goal, the engine has to be correct on the weakest plausible one. With no handler, a drifting handler cannot be written, so the convergence invariant holds structurally rather than by discipline. ## Coalescing is lossless, and that is the point Because hints carry nothing, N hints for a topic are indistinguishable from one. So they collapse into a single rebuild with no information lost — a property data-carrying events could never have. `flush-dirty!` drains a dirty-topic set; a burst of 100 hints on a hot topic produces one rebuild per affected context rather than 100. Measured on 1000 subscribed contexts: 100 hints without coalescing meant 100,000 re-renders; with coalescing, 1,000. The remaining amplification is one render per viewer, which is inherent — each viewer may be looking at something different. ## The bus is yours `PubSub` is a two-method protocol. The engine never implements it — Redis, NATS, core.async, or a bare atom for a single process are all the caller's choice. The engine only needs "deliver this topic to this process"; ordering, delivery guarantees and clustering are the bus's business, and the design above means none of them affect correctness.
Source — the durable truth :mount derives from.
The engine never implements this: your database, event log,
or external service does. The engine only injects the configured instance into
the context as :source and threads a basis token through it.
A caller could already merge anything into the context, so the protocol has to earn its place. It does, for one reason: basis tokens need a seam.
A basis is an opaque marker meaning "as of this point". On reconnect the client presents the basis its snapshot carried, and a store that can honour it rebuilds the same view the user was looking at rather than a newer one. Without a declared seam there is nowhere for that token to live, and reconnect-to-same-view becomes impossible — and that is the capability this whole namespace exists for.
Secondarily, :derive-key has to cover everything :mount reads. A
declared :source in the context makes "what did :mount read" answerable
rather than a matter of inspecting arbitrary map keys.
Stores with time-travel — Datomic, XTDB, an event log — can honour a basis.
A plain mutable table cannot, and says so by returning nil from basis; the
engine then rebuilds fresh, which is correct and simply loses
reconnect-to-same-view. The engine never interprets a basis, so an
implementation is free to make it a transaction id, a timestamp, an offset, or
anything else.
Not a query language, not an ORM, not a connection pool. fetch takes whatever
query value your implementation understands, because the alternative is
inventing an abstraction over every store — which is how a library becomes a
framework ( non-goals).
`Source` — the durable truth `:mount` derives from. The engine never implements this: your database, event log, or external service does. The engine only injects the configured instance into the context as `:source` and threads a **basis token** through it. ## Why a protocol rather than just a db handle in the context A caller could already merge anything into the context, so the protocol has to earn its place. It does, for one reason: **basis tokens need a seam.** A basis is an opaque marker meaning "as of this point". On reconnect the client presents the basis its snapshot carried, and a store that can honour it rebuilds the *same* view the user was looking at rather than a newer one. Without a declared seam there is nowhere for that token to live, and reconnect-to-same-view becomes impossible — and that is the capability this whole namespace exists for. Secondarily, `:derive-key` has to cover everything `:mount` reads. A declared `:source` in the context makes "what did `:mount` read" answerable rather than a matter of inspecting arbitrary map keys. ## Basis is optional, and no store is privileged Stores with time-travel — Datomic, XTDB, an event log — can honour a basis. A plain mutable table cannot, and says so by returning `nil` from `basis`; the engine then rebuilds fresh, which is correct and simply loses reconnect-to-same-view. **The engine never interprets a basis**, so an implementation is free to make it a transaction id, a timestamp, an offset, or anything else. ## What this deliberately is not Not a query language, not an ORM, not a connection pool. `fetch` takes whatever query value your implementation understands, because the alternative is inventing an abstraction over every store — which is how a library becomes a framework ( non-goals).
The transport half: patches onto a Datastar SSE stream, and the connection lifecycle around it.
Four things recurred across every application built on darkstar.live, and each was
written by hand every time:
{:selector :mode :html} into patch-elements! calls. Written four
times in four apps — and inconsistently: two handled all eight patch modes and two
handled only :inner/:outer, so a :remove patch would have been silently
mis-applied as an :outer replacement.connect!, write the connection id back into params,
mount!, subscribe to the topics the render reported, push the initial HTML, and on
close unsubscribe and disconnect!. Structurally identical everywhere.on-open and on-close. They are separate
closures, so every app wrote the same promise and (deref id* 1000 nil).read-payload.Name a server. This namespace requires only the Datastar SDK, so it works with
Jetty, http-kit or anything else. The caller supplies the SSE response wrapper
(->sse-response) and passes on-open/on-close through; handlers builds those two
functions and nothing more.
It also does not own subscriptions. :subscribe! and :unsubscribe! are functions you
supply, because how subscriptions are stored is a real choice: darkstar.pubsub gives
coalescing, and an application under heavy parallel fan-out may want a
ConcurrentHashMap index instead. Both were measured; neither is right for everyone.
The transport half: patches onto a Datastar SSE stream, and the connection
lifecycle around it.
## What this absorbs, and why
Four things recurred across every application built on `darkstar.live`, and each was
written by hand every time:
1. **Translating `{:selector :mode :html}` into `patch-elements!` calls.** Written four
times in four apps — and *inconsistently*: two handled all eight patch modes and two
handled only `:inner`/`:outer`, so a `:remove` patch would have been silently
mis-applied as an `:outer` replacement.
2. **The open/close lifecycle.** `connect!`, write the connection id back into params,
`mount!`, subscribe to the topics the render reported, push the initial HTML, and on
close unsubscribe and `disconnect!`. Structurally identical everywhere.
3. **Sharing the connection id between `on-open` and `on-close`.** They are separate
closures, so every app wrote the same `promise` and `(deref id* 1000 nil)`.
4. **Reading the Datastar payload from a POST.** Two strategies were needed for two
stacks and both cost real debugging time — see `read-payload`.
## What it does not do
**Name a server.** This namespace requires only the Datastar SDK, so it works with
Jetty, http-kit or anything else. The caller supplies the SSE response wrapper
(`->sse-response`) and passes `on-open`/`on-close` through; `handlers` builds those two
functions and nothing more.
It also does not own subscriptions. `:subscribe!` and `:unsubscribe!` are functions you
supply, because how subscriptions are stored is a real choice: `darkstar.pubsub` gives
coalescing, and an application under heavy parallel fan-out may want a
`ConcurrentHashMap` index instead. Both were measured; neither is right for everyone.Subscriptions discovered from the render, rather than declared beside it.
A component that updates when one thing changes previously had to say so three times, and keep the three consistent by hand:
:mount (fn [ctx] {:online? (presence/online? ch user)}) ; depends on…
:subscribe (fn [ctx] [[:presence ch user]]) ; …watch this…
:render (fn [view] (boundary [] …)) ; …patch here
Three declarations of one fact. Every live-children bug in this project was one of the three drifting from the other two, which is a structural problem rather than a run of bad luck.
watch collapses them. It reads a value and, as a side effect of rendering,
records that the surrounding fragment depended on that topic:
(fragment (member-id username)
(fn []
(let [online? (watch [:presence ch username]
#(presence/online? ch username))]
[:li {:id (member-id username)} …])))
The subscription set is now whatever the render actually read, so it cannot disagree with the render. There is one site to change.
This follows from the above but is easy to miss, and missing it produces a bug that looks like a pass. A dependency set is data: a roster that reads one presence topic per member depends on a different set of topics the moment its membership changes. Subscribing once, at mount, is therefore not enough.
Observed in a browser: a viewer mounted alone, a second member joined, and the viewer's roster re-rendered and showed them correctly — but that viewer had never subscribed to the new member's presence topic, because it did not exist at mount. The joiner appeared and then never went offline. The first render and the join both looked right, which is what let it through a headless test.
So a caller must treat the topics from every render as the current truth. See
darkstar.live/refresh!, which reports :added and :removed for exactly
this.
Recording happens in a dynamic binding, so anything evaluated outside that binding is invisible to it. A lazy sequence escapes:
[:ul (for [m members] (member-row m))] ; WRONG — rows render later
[:ul (mapv member-row members)] ; right — forced inside
This fails silently: the first render is correct because the seq is realised
during serialisation, but the rows' topics were never recorded, so nothing ever
re-renders them. render-recording therefore walks its result and throws on an
unrealised lazy seq, which turns a silent staleness bug into a loud one.
:subscribe and boundariesThis namespace requires nothing, and it is optional. A darkstar application can
push patches by hand — naming a selector and rendering the HTML for it — and never
touch watch at all. The two styles also mix within one component: markup wrapped
in fragment is managed by darkstar.live, and plain hiccup beside it is the
application's to push. Verified, not assumed.
So the trade is worth stating plainly. Manual pushes fail loudly — a wrong selector
produces PatchElementsNoTargetsFound in the browser console. watch fails silently,
and darkstar.diagnose exists because of that.
It was region first, which was wrong on two counts. It described geometry — an area
of the screen — when the interesting property is being independently pushable; a plain
[:div] is also an area of the screen. And it did not pair with watch: a verb about
dependency beside a noun about layout gave no hint the two are halves of one
mechanism.
"Fragment" is what this already is on the wire, and the word Datastar and htmx
users have for a piece of HTML pushed on its own. watch says why it re-renders;
fragment says what gets sent.
Subscriptions discovered from the render, rather than declared beside it.
## The problem this solves
A component that updates when one thing changes previously had to say so three
times, and keep the three consistent by hand:
:mount (fn [ctx] {:online? (presence/online? ch user)}) ; depends on…
:subscribe (fn [ctx] [[:presence ch user]]) ; …watch this…
:render (fn [view] (boundary [] …)) ; …patch here
Three declarations of one fact. Every live-children bug in this project was one of
the three drifting from the other two, which is a structural problem rather than a
run of bad luck.
`watch` collapses them. It reads a value and, as a side effect of rendering,
records that the surrounding fragment depended on that topic:
(fragment (member-id username)
(fn []
(let [online? (watch [:presence ch username]
#(presence/online? ch username))]
[:li {:id (member-id username)} …])))
The subscription set is now *whatever the render actually read*, so it cannot
disagree with the render. There is one site to change.
## The subscription set changes, so re-derive it on every render
This follows from the above but is easy to miss, and missing it produces a bug that
looks like a pass. A dependency set is **data**: a roster that reads one presence
topic per member depends on a different set of topics the moment its membership
changes. Subscribing once, at mount, is therefore not enough.
Observed in a browser: a viewer mounted alone, a second member joined, and the
viewer's roster re-rendered and showed them correctly — but that viewer had never
subscribed to the new member's presence topic, because it did not exist at mount.
The joiner appeared and then never went offline. The first render and the join both
looked right, which is what let it through a headless test.
So a caller must treat the topics from every render as the current truth. See
`darkstar.live/refresh!`, which reports `:added` and `:removed` for exactly
this.
## The laziness hazard — read this before using it
Recording happens in a dynamic binding, so anything evaluated **outside** that
binding is invisible to it. A lazy sequence escapes:
[:ul (for [m members] (member-row m))] ; WRONG — rows render later
[:ul (mapv member-row members)] ; right — forced inside
This fails *silently*: the first render is correct because the seq is realised
during serialisation, but the rows' topics were never recorded, so nothing ever
re-renders them. `render-recording` therefore walks its result and throws on an
unrealised lazy seq, which turns a silent staleness bug into a loud one.
## Relationship to `:subscribe` and boundaries
This namespace requires nothing, and it is optional. A `darkstar` application can
push patches by hand — naming a selector and rendering the HTML for it — and never
touch `watch` at all. The two styles also **mix within one component**: markup wrapped
in `fragment` is managed by `darkstar.live`, and plain hiccup beside it is the
application's to push. Verified, not assumed.
So the trade is worth stating plainly. Manual pushes fail loudly — a wrong selector
produces `PatchElementsNoTargetsFound` in the browser console. `watch` fails silently,
and `darkstar.diagnose` exists because of that.
## Why "fragment"
It was `region` first, which was wrong on two counts. It described geometry — an area
of the screen — when the interesting property is being independently pushable; a plain
`[:div]` is also an area of the screen. And it did not pair with `watch`: a verb about
dependency beside a noun about layout gave no hint the two are halves of one
mechanism.
"Fragment" is what this already is on the wire, and the word Datastar and htmx
users have for a piece of HTML pushed on its own. `watch` says why it re-renders;
`fragment` says what gets sent.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 |