Cross-viewer reuse: one cache, checked at two points in the update pipeline.
Shared derivation and fragment caching were designed as separate features cutting in at different depths, but they are the same operation — "another viewer already computed this, reuse it" — so they share one cache and one invalidation path.
:mount (query) -> :render -> extract -> serialise
~164 us 1.3 us 28.7 us 27.3 us
Entry point 1 — before :mount. Requires a component-declared :derive-key.
A hit skips the query and everything downstream. This is the 68% share the
fan-out benchmark attributes to queries.
Entry point 2 — after :render, before serialising. Requires nothing: the
key is a hash of the tree. A hit skips serialising. Hashing costs ~0.5% of a
serialise, so this is nearly free to attempt and therefore always on.
Invalidation. A hint must evict both the derived value and every fragment rendered from it. Two independent caches means two eviction paths that can disagree, leaving a stale fragment paired with a fresh query — a bug that would present as "the data updated but the screen didn't", which is exactly the class of failure this project keeps finding late.
Point 2 cannot leak. The key is the content, so two viewers share a string only when their trees are equal — meaning the output would have been identical anyway. No declaration, no tenant reasoning, nothing for an author to get wrong.
Point 1 carries the whole hazard (, risk 6). The intuitive key —
params — is wrong in any multitenant app, because the tenant reaches :mount
through the context map rather than params, and two tenants can have identical
params. A params-only key is a cross-tenant read. So:
:derive-key is never shared at point 1;:derive-key receives the full context, so the author can include whatever
determines the result;derive-key-for refuses a key of nil, so a :derive-key that fails to
produce one declines sharing rather than colliding on nil.The unsafe path requires no thought and the fast path requires a deliberate declaration, which is the opposite of the usual cache-key mistake.
Not a general memoisation library, not time-based, and not bounded by an eviction policy yet. It is scoped to one update cycle plus explicit topic invalidation, because a cache that outlives a hint would serve data the hint was announcing had changed.
Cross-viewer reuse: one cache, checked at two points in the update pipeline.
Shared derivation and fragment caching were designed as separate features
cutting in at different depths, but they are the same operation — "another
viewer already computed this, reuse it" — so they share one cache and one
invalidation path.
## The pipeline, with measured per-viewer costs (100-item list)
:mount (query) -> :render -> extract -> serialise
~164 us 1.3 us 28.7 us 27.3 us
**Entry point 1 — before `:mount`.** Requires a component-declared `:derive-key`.
A hit skips the query *and* everything downstream. This is the 68% share the
fan-out benchmark attributes to queries.
**Entry point 2 — after `:render`, before serialising.** Requires nothing: the
key is a hash of the tree. A hit skips serialising. Hashing costs ~0.5% of a
serialise, so this is nearly free to attempt and therefore always on.
## Why one cache rather than two
Invalidation. A hint must evict both the derived value and every fragment
rendered from it. Two independent caches means two eviction paths that can
disagree, leaving a stale fragment paired with a fresh query — a bug that would
present as "the data updated but the screen didn't", which is exactly the class
of failure this project keeps finding late.
## Safety: the two entry points have very different risk
**Point 2 cannot leak.** The key *is* the content, so two viewers share a string
only when their trees are equal — meaning the output would have been
identical anyway. No declaration, no tenant reasoning, nothing for an author to
get wrong.
**Point 1 carries the whole hazard** (, risk 6). The intuitive key —
params — is wrong in any multitenant app, because the tenant reaches `:mount`
through the context map rather than params, and two tenants can have identical
params. A params-only key is a cross-tenant read. So:
- a component with no `:derive-key` is **never** shared at point 1;
- `:derive-key` receives the **full context**, so the author can include whatever
determines the result;
- `derive-key-for` refuses a key of `nil`, so a `:derive-key` that fails to
produce one declines sharing rather than colliding on `nil`.
The unsafe path requires no thought and the fast path requires a deliberate
declaration, which is the opposite of the usual cache-key mistake.
## What this is not
Not a general memoisation library, not time-based, and not bounded by an eviction
policy yet. It is scoped to one update cycle plus explicit
topic invalidation, because a cache that outlives a hint would serve data the
hint was announcing had changed.(cache)Creates a cache. Held by the caller, like the other registries.
:derived maps a derive-key to a mounted view; :fragments maps a tree hash
to a rendered string. Both are cleared by the same invalidate!.
Creates a cache. Held by the caller, like the other registries. `:derived` maps a derive-key to a mounted view; `:fragments` maps a tree hash to a rendered string. Both are cleared by the same `invalidate!`.
(cached-mount c component ctx mount-fn)Runs :mount for ctx, reusing another viewer's result when it is provably
the same.
mount-fn is called only on a miss. With no :derive-key there is no key, so
every call is a miss and nothing is shared — the safe default.
Runs `:mount` for `ctx`, reusing another viewer's result when it is provably the same. `mount-fn` is called only on a miss. With no `:derive-key` there is no key, so every call is a miss and nothing is shared — the safe default.
(cached-render c render-fn tree)Serialises tree with render-fn, reusing an identical earlier result.
Keyed on the tree's hash, so a hit means the trees were equal and the output would have been identical. Cannot leak across viewers by construction.
A hash collision would serve the wrong string, so equality is confirmed against
the stored tree rather than trusted — hash is 32-bit and collisions are
reachable at fan-out scale.
Serialises `tree` with `render-fn`, reusing an identical earlier result. Keyed on the tree's hash, so a hit means the trees were equal and the output would have been identical. Cannot leak across viewers by construction. A hash collision would serve the wrong string, so equality is confirmed against the stored tree rather than trusted — `hash` is 32-bit and collisions are reachable at fan-out scale.
(derive-key-for component ctx)The cache key for ctx under component, or nil if sharing is declined.
Returns nil when the component declares no :derive-key, and also when a
declared one returns nil — a key-fn that cannot produce a key must decline
rather than let every such viewer collide on nil.
The cache key for `ctx` under `component`, or `nil` if sharing is declined. Returns `nil` when the component declares no `:derive-key`, and also when a declared one returns `nil` — a key-fn that cannot produce a key must decline rather than let every such viewer collide on `nil`.
(invalidate! c)Clears everything derived and everything rendered.
Deliberately total rather than selective. A hint says "topic X changed" and carries no data, so the engine cannot know which derived values or fragments depended on X without tracking a dependency graph — and a graph that is wrong in the stale direction produces exactly the failure this cache is meant to avoid. Clearing both together is the conservative choice, and it keeps the single-invalidation-path property that motivated one cache.
Cheap because the cache is scoped to a refresh cycle: it is populated by the viewers of one flush and dropped at the next.
Clears everything derived and everything rendered. Deliberately total rather than selective. A hint says "topic X changed" and carries no data, so the engine cannot know which derived values or fragments depended on X without tracking a dependency graph — and a graph that is wrong in the *stale* direction produces exactly the failure this cache is meant to avoid. Clearing both together is the conservative choice, and it keeps the single-invalidation-path property that motivated one cache. Cheap because the cache is scoped to a refresh cycle: it is populated by the viewers of one flush and dropped at the next.
(stats c)Hit/miss counters. Exposed because a cache whose hit rate nobody measures is a cache nobody can justify.
Hit/miss counters. Exposed because a cache whose hit rate nobody measures is a cache nobody can justify.
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 |