Images: which registrations a frame runs¶
Frames isolate state, but by default every frame runs every registration you have loaded. Most apps never need anything else, and you can skip this page until you do.
You need more when two examples on one page both register an event called
:todo/add, when an inspection tool runs beside the app it inspects, or when a test
needs a fake storage effect instead of the real one. Each of these asks the same
question: which registrations does this frame use?
That set is the frame's image. An image selects registrations; a frame is a running instance that looks its handlers up in that selection. One image can back several frames, and two images can each contain a registration with the same id.
The default image¶
Here is ordinary re-frame2, with no image in sight:
(ns app.todos
(:require [re-frame.core :as rf]))
(rf/reg-event :todo/add
(fn [{:keys [db]} [_ title]]
(let [id (inc (apply max 0 (keys (:todos db))))]
{:db (assoc-in db [:todos id] {:id id :title title :done? false})})))
(rf/reg-sub :todo/todos
(fn [db _] (:todos db)))
Those reg-* forms don't run anything. They add entries to the
registrar, the process-global table of every
registration, each tagged with the namespace it was
written in (its provenance, :rf.provenance/*).
A frame created without naming an image looks up every id in the whole registrar.
That selection of everything is the default image. In code it is simply a frame
created with no :images key. New registrations are picked up as soon as you write
them, and hot reload keeps working. You name an image only when "everything that's
loaded, with globally unique ids" is not what a frame should run.
Coming from JavaScript modules?
Think of the registrar as your full set of module exports, and the default image as
import * from everything. You don't normally think about it, until two modules
export the same name and you need to say which one a particular consumer gets.
When two registrations collide¶
The default image works only while ids are unique across everything that's loaded.
When two loaded namespaces register the same (kind, id) with different
implementations (two surfaces that both define :todo/add, say), the frame fails to
assemble with :rf.error/image-duplicate-id, naming the kind, the id and both source
namespaces.
The registrar keeps both registrations, and assembly will not guess which one should win. You have three ways out, each covered below: rename one id, narrow each frame to its own slice with an explicit image, or compose a later image that overrides the other.
From re-frame v1
In v1 the second reg-event of an id replaced the first, and you found out when the
wrong handler ran. re-frame2 keeps both and stops at assembly with a named error,
before any event touches state (fail loud, not silent).
Naming an image: rf/image¶
rf/image builds an image value, and you hand it to a frame through :images:
(def todos-image
(rf/image {:select-ns {:include ["app.todos"]}}))
(rf/make-frame {:id :app
:images [todos-image]})
:select-ns selects existing registrations by the namespace they were written in.
This frame uses only the registrations written in app.todos.
Notes:
- Building an image registers nothing and runs nothing.
rf/imagereturns plain data describing which registrations to select; nothing happens until a frame uses it. - Selection is by provenance, the namespace where a registration was written,
not by the keyword namespace of its id.
:todo/addwritten inapp.todosis selected because of the file it is in, not because its keyword starts withtodo. This one trips people up. - The framework's own feature handlers are always included. Routing, managed
HTTP, Resources, SSR and the
:rf/time-mscoeffect are registered with no source namespace, so:select-nscannot pick them; every explicit composition includes them beneath your images. A later image can override one of them (the shadow report names that base:rf/framework), but not a framework standard such as:rf/set-db(below).
Coming from a bundler's globs?
:select-ns is like a bundler's include/exclude globs, except it selects
already-loaded registrations rather than reading from disk. The namespace must
already be required through ordinary ns dependencies; the glob only chooses
from what the runtime already knows.
The three image keys¶
An rf/image spec takes three keys, all optional:
| Key | What it does |
|---|---|
:id |
Names the image in diagnostics and in the shadow report. Ids must be unique within one :images vector. Anonymous images are fine for tests and one-off examples. |
:select-ns |
Selects existing registrations by their source namespace. |
:registrations |
Defines registrations inline, inside the image. |
An image with neither :select-ns nor :registrations is valid and empty, which is
useful as a deliberate "no app registrations" image: (rf/image {:id :test/empty}).
Any other key fails with :rf.error/invalid-image, so a typo is caught when the image
is built.
Narrowing the glob: :include and :exclude¶
:select-ns is a {:include [...] :exclude [...]} map. :include is required;
:exclude is optional and subtracts from the selection.
Use :exclude when a recursive glob (**) picks up namespaces a frame must not use.
The usual case is a feature's test namespaces: in a dev build they are loaded, and
they often re-register the ids the production sources define. Selecting both would be
a collision, so :include the feature broadly and :exclude its tests:
(rf/image {:select-ns {:include ["app.todos.**"]
:exclude ["app.todos.**.*-cljs-test"
"app.todos.test-helpers.**"]}})
The glob grammar
The grammar is small and case-sensitive, and works on the dotted namespace path:
- a literal segment matches itself;
*matches exactly one dot-free segment;**matches zero or more segments;- a
*inside a segment matches zero or more characters within that segment, never crossing a., so*-cljs-testmatches the leaf ofapp.feature.mount-cljs-test.
So docs.shared.widgets.* matches docs.shared.widgets.button but not
docs.shared.widgets (no leaf) or docs.shared.widgets.forms.input (two leaves);
docs.shared.** matches all three.
An :include pattern that matches nothing is an assembly error
(:rf.error/image-zero-match), so a typo, a forgotten require or a
dead-code-eliminated namespace fails loudly instead of leaving the frame
incomplete. An :exclude pattern that matches nothing is ignored, so a production
build that never loads the excluded test namespaces works unchanged. :exclude
applies only to glob-selected registrations, never to inline :registrations.
Defining registrations inline: :registrations¶
Most code should use ordinary reg-* forms and select them by namespace. For
generated code, tests or library packaging, where a whole namespace for one fake is
overkill, you can define registrations inside the image. The keys mirror the reg-*
names, and each registration is a vector of the arguments you would pass to that
reg-* call: the id, an optional metadata map, and the handler fn:
(def small-image
(rf/image
{:id :test/small
:registrations
{:reg-event [[:todo/add
{:doc "Add a todo."}
(fn [{:keys [db]} [_ title]]
{:db (assoc-in db [:todos 1] {:id 1 :title title :done? false})})]]
:reg-sub [[:todo/todos
{:doc "The todos map."}
(fn [db _] (:todos db))]]}}))
Inline :registrations supports four kinds: :reg-event, :reg-sub, :reg-fx and
:reg-cofx, the kinds a test double or generated slice needs. Any other section key
(:reg-interceptor, :reg-view, :reg-route, :reg-flow, …) fails loud; write
those in a namespace and select them with :select-ns. An inline :reg-sub has one
body fn and declares its dependencies in the metadata, so a derived sub works inline
with {:inputs [[:todo/todos]]} (or an :inputs producer fn). Without :inputs, the
body reads app-db as (fn [db query] …). Each entry is parsed by the same code as its
reg-* call, so a malformed entry fails the way that call would.
Gotcha: one id cannot come from both :select-ns and :registrations
An image may use both keys, but one (kind, id) may not be both selected by
namespace and defined inline in the same image. That is an error
(:rf.error/image-within-image-collision), not an override. To make an inline
definition win over a selected one, put it in a later image (see
Composing images: the later one wins).
Registration ids and frame ids¶
Two examples on one page can share ids because there are two id spaces with different scopes:
| Id space | Example | Scope | Rule |
|---|---|---|---|
| Registration ids | :todo/add, :todo/todos |
the resolved image | reusable across images; unambiguous within one image |
| Frame ids | :todos/work, :todos/home |
the process's frame registry | unique among live frames |
Two images may both contain a :todo/add event. Two live frames may not both use the
id :app. So a docs page can reuse the same event and sub ids in every example, while
each mounted example gets its own frame id:
(def todos-basic (rf/image {:select-ns {:include ["docs.todos.basic"]}}))
(def todos-persisted (rf/image {:select-ns {:include ["docs.todos.persisted"]}}))
;; Same ids inside each image (:todo/add, :todo/todos), different behaviour.
;; Distinct frame ids, because frame ids are globally unique.
(rf/make-frame {:id :docs.todos/basic :images [todos-basic] :initial-events [[:rf/set-db {:todos {}}]]})
(rf/make-frame {:id :docs.todos/persisted :images [todos-persisted] :initial-events [[:rf/set-db {:todos {}}]]})
The reader sees the same ids across lessons instead of :todo-v1/add,
:todo-v2/add. The image decides what each id means; the frame ids keep the running
instances apart.
Coming from Redux?
An image is the set of reducers and selectors a store runs, as a value you can name
and compose: combineReducers if it returned data instead of a function. The
difference is that registration names are scoped to the image, so two stores can
each define :todo/add meaning different things without a global collision.
Choosing between an image and a frame¶
An image holds behaviour: the registrations. A frame holds state and history: its own app-db, changing as events run. So different behaviour means a different image, and the same behaviour with different state means the same image in a different frame.
The shapes you'll meet:
- Two surfaces on one page. A todo list and a settings panel that both want
simple local ids such as
:boot/init. Give each its own image with disjoint:select-nsselectors; each frame resolves only its own. - An inspection tool beside its target. Xray is itself a running surface with its own events, subs and app-db. It runs in its own image and frame and inspects the target frame as data, so it never has to coordinate ids with the app.
- Progressive docs examples. Four versions of the todo list, one per lesson, each
its own image, all reusing
:todo/addand:todo/todos. - A library slice you compose in. A library ships an image value; you build a frame from your image plus theirs.
Composing images: the later one wins¶
:images is a vector, and order matters. A frame can compose several images and
still runs one resolved result:
If two images provide the same (kind, id), the later one shadows the earlier.
Assembly records each override in the shadow report
(next section), and you decide what to do with
it: assert there are none, assert a known set, or log them.
A collision within one image is still an error: an image must resolve to one
registration per (kind, id). To override, compose a later image.
You cannot override a framework standard such as :rf/set-db: that collision fails
assembly with :rf.error/image-standard-replacement-forbidden. Three framework
defaults are the exception, meant to be replaced; see
Framework standards, and the defaults you're meant to replace.
Before a frame runs an event, its images are resolved into one sealed generation: framework registrations added, collisions and references checked, the result frozen. Every lookup the frame makes goes through that generation, and a bad composition fails here, with a named error id, before any event touches state. The ids are listed under Assembly errors.
Reading what a frame is running¶
You can ask a live frame what it is running. This is useful in tests, in tools like Xray, and at the REPL when a composition didn't resolve the way you expected.
rf/frame-generation returns the frame's generation as data. The key you will use
most is :rf.gen/shadows, the override report: a vector with one entry per
override:
(:rf.gen/shadows (rf/frame-generation :app))
;; => [{:registration [:fx :todo.storage/save] ;; the shadowed (kind, id)
;; :image :app/main ;; the image the loser was defined in
;; :shadowed-by :test/doubles}] ;; the image of the final winner
An empty vector means nothing was overridden, so
(empty? (:rf.gen/shadows (rf/frame-generation frame))) asserts that no image
overrode another. A default-image frame has a generation too, and its shadow report is
[]. For a target that is not a live frame (an unknown or destroyed frame id),
rf/frame-generation throws :rf.error/frame-no-generation rather than returning
[] or nil.
The rest of the generation, and frame-targeted queries
The rest of the generation is data too: :rf.gen/resolver (one descriptor per
(kind, id)), :rf.gen/images (in :images order, later wins), and
:rf.gen/kinds (the kinds present).
Registrar queries can resolve through one frame's image instead of the global
registrar, by passing a {:frame …} map:
(keys (rf/registrations {:frame :app :kind :event}))
(rf/handler-meta {:frame :app :kind :sub :id :todo/todos})
A query map must name exactly one source: {:frame f …} for this frame's image,
or {:source :store …} for the process-global registrar. A map naming neither, or
both, throws :rf.error/registrar-query-needs-source, so you never read the global
registrar by accident. The metadata includes :rf.provenance/ns, so when two
images both define :todo/add you can see which one won and where it was written.
Every read accepts a frame id or a frame value, and throws
:rf.error/frame-no-generation if the target isn't a live frame. When images form
a chain over one (kind, id), such as [base override-a override-b], every loser
names the final winner, so an assertion never walks a chain.
Tests: behaviour is the image, state is the frame¶
A test fixes both behaviour and state. The image gives you the behaviour; the frame gives you the state. So to swap in a fake effect handler, you don't change the process-global registrar under a running frame; you compose the fakes as a later image:
(def todos-image
(rf/image {:id :app/main
:select-ns {:include ["app.todos.**"]}}))
(def test-doubles
(rf/image {:id :test/doubles
:registrations
{:reg-fx [[:todo.storage/save (fn [_ctx _todos] nil)]]}})) ;; no localStorage
(rf/with-new-frame [frame (rf/make-frame
{:images [todos-image test-doubles]
:initial-events [[:rf/set-db {:todos {}}]]})]
(rf/dispatch-sync [:todo/add "Buy milk"])
(rf/subscribe-once [:todo/todos]))
The fake goes in a separate, later image because defining it in the same image as the
real :todo.storage/save would be a collision.
State setup is a frame concern: :initial-events (such as a leading
[:rf/set-db {…}]), a restored frame-state value, or setup events. Behaviour setup is
an image concern: select or override registrations before the frame runs. Because the
two are separate, a test can vary one without touching the other.
with-new-frame makes the frame current for both calls and destroys it on exit.
subscribe-once reads without keeping a cache reference. Outside that scope, pass
{:frame frame} to either call; Frames covers explicit targets.
Coming from Jest mocks or MSW?
With jest.mock or MSW handlers you mutate a shared module registry and remember
to reset it between tests. Here the test double is a value you compose into a fresh
frame, so there is no global to dirty and nothing to reset.
Hot reload swaps the image, keeps the state¶
During development the registrar changes every time you save a file. Re-evaluating a
reg-* form does not change any running generation. Instead the runtime marks every
image that selects the changed namespace as dirty, resolves new generations, and swaps
them into the affected frames. The frames keep their app-db,
runtime-db, queues and still-valid subscription caches. You
save a file and the live frames pick up the change without losing state.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
:rf.error/image-duplicate-id from a frame with no :images |
Two loaded namespaces register the same (kind, id) |
Rename one id, or give each frame its own image (When two registrations collide) |
:rf.error/image-within-image-collision after adding a test double |
The fake is in the same image as the registration it replaces | Put the fake in a later image |
:rf.error/image-zero-match |
An :include glob matches no loaded namespace: a typo, a missing require, or a namespace removed as dead code |
Fix the glob, or require the namespace |
| An explicit image is missing a registration | It was made through a function alias or generated code, so it has no source namespace | Add :ns to its metadata |
:rf.error/image-standard-replacement-forbidden |
An image registers a framework standard such as :rf/set-db |
Use your own id; only replaceable defaults can be replaced |
Advanced¶
Swapping a frame's images¶
To change a frame's composition outright, call rf/make-frame again with the same
:id and a new :images vector. There is no separate reload function:
The new vector is assembled into a new generation and installed on the existing
frame. App-db, runtime-db, queues and still-valid subscription caches are kept. Config
other than :images is replaced too, as with a Clojure def, so pass again anything
you want to keep. You need this only when you want to swap a frame's whole
composition on purpose: a test that points a running frame at a different image
stack, a tool driving a frame through several configurations, or a story swapping one
set of registrations for another.
To see what changed, read frame-generation before and after and compare them with
generation-diff:
(let [before (rf/frame-generation :app)
_ (rf/make-frame {:id :app :images [todos-image routing-image sync-v2-image]})
after (rf/frame-generation :app)]
(rf/generation-diff before after))
;; => {:added #{[:event :todo/sync-now]}
;; :changed #{[:sub :todo/visible]}
;; :removed #{[:event :todo/legacy-sync]}
;; :retained #{[:event :todo/add] …}}
generation-diff partitions the (kind, id) space four ways, so you can invalidate
only what moved (a sub whose definition didn't change keeps its cache). The reloaded
frame's :rf.gen/shadows shows its new override set.
Assembly errors¶
Each assembly failure has a named error id you can catch and assert on:
| What's wrong | Error id |
|---|---|
Two selected registrations for one (kind, id) from different source namespaces inside one image (except an app registration of a replaceable default) |
:rf.error/image-duplicate-id |
| An inline entry colliding with a selected one, or two inline entries, in one image | :rf.error/image-within-image-collision |
An :include glob that matches no loaded source namespace |
:rf.error/image-zero-match |
Two images sharing an :id in one composition |
:rf.error/image-duplicate-image-id |
| An app registration colliding with a protected framework standard | :rf.error/image-standard-replacement-forbidden |
A selected event names an interceptor, or a resource names a {:from-db …} scope resolver, that the image doesn't select |
:rf.error/image-missing-reference |
A malformed rf/image spec: a retired or unknown key, a bad :select-ns, a glob that isn't a string, or an unsupported or malformed inline entry |
:rf.error/invalid-image |
:images [], or a non-vector :images |
:rf.error/make-frame-bad-images |
:images [] is an error because an empty vector almost always means "I meant to put
images here and forgot". For a frame with no app registrations, pass one empty image:
(rf/make-frame {:images [(rf/image {:id :test/empty})]}).
A reload runs the same checks, and the swap is all-or-nothing: a bad new composition
fails with the same error ids and leaves the frame on its existing generation.
Reloading one frame never changes a sibling that shared its generation, and the old
generation is never mutated. Only a frame with an :id can be re-made this way; a
frame created without one is a local object, so discard it and make a new one.
The default image is a sealed generation¶
make-frame with no :images key resolves a sealed default generation over the whole
registrar, framework standards included. It goes through the same assembly checks as
an explicit selection, which is why the default path also fails on a cross-namespace
duplicate id instead of letting load order pick a winner. The one exception is a
replaceable framework default, described next: when your app registers the same id,
the framework's own copy is dropped.
Framework standards, and the defaults you're meant to replace¶
Not everything the framework registers is a standard, and the difference decides whether your registration of the same id is an error or the documented recipe.
- A framework standard implements how a frame executes registrations:
:rf/set-db, the interceptor algorithm, queue ordering, app-db commit semantics. An app registration of the same id fails assembly with:rf.error/image-standard-replacement-forbidden. There is no opt-in. - A replaceable framework default is the framework's placeholder for a decision
your application makes, registered so the feature works when you register nothing.
The framework marks each one with the reserved
:rf/framework-default?metadata key. There are three. The routing events:rf.route/entry-deniedand:rf.route/navigation-blockedare no-ops, so a:can-enterdenial or a:can-leaveblock always has a handler, and the auth recipe has you register your own. The:rf.http/managedeffect is the managed-HTTP handler, which an app re-registers to declare its HTTP carriers.
So (rf/reg-event :rf.route/entry-denied …) in your own namespace is not a
duplicate-id collision. Once your registration exists, assembly drops the framework's
copy. Two details:
- Two app registrations of one framework-default id are still
:rf.error/image-duplicate-id, like any other duplicate. - Putting
:rf/framework-default?on your own registration does nothing. The key counts only on a registration with no source namespace, which is how the framework's own copy is identified, and areg-*macro always records the namespace it was written in.