Why Tropical is built the way it is. The README and the guides say what it does and how; this is the reasoning behind it, and the alternatives that were rejected. Measurements behind these claims are in logbook.md.
Tropical exists to keep two guarantees that Electric gives a Datastar application, without a reactive graph:
resource) enforces it: one physical resource per key, opened by the first subscriber, lingering after
the last. Closing forgets it, then cancels it, without waiting for its teardown, so one opened again soon after can
run beside the last as it tears down: an open question.An earlier version built the islands as a Missionary DAG. It worked and was as fast, but page code became explicit flow wiring: declared inputs, slots and gates. Ordinary functions plus a small runtime give the same guarantees, with page code that reads as plain Clojure and plain stack traces, and islands that render in a test harness with no flows involved. Libraries considered:
What this gives up: Missionary's operators, such as time-based ones, and its supervision semantics. Scoped lifetimes and coalescing come from the runtime and the session pump instead, and are covered by the tests.
cc/compile pre-serializes static parts at macroexpansion and halves that, with the same syntax,
below an island's root (which must stay a vector, so the runtime can put the id on it). A second, Electric-shaped
syntax over the same output was considered and rejected.before/after modes next to
a kept sibling's id, and the removed ones with remove; anything else resends the island, which is correct by
construction. Added children with no kept sibling beside them, but taking the place of removed ones, with no text
between, go in after the last of those, which are removed after them: a child whose key changed, amid its parent's
markup. Placeholders, the parent resent with empty stand-ins for children the client keeps, were rejected:
Datastar's data-ignore-morph skips an element only when both the element in the page and the incoming one carry
it. The server side still renders the parent over the whole list; a differential for-by over a source's diffs is
parked.use-state option resetting a state when a value it belongs to changes, (use-state :nav nil {:for opened-at}), was
asked for. Sending the new child in the old one's place made it cheap instead, so there is one way to start over,
and it resets what else the island holds, its actions and holds, with its state.co.multiply.tropical.frame.Splice) over both templates. It compares their
strings as one text, across the holes left out, without joining them, and takes a child at the same place in both
as kept without asking either slot map. At 1,000 rows it costs 61 µs, against 950 µs for the first version in
Clojure (logbook).observe. Rendering is cheap; a database query is not. A query in a render runs on every
re-render, its cost lands on the database where it is hard to trace back to the view, and caching it by hand means
inventing invalidation at every call site. observe makes the rule structural: what is read from outside the page is
a resource, opened once for every reader of the same call site and arguments, and streamed.observe. An earlier <- took a database's
shape (a name, a path, an open fn), and a separate ? ran one-off queries per island.observe. The open fn is called with an emit! fn and returns what undoes it, the shape callback
APIs, loops and queries all fit. It returns a cleanup fn or a task. A cleanup fn suits a callback API. A task carries
its failure to the registry, where a cleanup fn returned for a loop would hide it: readers would stay on the crashed
loop's last value, and nothing would reopen it. Unlike Missionary's, emit! keeps only the latest value, as signals
do.use-watches of one atom are.
An app wraps observe in hooks of its own, whose reads all share the wrapper's call site, and two hooks asking of the
same data in one render made the same call twice, which threw; an argument added only to tell them apart opened a
resource per question over the same data. So use-hold takes :idempotent, which observe and use-shared set: a
repeat returns what the first ask got. One primitive with a flag, not two, since the two kinds differ only there:
acquired on the first ask, released after the first render that doesn't ask, alike. Strict stays the default, as
the one that catches a bug. The caller's call site in the key instead would take a macro at every hook over
observe, and would only hold one resource twice.A change needs to reach every session that read it, and a session cares only about the latest value. So a signal pushes invalidation and the session pulls the value: a change marks the session's subscription dirty, at most once until the session takes it, and the session reads the current value when it renders. This is the protocol of Missionary's continuous flows (notify once, read on transfer).
It is written in Java (co.multiply.tropical.signal) because it is on the path every change takes, and Java states the
properties directly: a CAS on a dirty flag decides which change pushes, an intrusive Treiber stack threaded through the
subscriptions bounds the inbox by the number of subscriptions, and LockSupport parks and wakes the pump. It allocates
nothing per change, which matters less for time than for garbage: the cost is paid on the publishing thread, once per
reading session. The invariants (drain order, clear-before-read, wake on empty to non-empty) are documented in the
sources.
Atoms remain the user-facing state: RefSignal gives each atom one watch, however many sessions read it.
:open-when-hidden is for
development, where browser automation can drive a page its browser keeps hidden: there the closed stream made a click
right after a load look lost (logbook, 2026-10-09). A page left behind lets go of its stream all the same. Chrome
freezes a left page in its back/forward cache, no-store or not, where the stream holds its connection for about a
minute, and HTTP/1.1 allows six to a host: clicking from page to page, the sixth waited a minute. So with the option,
the stream is cancelled as the page is left, and opened again if the page is restored (logbook, 2026-10-10)./stream and /act/<token> paths carried no route: a resync could only
rebuild whatever one app the stream handler had, and middleware binding context from the path had nothing to bind.
Carrying the page's URL in a query parameter would have let the root route, but not the middleware run. Headers,
not query parameters, keep the route's query the app's. Datastar cancels a request in flight when another goes to
the same method and URL, which every action on a page now shares, so actions turn that off.request-kind is the handler's own dispatch, so the two can't disagree. An
action needs a JSON body as well as its header: the header is the client's to send, and a filter admitting any body
that carries it would hand that body to the form and multipart parsers it exists to keep bodies from. Those ignore
JSON, so an admitted action body reaches only the handler, which reads it after finding the user, and at most
:max-signals-bytes of it.retry: 'always', every status but 200, 204 and 3xx), then gives up, and it follows a redirect to a response it
can't use as a stream, so the tab stays stale with nothing to say so. Only the page request gets the app's real
answer, so a refused stream becomes a stream that reloads the page. That takes middleware outside the app's, since
the refusals happen before the handler. A 5xx is left to the retries, since a deploy or a restart gives one for a
moment. A page that answers while its stream is refused would reload forever, so the reload sets a short-lived
cookie naming the page and the load told to reload, and a refusal from a later load of the page gets a 204, which
ends Datastar's retries. The load told to reload is exempt because Datastar retries a finished stream after a
second, and a slow reload lets that retry out first. An open stream isn't asked again: what the page shows going
away while it is open, access included, is for the islands to read and render.The page is the app's, and the root island is the session's. The page renders once, and opens the stream that patches
the islands, so what sets the session up can't live in an island: a patch morphs an island back to its render, and
would strip the attributes that opened the stream. ring/mount is the session's part of the page: the element the app
mounts in, holding the first frame, opening the stream and keeping the state the browser owns. It reads the tab and
the frame from the page being rendered, as ui/page-attrs reads the page's user, so :page needs only its request,
for what is the app's own: the head, its Datastar and the CSP nonce.
mount takes it from :root.ui/page-attrs works, so it is logged, once: an
app with no state the browser owns and no drop areas loses only the drop guard.:open-when-hidden is the app's, not the page's. It is a choice for a deployment, development's browser
automation, and every page of the app makes it alike.use-session, where an app would otherwise bind it itself, around its handler. The request is the page's, or for a
tab rebuilt after a restart its stream's, to the same URL: its path and query are the page's either way, and its
headers the request's.Actions are capabilities: an unguessable token, bound to the user, that exists while an island renders it. The authority check is the island's decision to render the action, and the closure carries it. A request can race a revocation by up to a frame, as in Electric, so writes that matter check again in the write path.
An action navigates through its response: a handler returns (navigate path), and the response is a script, which
Datastar runs, giving it the page's nonce in CSP mode. A signal the page watches, the alternative, needs an element
declaring the effect to stay mounted until the response lands, and makes page state of something that isn't. An event
over the stream would arrive only while the stream is up, apart from the click that caused it. The target must be a
path on the page's origin, checked when the handler builds it, so a handler passing on what a client sent can't become
an open redirect. A page on another origin, such as a sign-in provider's, takes an explicit {:external true}: the
option is where the handler says it built the URL, and it is easy to find in review. Only http and https pass,
since the script assigns the URL to location, where javascript: would run in the page. The alternative, an app
route redirecting to the provider, is one every app wanting it would write, with the URL looked up again from an id.
An action's handler reads the request it came on with request, a scoped value bound around the handler's call, so
its arguments stay what the client sent. It is the action's own request, not the session's. A render sees the request
that created its session, fixed for its life, and the two differ where it matters: a cookie the page's own response
set is on every later request but not on the page's. Outside an action it is unbound, and request throws, rather
than falling back to the session's request: a render that read it, with a handler using what it read, would hand the
action the session's request with nothing to show for it. Middleware binding what it reads from each request also
reaches the handler, which runs on its request's stack, but it binds for the page's request too, where renders read it.
A page whose subject moved, such as an item moved into another group, goes to its new URL through an element the
island renders, navigate-element, which runs its script with data-init. That the page is at the wrong URL is
state, not an event, so it is rendered as the rest of the island's state is: a tab gets it in a patch, in the first
frame of a page, or in the first frame of a session its stream reconnected into, where an event sent once over the
stream would miss a tab whose stream was down. Datastar runs an element's data-init when the element is added, or
when that attribute changes, and the morph sets only the attributes that differ, so later renders of the same element
don't run it again. The script compares the path with the page's URL first, both as the browser resolves them, since
an island comparing a canonical path with the request's in another encoding would otherwise reload the page in a loop.
Signals reach the page outside an action through the island, use-patch-signals, for work that lands after its
action answered. Setting a signal is an event, not state, so it goes over the stream once, as an action's answer would
have carried it, where the way before rendered the result into a hidden element's data-signals: state the island had
to keep, applied whenever Datastar applies that attribute, again when it changes. A patch! posts to the session's
inbox, as a state change marks it, and the pump sends the patch after the frame rendering what changed with it, so the
elements bound to its signals are in the page first. One from an island no longer mounted, as the instance that made
it, is dropped, as a late set-value! changes nothing anyone sees. While the tab is away, patches wait for it, so a
result landing during a blip isn't lost, and they don't pile up: a patch is merged into the one before when one patch
does both, as Datastar applies merge patches, which is always but where the first replaces a value and the second
merges a map into it. Then the two go in turn.
A value only the browser knows when an action is invoked, such as which reference in a block of HTML was clicked,
travels in that action's body, beside its signals. Datastar's payload replaces the signals in the body, so the
expression puts back the ones @post would have sent: all but those under a key starting with _, as Datastar
filters them, read peeking, as @post reads them. The alternatives:
@post leaves out, would leave out this action too. Signals are shared by the whole page, so every instance of the
island fills the same one.A value-taking action vouches for less than an action per element: its token says the island rendered it, not that the island rendered what the value names, so the handler checks that.
The browser owns some state: it changes without a round trip, and outlasts a page load. Two kinds differ in what the server must know of it. Whether a section is open is small, and the server renders it in the first frame, with no flash of the default. A draft the user is writing is large, and the server needn't render it, only take it when the user acts.
localStorage, which none does. The store decides when the server sees a value (as the session begins, or never
unasked), how much it holds, and what travels with each request, and a developer reads all three off its name. So
each is a namespace, cookie and storage, whose hooks say which, rather than an option on one defstate: under
that, the server's read would have meant two things, a value from the session's request for one, and a value pending
until the browser answers for the other. What works over any signal, attr and the helpers over a handle, stays in
ui, as does page-attrs, since an element takes one signal-patch handler and one effect.data-preserve-attr keeping the browser's attribute through morphs, was the way before: it protects an element that
stays in the page, but an island that unmounts and mounts again renders the stale copy. A signal outlives any element,
so a bound attribute (data-attr) takes the browser's value wherever and whenever the element appears. The server's
value matters only for the first paint, before Datastar runs, and stays in the attribute for that.data-attr keeps it right; data-preserve-attr spares churn. Datastar's attribute binding watches the attribute
it sets and sets it again when something else changes it, before the page paints. Without data-preserve-attr, each
patch of the island would write the stale value and have it undone; with it, the morph leaves the attribute alone.attr renders an attribute's first-paint value, binds it to an expression
and preserves it; shown and aria-expanded are it over a state's handle, and an app's own signals use it directly,
with the first-paint value the server works out. It takes the element's attributes and returns them with its own:
data-preserve-attr is one attribute listing every name, so two helpers' maps merged would keep one list. An aria-*
state is a word, where Datastar writes true as an empty value, which no state reads as true; the expression's
booleans are turned into words in the browser, and a nil still removes the attribute, which String(...) would
write as "null". Text needs none of it: data-text sets the text again when a morph changes it, as data-attr
does an attribute (logbook, 2026-10-09).defcookie and defstorage declare a state where the code using it lives, next to one
island or in a namespace several share, decoupled from both the island tree and the app. Declared in the app map, a
state sits far from the islands using it; declared on an island, as its :key is, a state can't be shared by an
island elsewhere. An item, such as a group's id, gives a state per item, so one declaration covers states that depend
on data. An item's name can't hold a ., which Datastar reads as a step into the signal.data-signals__ifmissing, which Datastar applies per leaf. So whichever renders first declares it, and a later
declaration, a remount or a patch carrying the stale copy changes nothing: no island owns the state, and none has to
come first._cookie signal into the
cookie, merged with what it holds, where writing the whole of _cookie would let each tab undo another's changes.
Declarations patch signals too, so it drops values equal to their state's default, which also keeps the cookie to
what the user changed; it drops states no longer declared, and the oldest values past a size well under the 4 KB a
browser keeps. It is an arrow function taking no arguments, called with none: Datastar splits an expression that
returns a value into statements at each ; outside strings and such a function, and returns the last, so any other
shape is cut apart and fails to compile (logbook, 2026-10-08).patch), checked as it builds them:
the islands using the state follow, and the body's handler keeps it, as it keeps the browser's own changes. That
covers an item the page doesn't render too, whose signal the patch creates.navigate's :signals, then the script. The body's handler keeps what they set
as they land, for either store, so navigate takes the map a handler returns rather than an option per store and
operation. It used to put a copy of the cookie's writer into its script, for cookie states alone. Set-Cookie on
the action's response would write the cookie from the server, as a second writer of its format, in Clojure, to keep
in step with the merge, the defaults and the cap, and couldn't reach the storage. navigate refuses an option it
doesn't take, so one left over from a rename fails loudly.decode reads back, untrusted.island/js: the browser's entry, under the state's
version and young enough, else the default. Datastar applies it per leaf, which for a map would bring back a key the
user removed from a stale copy; but the copy it reads is the browser's own entry, the signal's mirror, which has no
such key. A server's copy would bring the key back, which is why maps in a cookie state are parked.localStorage, so
the server can't render the browser's value before the page's script runs. Most of what shows a draft is a binding,
which needs no server. What only the server can render from it, a row per item or a count, reads it with observe:
a hold that mints a token bound to the user, as an action's is, and declares _storageWatch.<token> naming the
entry. The handler sends the entry to the token as the watch appears, and a moment after each change. When the
island stops reading, the token is revoked, and the next send's 404 drops the watch. Its pending isn't a
resource's, so the first frame doesn't wait for it: the browser can't answer before it has the page.forget patches _storageForget, which the body's handler hears whether or not
anything on the page uses the entry: it drops the entry, sets a declared value back to its default, and clears the
signal, so the next forget is a change again.:encode and :decode map Clojure values at the server's edges, and the declaration checks that the
default comes back as it went. A nil, which Datastar takes for deleting a signal, and a key holding a ., which it
reads as a step, are refused.;, and JSON in an expression
writes a backslash as \u005c: Datastar's statement pattern reads the end of "a\\" as an escaped quote, and
would go on to cut an expression at a ; in a later string (logbook, 2026-10-09).Every route is a page load and a session of its own, so state that should outlive a page, such as a user's toasts,
which every tab of theirs shows and a navigation keeps, had no home: an island's state ends with its page, and a def
never ends. In Electric a session lasts a tab's whole life, so its session-locals covered most of this, and globals
were a def there too. An app keeping such state in a def'd atom does the bookkeeping a resource's lifecycle
already does: who still reads it, and when to drop it. So shared state takes that lifecycle.
observe is, nothing outside the render could name it, and an
action, a resource or a task of the app's own has to change it. defshared declares it, as defcookie does, with
one :init for every reader.:init, live, so the render that creates it has its value,
where a resource's subject runs on a thread of its own and its first reader sees pending. Its linger is its
state's, since state meant to outlast a page may want longer than a subscription's churn needs.swap! returns whether it found one.swap! runs its fn once, under the instance's lock, where an atom retries it: a quick fn costs the same, and
one with a side effect isn't run twice.sessionStorage names a tab across its page loads, and a page load can't
carry it, so a first frame couldn't render such state. A user's id or a browser's cookie is on every request.Some of a page has to run in the browser, such as a web component that tracks scrolling. An island declares the script it needs, as it declares a signal, and the page loads it once, whether the island is in the first frame or patched in later (measurements in the logbook, 2026-10-08).
ring/scripts), so they load
alongside Datastar, and the stream sends them only to a page that didn't. Tropical doesn't render the page, so the
helper records that it ran, as mount does; a page that left them out still works, its scripts loading once the
stream connects. They went both ways before, which cost a second tag for each, for a module that runs once
(logbook, 2026-10-10).wrap-scripts serves it under one path, named by its var and the
hash of its content, so it is kept for good and a change is a new URL. A page from before the change, asking for the
old version, gets the current one, revalidated, rather than a 404.A file's bytes go from the browser straight to where the app keeps them, such as a bucket taking presigned PUTs. Through the app, a large file would hold a request and its memory twice over, once arriving and once going on to storage. Datastar binds a file input into signals as base64, which an action's body cap rules out for all but small files. The shape is Phoenix LiveView's external uploads: a fn per file names the target, the browser sends the bytes there, and the server takes the files when it needs them.
take! reads and
removes what arrived at once, where a send action reading the render's list could miss a file arriving as it ran.
:target runs one pick of the island at a time and sees the list with the pick's earlier files, so a limit it
checks holds against two picks at once.:arrived refusing it or remove!, and passes what arrived to
:settled then, still under the lock, so no report or take! falls between reading the batch and taking it. One
failed or refused doesn't hold back the rest. If :settled throws, its uploads fail, as when :arrived throws:
taking them would lose them, and leaving them arrived would hand them over again with the next batch, to work that
may have started.:target returns the app's reference, such as the
storage key, and the browser reports on an upload by the id the island gave it, to the island's token. So a client
can't make the app take an object of its choosing, or another session's. The key is no secret: it is in the
presigned URL's path. The report that an upload arrived is still the browser's word, so :arrived can check.dragover. While files are dragged, the browser fires dragover at the element under the pointer
again and again, and it bubbles: the innermost area around that element takes the drag and stops it there, so an area
inside another needs no counting of the dragenter and dragleave each child fires, and only the area that would
take a drop shows that it would. A drag that reaches the window is over no area. The page's body refuses it there,
since the browser would open a dropped file in the tab, and clears what an area showed: ui/page-attrs carries that,
as it carries the browser's stores, since the document around the root island is the app's, and tropical renders from
the root island down. An area is named by its uploads' token, so drop-area and dropping take the start that
use-uploads returns, and two areas for one list are one area to dropping.Tropical-Action, minted bound to the user and revoked with the island, so they pass the route's middleware and
need no route of their own. action/use-token is the primitive: the handler gets the body, and a map it returns
goes back as JSON, which the script reads.XMLHttpRequest sends the bytes, since fetch
can't tell how much of a body it has sent. The island declares each signal only if missing, since the frame
showing an upload can reach the page before the browser has read its target, and a binding reading a missing
nested signal throws.$, the live signals. Importing Datastar from the module would need the URL the app loaded it from,
and a second copy would be a second store; a global would be one more name on the page. Reading a signal that
doesn't exist makes it "", and setting an object replaces the keys of the one there, so the script sets the
signal holding every upload whole only while it isn't an object yet.Left out: aborting a PUT when its file is removed, which the script could do by id; resuming or splitting large uploads; and retrying a failed one, which the user can do by picking it again.
What the page needs to name, it names after the app: an island's element is found by its id, the path of the functions that placed it; a cookie or storage state's signal by its var, namespace and all; a script's URL by its var. None of it is a secret, but none of it is the page's business either, and a script running in the page, or anyone reading it, learns the app's layout from it.
co.multiply.tropical.readable-names shows the names as
written, for debugging. A mode is a trap: what names an element or a signal by hand works in it and fails without
it. So the safe direction is the default, everywhere, bb dev included: readable names are something a developer
asks for, with bb dev:readable, not what they work in. Code names them through the helpers (ui/signal,
ui/path), which follow either way. A server's setting renames every state, so every server of an app runs one
way.:error-view.clojure.tools.logging, not a backend. A library shouldn't pick where logs go. tools.logging is the facade most
Clojure apps already route, to SLF4J, Telemere or others, and exceptions go with their stack traces. A hook in the
app map was the alternative: it fits a session's events, but not the resource registry's, which belong to no app.EventStream guarantees whole, ordered events, a final close, and
closing on a failed write. sse says which events tropical sends and what they carry.com.github.luben.zstd, with no registration
that a relocated class could use. A classloader of tropical's own could hold a second copy, but its jar would have to
sit outside the app's classpath, wherever the app is deployed. And the one pure-Java zstd at hand doesn't flush:
aircompressor's ZstdOutputStream holds everything until it closes. So tropical ships libzstd itself and calls it
through the JDK's foreign function API, which needs no glue code, only zstd's own library: built from zstd's signed
release source by native/build-zstd.sh, compression only, single-threaded, exporting zstd's API alone and binding
its own calls to itself. It needs nothing of libc but six memory functions, so one Linux build runs on glibc from
2.14 (2.17 on aarch64) and on musl. Its output is zstd-jni's, byte for byte, at the same version and settings. Each
write is copied into native memory, where zstd-jni pins the Java array for the call instead, which would hold off
the garbage collector for as long as a call compresses: about a tenth more CPU per frame (logbook).native/build-brotli.sh from brotli's release source: the encoder and the code it shares with the decoder, exporting
brotli's API alone and binding its own calls to itself. Of libc it needs the six memory functions libzstd does and
exit, and of libm log2, which glibc 2.29 gave a new version; the build binds the first, so the library runs on
glibc from 2.14 (2.17 on aarch64) and on musl, as libzstd does. brotli signs no release: the source is GitHub's
archive of the release tag, checked against the release commit, and it is the source brotli4j carries, file for file.
Its output is brotli4j's, byte for byte, at the same version and settings. Like every build of brotli's own, and
brotli4j's, the encoder ends the process if malloc fails: brotli's alternative, cleaning up and returning an error, is
built by none of them, and tracks at most 128 live allocations, unchecked outside debug builds. On Linux, malloc
failing is rarer than the kernel ending the process for its memory.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |