@excom/renderable-element
v0.3.0
Published
RenderableElement base for Neutron elements
Maintainers
Readme
renderable-element
Composition base for Neutron elements that defer rendering a <template>
until the right moment. It owns the load + render lifecycle so subclasses
only decide when to flip is-active. Used by <include-content>,
<spa-route>, and any custom element you compose yourself.
The demos below use <include-content> (the simplest concrete subclass)
to exercise behavior that comes straight from this mixin.
<p>Type into both inputs, then toggle them off and on. The right side
keeps its value because <code>persist-content</code> reuses the same
template node instead of cloning a fresh copy each render.</p>
<style>
.persist-demo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.persist-demo include-content {
display: block;
border: 1px dashed currentColor;
padding: 8px;
margin-top: 4px;
}
</style>
<div class="persist-demo">
<div>
<strong>Default (cloned each render):</strong>
<label>
<input type="checkbox" checked />
Active
</label>
<include-content is-active>
<template>
<input type="text" placeholder="Type, then toggle off + on" />
</template>
</include-content>
</div>
<div>
<strong><code>persist-content</code>:</strong>
<label>
<input type="checkbox" checked />
Active
</label>
<include-content is-active persist-content>
<template>
<input type="text" placeholder="Type, then toggle off + on" />
</template>
</include-content>
</div>
<quark-sheet>
label {
@on change { + include-content { is-active: event.target.checked; } }
}
</quark-sheet>
</div>Features
- Template resolution via
template-refin-document selectors or remote URLs - Prefetch strategies
lazy(default),eager, oridle - Configurable render host light DOM, shadow, author iframe, or any selector
- Cancelable render / unrender parents can wrap updates in view transitions
- Persistable content keep live subtree state across unrender / render cycles
- Ready coordination
ready-on+delaying-readyfor paint-synced reveals
Installation
@excom/renderable-element v0.3.0
pnpm add @excom/renderable-elementnpm install @excom/renderable-elementyarn add @excom/renderable-elementImport
import { /* … */ } from "@excom/renderable-element";Usage
Compose RenderableElement into a Neutron class and toggle isActive
from whatever signal makes sense — a media query, a websocket message,
an experiment flag, etc. Everything else (template fetch, caching, host
resolution, lifecycle events) is inherited.
import { Neutron } from "@excom/neutron";
import { RenderableElement } from "@excom/renderable-element";
export const MediaGated = Neutron.compose([
RenderableElement,
Neutron({
tag: "media-gated",
props: {
mediaQuery: String,
},
}),
])
.onPropChanged("mediaQuery", (el, prev) => {
prev.mediaQuery && el._mql?.removeEventListener("change", el._sync);
if (!el.mediaQuery) return { isActive: false };
el._mql = window.matchMedia(el.mediaQuery);
el._sync = () => (el.isActive = el._mql.matches);
el._mql.addEventListener("change", el._sync);
el._sync();
});
MediaGated.define();<media-gated media-query="(min-width: 900px)">
<template>
<wide-screen-only></wide-screen-only>
</template>
</media-gated>Event names are prefixed with the concrete tag (shown as {tag}-… in the
API). For <include-content> that means include-content-render, etc.
API Reference
Attributes
| Name | Surface | Type | Default | Values | Description |
| --- | --- | --- | --- | --- | --- |
| template-ref | option | string | ":scope > template" | <CSS Selector> | <URL> | Source <template> — in-document selector or remote URL. Changing mid-flight aborts and reloads. Can use :scope to relatively select elements: e.g. main:has(:scope) > template |
| bypass-cache | option | boolean | | | Skip the in-memory response cache (URL template-ref only). |
| pre-fetch | option | string | "lazy" | "" | "eager" | "idle" | "lazy" | When to fetch the template, independent of when it renders. "" aliases eager. idle never runs in a prerender. |
| persist-content | option | boolean | | | Reuse the same live nodes across unrender / render (held on _persistedTree) so form values, scroll position, and subtree state survive toggles. |
| host-ref | option | string | | "shadow" | "iframe" | <CSS Selector> | Where rendered children land. Unset = this element's light DOM. shadow attaches an open shadow root. iframe paints into a child <iframe data-render-host> body (you supply the iframe — useful for sandboxed / third-party document isolation). Any other value is a portal selector. |
| ready-on | option | string | | <Event Name> | Event name that marks rendered children "ready". Until it fires, delaying-ready is set so CSS can hide the host for a coordinated paint / view transition. Prerendered content kept at hydration is ready at once, never hidden. |
| is-active | hybrid | boolean | | | Master switch. Set to load (if needed) and render; unset to unrender. Drive from visibility, route match, hover, etc. |
| is-loading | state | boolean | | | Template fetch in flight. |
| did-load | state | boolean | | | Template resolved at least once. Stays set across is-active toggles so consumers know later paints are warm (URL refs reuse the shared fetch cache in kit-utils). Cleared when template-ref changes or --reload forces a fresh resolve. |
| is-error | state | boolean | | | Latest template fetch rejected (excluding abort). Fires with the error event. |
| delaying-ready | state | boolean | | | Between render and the matching ready-on event (or a failed load). Hook with CSS for coordinated paints / view transitions. |
Recognized Elements
| Relationship | Selector | Required | Description |
| --- | --- | --- | --- |
| template | child | no | Optional immediate <template> child used when template-ref is the default ":scope > template". Not required when template-ref points at a selector or URL elsewhere. |
| iframe[data-render-host] | child | no | Required when host-ref="iframe". Content paints into iframe.contentDocument.body. Provide your own iframe (e.g. with srcdoc); the element will not create one. |
Fires
| Name | Type | Description |
| --- | --- | --- |
| {tag}-render | RenderableRenderEvent (CustomEvent & { type: "{tag}-render"; detail: () => Promise<void>; bubbles: true; cancelable: true; composed: true }) | Cancelable. Dispatched when the element becomes active and is about to place template content into the host. event.detail is a thunk that performs the load (if not already loaded) and renders the children, returning a Promise that resolves once the corresponding ready-on event fires (or immediately if ready-on is unset). The promise rejects if the template fails to load, host-ref resolves to no host (nor an author iframe still loading), or the element is torn down mid-flight (startTeardown while loading / delaying-ready). Call preventDefault() to defer rendering and invoke event.detail() later. |
| {tag}-unrender | RenderableUnrenderEvent (CustomEvent & { type: "{tag}-unrender"; detail: () => void; bubbles: true; cancelable: true; composed: true }) | Cancelable. Dispatched when the element becomes inactive and content is already painted. event.detail is a thunk that removes the rendered children. Call preventDefault() to defer the removal. Not fired when teardown cancels an in-flight load — that path emits aborted instead. |
| {tag}-did-render | RenderableDidRenderEvent (CustomEvent & { type: "{tag}-did-render"; detail: void; bubbles: true; cancelable: true; composed: true }) | Dispatched after the template content has actually been placed into the host. |
| {tag}-did-unrender | RenderableDidUnrenderEvent (CustomEvent & { type: "{tag}-did-unrender"; detail: void; bubbles: true; cancelable: true; composed: true }) | Dispatched after rendered children have been removed from the host. |
| {tag}-error | RenderableErrorEvent (CustomEvent & { type: "{tag}-error"; detail: void; bubbles: true; cancelable: true; composed: true }) | Dispatched when the template promise rejects with anything other than an AbortError. |
| {tag}-aborted | RenderableAbortedEvent (CustomEvent & { type: "{tag}-aborted"; detail: void; bubbles: true; cancelable: true; composed: true }) | Dispatched when an in-flight load / ready wait is canceled because is-active was unset (via startTeardown). |
Commands
| Command | Action |
| --- | --- |
| --reload | Stops waiting for any in-flight load and re-resolves the template, bypassing the cache for URL refs (useful after remote content changes). A URL request is shared by the page, so the one in flight is not cancelled. |
Default actions
| Event | Default behavior (unless preventDefault() is called) |
| --- | --- |
| {tag}-render | Invokes event.detail() to load (if needed) and render the template into the host. |
| {tag}-unrender | Invokes event.detail() to remove rendered children from the host. |
Examples
Choosing a render host
Unset host-ref renders into the element's light DOM. "shadow" attaches
an open shadow root for style isolation:
<p>Two <code><include-content></code> elements rendering the same template into different hosts. The shadow-root version is style-isolated.</p>
<style>
.host-demo include-content {
display: block;
border: 1px dashed currentColor;
padding: 8px;
margin: 8px 0;
}
/* this rule cannot reach into a shadow root */
.host-demo h4 { color: rebeccapurple; }
</style>
<div class="host-demo">
<strong>Light DOM (default):</strong>
<include-content is-active>
<template>
<h4>I'm rebeccapurple</h4>
<p>Light-DOM children inherit the host page's CSS.</p>
</template>
</include-content>
<strong>Shadow DOM (<code>host-ref="shadow"</code>):</strong>
<include-content is-active host-ref="shadow">
<template>
<style>h4 { color: tomato; }</style>
<h4>I'm tomato — outer styles can't touch me</h4>
<p>Encapsulated inside an attached shadow root.</p>
</template>
</include-content>
</div>"iframe" paints into a child <iframe data-render-host> you provide —
sandbox styles / scripts / document context. Only nodes move: custom
elements upgrade there only if the iframe document loads their definitions:
<include-content is-active host-ref="iframe">
<template>
<style>
body { font-family: system-ui; padding: 12px; color: #333; }
h4 { color: navy; margin: 0 0 4px; }
</style>
<h4>Hello from inside the iframe</h4>
<p>Rendered into <code>iframe.contentDocument.body</code>.</p>
</template>
<iframe data-render-host
srcdoc="<!doctype html><html><body></body></html>"></iframe>
<style>
#demo-renderable-element-host-iframe > :first-child {
display: block;
border: 1px dashed currentColor;
padding: 8px;
> iframe {
width: 100%;
height: 140px;
border: none;
background: white;
}
}
</style>
</include-content>Any other value is a CSS selector — the template lands in whatever element it resolves to:
<p>Any other <code>host-ref</code> value is treated as a CSS selector. The element renders its template into whatever the selector resolves to.</p>
<style>
.selector-demo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.selector-demo .mount-point {
border: 1px dashed currentColor;
padding: 8px;
min-height: 80px;
}
</style>
<div class="selector-demo">
<div>
<strong>Source element:</strong>
<include-content is-active host-ref=".sidebar-mount">
<template>
<p>I render into <code>.sidebar-mount</code> →</p>
</template>
</include-content>
</div>
<div>
<strong>Mount target:</strong>
<div class="mount-point sidebar-mount"></div>
</div>
</div>On a prerendered page the light DOM and a selector host keep their prerendered content; shadow and iframe hosts are not prerendered and render in the browser.
Hooking render with view transitions
render and unrender are cancelable; event.detail is the update
thunk. A parent can preventDefault() and run the mutation inside
document.startViewTransition() — the same pattern <spa-manager>
uses to batch sibling routes.
<p>Toggle the checkbox to activate the element. The cancelable
<code>include-content-render</code> event is intercepted, the actual
DOM mutation is wrapped in <code>document.startViewTransition()</code>,
and a parent listener can defer or skip the render entirely.</p>
<style>
.render-event-demo {
border: 1px solid var(--border, #444);
padding: 12px;
border-radius: 4px;
}
.render-event-demo include-content {
display: block;
margin-top: 8px;
padding: 8px;
border: 1px dashed currentColor;
}
.render-event-demo include-content > article {
background: rgba(255, 200, 80, 0.15);
padding: 12px;
view-transition-name: render-demo-card;
}
::view-transition-old(render-demo-card),
::view-transition-new(render-demo-card) {
animation-duration: 350ms;
}
</style>
<div class="render-event-demo">
<label>
<input type="checkbox" />
Active
</label>
<include-content>
<template>
<article>
<h4>Just rendered (with a view transition)</h4>
<p>The host swallowed the default render and ran it through <code>startViewTransition</code>.</p>
</article>
</template>
</include-content>
<quark-sheet>
@use "/demo-utils" as *;
label {
@on change { + include-content { is-active: event.target.checked; } }
}
include-content {
@on include-content-render, include-content-unrender (handle: renderInTransition);
}
</quark-sheet>
</div>The thunk's returned Promise resolves when the view is ready (immediately, or when ready-on fires). If is-active is unset while still loading / delaying-ready, teardown rejects that Promise and emits aborted instead of unrender.
Pair with ready-on so delaying-ready stays set until your transition
has committed:
<media-gated ready-on="my-app-paint" media-query="(min-width: 900px)">
<template>...</template>
</media-gated>media-gated[delaying-ready] {
display: none;
}Persisting content across cycles
Once a template resolves, did-load stays set so consumers know later
toggles are warm — URL template-refs reuse the shared fetch cache in
kit-utils. Without persist-content (the default), each activation
re-resolves and imports a fresh clone — subtree state is lost on
unrender. With it, the same live nodes are held across toggles:
<p>Type into both inputs, then toggle them off and on. The right side
keeps its value because <code>persist-content</code> reuses the same
template node instead of cloning a fresh copy each render.</p>
<style>
.persist-demo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.persist-demo include-content {
display: block;
border: 1px dashed currentColor;
padding: 8px;
margin-top: 4px;
}
</style>
<div class="persist-demo">
<div>
<strong>Default (cloned each render):</strong>
<label>
<input type="checkbox" checked />
Active
</label>
<include-content is-active>
<template>
<input type="text" placeholder="Type, then toggle off + on" />
</template>
</include-content>
</div>
<div>
<strong><code>persist-content</code>:</strong>
<label>
<input type="checkbox" checked />
Active
</label>
<include-content is-active persist-content>
<template>
<input type="text" placeholder="Type, then toggle off + on" />
</template>
</include-content>
</div>
<quark-sheet>
label {
@on change { + include-content { is-active: event.target.checked; } }
}
</quark-sheet>
</div>On a prerendered page the nodes it holds are the prerendered ones.
Loading strategies
pre-fetch controls when the template is fetched, separately from
when it is rendered:
<!-- default: fetch on first activation -->
<my-el></my-el>
<!-- pre-warm immediately on attribute set -->
<my-el pre-fetch="eager"></my-el>
<!-- backfill on idle -->
<my-el pre-fetch="idle" template-ref="/fragments/hero.html"></my-el>Pair with bypass-cache for revalidation when the element activates
multiple times. Invoke the --reload command to force a refresh.
pre-fetch="idle" never runs in a prerender: the template is fetched in the browser.
Release notes
0.3.0 (2026-10-07)
- Add hydration of prerendered content: an element whose host already holds the server's render of the same template does not render again, fires
did-renderonce and is ready at once (neverdelaying-ready);persist-contentkeeps those nodes across toggles andpre-fetch="idle"does not run during a prerender - Fix an issue where a first mount into a
host-refselector cleared the host and fireddid-unrender: what the host holds now stays until the first render replaces it
0.2.0 (2026-09-30)
- Update a URL
template-refthat answers with an error status to setis-errorand fire the error event, paint nothing and log one error with the status as its cause; the error page is no longer rendered as content - Fix an issue where a failed template load held
readyuntilrender-timeout: the promise fromrenderrejects at once anddelaying-readyclears - Fix an issue where the promise from
rendernever settled anddelaying-readystayed set whenhost-refresolved to no host: the promise now rejects at once anddelaying-readyclears, while an author iframe still loading keeps waiting
0.1.1 (2026-09-23)
- Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
Full documentation: https://nucleus.excom.dev/packages/renderable-element
