@verajs/renderer
v0.2.2
Published
Reef JS renderer repurposed as lightweight renderer option for use with VeraJS
Downloads
916
Maintainers
Readme
@verajs/renderer
The DOM renderer for VeraJS — 4.58 KB gzipped, no dependencies, no build step required.
Tagged templates parse once and clone; every render after the first walks only the value slots, so updates touch the DOM and nothing else. Lists are keyed by value, not by directive. Server-rendered pages hydrate through a separate entry that non-SSR apps never download.
npm i @verajs/core @verajs/rendererQuick start
wire([renderer]) is the only wiring. Core's html tag already produces the shape this accepts, so
there is no second call to make.
import { init, createStore, render, wire, html } from '@verajs/core';
import { renderer } from '@verajs/renderer';
wire([renderer]);
customElements.define(
'click-counter',
class extends HTMLElement {
connectedCallback() {
init(this, { mode: 'open' });
const state = createStore({ count: 0 });
render(() => html`<button @click=${() => state.count++}>Clicked ${state.count} times</button>`);
}
}
);
document.body.append(document.createElement('click-counter'));Without it, core has no renderer at all: render() warns once in development and puts
nothing on the page. @event, .prop and ?bool bindings are the first things to go missing.
renderInto(result, container)
The same write, called directly — no reactivity, no lifecycle, no custom element. wire([renderer])
registers exactly this function, so a component's render() ends up here; calling it yourself is how
you draw into a plain node, which is the incremental-adoption path and the way to test a template
without spinning up an element.
import { renderInto } from '@verajs/renderer';
import { html } from '@verajs/core';
renderInto(html`<p>${count}</p>`, document.querySelector('#app'));It owns its own range and nothing else: the first call anchors a root part at a marker it appends, later calls with the same container reuse that part and walk only the value slots, and whatever was already in the container stays. It is not reactive — call it again to update, or use a component and let a store do it.
It was named render until 0.2.0, which collided with core's render — a different function, with
a different arity, that declares a reactive template and commits a component's setup. Both are
public and both are documented, so a reader who knew one misread the other.
Bindings
| Written | Means |
| --- | --- |
| <p>${value}</p> | child content — see Values |
| <p title=${value}> | attribute. null/undefined remove it |
| <p class="a ${b} ${c}"> | attribute built from several expressions and the static text between them |
| <x-item .item=${value}> | property assignment, uncoerced — objects, arrays, functions |
| <p ?hidden=${value}> | boolean attribute, present when truthy |
| <input !checked=${value}> | property written from the live DOM rather than from what the binding last wrote — see below |
| <button @click=${fn}> | event listener. An object with a handleEvent method works too — addEventListener takes both |
| <button onClick=${fn}> | the same thing, React-style. Strictly on + a capital — a template-written onclick stays a plain attribute (yours, greppable); spread refuses it (see the spread section's security note) |
| <input ${fn}> | element ref: a function is called with the element |
| <input ${obj}> | element ref: an object gets the element assigned to .value, so core's ref() works here |
| <input ${spread(props)}> | names resolved at runtime — see /spread |
A ref runs once per distinct value, not once per render.
A ref is released when its subtree is rendered away, and not when its component is removed from
the document. Toggling ${show ? html<i ${box}> : 'gone'} sets box.value back to null (and
calls a function ref with null); removing the whole component leaves box.value pointing at the
element, now detached. That is deliberate rather than an oversight: here a disconnect is not a
destruction — moving a node between parents fires one, and the component re-renders on reconnect —
so releasing would blank every ref for the frame it takes a move to complete. Guard with
box.value?.isConnected if you need to know, and note that a ref created inside the component (the
way every example writes it) becomes garbage along with it either way.
An event handler is called with the element as this, and the listener is registered once — swapping
the handler across renders never touches the DOM, so there is no add/remove churn and no way to end
up with two. undefined, null and false all mean no handler, so @click=${enabled && onClick}
binds conditionally without a ternary. Anything else that cannot listen — a string, a number, an
object with no handleEvent — is inert, and development names it at the binding: a listener is
only ever called when a user clicks, so left unchecked the mistake surfaces at a point where nothing
on the stack says where the value came from.
!name — a live property
Every other binding skips a write when the value matches what it last wrote. That is what keeps a field someone has typed into, and it is exactly wrong for a control whose DOM state changes as a side effect of interacting with a sibling:
html`<input type="radio" name="pick" !checked=${state.picked === 'one'} />
<input type="radio" name="pick" !checked=${state.picked === 'two'} />`Clicking the second radio unchecks the first in the DOM, with no event on it. With .checked the
first binding still says true, still matches what it committed, and never writes again — the model
and the page diverge and no amount of re-rendering reconciles them. A <select>'s options are the
same shape.
It is deliberately narrow, and it is a property binding only:
- Not for text inputs. Bind those with
.valueand let a person's typing stand.!valueexists and is authoritative, which is precisely why it is not the default. - Not offered for attributes or booleans. Nothing changes those behind the renderer's back, so there is nothing to re-read.
- It yields during hydration. A click that happened before the bundle landed had no handler to report it, so adoption records the value without writing; live semantics resume on the first state-driven render.
spread({ '!checked': … }) means the same thing, and @verajs/ssr serializes it exactly as
.checked — a server has nothing to re-read.
Bindings inside comments are not supported — the value is consumed and ignored. A dynamic tag
name is a binding a template cannot express either, and has its own entry:
/tag.
Values
What a child position does with each kind of value. These match lit-html exactly, null and
undefined included.
| Value | Renders |
| --- | --- |
| string, number, 0 | as text |
| true, false | as text — and development says so; see below |
| null, undefined | nothing |
| a template result | the template, updated in place while its shape holds |
| an array or iterable | each entry in order; key them with keyed() |
| a DOM node or fragment | itself, moved into place |
| anything else | String(value) |
Strings render as text, always. There is no path by which an interpolated value becomes markup — see Trusted HTML.
A boolean child renders the word, and JSX is the exception
${items.length > 0 && html\…`}is the ordinary conditional idiom, and when the test fails the
whole expression isfalse` — which renders the text "false" on the page. The value is
legitimate and nothing throws, so development names it at the binding rather than leaving it to
be found by looking at the page. Write the form that renders nothing:
html`<p>${cond ? html`<em>yes</em>` : null}</p>` // or: ${(cond && html`…`) || null}In JSX the same code needs no change — @verajs/jsx compiles a boolean child away, which is
React's rule and where React expectations live. That is the one value semantic on which JSX and
a hand-written template differ, and this warning is what meets JSX-shaped code pasted into a
template. 0 still renders in both, exactly as in React: the rule is about booleans, not falsiness.
A DOM node renders as itself, which is how a template holds something another library owns:
const chart = document.createElement('canvas');
new Chart(chart, config);
renderInto(html`<figure>${chart}<figcaption>${title}</figcaption></figure>`, host);Lists — keyed()
import { renderInto } from '@verajs/renderer';
import { keyed } from '@verajs/renderer/keyed';
renderInto(html`<ul>${rows.map((row) => keyed(row.id, html`<li>${row.label}</li>`))}</ul>`, host);keyed(key, result) tags a result with its identity, so a reorder moves the existing elements
instead of rebuilding them — focus, scroll position, form state and running animations all survive.
It is its own entry because most apps never reorder a list, and the algorithm that makes reordering
cheap is 923 B gzipped they would otherwise carry. Importing keyed is the whole installation:
nothing registers, and there is no wire() call — the marker stamps each result with the strategy
that understands it, so a list always names its own reconciler and two strategies cannot disagree
about one. Lit splits repeat out for the same reason; the difference is that this one arrives on
the values rather than through a directive protocol.
Keep it on the same version as @verajs/renderer. It reaches the renderer through a handful of
two-character members that are exempt from property mangling, and nothing checks that both sides
agree about them — a keyed bundle paired with a different renderer release fails at runtime rather
than at install. Both ship from this package and bump together, so a single version range covers it;
the trap is pinning one and floating the other. @verajs/renderer/spread carries the same rule for
the same reason.
It is additive, not a substitute. Unlike /hydrate and /profiler, this entry imports nothing
at all — it reaches whatever renderer is present through a handful of mangling-exempt members — so
it is safe alongside any of them, /hydrate included.
A list is keyed because keyed() marked it, not because its results have a key property.
Setting .key by hand no longer makes a list keyed — the marker is what carries the algorithm, so
it is what the renderer looks for, on the client and when adopting server markup alike.
Key every item in a list, or none of them. A list is keyed when its first item is, and an unkeyed item in a keyed list has no identity to match on.
An unkeyed list is not wrong — it updates each position in place, which is exactly right for a list whose order never changes.
Preserving DOM — hold()
renderInto(html`<div>${hold(editing ? editor(state) : viewer(state))}</div>`, host);hold(result) parks the DOM it replaces instead of destroying it, keyed by template identity, and
brings it back when that template returns. lit calls this cache. What survives is everything no
attribute records: what the user typed, a checkbox or radio the user set, a <select>'s chosen
option, a <details> left open, a media element's playback position — and the nodes themselves, so
the element that had focus is the same element when it comes back.
A scroll offset does not survive, and cannot. Every engine resets scrollTop to zero the moment
an element leaves the document — measured on Chromium, Firefox and WebKit, which report 0 while
parked, 0 on return, and 0 even for a node moved directly between two attached parents. Nothing an
applier does with the nodes can hold it, and lit's cache() cannot either. If a scroll position
matters, read it before the toggle and restore it after.
Anything that is not a template passes straight through — there is nothing to park for a string, a list, null or false — so hold(editing && editor()) is safe to write.
It only re-adopts a template it has seen at that same call site — two hold() calls in
different templates are two different templates, and neither adopts the other's DOM. The other
direction is also safe, and reads less obviously from "call site": every keyed row of a list
shares one call site, and each still holds its own state — the cache rides the row's own part, so
two rows toggling through the same hold never adopt each other's parked DOM, including through a
reorder while one is parked. Measured, and pinned in the combination matrix.
It keeps every shape it has parked, for as long as the part lives. That is the point — a tab
strip of twelve panels holds twelve, and each comes back exactly as it was left — but it is a cache
with no eviction, so hold over an unbounded set of templates retains an unbounded amount of DOM.
Verified: fifty distinct shapes cycled through one hold and the first still re-adopted its own
nodes. lit's cache() behaves the same way, and for the same reason — evicting would silently throw
away the state the applier exists to preserve, which is worse than holding it. Use hold for a
set you can name, and let an unbounded one rebuild.
Write stable shapes
Rendering the same elements every pass and toggling hidden is faster than swapping one subtree for
another. Template identity holds, so values update in place rather than the subtree being torn down
and rebuilt. Both forms are correct; this one is cheaper.
// fragile — two sibling parts, each swapping between a template and ''
html`<section>
${items.length === 0 ? html`<p>empty</p>` : html`<ul>${rows}</ul>`}
${busy ? html`<p>loading</p>` : ''}
</section>`;
// preferred — one shape, visibility toggled
html`<section>
<p ?hidden=${items.length > 0}>empty</p>
<ul ?hidden=${items.length === 0}>${rows}</ul>
<p ?hidden=${!busy}>loading</p>
</section>`;/profiler exists to make the difference visible: it counts templates
committed in place against templates that replaced a different template.
Entries
| Import | What it adds | Ships in production |
| --- | --- | --- |
| @verajs/renderer | the renderer | yes |
| @verajs/renderer/hydrate | a superset whose first render adopts server-rendered DOM | yes |
| @verajs/renderer/keyed | keyed(key, result) — keyed list reconciliation | yes |
| @verajs/renderer/spread | spread(props) — binding names resolved at runtime | yes |
| @verajs/renderer/tag | tag`h1` — an element whose tag name is decided at runtime | yes |
| @verajs/renderer/slots | slots (wire it) + slotted(host, name?) — light-DOM <slot> distribution | yes |
| @verajs/renderer/profiler | a superset that measures template churn | no — development only |
/hydrate and /profiler each re-export the whole public API, so they are drop-in replacements for
the base import. Never mix two of them in one app — that loads two renderers with two template
caches.
Which means an app can have one of them, not both — so a hydrating app cannot be profiled. They
each bundle their own renderer with its own instrumentation hook, so profiling while rendering
through /hydrate observes an instance nothing renders into: measured, three renders reported zero
frames while the page updated correctly. formatReport says so when it observed nothing, because a
zero report is otherwise indistinguishable from an app with nothing to optimise.
@verajs/renderer/slots — light-DOM slots
Native <slot> needs a shadow root. This entry teaches the renderer to distribute a light-DOM
component's own children into the <slot name="…"> positions of its template, so one component
works in both modes — users write <div slot="title"> exactly as they would against shadow DOM.
Wire it at your app entry, before anything renders. A template resolves this once, at
construction, and is interned per call site for the life of the page — so wiring slots after a
component has already rendered does not reach that component, and it keeps showing its fallback
content while the children the host was given sit beside it as stray markup. Development names it
when it happens, but the rule is cheaper than the diagnostic: wire it in the same call as the
renderer, wire([renderer, slots]).
Given this markup:
<my-card>
<h2 slot="header">Hello</h2>
Body text goes to the default slot.
</my-card>import { init, render, wire, html } from '@verajs/core';
import { renderer } from '@verajs/renderer';
import { slots, slotted } from '@verajs/renderer/slots';
wire([renderer, slots]);
customElements.define(
'my-card',
class extends HTMLElement {
connectedCallback() {
init(this); // LIGHT DOM — no shadow options
render(
() => html`<article>
<header><slot name="header" @slotchange=${(e) => this.onHeader(e.target)}>Untitled</slot></header>
<main><slot>Nothing here yet.</slot></main>
</article>`
);
}
/** Written exactly as it would be against a shadow root, and it runs in both. */
onHeader(slot) {
console.log('header is now:', slot.assignedElements().map((el) => el.textContent));
}
}
);
const card = document.createElement('my-card');
card.innerHTML = '<h2 slot="header">Hello</h2>Body text goes to the default slot.';
document.body.append(card);
await new Promise((resolve) => requestAnimationFrame(resolve));
console.log(card.querySelector('header').textContent); // "Hello"
console.log(card.querySelector('main').textContent); // "Body text goes to the default slot."
console.log(slotted(card, 'header').length); // 1slots is the insert descriptor you wire; that is all a consumer touches.
Wire the renderer as a MODULE —
wire([renderer, slots])— not as a bare function. Both spellings register a renderer, and only the module form runs the descriptor'sconnect(), which is how the renderer receives the app's insert registry and therefore how it finds this seam at all.wire({ on: 'render', fn: renderInto, priority: 50 })leaves that registry unset, so every<slot>renders its fallback and nothing says why. (That spelling is documented for wiring a DIFFERENT renderer — lit-html — where there is no seam to find.) The assignment follows the platform's own rules — elements to the slot theirslotattribute names, text to the default slot, fallback shown only while a slot is unassigned and restored when it empties, direct children only. Live: appending, removing, or re-slotting children redistributes automatically, with one documented divergence — see Late children below. Re-renders leave slotted nodes in place, identity intact, so focus and input values survive; SSR emits already-distributed markup and hydration adopts it.
A <slot> inside another slot's fallback works, and takes over at the moment that fallback
becomes visible — the same thing the platform does, verified by layout on three engines. Slots are
handed over in document order, so a slot's nested slots are taken over first, while they still have
a parent to anchor into.
::slotted() is shadow-only and deliberately not translated. In light DOM the slotted content
is in the same tree, so an ordinary descendant selector reaches it — and reaches deeper than
::slotted() can, which only ever matched top-level assigned nodes. Measured on three engines, the
two are exactly complementary: neither spelling reaches across. A component that renders both ways
writes both, ::slotted(img), [part="body"] img, exactly as :host, :scope was needed before
:host became translatable.
The asymmetry with :host is the reason one is translated and this is not. :host styles the
component ITSELF and nothing else can supply it, so a component from npm that uses it is visibly
broken in light mode. ::slotted() styles the USER'S content, which in light DOM the page author
can already reach — so the failure is a missing default rather than a broken component, and making
it work would mean writing framework attributes into the user's own markup.
Late children
A node added AFTER the first render joins its slot with full native semantics — bare text and
attribute-less elements included, in document order, exactly as a shadow root would assign them.
The renderer stamps everything it emits at a light host's top level with a hidden, non-enumerable
property, so an unstamped node there is knowably the user's; slot=""/slot="name" still route,
they are simply no longer required.
This used to be the feature's one documented divergence, and the rule existed because a light
host's children after the first render are also the component's own rendered output, with nothing
in the DOM to tell them apart. Ownership is written down now rather than inferred from position,
so the ambiguity — and the rule — are gone. Two things remain worth knowing: whitespace appended
to a light host suppresses the default fallback, which is parity (a shadow root does the same), and
re-slotting a node (slot="a" → "b") rejoins in light-tree order rather than arrival order.
Every position you can reach orders exactly as the platform does — what light has fewer of is
positions you can name, since a distributed child is no longer a direct child of the host.
Cloning a RENDERED light component does not work, and cannot. cloneNode(true) copies a
light host's children — which after a render are the component's own output with the user's slotted
nodes already distributed into it. The clone then captures all of that as its slot content, so its
rendered tree ends up nested inside its own default slot. A shadow component clones cleanly for the
opposite reason: cloneNode does not copy a shadow root, so the clone re-renders and its light
children are still just its light children.
Nothing can detect this — the ambiguity is the same one behind the late-children rule above, and the original user content was consumed at the first render, so there is nothing to recover. To duplicate a component, clone the SOURCE markup and let the copy render itself, rather than cloning a live instance. This matters most to anything that duplicates components as an operation: an editor canvas, a repeater, a drag-to-copy.
slotted(host, name?) is the component-internal accessor — what the user assigned to a slot,
answered identically in shadow mode (native assignment) and light mode (the capture map). Omit
name for the default slot. Component authors reach for this; app users do not.
The <slot> element is still the component's handle on the slot
A slot is not only a position, and a component that keeps up with what it was given binds to the element itself. All of that means here what it means in a shadow root:
render(() => html`<header>
<slot name=${section} &ref=${(el) => (this.slot = el)}
@slotchange=${(e) => wire(e.target.assignedElements())}>Nothing yet</slot>
</header>`);@slotchangefires on first assignment and on every change after it — and only then, so a child added without aslotattribute fires nothing. The sequence and each event'sassignedNodes()are asserted against real shadow DOM, which is the oracle for this. It fires for a slot whose rendering is currently displaced too (assignment is independent of rendering), exactly as native does for a slot that is not on screen.node.assignedSlotanswersnull— ask the slot, not the node. The reverse lookup is a platform accessor tied to real shadow assignment, and overriding it on YOUR nodes is an intrusion this module refuses. The forward reads carry the same fact:slot.assignedNodes().includes(node)through a&ref, orslotted(host, name)from outside.- Bind
slotchangedirectly — it does not bubble to the host. In a shadow root one listener on the root hears every slot by bubbling; here the slot handles are deliberately out of the document, so there is no tree for the event to climb andhost.addEventListener('slotchange', …)hears silence. Use@slotchangeon each slot, which also hands you the rightevent.target. (The same boundary asquerySelector('slot'): fewer places to listen, not fewer events.) assignedNodes(options)/assignedElements(options)answer from the live assignment, throughevent.targetor a&ref. With nothing assigned,{ flatten: true }gives the fallback actually on screen — slottables only, so a comment you wrote into fallback content is not in it, and a nested slot flattens through to whatever it is showing, both as the platform does.- Use
child.before(node)/child.after(node), neverhost.insertBefore(node, child). A light host's children are physically moved into the slot they are assigned to, so a child you appended and kept a reference to is no longer a direct child of the host — andinsertBeforethrowsNotFoundErrorwhen its reference node is not a child of the node it is called on. In a shadow root the same line works, because there nothing moves.before()/after()route through the node's own current parent, so they are correct in both modes, and the content lands in the order you asked for. This is the sharpest difference between the two modes and the easiest to hit. - A displaced node is disconnected here; native slotting never disconnects it. Shadow
distribution is virtual — a slottable no slot names stays in the light tree, connected, merely
unrendered. Light slots park it physically, so a custom element inside displaced content runs its
disconnectedCallback(and a Vera component's effect cleanups) on the way out, andconnectedCallbackagain when its slot returns — with its element identity, stores and typed-in state intact, and reactivity re-established by the re-init. Component authors already handle this pair for anyappendChildmove; the difference is only when it happens: a component that pauses a video on disconnect pauses while displaced here and keeps playing in a shadow root. The slot element is deliberately not in your DOM (see below), so it is unreachable by selector and reportsisConnected === false. It is a live API object, not a position in the tree. namecan be a binding.<slot name=${section}>routes by the name it actually has, and re-routes if it changes between renders.
Keeping the <slot> in the tree — a strategy you can own
Every boundary above has one cause: the shipped strategy removes the <slot> element so your
markup stays exactly what you wrote. The seam it registers through is public, single-registrant,
and takes whole strategies — so if you would rather have the shadow tree's own structure (the
slot element present, selectable, :first-child-countable, made layout-invisible by the same
display: contents the UA stylesheet gives real slots), you can wire a strategy that keeps it.
This one is complete enough to run — and it runs, in CI, as written:
import { init, render, wire, html } from '@verajs/core';
import { renderer } from '@verajs/renderer';
/** Distribution that KEEPS the <slot>: content moves INSIDE it, fallback shows when it is empty. */
const slotsInTree = {
name: 'my-app/slots-in-tree',
on: 'slot',
priority: 50,
fn(slot, root, name) {
if (root.nodeType !== 1) return null; // a shadow root keeps native slotting
const host = root;
const fallback = [...slot.childNodes];
const isMine = (n) =>
n.nodeType === 1 ? (n.getAttribute('slot') ?? '') === name
: name === '' && n.nodeType === 3 && n.data.trim() !== '';
let shown = -1;
const fill = () => {
for (const n of [...host.childNodes]) if (isMine(n)) slot.append(n);
const assigned = [...slot.childNodes].filter((n) => !fallback.includes(n));
for (const n of fallback) (assigned.length ? n.remove() : slot.append(n));
observer.takeRecords(); // our own moves are not the user's
if (assigned.length !== shown) {
shown = assigned.length;
slot.dispatchEvent(new Event('slotchange', { bubbles: true })); // real ancestors — it CLIMBS
}
};
const observer = new MutationObserver(fill);
fill();
observer.observe(host, { childList: true, subtree: true });
return { _$park$: () => { observer.disconnect(); for (const n of [...slot.childNodes]) if (!fallback.includes(n)) host.append(n); } };
},
};
wire([renderer, slotsInTree]);
document.head.insertAdjacentHTML('beforeend', '<style>slot{display:contents}</style>');
customElements.define('tree-card', class extends HTMLElement {
connectedCallback() {
init(this);
render(() => html`<article><slot name="header">Untitled</slot></article>`);
}
});
const card = document.createElement('tree-card');
card.innerHTML = '<h2 slot="header">Hello</h2>';
document.body.append(card);
await new Promise((resolve) => requestAnimationFrame(resolve));
/** Every documented boundary of the shipped strategy, working: */
let heard = 0;
card.addEventListener('slotchange', () => heard++); // a HOST-level listener — bubbling exists here
if (!card.querySelector('slot')) throw new Error('querySelector finds the slot');
if (card.querySelector('h2').parentElement.localName !== 'slot') throw new Error('reverse lookup');
card.querySelector('h2').remove();
await new Promise((resolve) => setTimeout(resolve, 0));
if (card.querySelector('article').textContent !== 'Untitled') throw new Error('fallback returns');
if (heard !== 1) throw new Error('slotchange bubbled to the host');The trade is the one the platform itself makes: this is the shadow tree's structure, so the
<slot> now appears in your host's serialized markup (as it appears in a shadowRoot's), your
component CSS can select it — and structural selectors written against the template see it as the
child it is, because selectors follow the tree, not layout. Wire it instead of slots — the
seam is single-registrant, and wiring both says so in development, by name.
What this recipe deliberately does not do is the audited module's territory: light-tree ordering
under re-slots and prepends, duplicate-name handover, nested slots in fallbacks, dynamic
name=${…}, SSR and hydration. It is a starting point you own, not a drop-in peer — the measured
design for a full sibling lives with the maintainers.
The one thing a light-DOM slot cannot carry is presentation — class, style, id and other
plain attributes. A light host has no second tree, so the slot element is not rendered and there is
nothing for them to apply to, while in a shadow root they do apply. Put them on a real element
around the slot. Development builds say so, naming the attribute, rather than leaving it to be
found.
Additive like keyed/spread: it imports no renderer and reaches the one present through the wired
seam, so it is safe beside any renderer entry on a CDN page. The entry is
3.35 KB gzipped and only apps importing it pay;
@verajs/renderer itself carries just the seam that records where a template's slots are. It is
also Node-safe — it imports nothing and touches no global document — so a universal app can wire it
on both sides.
@verajs/renderer/hydrate
import { renderer } from '@verajs/renderer/hydrate'; // instead of '@verajs/renderer'Same name, same wire([renderer]), different entry — this one's renderer binds the adopting
render below, so swapping the import (or the importmap target) really is the only change.
The first render into a container that already has children adopts them as server output of the same template: node identity is preserved, listeners attach, and updates mutate the adopted nodes. Hydration here is markerless — server HTML carries no framework comments, and the client repairs its own anchors into the adopted DOM.
Any disagreement with the server markup clears the container (keeping <style data-vm-sheet="styles"> tags)
and renders fresh, so correctness never depends on the server output being right. A DOM node at a
child position is the one thing the server cannot have rendered; it is inserted without giving up
adoption of everything around it.
A fallback warns in development, naming the first place the two renders disagreed — "expected
<p> and found <div>", "<ul> contains <li>, which the template does not describe". The page
is correct either way, which is the point of the fallback and also why it needs saying: that
container's markup was just thrown away, and with nothing observable to notice, the only symptom is
a first paint that is slower than the one you paid a server render for. An attribute that disagrees
is simply re-set during adoption and is not a fallback at all.
A fallback costs one container, not the page. Adoption is decided per container, so components
hydrating into their own roots are independent: one that disagrees rebuilds and warns, and every
other one keeps the server's nodes. Measured in tests/hydrate-mismatch.test.mjs — three containers,
one mismatch, one warning, two adoptions. Worth stating because the warning used to imply otherwise
and sent the reader hunting for a page-wide cause.
Comments are outside the comparison, in both directions — a comment in your template is not required in the server markup, and one in the server markup that your template does not have is not a disagreement. A comment renders nothing, so neither direction changes what a reader sees; matching on them would only cost every commented template its adoption.
On a CDN page, point the import map's @verajs/renderer at vera-renderer-hydrate.min.js and
nothing else changes. Apps that never hydrate download none of this.
Importing this in Node
@verajs/renderer needs a DOM to be imported at all, not merely to render. It captures
document at module scope and builds two shared TreeWalkers there, which is what saves an
allocation per instance — so import '@verajs/renderer' on a server throws
ReferenceError: document is not defined before any of your code runs. The same is true of
/hydrate and /profiler, and of @verajs/jsx/standalone, which contains a renderer.
This is worth stating because @verajs/router documents the opposite
about itself, and the asymmetry is easy to read the wrong way. The rest of the family is Node-safe:
@verajs/core, @verajs/inserts, @verajs/store and /collections, @verajs/router,
@verajs/styles, @verajs/autoloader, @verajs/jsx (the transform), @verajs/ssr — and
@verajs/renderer/keyed, /spread and /tag, which hold no DOM of their own even though their
parent entry does.
In a universal app, hand the renderer in rather than importing it in shared code — which is what
examples/kitchen-sink/wiring.js does, taking it as a parameter so the server can pass null.
tests/node-import-safety.test.mjs holds both halves of that list.
@verajs/renderer/spread
Spread a props object onto an element, with names resolved at runtime.
import { init, createStore, render, wire, html } from '@verajs/core';
import { renderer } from '@verajs/renderer';
import { spread } from '@verajs/renderer/spread';
wire([renderer]);
customElements.define(
'x-field',
class extends HTMLElement {
connectedCallback() {
init(this, { mode: 'open' });
const state = createStore({ disabled: false });
const props = {
id: 'email', // attribute
placeholder: '[email protected]', // "
'.value': '', // property
'?disabled': state.disabled, // boolean attribute
onInput: (e) => console.log(e.target.value), // event — @input works too
};
render(() => html`<input ${spread(props)} />`);
}
}
);
document.body.append(document.createElement('x-field'));Keys carry the same sigils as written bindings, so a spread key and a written binding mean the same
thing — .value, ?disabled, @click, onClick, and &ref for an element ref.
A key that cannot be written into markup is skipped, with a warning in development. That is any
name holding whitespace, a quote, <, >, /, =, a backtick or a control character. Engines are
more permissive than markup — measured across Chromium, Firefox and WebKit, setAttribute accepts
", ' and < — but a name that binds in the browser and cannot survive server rendering is worse
than one that works nowhere, so both sides apply the same rule. Skipped rather than thrown: the keys
are runtime data, and one bad name in a props bag should not cost the render.
Several spreads on one element are supported — each element position owns its own keys, so
<div ${spread(a)} ${spread(b)}> works and neither releases the other's bindings.
Keys are strings carrying sigils, so TypeScript cannot check them against the element's attributes. That is a genuine step down from written bindings, and the trade for names that are not known until runtime.
props() — a typed bag of property bindings
For the common case where every key is a property, props() removes both the sigils and the
typing gap:
import { props } from '@verajs/renderer/spread';
html`<calendar-day ${props({ date, events })}></calendar-day>`<calendar-day {...props({ date, events })}></calendar-day>
<calendar-day date={date} events={events} /> // JSX only: bare props ARE props on a component tagOne function, both surfaces — JSX compiles {...x} to spread(x), and spread() recognises an
already-branded result, so the two spellings are literally the same call. In JSX the bag is
optional altogether: on a dash-named tag a bare prop compiles to the .name binding directly
(@verajs/jsx's README has the two attribute carve-outs), so props() is the template's
spelling and the bag for names not known until runtime. It exists because an
attribute is always a string: an array, a Date or a store can only reach a custom element as a
property — and while vera's JSX accepts the sigil spelling .date={d}, TSX's type-checker refuses
it (TS1003), so the bag is the typed path.
Three rules, each earned:
- Keys are property names, never sigils —
props({ date })binds.date. Events and boolean attributes keep their own spellings (@click/onClick,?disabled); this bag is properties only, by definition. - Prefer keys spelled conditionally over bags that change shape:
props({ date: loaded ? date : null })— key present from the first render — overprops(loaded ? { date } : {}). Both work (a key that appears later on a component is adopted live, and one that disappears restores what the element held), but a stable shape means values update in place, the same reason templates prefer?hiddenover swapped subtrees. - A type argument makes the bag checked:
props<CalendarDay>({ dat })is a compile error naming the misspelling — the checking that sigil-keyed spread genuinely cannot have.
How a component receives them
Nothing to declare on the other side — no static properties, no props argument. A component that
calls init() finds every bound property on this, reactively: reading this.date in a
render tracks it, the parent's next commit re-renders, and a store or ref() passed through stays
live because it arrives by identity. The full reception contract — including what happens when the
component's module loads after the parent rendered — is documented with init() in
@verajs/core's README; this package's half is the delivery:
- A property is not an attribute.
date="…"is an attribute — always a string, visible in markup..date=${…}/props({ date })is a property — any value, by identity, invisible togetAttribute. Components receive properties; attributes are for CSS hooks and static markup. - Delivery survives lazy definition. A property bound before the element's module runs would
otherwise be destroyed by the class's field initializers at upgrade (at ES2022,
item?: Temits a realitem;that runs during upgrade). The renderer records what nothing received yet, andinit()re-applies it — so a bound value outranks a class default, in both field spellings. Elements that never callinit()keep the development warning and thedeclareadvice instead. - Platform and foreign elements are untouched. A property with an accessor anywhere —
.title, a Lit-style element, anything that already receives it — is delivered plainly and never recorded. - SSR delivers, never serializes. Under
@verajs/ssr, a property bound on a rendered component tag reaches that child's server render by identity — the markup never carries it, and an unregistered tag passes through for the client to handle.
Removing a key
A key that disappears between renders restores what the element held before the binding existed.
renderInto(html`<input type="text" ${spread({ type: 'number' })} />`, host); // type="number"
renderInto(html`<input type="text" ${spread({})} />`, host); // type="text" againNot removed — restored. The usual framing, "what value means absent", has no answer for a
property: delete cannot remove a prototype accessor, and assigning undefined puts the literal
string "undefined" into a form field. Asked as "undo what this binding did" it is well defined for
every kind, because it reads the element's own pristine state — "" for input.value, undefined
for a custom element's property.
On a hydrated page it restores the server's value, because that is genuinely what was there
before the binding: the server rendered this same spread, and a spread key replaces a static
attribute in server markup exactly as it overwrites one on the client. The original is gone by
construction, so bind null when you mean removal:
renderInto(html`<input ${spread({ id: null })} />`, host); // removes, on either pathOne residue worth knowing: .value, .checked and .selected are mirrored to attributes
server-side so hydration can read them back, and releasing the property does not clear that
attribute. The property is correct either way; the attribute lingers as the field's default value.
A released event binding stops dispatching; the listener itself stays registered, which is how
written @event bindings behave too.
What it costs, and why it is a separate entry
@verajs/renderer grows 5 B gzipped for the protocol this uses, whether or not you import it —
measured 2026-08-27 by deleting the _$apply$ branch and rebuilding, as a difference rather than
a pair of totals — the totals move with every change to this package and the difference does not,
which is the mistake this line already made once. llms.txt and this file disagreed about the figure
for a while, at 16 B and 8 B respectively, and both were wrong. Nothing regenerates it, so it is
dated; re-measure the same way if it matters.
The entry itself is 1.60 KB gzipped, and only apps
that import it pay for that.
Runtime is at parity with writing the bindings out: both do one comparison per binding per render, and the spread does one part-dispatch where five written bindings do five.
Template renderers bake attribute names into the template at parse time. That is what makes them small and fast, and it is why neither this renderer nor lit-html has spread built in — lit's spread PR has been an open draft since 2021.
The renderer itself holds only a protocol: a value at element position carrying _$apply$ applies
itself. Everything else lives in this entry, which imports nothing — not even from the renderer — so
it loads alongside any renderer that honours the protocol, including your own.
@verajs/renderer/profiler
import { renderInto, profile, formatReport } from '@verajs/renderer/profiler';
/** `profile` awaits an async driver — driving an app means awaiting frames, and the render
scheduler is `requestAnimationFrame`, so nothing commits inside one synchronous turn. */
const { report } = await profile(async () => { /* click around, await frames */ });
console.log(formatReport(report));
// 39 updated in place, 2 created, 19 rebuilt (32% of commits)
// Template identity churn — these were torn down, not updated:
// 10x at body > main#app > ul.todo-list
// <li class="done"><s>${…}</s></li>
// -> <li><label><input type="checkbox">${…}</label></li>showProfiler() puts the same numbers in a live panel in the corner of the page and returns a
function that removes it. The panel is plain DOM in a shadow root — it never renders itself through
the renderer, so it does not appear in its own measurements.
Calling it again replaces the panel rather than adding one, and a second call's options take
effect. That matters because the natural way to use this is a console — showProfiler(), look,
showProfiler() again — where the first return value is already gone: two panels would sit on top of
each other in the same corner, and the second one's teardown would stop profiling for the first,
which kept repainting a frozen report.
Full API: startProfiling(), stopProfiling(), getReport(), isProfiling(), profile(fn),
formatReport(report), showProfiler(options?). The first four are profile() unrolled, for a
session that does not fit one callback — a long-running tab, a REPL:
import { startProfiling, stopProfiling, getReport, isProfiling, formatReport } from '@verajs/renderer/profiler';
startProfiling();
// … interact with the app for as long as you like …
if (isProfiling()) console.log(formatReport(getReport())); // read mid-flight without stopping
stopProfiling(); // freezes the reportThis costs production nothing, and there is nothing to strip: the instrumentation sits behind a
__DEV__ constant the build folds to false, so vera-renderer.min.js is byte-identical whether
or not this entry exists.
Trusted HTML, and why there is no unsafeHTML
Every interpolated value is escaped at the render boundary. There is no unsafeHTML and there will
not be one: shipping a sanctioned opt-out puts an XSS sink in the public API, where it reads as
blessed in tutorials and in review.
Trusted markup goes through a property binding, so you write the sink yourself:
renderInto(html`<div .innerHTML=${trustedMarkup}></div>`, host);Greppable, obviously yours, reviewable as the security decision it is. Sanitize first
(DOMPurify.sanitize) unless the markup is genuinely your own, and put it on an element whose
children nothing else binds — the renderer owns the content of elements it renders into.
Security: spread refuses the injection sinks. The .innerHTML posture above rests on the
template spelling being greppable, obviously yours, and reviewable — three properties a spread key
does not have, because spread names arrive at runtime inside a props object that is often built
from data. So spread() refuses .innerHTML/!outerHTML property keys, the srcdoc attribute,
and any inline-handler attribute name (onclick and friends, any casing — on + Capital with a
function remains the documented event spelling and still works), on the client AND in the SSR
serializer alike. Development builds name each refused key and the sanctioned template spelling.
If you genuinely need one of these dynamically, write the binding in the template where a reviewer
can see it.
@verajs/renderer/tag
An element whose tag name is decided at runtime — a heading whose level comes from data, a
component that renders <a> or <button>.
import { html, tag } from '@verajs/renderer/tag';
const HEADING = { 1: tag`h1`, 2: tag`h2`, 3: tag`h3` };
const H = HEADING[state.level];
renderInto(html`<${H} class="title">${state.text}</${H}>`, host);A template renderer bakes tag names into its statics — that is what template identity is, and
what every fast path here depends on. So a runtime tag cannot be a binding: it is spliced into the
statics before the renderer sees the template. Downstream nothing changes. The renderer,
@verajs/ssr and hydration all receive an ordinary template and are unaware this entry exists.
Note the html import: in a template containing a tag, use the one from here rather than core's.
In JSX
A tag is also a component. A capitalized JSX tag compiles to H({…}), and a tag is that
function, so the same value works in both notations with no compiler change and no new syntax —
<{expr}> is not valid JSX or TSX, and inventing it would break tsc, Prettier and every editor.
const H = HEADING[state.level];
return <H className="title" hidden={state.muted}>{state.text}</H>;React's names are mapped here exactly as the compiler maps them on a written element, so <H
className="t" hidden={false}> and <h1 className="t" hidden={false}> mean the same thing. That is
a correctness matter, not an ergonomic one: passed through raw, hidden={false} becomes the
attribute hidden="false" and any value at all applies it.
ref is mapped too — <H ref={r}> binds exactly as <h1 ref={r}> does. key never reaches the
component: @verajs/jsx consumes it into keyed(…) for both spellings, and a hand-written
H({ key }) drops it and says so in development, because a key marks a template for reconciliation
and this call returns one rather than being one.
dangerouslySetInnerHTML is the one React name a tag cannot honour, and that is a security
property rather than a gap. A tag reaches its element through /spread, whose names are only
known at runtime — which is exactly what makes that sink unreviewable — so /spread refuses
.innerHTML outright. Write the element directly, with the value sanitized first:
renderInto(html`<${H} .innerHTML=${trusted}>`, host);tests/jsx-component-equivalence.test.mjs drives every one of these both ways, compiled and
rendered, and compares the DOM — because the name-level pin that preceded it passed for the entire
life of three defects.
What to know
- A string can never become a tag. Only another tag may be interpolated, so the set of tags an
app can produce is fixed by its source. That is what keeps a tag out of reach of a request — the
same reasoning as there being no
unsafeHTML— and it is what bounds the cache below. - Each tag is its own template. Switching tags rebuilds the subtree, which is correct: the element genuinely changed. Within a tag it updates in place like anything else.
- The cache is per call site. Spliced statics hang off the call site's own
stringsarray, so two template literals in the source are two entries however identical they look. Right for real code, where a template lives at one place in a render function — and the thing that catches people writing tests for it. - HTML only. There is no
svg/mathmlequivalent yet.
needs it to apply props whose names it cannot know. Additive, like /spread and unlike the other
entries: it inlines no renderer internals, so it is safe alongside any of them.
This entry also exports jsxName and BOOLEAN_ATTRIBUTES, which are not API for applications.
They are the table this entry uses to map React's names, and @verajs/jsx carries its own copy
deliberately — the two are build-time and runtime, and a shared package would be a dependency where
a test does the job. tests/jsx-name-mapping.test.mjs asserts the two agree on every key, and it can
only do that against the built artifact, which is why they are exported at all. Read them if you are
writing something that has to agree with both; do not build on them:
import { jsxName, BOOLEAN_ATTRIBUTES } from '@verajs/renderer/tag';
jsxName('className'); // 'class' — the runtime half of the React-name mapping
BOOLEAN_ATTRIBUTES.has('disabled'); // true — the names the tag entry toggles rather than assignsExtending it — _$apply$ and _$child$
The renderer holds no directive system. It holds a protocol, at the two positions worth
extending, and everything built on it is an ordinary package the renderer knows nothing about —
@verajs/renderer/spread is the proof, at 5 B of protocol in this bundle and its own weight only
for apps that import it.
| position | brand | called as |
| --- | --- | --- |
| element — <div ${value}> | _$apply$ | value._$apply$(element, part) |
| child — <div>${value}</div> | _$child$ | value._$child$(part, previous) |
A child-position value carrying _$child$ applies itself. It is handed the part and whatever it
returned last time at that part, and calls part._$commit$(value) to render content. That is
the whole surface: until() is nine lines against it.
/** Hoisted — the applier's identity is its own continuity key. */
function applyUntil(part, previous) {
if (previous && previous.promise === this.promise) return previous;
if (previous) previous.live = false;
const state = { promise: this.promise, live: true };
part._$commit$(this.placeholder);
this.promise.then((value) => { if (state.live) part._$commit$(value); });
return state;
}
const until = (promise, placeholder) => ({ _$child$: applyUntil, promise, placeholder });
renderInto(html`<p>${until(fetchUser(), html`<em>loading…</em>`)}</p>`, host);Three rules, each of which is a real trap:
Hoist the applier. Written as an object-literal method it is a new function per call, so the part can never recognise it and
previousis alwaysundefined. Its identity is what keeps two appliers at one part from reading each other's state.Continuity lives in the return value, not in an applier instance. That is what makes this a protocol rather than a framework — no base class, no
directive()factory, no lifecycle.Teardown is opt-in, on the applier.
applyThing._$detach$ = (previous) => …is called with whatever the applier last returned, when the subtree holding it is removed — replaced, dropped from a keyed list, or shrunk out of an unkeyed one. Declaring it is what arms the walk; an applier that does not declare it costs nothing, and neither does an app with no such applier anywhere.It notifies, it does not defer. The nodes are already going. To hold content on screen while it animates out, do not remove it: an applier owns what it commits, so it can simply not commit the removal until it is ready — see Deferring a removal below.
Why declaring
_$detach$is what arms it:_clearbulk-removes DOM — when the part owns its parent, oneparent.textContent = '', which is what makes clearing a 1 000-row table ~5 ms against lit-html's ~22 ms. Notifying nested appliers means walking the part tree on removal, which is exactly the per-node work that fast path exists to skip — so the walk runs only once something, anywhere, has declared teardown, and an app with none pays nothing.
Deferring a removal
An applier owns the content it commits, so an exit animation needs nothing from the renderer — it just does not commit the removal until the animation has finished:
function applyTransition(part, previous) {
const next = this.value;
const state = previous ?? { shown: undefined, timer: null };
if (next != null) { part._$commit$(next); state.shown = next; return state; }
if (state.shown != null && state.timer === null)
state.timer = setTimeout(() => { state.timer = null; part._$commit$(null); }, 300);
return state;
}
const transition = (value) => ({ _$child$: applyTransition, value });This works for a child position and for a whole list committed as one value. It does not work for one row inside a keyed list: that row's removal is decided by the reconciler, and nothing inside it is asked.
Both names survive minification by construction: the renderer mangles /^_[a-z]/, and _$…$ does
not match it. tests/minification-contracts.test.mjs holds that.
The check costs the hot path nothing: it sits after the template branch, and a template — the common object at a child position — returns before ever reading it, so only arrays, nodes and appliers pay a property read. Measured with no runtime difference distinguishable from noise.
The whole protocol is 116 B gzipped — the check, the two fields holding an applier's state and
whose it is, the save/restore in _$commit$ that stops an applier's own rendering from destroying
its continuity, and the _$detach$ call. It was 94 B before teardown existed.
Animating things in and out
There is no transition component, and none is needed — but the shapes are worth knowing, because one of the obvious ones does not work in every engine.
Fading on ?hidden. The framework's own advice is to prefer a stable shape with ?hidden=${…}
over swapping subtrees, and that is also what makes an exit transition possible: the element is
still there to animate.
.fade { opacity: 1; transition: opacity 200ms; }
.fade[hidden] { display: block; opacity: 0; pointer-events: none; }The display: block is load-bearing — it overrides the user agent's [hidden] { display: none },
which would otherwise remove the element from rendering instantly with nothing to fade.
Do not reach for transition-behavior: allow-discrete on display for this. Measured
2026-08-24 across all three engines, transitioning display with allow-discrete: Chromium and
WebKit fade correctly, Firefox jumps straight to display: none and no transition runs. All
three report CSS.supports('transition-behavior', 'allow-discrete') as true, so the feature test
does not tell you. The opacity-only shape above behaves identically in all three.
Animating a removal, where the element really does leave the render, is the browser's job:
const flushSync = (fn) => {
const previous = setRenderScheduler((run) => run());
try { fn(); } finally { setRenderScheduler(previous); }
};
document.startViewTransition(() => flushSync(() => { state.rows = next; }));The View Transitions API snapshots the DOM around the callback and cross-fades, so a row that
disappears fades out and the rows below animate up. startViewTransition is present in Chromium,
Firefox and WebKit. Two things it needs: the state change must happen inside the callback —
which is what flushSync is for, since a render deferred to the next frame lands after the snapshot
— and each row needs its own view-transition-name, or the whole page cross-fades as one image.
Or keep it in state. Mark the row leaving, animate, then drop it. Vue's <Transition> is
sugar over exactly this, and it is the only one of the three that gives you a completion callback.
Absent on purpose
Directives other renderers ship, and what replaces them here.
| Elsewhere | Here |
| --- | --- |
| repeat() | keyed() |
| cache() | hold() |
| ref() | an element-position expression, <input ${myRef}> |
| ifDefined() | built in — null/undefined remove an attribute |
| classMap() / styleMap() | build the string: class="base ${extra}" |
| guard() | reactivity already skips unchanged work |
| until(), asyncReplace() | render a loading state and re-render from an effect — or nine lines against _$child$ |
| unsafeHTML() | .innerHTML=${trusted}, above |
| literal() / static-html | /tag — and a tag doubles as a JSX component |
| live() | !name — a sigil, for the case that needs it |
Types
TemplateResult is exported for annotating what a template function returns. The rest of the
surface is inferred.
For AI assistants — and anyone who wants the whole API on one page
The repository root's llms.txt is the complete, hand-maintained API
reference for every package, written to be pasted into a model's context window: full export
tables, the buildless CDN and JSX recipes, semantics that differ from other frameworks, and the
mistakes that come up most. Its recipes are executed by the test suite, so they stay honest.
License
MIT
