Skip to content

Directives

WebJs ships the lit-html directives that have no clean native equivalent, under their familiar lit names, so AI agents writing lit-shaped directive code land on what they expect. The directives that ARE just sugar over plain JavaScript (classMap / styleMap / ifDefined / when / choose) are deliberately not shipped (see below). The implementations live in packages/core/src/directives.js and the renderers (render-server.js, render-client.js).

import { html, repeat } from '@webjsdev/core';
import {
  unsafeHTML, live,
  keyed, guard, templateContent, ref, createRef,
  cache, until, asyncAppend, asyncReplace,
} from '@webjsdev/core/directives';

repeat(items, keyFn, templateFn)

Keyed list reconciliation. Without it, re-rendering an array destroys and recreates all DOM nodes, losing focus, scroll position, and component state.

html`<ul>
  ${repeat(
    items,
    (item) => item.id,
    (item) => html`<li>${item.name}</li>`
  )}
</ul>`;

Use for any list where items can be added, removed, or reordered and you need to preserve DOM identity (animated lists, forms with inputs, draggable items). For static lists, plain ${items.map(...)} works.

unsafeHTML(htmlString)

Renders a raw HTML string without escaping. The only way to inject pre-built HTML (CMS content, markdown output) into a template.

html`<article>${unsafeHTML(markdownToHtml(post.body))}</article>`;

Security: NEVER use with user-supplied input. This is an XSS vector.

live(value)

Dirty-checks against the live DOM value instead of the last rendered value. Solves the input desync problem where the user types between renders.

html`<input .value=${live(this.query.get())}
       @input=${(e) => this.query.set(e.target.value)}>`;

keyed(key, template)

Wrap a template with a key. When the key changes between renders, the renderer discards the prior DOM and creates fresh. Useful for forcing a remount when the logical identity of the rendered content changes.

html`${keyed(this.userId, html`<edit-form .user=${this.user}></edit-form>`)}`;

guard(deps, fn)

Memoize a sub-template by its dependencies. The client renderer skips re-evaluating fn() when the deps array is shallow-equal to the prior call. On the server (one-shot render) fn() is always invoked.

html`<header>
  ${guard([this.title], () => html`<h1>${this.title}</h1>`)}
</header>`;

templateContent(tpl)

Render the content of a native <template> element. The content is cloned on the client; on the server, its innerHTML is emitted verbatim. The HTML inside the template is trusted (not escaped).

const tpl = document.querySelector('#my-tpl');
html`<div>${templateContent(tpl)}</div>`;

ref(refOrCallback) + createRef()

Bind a Ref object or callback to the element at this position. The Ref's value is populated after the first client-side render. SSR is a no-op (no DOM yet).

class MyForm extends WebComponent {
  _input = createRef();
  render() { return html`<input ${ref(this._input)}>`; }
  firstUpdated() { this._input.value?.focus(); }
}

Pass a callback instead of a Ref object to receive the element directly: ${ref((el) => this._captureEl(el))}.

cache(value)

Retain detached DOM when toggling between sub-templates. When the inner value's template strings match a previously-rendered template at this position, the renderer re-attaches the stashed nodes and reconciles values instead of creating fresh DOM. Preserves input state, scroll position, and focus across "tab"-style toggles.

render() {
  return html`
    <nav>
      <button @click=${() => this.tab = 'a'}>A</button>
      <button @click=${() => this.tab = 'b'}>B</button>
    </nav>
    ${cache(this.tab === 'a' ? html`<panel-a></panel-a>` : html`<panel-b></panel-b>`)}
  `;
}

On the server, cache is a pass-through (one-shot render, no DOM to cache).

until(...args)

Render the highest-priority resolved candidate. Priority is left-to-right: args[0] is highest. The highest-priority synchronous candidate renders immediately; higher-priority Promises that later resolve replace the rendered value. Lower-priority Promises are ignored once a higher-priority candidate is in place.

html`<div>${until(this.dataPromise, html`<p>Loading…</p>`)}</div>`;

When the marker is torn down (a re-render replaces the directive), in-flight Promise tracking is aborted so late resolves cannot overwrite newer DOM. On the server, until awaits Promise.race when all candidates are Promises, or renders the highest-priority synchronous candidate.

For component-scoped async data with full pending/error states, prefer the Task controller from @webjsdev/core/task.

asyncAppend(iterable, mapper?) / asyncReplace(iterable, mapper?)

Stream values from an AsyncIterable. Each yielded value is mapped (optional) and rendered as a node group. asyncAppend accumulates the rendered groups before the marker; asyncReplace swaps out the previous output each yield. Iteration aborts when the directive is replaced (so leaked iterators don't hold references to detached DOM).

async function* logTail() {
  for await (const line of socket) yield line;
}

html`<ul>${asyncAppend(logTail(), (line, i) => html`<li>${i}: ${line}</li>`)}</ul>`;

On the server, both directives render empty (no iteration on a one-shot render). For page-level streaming, prefer Suspense({ fallback, children }).

Native patterns (no directive needed)

For conditional classes, inline styles, optional attributes, and conditional rendering, lit reaches for the classMap / styleMap / ifDefined / when / choose directives. WebJs deliberately does NOT ship those: native JavaScript inside render() expresses the same thing with no runtime overhead and shows up directly in the template, so it is the framework's preferred form (and what AI agents should emit). The directives WebJs DOES export are the ones with no clean native equivalent, listed above (repeat, unsafeHTML, live, keyed, guard, cache, until, ref, the async directives, watch).

Conditional CSS classes

html`<div class=${[x && 'active', y && 'error'].filter(Boolean).join(' ')}>`;

Dynamic inline styles

html`<div style=${`color:${c};font-size:${s}`}>`;

Optional attributes

html`<img src=${src ?? null}>`;  // null removes the attribute

Conditional rendering

html`${loggedIn ? html`<p>Welcome</p>` : html`<a href="/login">Sign in</a>`}`;