The escape ladder¶
Sometimes a screen needs something ordinary Fresco Hiccup cannot express, and you step outside it. Every such escape is explicit in source, visible to the tools, and reversible. There are two reasons to take one, and each has its own rules.
Two different reasons to leave¶
Interoperability. There is no Hiccup spelling for what you need: a date picker with forty props, a virtualiser with its own scheduler, a mapping SDK that owns a DOM node. Without the escape you have no implementation at all.
Performance. You have written the Hiccup version, and a measured interaction misses a budget with Fresco's own rendering work as the cost owner. The ordinary version exists and is too slow.
Most mistakes here treat one reason as the other: deleting a host because it did not recover 20%, or building a native island because a screen felt heavy and nobody measured.
The performance ladder¶
Take each rung only after the one above it has failed. Each rung is a change in
source: there is no :fast mode or build setting that changes what Hiccup
means. Rung 1 is taught in
Views and reads, rung 2 in
Lists and collections, and the code for rungs 3
to 5 is in Islands.
| Rung | What you write | Take it when |
|---|---|---|
| 1 | Ordinary Fresco: Hiccup, h/sub, event vectors |
always; every screen starts here |
| 2 | Tuned Fresco: view boundaries, keys, read shape, chunking, windowing | a measured interaction invalidates too much work |
| 3 | Direct React return: a defview body returns a React element and keeps its frame, reads, and memo |
Hiccup conversion is the measured cost |
| 4 | A React island: raw React or UIx, mounted through h/defhost under the same root and frame |
hooks, vendor internals, reconciliation, or high-rate local work dominate |
| 5 | A native screen, written in React under the same adapter, root and frames | the screen is React-shaped by design |
Most performance work ends at rung 2, which is still ordinary Fresco: moving a read down, drawing a view boundary differently, or windowing a list.
The interoperability ladder¶
Two rungs, taught in Interop. Their order has nothing to do with speed.
| Rung | What you write | Take it when |
|---|---|---|
| A | A declared host, h/defhost |
the component is foreign and you need its behaviour |
| B | The raw escape, [:> Component …] |
during migration, or for a one-off dynamic choice of component |
Prefer A. A declaration is validated once and has a name the tools can show;
the raw escape is validated at every use and has no name.
Interop lists everything else the raw escape gives
up. Once a component appears in two raw [:>] escapes, declare it.
What each rung costs¶
Each rung below ordinary Fresco mostly costs you what tests and tools can see (L2 and L3 are the test levels in Testing):
| At and past | Semantic tests | Tools | Server rendering | Frame carriage |
|---|---|---|---|---|
| Performance rung 2 | unchanged | unchanged | unchanged | unchanged |
| Performance rungs 3–5 | assert React behaviour at L3 | a rung-3 view keeps its Xray name and reads; an island's n/use-sub reads show, its React subtree is opaque, and Xray times neither |
the island's h/defhost declares :server :render, or stays Client-only |
(rf/capture-frame) in a rung-3 body carries the frame; inside an island, n/use-frame does |
| A declared host | the crossing is opaque to L2; assert it at L3 | Xray names the crossing; it does not time it or see inside it | yours to declare: :server :render, or Client-only with an optional :fallback |
an h/event or intent vector at an on* prop carries the frame; a plain function does not |
| The raw escape | opaque to L2; assert at L3 | the crossing has no authored name | Client-only, with no fallback of its own | contracts are inferred from the spelling as on a declared host; there is no override and no slot |
ht/tree throws at a host or a raw React element and names L3 as the level to
test it at (Testing).
A controlled text field moved into an island loses Fresco's controlled-field handling, and native construction does not make typing faster. Keep those fields in ordinary Fresco (Islands).
Taking a performance escape¶
Do not take one without a reproducible interaction and an attributed owner. The procedure is Performance's measurement loop, and its fourth and fifth steps are the performance rungs above.
Then apply the benefit rule. Keep the escape only if, on the interaction you scripted and against the same screen written the ordinary way, it:
- recovers at least 20% of the measured interaction,
- saves at least 2 ms at p95, or
- turns a failed user-visible budget into a pass.
Otherwise remove it. Re-run the comparison when the surrounding code changes materially, because an escape justified against the old code may not be justified against the new.
Measure each escape on its own screen. Gains from direct React return, for example, sit close to the 20% line, so the same technique can pass on one screen and fail on another.
The rule an interoperability escape is not judged by¶
The benefit rule compares against the same screen written the ordinary way.
An interoperability escape has nothing to compare against: there is no Hiccup
version of react-datepicker. A defhost that recovers 0% of an interaction
has not failed the benefit rule, because the rule does not apply to it.
Judge it on the questions that do apply:
- Is the crossing declared, so it is validated once and named to the tools?
- Does every value that drives the component arrive on its own props?
- Does each callback's inferred contract match the library? In particular, is
any
on*-named prop really a render prop, which needs a{:callbacks {… :render}}override? - Does the declaration state the server policy you meant, rather than defaulting to Client-only?
- If the component acquires anything, does something release it?
Interop covers each of these.
What every escape must preserve¶
An escape changes how a subtree is written, but the application must behave the same. After taking any rung, re-run the checks in Islands, which Fresco can no longer perform inside the React subtree.
Climbing back¶
Remove an escape when the pressure that justified it is gone: a subscription made finer, a list windowed, a vendor component replaced by twenty lines of Hiccup, a React release that fixed the slow path.
The usual signal is the benefit rule failing on a re-run. Delete the island, restore the ordinary Fresco version, and re-measure.
Things that look like escapes and are not¶
These are ordinary Fresco features, not escapes, and the benefit rule does not apply to them:
| Doing this | Is |
|---|---|
| A callback ref that attaches an imperative SDK and returns its cleanup | the supported way to own a DOM-attached SDK (Interop) |
h/portal |
a container mechanism; the subtree stays interpreted and in the same frame |
| Ephemeral state for open/closed, hover, or draft UI | Ephemeral state, a Fresco feature |
h/as-component or h/as-element |
handing a Fresco view to a React parent; the view keeps its reads, memo and frame |
A plain defn helper that returns Hiccup |
a helper called in place, inside its caller's view |
When you are not on either ladder¶
If the screen is React-shaped by design — a canvas editor, a diagramming surface, a vendor grid at the centre of the product — you are not escaping anything. Implement it natively under the same adapter, root and frames, and keep one state owner (Islands).
If the whole application is React-shaped, the UIx adapter is a better fit than a Fresco application made of islands. Decide that up front rather than drifting into it one island at a time.