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.(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.
(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).
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.(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 absentA 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.(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.
(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.(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.
: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.
Every dirty fragment that no other dirty fragment contains is patched. Both halves of that matter:
[: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`.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 |