Liking cljdoc? Tell your friends :D

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

connect!clj

(connect! {:keys [registry]} component-name {:keys [send! params]})

Registers a connection. Renders nothing — the caller decides when to mount.

Registers a connection. Renders nothing — the caller decides when to mount.
sourceraw docstring

disconnect!clj

(disconnect! {:keys [registry]} id)
source

dispatch!clj

(dispatch! eng id event args)

Runs the :on handler for event and returns the topics it says changed.

A handler is (fn [ctx args] -> topics), where ctx is {:id :params :component-name} and topics is a seq of topics to publish (or nil).

Why a handler returns topics rather than a new view

There is no view. The old engine's handler was (fn [view ctx args] -> new-view) because the view was the state, and the engine diffed the return value to find what to push. Here the state lives in the application's own atoms, tables and sources, and a handler mutates those directly — so the only thing the engine needs back is which topics to invalidate.

That keeps one invalidation path. A hint published by a handler and a hint published by another user's action are the same kind of thing, and both flow through the caller's pubsub into refresh!. The alternative — pushing from inside dispatch! — would mean an event updated the acting connection by one code path and everyone else by another, which is how the old engine ended up with a warm path and a cold path that could disagree.

Pushing is deliberately not done here. This returns topics; the caller publishes them. The engine has no pubsub and should not grow one.

Runs the `:on` handler for `event` and returns the topics it says changed.

A handler is `(fn [ctx args] -> topics)`, where `ctx` is
`{:id :params :component-name}` and `topics` is a seq of topics to publish (or nil).

## Why a handler returns topics rather than a new view

There is no view. The old engine's handler was `(fn [view ctx args] -> new-view)`
because the view *was* the state, and the engine diffed the return value to find
what to push. Here the state lives in the application's own atoms, tables and
sources, and a handler mutates those directly — so the only thing the engine needs
back is which topics to invalidate.

That keeps one invalidation path. A hint published by a handler and a hint published
by another user's action are the same kind of thing, and both flow through the
caller's pubsub into `refresh!`. The alternative — pushing from inside `dispatch!` —
would mean an event updated the acting connection by one code path and everyone else
by another, which is how the old engine ended up with a warm path and a cold path
that could disagree.

**Pushing is deliberately not done here.** This returns topics; the caller publishes
them. The engine has no pubsub and should not grow one.
sourceraw docstring

engineclj

(engine {:keys [components render-fn registry]})

Creates a watch engine. Starts nothing.

  • :components name -> component (see below)
  • :render-fn (fn [tree] -> string)
  • :registry optional atom; one is created if absent

A component is either a bare (fn [params] -> tree) reading through watch, or a map {:render (fn [params] -> tree) :on {event-name handler}} when it also handles events. The bare form stays valid because most components have no events of their own, and requiring a map for all of them would be ceremony.

Creates a watch engine. Starts nothing.

- `:components` name -> component (see below)
- `:render-fn`  (fn [tree] -> string)
- `:registry`   optional atom; one is created if absent

A component is either a bare `(fn [params] -> tree)` reading through `watch`, or a
map `{:render (fn [params] -> tree) :on {event-name handler}}` when it also handles
events. The bare form stays valid because most components have no events of their
own, and requiring a map for all of them would be ceremony.
sourceraw docstring

fragments-ofclj

(fragments-of {:keys [registry]} id)

The recorded fragments for a connection. For tests and REPL inspection.

The recorded fragments for a connection. For tests and REPL inspection.
sourceraw docstring

mount!clj

(mount! {:keys [registry render-fn] :as eng} id)

Renders the component, stores its fragments, and returns the HTML.

Returns {:html :topics} — the topics being what the caller must subscribe the connection to. They came from the render, so a caller cannot subscribe to the wrong set unless it ignores this.

These are the topics as of this render only. See refresh!: the set changes with the data, so subscribing here and never again is a bug.

Renders the component, stores its fragments, and returns the HTML.

Returns `{:html :topics}` — the topics being what the caller must subscribe the
connection to. They came from the render, so a caller cannot subscribe to the
wrong set unless it ignores this.

These are the topics as of *this* render only. See `refresh!`: the set changes with
the data, so subscribing here and never again is a bug.
sourceraw docstring

refresh!clj

(refresh! eng id topic)
(refresh! {:keys [registry render-fn]} id topic {:keys [read-fns] :as _opts})

Re-renders the fragments that read topic, pushes each one, and reports the connection's current topic set.

Returns {:patches :topics :added :removed}, so a test can assert on it without a browser and a caller can adjust its subscriptions.

A dependency set is data, so it changes

:topics is not a formality. What a component depends on is itself dependent on the data it read: a roster that reads one presence topic per member depends on a different set the moment the membership changes. Subscribing once at mount is therefore wrong, and wrong in a way that looks fine — found in a browser, where a member who joined after a viewer connected showed up in that viewer's roster and then never went grey, because the viewer had never subscribed to a topic that did not exist when it mounted.

So a caller must re-subscribe on every refresh. :added and :removed are supplied to make that a diff rather than a teardown.

Only the narrowest matching fragment is pushed

Every dirty fragment that no other dirty fragment contains is patched. Both halves of that matter:

  • a containing fragment inherits its children's topics, so patching parent and child together would send the change twice and the outer patch would replace the inner element just updated — the bug the old engine showed as duplicated list items;
  • but two SIBLING fragments can legitimately read one topic (a roster and a message list both reading [:channel id]), and both must be patched. Taking only the narrowest match broke that silently: the sibling that lost the sort never updated.

See watch/innermost-independent.

Re-renders the fragments that read `topic`, pushes each one, and reports the
connection's current topic set.

Returns `{:patches :topics :added :removed}`, so a test can assert on it without a
browser and a caller can adjust its subscriptions.

## A dependency set is data, so it changes

`:topics` is not a formality. What a component depends on is *itself* dependent on
the data it read: a roster that reads one presence topic per member depends on a
different set the moment the membership changes. Subscribing once at mount is
therefore wrong, and wrong in a way that looks fine — found in a browser, where a
member who joined after a viewer connected showed up in that viewer's roster and
then never went grey, because the viewer had never subscribed to a topic that did
not exist when it mounted.

So a caller must re-subscribe on every refresh. `:added` and `:removed` are
supplied to make that a diff rather than a teardown.

## Only the narrowest matching fragment is pushed

Every dirty fragment that no *other* dirty fragment contains is patched. Both halves
of that matter:

- a containing fragment inherits its children's topics, so patching parent and child
  together would send the change twice and the outer patch would replace the inner
  element just updated — the bug the old engine showed as duplicated list items;
- but two SIBLING fragments can legitimately read one topic (a roster and a message
  list both reading `[:channel id]`), and both must be patched. Taking only the
  narrowest match broke that silently: the sibling that lost the sort never updated.

See `watch/innermost-independent`.
sourceraw 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