Status: accepted · 2026-09-21 · extends ADR-0009, ADR-0019
A setup runs once, so a view chosen in it is chosen for ever:
component(() => {
if (token.value === null) {
return <Navigate to="/sign-in" />; // never runs again
}
return <Page />;
});
0.7.0 answered this with a build error (ADR-0019,
checkDecidedOnce), which is the right thing to do about a mistake that
produces no exception and a wrong screen twenty minutes later. But a rule whose
whole job is to forbid the natural spelling is a hole in the model, not a
feature of it. Every other framework lets you write an if above your markup;
this one had nowhere to put one.
Three things made that hole visible at once:
.value, so a
deepSignal read goes through unreported, and props were deliberately
excluded after a recursive component branching on props.depth was wrongly
refused. It catches an important class, not the class.Option 3, with one rule doing all the work:
Every function you write is a reactive scope: it runs again when something it read changes. JSX beneath it makes the smallest scopes it can — as long as they need nothing from the run that made them.
component(() => {
const draft = signal(''); // setup: once
return () => {
// run: again, when what it read changes
if (!draft.value) {
return <Empty />;
}
return <Editor draft={draft} />;
};
});
The classification is forced, not chosen. For every expression in the markup the compiler asks whether it names a binding the run made. If it does not, it becomes a part with its own scope, exactly as today. If it does, it cannot become a part — that value belongs to one call of the run, and a part holding it would hold a value from a run that is over — so the run writes it, guarded by what it last wrote. There is no policy here to get wrong: one of the two is impossible in each case, and the possible one also happens to be the faster one.
A site is made once and written afterwards. The store is an array declared
in the setup, which runs once per instance, so it needs no registry and no
ambient lookup. Each site takes a numbered place in it, fixed at compile time:
a site inside an if keeps its place whether or not the branch was taken,
which is why there is no ordering rule of any kind.
What a site makes belongs to the instance. Parts, listeners and child components created when a site is built are opened under an owner taken from the store, not under the run — a run’s scope is cleared before it runs again, and anything left there would be disposed by the very next run. This was found by measurement, not by reasoning: the first implementation put them in the run and the second run tore them down.
A branch the run leaves is disposed. Every return passes through ran,
which disposes the sites this run did not reach. Cleanups fire, parts stop,
nodes go. Coming back builds it again.
Children keep their instance. A component in a run is made once; props fed from run locals are held in cells the run writes, so a prop that did not change reaches nobody. Props that are not fed from the run stay ordinary live reads and cost nothing extra.
Anything a run hands to something built once goes through a cell. This is the general form of the rule above, and it is written out because it has been got wrong three times by being read as a rule about props. A run local is a binding belonging to one call of the run; anything built once that captures it goes on reading that call’s value for ever, silently, because the page still renders. So the same treatment is owed to a child a run gives a component, and to the data a run gives a keyed list, as to a prop — a cell the run writes, holding the reading rather than the result, so that what it reads is attributed to the part that displays it rather than to the run. The fourth instance of this should be caught in review rather than in an issue.
A site belongs to every place the run reaches, including through the
compiler’s own wrappers. The rule above says which markup a run keeps; this
says where. A dynamic child is emitted as part(() => …), and that arrow is a
function — so the question “which run is this markup in?” answered itself
wrongly for anything inside one. It is the compiler’s own wrapper, but it is
not always a place the run returns to: in a kept template the part around it is
built on the first run and never again, and what is inside belongs to that
part; in a fragment a run returns, the array and every part in it are built
afresh each time, so what is inside belongs to the run. So the wrapper is
stepped over exactly when the code that creates it runs again, which is a
question the same function already answers one level up. Until #47 the answer
was “never”, and a component inside a fragment was the one child position a run
could not keep: the whole fragment, and every row under it, was rebuilt on
every run. A list’s rows are unaffected either way, because a
row sits in a callback the author wrote and that is a boundary.
Handlers are what they look like. A closure over a run local is a new
function on every run, and it replaces the previous one on the node — so it is
never stale, and always exactly as old as the DOM beside it. on() had to
learn to replace rather than add for non-delegated types, keyed by the options
as well as the type so that onClick:native and onClick:once remain two
listeners.
What cannot be written is built again. A spread, a ref or a keyed list
that depends on the run turns retention off for that site, which falls back to
today’s behaviour: slower, never wrong.
Two refusals, both the existing rule said once more:
signal, computed, effect,
useResource, …). Persistent things are made in the setup.And a plain function that returns markup is a view — a reactive scope with
no setup, recognised inside a module by the compiler and across one by a mark
it emits. Writing it as a tag is what gives it a place and a scope; calling it
is a function call and behaves like one. This is what component() is for
after this ADR: it means the view needs a setup.
npm run bench:runs, recorded in benchmarks/results/render-functions.json.
1000 components of 20 sites derived from one signal, both compiled by the real
compiler and both verified to render 20 000 nodes before timing:
| mount | update | heap | |
|---|---|---|---|
| a site per expression | 31.40 ms | 11.435 ms | 14.0 MB |
| one render function | 20.70 ms | 8.355 ms | 8.8 MB |
1.52x, 1.36x and 1.59x. Most of it is arithmetic on effects: twenty subscriptions and twenty reads of one signal become one of each.
The shape matters and the ADR says so: twenty sites with twenty independent sources is the opposite case, and there the fine-grained form wins, because a run would look at all twenty to write the one that moved. The classification means a component that mixes the two gets both behaviours, so the question rarely has to be answered by the developer at all.
Three measurements changed the design rather than confirming it:
text.data !== next) is slower than
no guard at all — reading a text node back materialises a string. The
remembered value is kept instead, and the same guard was added to the
existing part path, where it is worth about 2x on its own.site(store, index) lookup per write.
The stand-in is in the repository’s history, and the honest number is the one
above.Less, not more: 13.8 MB to 8.8 MB in the measured shape, because an effect with its dependency links costs more than a slot in an array. A site store is one array per instance plus one small record per site and per write.
The published runtime grew about 0.35 kB gzip, and the bundle budget went from 6 kB to 7 kB to admit it. The new functions are separate exports, so an application that never returns a render function does not pay for them; the figure that moved is the everything-imported one.
What a developer does about this: nothing, unless they want an if. Shape 2 —
a setup returning markup — is shape 3 with an empty body, and is unchanged in
behaviour and in cost.
What they gain is that the reactive graph is drawn with function boundaries
they can see. const person = profile.value in a statement says this view
treats the profile as one thing; the same read in the markup says this site
follows it on its own. One normal line of JavaScript, two granularities, both
visible.
What they must know is the short list the guide ends on: statements are the run’s dependencies and markup expressions are not; persistent things go in the setup; repeated markup takes a key; a handler passed to a child is a new function every run. Development says the last one out loud rather than leaving it to be found, and says so too when a run keeps running and keeps writing nothing:
<OrderTable> ran 20 times and wrote nothing.
That is the shape of every diagnostic this ADR adds: bookkeeping, said out loud, with nothing optimised behind anyone’s back.
const sorted = expensiveSort(rows.value) and remove the one sharp edge of
the model. It would also mean code that the source says runs does not run,
which is the kind of hidden cleverness this project refuses. computed is
the tool, it already exists, and the developer places it where they can see
it.<Editor/> keep its state for ever. The control flow
says the branch is gone.