Liking cljdoc? Tell your friends :D

darkstar.action

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.

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.
raw docstring

darkstar.diagnose

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:

#mistakesymptom
1two sibling fragments read one topic; only one was patchedone goes stale
2a fragment read one topic twice; the second read overwrote the firstpruning suppresses a real change
3a reader rebuilt its collection every callpruning never fires; slow, not wrong
4a value was passed in as an argument instead of read via watchfragment subscribes to nothing, freezes
5a topic name was invented at the read site that nothing publishesfragment 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.

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.
raw docstring

darkstar.live

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.

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.
raw docstring

darkstar.pubsub

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.

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.
raw docstring

darkstar.source

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

`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).
raw docstring

darkstar.sse

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.

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.
raw docstring

darkstar.watch

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.

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.
raw docstring

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