# 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.


```html
<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-ref` in-document selectors or remote URLs
- **Prefetch strategies** `lazy` (default), `eager`, or `idle`
- **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-ready` for paint-synced reveals

## Installation


`@excom/renderable-element` v0.3.0

```bash
pnpm add @excom/renderable-element
```

```bash
npm install @excom/renderable-element
```

```bash
yarn add @excom/renderable-element
```

### Import

```ts
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.

```ts
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();
```

```html
<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:


```html
<p>Two <code>&lt;include-content&gt;</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:


```html
<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:


```html
<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](/docs/prerendering) 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.


```html
<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:

```html
<media-gated ready-on="my-app-paint" media-query="(min-width: 900px)">
  <template>...</template>
</media-gated>
```

```css
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-ref`s 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:


```html
<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:

```html
<!-- 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-render` once and is ready at once (never `delaying-ready`); `persist-content` keeps those nodes across toggles and `pre-fetch="idle"` does not run during a prerender
- Fix an issue where a first mount into a `host-ref` selector cleared the host and fired `did-unrender`: what the host holds now stays until the first render replaces it

### 0.2.0 (2026-09-30)

- Update a URL `template-ref` that answers with an error status to set `is-error` and 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 `ready` until `render-timeout`: the promise from `render` rejects at once and `delaying-ready` clears
- Fix an issue where the promise from `render` never settled and `delaying-ready` stayed set when `host-ref` resolved to no host: the promise now rejects at once and `delaying-ready` clears, 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
