Compilation¶
Most Freehand views should stay interpreted. Compilation is a power tool for hot boundaries and library leaves — not a second product you live in from day one.
High :stable-renders means fix subscriptions or props, not compile. Measure
first; compile second.
Freehand remains one view layer. Compiled mode is an option on the same
v/defview. Call sites stay [view props]. Structural tests do not change. There
is no second compiler and no hidden interpreter inside a compiled template.
Measure → fix structure → then
{:compiled true}. Never the reverse.
Day-one: usually do not compile¶
You can ship entire apps interpreted. Promote a boundary only when:
- measurement shows interpretation cost on a hot template, or
- a library leaf needs static proof and a manifest.
There is no separate check step, and no dry run. Adding the marker is the check: if the body sits inside the grammar the marker is the whole change, and if it does not the build fails and names a recovery.
Promotion recipe¶
Call sites and structural tests stay the same. Demote by removing {:compiled true}
when debugging.
What compilation buys — and what it does not¶
When a view body is inside the finite subset, compilation can give you:
- direct React lowering and a JVM structural emitter
- finite manifests for subscriptions, events, slots, host edges, presence, and top-layer forms (static “what can happen,” not only what one render observed)
- generated prop comparators and prebound handlers where the compiler can prove them
- static diagnostics with source coordinates
- static asset / SSR evidence where the emitter supports it
- capability elision — unused machinery drops out; a proved sub-free view can omit the reactive ViewCell shell entirely
Compilation does not buy:
- faster subscription derivation (the sub graph still runs)
- free React reconciliation in dynamic regions
- automatic promotion or a second authoring language
- a silent interpreter for “just this one dynamic hole”
- omniscience for interpreted siblings (they still only report realized reads)
Think of compilation as a capability you turn on when evidence says you need it — not a quota or a badge.
Selecting compiled mode¶
(v/defview todo-row
{:compiled true}
[{:keys [id]}]
(let [{:keys [title completed]} (v/sub [:todo/by-id id])]
[:li {:class {:completed completed}}
[:input {:type :checkbox
:checked completed
:on-change [:todo/toggle id]}]
title]))
Call sites stay [todo-row props]. Structural tests do not change. The public var
remains a descriptor — mounted, never invoked. (todo-row {}) does not
render; it raises :rf.error/view-called-directly, naming the three recoveries.
The grammar version is artifact-wide (:re-frame.freehand/v1), carried on findings
and manifests. It is not a per-view compatibility profile between products, and
not a second namespace or macro.
There is no author-facing :reads declaration in v1. Reads are inline
(v/sub …) only. Tools distinguish evidence with scope / basis / completeness /
loss, not a second read language.
What a refusal looks like¶
There is no read-only checker to run first. You add {:compiled true} and the
build answers: a form the grammar does not admit is a build failure carrying
source coordinates and naming a recovery, never a silent demotion to interpreted.
A refusal reads roughly like this:
{:view-id :app.people/people-list
:source {:file "…" :line 42 :column 1}
:current-lowering :interpreted
:target-grammar :re-frame.freehand/v1
:compile-eligible? false
:findings
[{:id :re-frame.freehand.compile/opaque-markup-call
:source {:line 47 :column 5}
:form '(render-person person)
:reason :markup-hidden-from-analyzer
:recovery [:extract-declared-child
:make-template-visible
:keep-interpreted]}]}
Each finding’s recovery list is the work order. Prefer:
- keep interpreted — helper-heavy app pages and composition
- extract declared child — hot leaves / rows you still want compiled
- make template visible — finite
for, literal heads, visiblesub
Changing the declaration ({:compiled true}) is the last step, not the
discovery mechanism. Freehand never promotes from a percentage threshold or silent
rewrite.
Mini promotion transcript¶
;; 1) interpreted, fails check — opaque helper + map markup
(v/defview people-list [_]
[:ul (map (fn [p] (person-row p)) (v/sub [:people]))])
;; 2) after taking the build's named recoveries — keyed for + declared child
(v/defview person-row
{:compiled true}
[{:keys [person]}]
[:li (:name person)])
(v/defview people-list
{:compiled true}
[_]
[:ul
(for [p (v/sub [:people])]
[person-row {:key (:id p) :person p}])])
Promotion is not transitive. Compiling the list does not force every child
compiled, and compiling a leaf does not require its parent compiled. Nested
[child props] works both directions when the child is a statically named
descriptor.
Placement defaults (cold start)¶
| Surface | Default | Why |
|---|---|---|
| App pages, routes, composition | interpreted | freedom and evidence later |
| Library leaf controls | compile when in-subset | elision; consumers pay your choice N times |
| Keyed row templates at scale | compile when clean; window first | wide-parent churn |
| Flap-prone controlled sites | compile to pin the door | predictability |
| Everything else | interpret until evidence says otherwise | anti-folklore |
Optimization order:
- narrow subscriptions and choose boundaries
- window large collections
- use a qualified React leaf if substrate semantics inside the hot leaf do not matter
- compile remaining interpretation-dominated or static-evidence needs
- measure again with the same counters
The closed vocabulary (what compiled mode can see)¶
Interpreted mode runs full Clojure: any helper may return Hiccup, any head may
be chosen at runtime, any sub may appear in ordinary control flow (subject to
good style).
Compiled mode is different. The compiler must prove at compile time:
- the structure of the template (tags, children, keys)
- every reactive site (
v/sub) - event / host / presence shapes where it claims static evidence
Anything it cannot see through is a checker finding (and a build failure once
you mark {:compiled true}), with a named recovery — not a silent runtime
fallback.
One-sentence rule¶
If the compiler cannot prove the structure, the reactive sites, and the list keys of a piece of markup at compile time, that spelling is illegal in a compiled
v/defviewbody.
Ordinary branching and binding are free when they use forms the analyzer
understands (let, if, when, if-let / when-let / if-some / when-some,
cond, case, for, …). Both arms of an if and every cond clause are
visible. You are not forced into one linear template.
Mode comparison¶
| Concern | Interpreted | Compiled (:re-frame.freehand/v1) |
|---|---|---|
| Body language | full Clojure | closed, compiler-visible template/control forms |
(v/sub …) |
dynamic capture; helpers may read | finite, lexically visible sites; helpers must not hide reads |
| View / element heads | runtime choices among descriptors OK | statically named or finite explicit branches |
| Child markup as a value | pass Hiccup around freely | compiler-owned structure only |
| Loops | ordinary map / seq realization |
compiler-owned keyed for; reactive rows → child views |
| Props maps | runtime maps OK at the edge | literal shape + v/spread / v/spread-safe (composition) |
| Events | runtime-classified common grammar | same grammar; static where knowable |
| Parameterized rows | ordinary pure fn OK | lexically visible v/render-fn / v/slot |
| Presence / top-layer | runtime plans | statically recognized forms |
| Host leaves / behaviors | qualified descriptors | statically named descriptors |
| View-local cells / neutral hooks | rejected | rejected (host boundary instead) |
| Dynamic markup in template | natural | no inline valve — see v/markup below |
There is no v/interp and no “compiled except this unknown subtree.”
Template structure — illegal forms¶
| You cannot (compiled) | Why | Do this instead |
|---|---|---|
Dynamic tag heads — [(if big? :h1 :h2) title] |
Emitter must know the constructor | Both branches, or one fixed tag + varying attrs/class |
Markup-returning map — (map row-view rows) |
Hides structure, keys, and sub sites |
(for [r rows] [row-view {:key … :row r}]) |
| Unkeyed items in a dynamic list | List identity is a proof obligation | {:key …} on every list child |
Opaque helper returning Hiccup in template position — (field-help props) |
Analyzer cannot lower the returned value | [field-help-view props] (declared child), inline the markup, or stay interpreted |
(sub …) inside an unbounded loop |
Reactive sites must be finite | Extract a keyed child view that subscribes for one row |
| Event site capturing a loop binding as a closed-over value for many rows | One lexical committed slot cannot mean N instances | Keyed child whose props carry id / row data |
Hiding sub inside an unaudited macro or deep helper |
Manifest would lie | Visible v/sub in the view, or value computed in a pure helper and passed in |
| Wholly dynamic props map on an internal view | Per-slot memo / analysis need known keys | Literal props; v/spread-safe (internal/controlled) or v/spread (foreign) — composition |
| Dynamic head registry — runtime choose among unknown views | Head must be finite | case/cond over known descriptors, or interpreted parent |
| Bare fn on a foreign callback prop | Phase/identity unknown | v/event / v/handler / v/render-fn / v/raw-fn |
| Bare React component at a vector head | A component is not a descriptor | create the element and put it in a child position |
| Inline interpreter fallback for “this bit of runtime Hiccup” | Unpredictable cost and false manifests | [v/markup {:value hiccup}] or keep parent interpreted |
| View-local cells, neutral hooks, refs or effects in the view body | One state system; host work is explicit | re-frame state or host boundaries |
Author :reads […] block (v1) |
Not in the grammar | Inline (v/sub …) only |
Also named by the checker (donor-class structural rules): keywords in child
position ([:div :oops]), app props named :key, children passed to a view that
did not accept :children, #js / hand-rolled camelCase on compiled DOM paths
(use keyword props; one conversion table).
Reactive sites must be finite¶
Every (v/sub …) in a compiled view is a compile-indexed site. Conditional
sub in an if / when is fine (not a hook). sub in a for over a
runtime collection is not — extract:
;; illegal when compiled — sub site per loop iteration is not finite in the parent
(for [id ids]
[:li (v/sub [:item id])])
;; legal — finite site in the child
(v/defview item-row
{:compiled true}
[{:keys [id]}]
[:li (:title (v/sub [:item id]))])
(v/defview item-list
{:compiled true}
[_]
[:ul
(for [id (v/sub [:item-ids])]
[item-row {:key id :id id}])])
Expression positions (prop values, conditions, for collections) hold ordinary
Clojure values, but their syntax is audited so every reactive call stays
visible. Prefer transparent core forms and plain functions for computation. Move
exotic macros out of the template body.
Dynamic markup: v/markup, not a valve¶
Interpreted code often does this:
(defn field-help [{:keys [error hint]}]
(if error [:p.error error] [:p.hint hint]))
(v/defview editor [_]
[:section (field-help {:error (v/sub [:e]) :hint "…"})]) ; fine interpreted
Compiled mode rejects treating that return value as template syntax. Recoveries:
- Declared child — promote the helper to
v/defviewand call[field-help …]. - Inline the branches in the parent template.
- Stay interpreted — leave
{:compiled true}off. - Inert markup already in hand — mount a visible interpreted boundary:
v/markup is an ordinary declared interpreted child. The compiled parent sees
one static descriptor; the child owns the walk. Manifests count those mounts as
:interp-slots. It is not an invisible “compile this except here” hole.
What is still allowed (so the grammar is not barren)¶
Compiled views still use the same application-facing vocabulary as interpreted ones where the form is visible:
- Hiccup tags, classes, props, trailing
:children (v/sub …)when sites are finite and visible (including conditional branches)- Event vectors, options maps,
::v/value/::v/checked/::v/key v/event/v/handler/v/render-fn/v/raw-fnat the right phasesv/presence, top-layer desired-state props, when forms are statically recognized- Qualified host leaves and behaviors when statically named
v/spread-safe/v/spreadunder the common ABI- Nested
[child props]whenchildis a known Freehand descriptor v/render-fn+v/slotwhen the slot body is pure and lexically visible (parameterized rows); stateful customization is a declared child view, not a closure that hidessub
The cliff is dynamic structure and hidden reactivity, not “you may not use events” or “you may not branch.”
Parameterized content (render-fn / slot)¶
(v/defview data-table
{:compiled true}
[{:keys [rows row]}]
[:table
[:tbody
(for [item rows]
[:tr {:key (:id item)}
(v/slot row item)])]])
;; caller — pure, visible body, and compiled itself
(v/defview people-table
{:compiled true}
[{:keys [people]}]
[data-table
{:rows people
:row (v/render-fn [person]
[:td (:name person)])}])
Note the caller’s own {:compiled true}. A slot is the one place the caller
must be compiled too — the exception to
interpreted parent → compiled child: fine
below, which still holds for ordinary child views. A v/render-fn written in an
interpreted body answers raw Hiccup, and the compiled parent’s child collector
refuses that value with :rf.error/ui-tree-malformed — “A compiled view produced
a vector where a child was expected.” So the usual one-leaf promotion (mark the
child {:compiled true}, leave callers untouched) does not reach a slot: promote
the calling view as well, or leave both interpreted.
If the row needs a subscription or its own events, extract
[person-row {:key … :id …}]. Do not smuggle sub inside an opaque function
the compiler cannot see.
Full children taxonomy (trailing children, compound regions, when to promote to a declared view): Composition.
Crossing modes and seam laws (author-facing)¶
| Law | Meaning |
|---|---|
| Call-site invariance | [view props] both ways; promotion edits the definition, not callers |
| Test invariance | structural tests cannot tell which mode produced the tree (common subset) |
| One meaning | shared props, keys, events, frame, controlled door, tree schema — not parallel promises |
| Evidence continuity | counters that justified promotion still flow after; manifests add proof |
| Visible boundary cost | interpreted children of a compiled parent are explicit ([child …] or v/markup); no hidden walker |
Mechanically:
- Interpreted parent → compiled child: fine.
- Compiled parent → statically named interpreted child: fine (emitted boundary).
- Compiled parent → runtime-chosen head: put the dynamic choice inside an interpreted child, or stay interpreted.
- Direct call
(view props): always wrong — the descriptor answers it with:rf.error/view-called-directlyrather than rendering.
Evidence: static vs realized¶
| Mode | What tools can claim |
|---|---|
| Interpreted | Realized reads/events for a committed render; not a full static inventory of every path |
| Compiled | Possible sites from the manifest (static) and which sites were active this render |
Every evidence projection should state scope, basis, completeness, and loss (D012). Compiled does not make interpreted siblings “proven.”
HMR note¶
Compatible HMR preserves the shell and reconciles the next target set. Compiled views may carry an internal site-signature generation: a body change that alters the finite site set is fenced so an old speculative render cannot publish into new code. Authors do not manage this. Know that a big grammar change can remount that shell cleanly.
Props schemas¶
Schemas are optional in the grammar (including for many compiled app views). They are mandatory by policy for:
- shipped reusable library / catalogue surfaces
- claims of generated coverage or generative parity for a particular view
(v/defview todo-row
{:compiled true
:props [:map [:id :int]]} ; vector-form Malli; closed by default for maps
[{:keys [id]}]
…)
:keyis outside the schema; children policy is descriptor metadata.- Missing schema reports as absent, never as implicit
:any. - Literal calls may be checked statically; dynamic calls in development.
- Validation is removable from production builds.
- Analyzer-derived prop slots are private compiler facts, not a substitute public schema.
If something feels wrong¶
| Symptom | Fix |
|---|---|
| Checker rejects opaque helper markup | extract declared child, pass values, or stay interpreted |
| Dynamic tag / unkeyed list under compile | finite branches; real :keys |
| Need a dynamic hole in a compiled parent | [v/markup {:value …}] — visible interpreted child |
| Still slow after compile | host cost or data size — not another Hiccup compiler |
High :stable-renders |
fix subs/props — do not promote for that alone |
What not to do¶
- Do not compile every view "to be safe."
- Do not invent a second authoring API for hot views.
- Do not hide interpretation inside compiled template space.
- Do not expect full-Clojure markup helpers to “just work” after
{:compiled true}— the build will tell you, and taking its recovery is the work. - Do not promote on a high “stable output” rate alone — that usually means
narrower subs, which helps both modes; promote when interpretation work
(
nodes× renders / self-time) still dominates after structural fixes.