Skip to content

Runtime: definitions and binding

src/runtime.js (also @johnhenry/html-modules/runtime) is the only place component semantics live. The runtime loader and compiled modules both build definitions with defineHTMLComponent() and bind them with bindModule(), so the two paths cannot drift apart. A definition is inert: nothing registers until something binds it to a tag.

An HTML Component Definition: a component’s module-local identity and how to render it. One definition can be registered under any number of tags.

new HTMLComponent(spec?: {
name?: string | null, // = null: the identity, e.g. "custom-card" (null for a default-only export)
template?: string | DocumentFragment, // the <template>'s content HTML (or a DocumentFragment of it, stamped without being parsed: what a sanitizer may return); required unless `element` is given
shadow?: 'open' | 'closed', // = "open"
delegatesFocus?: boolean, // = false
styles?: string[], // = []: CSS texts, adopted into every shadow root (one sheet per definition)
formAssociated?: boolean, // = false: static formAssociated + ElementInternals (see html-syntax.md)
formControl?: string, // a selector for the control in the template that supplies the form value
formRole?: 'submit' | 'reset', // a button: needs formAssociated, excludes formControl
props?: Array<{ name: string, type?: 'string' | 'number' | 'boolean' }>, // = []: attributes that are also properties (observed, reflected)
imports?: Array<{ // = []: modules this component uses, bound before it is registered
module?: object, from: string, as?: string, bindings?: Array<{ export, element?, adopt? }>,
delimiter?: string, conflict?: 'error' | 'reuse', errors?: 'event' | 'throw', load?: 'eager' | 'lazy',
lazy?: () => Promise<object>, // an entry with no `module` and a `lazy` loader is bound on first use of its tags
}>,
element?: Function, // a JS-authored class extending HTMLElement, instead of a template
url?: string, // where it came from, for messages
})
defineHTMLComponent(spec | Class | HTMLComponent): HTMLComponent

defineHTMLComponent() is the usual way to make one: it returns an existing definition as-is, wraps a class (defineHTMLComponent(MyElement) ≡ new HTMLComponent({ element: MyElement })), or builds from a spec.

Member Description
name The identity (string or null). For an element class without a name, the class name kebab-cased (FancyButton → fancy-button).
template The template HTML, or null for a class-backed definition.
props The declared props, frozen, each { name, type } (type defaults to "string"). Every one is an observed attribute and a reflected property of the registered class; every attribute the template binds with {{…}} is observed too. See Data binding. Registering a definition whose template has an invalid binding (an on*, style or srcdoc target, an expression, an unterminated {{) throws a SyntaxError before anything is registered.
formAssociated, formControl, formRole As given (formControl and formRole are present only when given, both need formAssociated, and a button has no formControl: TypeError: defineHTMLComponent: \formRole` needs `formAssociated: true`, SyntaxError: defineHTMLComponent: Invalid formRole “x”: use “submit” or “reset”, TypeError: defineHTMLComponent: a button (`formRole`) has no `formControl`: it carries no value). See [Form-associated components](/html-modules/api/html-syntax/#form-associated-components). Every template-backed class memoizes attachInternals()`.
shadow, delegatesFocus, styles, imports As given (normalized; styles and imports frozen, each import and its bindings frozen).
url Present only when given. The runtime loader sets the module URL; compiled output sets import.meta.url.
isClass true for a JS-authored (element) definition.
element The base element class for globalThis, to extend: customElements.define('x-card', class extends def.element { … }). For a class-backed definition, the class itself.
elementFor(window = globalThis) The base class for another window (a test DOM, an iframe). Built once per window.
define(tag, { registry?, window?, conflict? }) Register under tag and return the registered class: defineElement(tag, this, options).
String(def) "[object HTMLComponent]".

The instance is frozen.

What an instance of a template-backed element does when constructed:

  1. It looks for a shadow root it already has: this.shadowRoot, and for a shadow="closed" component also attachInternals().shadowRoot, the only way to see a closed declarative shadow root (a server-rendered <template shadowrootmode="closed">). Because attachInternals() can be called once per element, a closed component’s base class calls it itself: a subclass of one that needs its own ElementInternals should be shadow="open". If the root’s mode is not the component’s shadow, it throws Error: <tag> already has an open|a closed shadow root (server-rendered?), but "<name>" is shadow="<mode>": render it with shadowrootmode="<mode>" (renderDeclarative() does), ….
  2. Otherwise it attaches a shadow root ({ mode: shadow, delegatesFocus }).
  3. Either way it adopts the component’s stylesheets (its own styles as one shared constructed sheet per window, with relative url()s made absolute against the module’s URL, then any stylesheet its module’s imports adopt), so a server-rendered root is styled like a stamped one. If the root already has content (server-rendered) it is kept and the template is not stamped again; if it is empty, a clone of the template content (parsed once per window, on first use) is appended.
  4. It reports the shadow root to lazy loading (so lazily imported tags inside it are seen, even when closed).

The base class is named after the identity in PascalCase (custom-card → CustomCard, HTMLModuleElement when the name is null), and has a static component getter returning the definition, so customElements.get('ui--card').component === ui.card. Classes that extend it (every registered tag, and your own subclasses) inherit that getter. A class-backed definition registers the class itself (subclassed); it has no template, styles or component getter unless the class extends a template-backed base.

import { defineHTMLComponent } from '@johnhenry/html-modules/runtime';
const card = defineHTMLComponent({
name: 'card',
template: '<article><slot></slot></article>',
styles: [':host { display: block }'],
});
card.define('my-card'); // → the registered class
card.define('invoice-card'); // another tag: a different class, the same component
customElements.define('fancy-card', class extends card.element { connectedCallback() { /* behaviour */ } });
// HTML template + JS behaviour, carrying the template module's own imports:
class LikeButton extends likeView.element { connectedCallback() { /* … */ } }
export const components = { 'like-button': defineHTMLComponent({ element: LikeButton, imports: likeView.imports }) };

Throws TypeError: defineHTMLComponent: pass a `template` string (or a DocumentFragment) or an `element` class, TypeError: defineHTMLComponent: `element` must be a class extending HTMLElement, or SyntaxError: Invalid shadow mode "…": use "open" or "closed".

true for a definition. The brand is Symbol.for('html-modules.component'), a global symbol, so definitions made by another copy of the library are recognized too.

A stylesheet export: CSS text that can be adopted into documents and shadow roots.

new HTMLStylesheet(spec?: { name?: string | null, css?: string, url?: string })
defineHTMLStylesheet(spec | HTMLStylesheet): HTMLStylesheet
Member Description
name The export name, or null.
css The CSS text (String(css), default "").
url Present only when given.
resolvedCss css with each relative url(...) made absolute against url (unchanged without a url): the text actually applied by sheetFor(), the <style> fallback and renderDeclarative(). Browsers ignore CSSStyleSheet’s baseURL, so the text is rewritten instead.
sheetFor(window = globalThis) A constructed CSSStyleSheet for that window (built once and shared), or null where constructable stylesheets are unavailable.
adopt(root, { window? }) adoptStylesheet(root, this, options).
String(sheet) "[object HTMLStylesheet]".

Frozen. Branded with Symbol.for('html-modules.stylesheet').

adoptStylesheet(root: Document | ShadowRoot, value: HTMLStylesheet | CSSStyleSheet, options?: { window? }): void

Adopt a stylesheet into a document or shadow root. Adopting the same sheet into the same root again is a no-op. Where adoptedStyleSheets is available, the window’s constructed sheet is appended to it. Otherwise (for an HTMLStylesheet) a <style data-html-module="<name>"> is appended once to document.head (for a document) or the shadow root. window defaults to the root’s window; the fallback <style> gets the window’s configured nonce. Throws TypeError: adoptStylesheet: not a stylesheet, or TypeError: This document cannot adopt a CSSStyleSheet for a raw CSSStyleSheet where adoption is unsupported.

renderDeclarative(def: HTMLComponent, innerHTML?: string): string

Declarative shadow DOM markup for one server-rendered instance of a template-backed component, to put inside its host tag, followed by the host’s light DOM (innerHTML, inserted as written: escape untrusted text yourself):

const html = `<ui--card>${renderDeclarative(ui.card, '<h2>Title</h2>')}</ui--card>`;
// <ui--card><template shadowrootmode="open"><style>…</style><article>…</article></template><h2>Title</h2></ui--card>

shadowrootmode and shadowrootdelegatesfocus come from the definition. Its styles, and those its imports adopt, are written as <style> elements in the template so the first paint is styled before any script runs; when the element upgrades the component adopts its constructed sheets as well, so the rules are listed twice (harmless). </style inside the CSS is escaped. It is pure string work: it runs in Node, on definitions from HTMLModules.load() or from a compiled module. A definition whose template is a DocumentFragment (a sanitized one) is serialized from that DOM; the browser parses the string again, which is the step a fragment otherwise avoids (see Sanitizing templates). Throws TypeError: renderDeclarative: pass a component definition …, or … is a JavaScript-authored class, not a template; there is no template to render. It does not render the module’s nested components (a template that uses <ui--icon> gets the declarative markup of that one from you), and it does not set the page’s Trusted Types policy: server-rendered markup goes through the HTML parser, not innerHTML.

configureRuntime(window, options: { trustedTypes?: { createHTML(html: string): unknown } | false, nonce?: string }): void

Per-window page-security settings (only the keys given change). trustedTypes is the policy wrapping the template HTML the runtime stamps (false: never; default: a policy named html-modules where window.trustedTypes exists); nonce goes on the fallback <style> elements adoptStylesheet inserts. createHTMLModules({ trustedTypes, nonce }) calls this for its window; call it yourself when only compiled modules run in the page. Throws TypeError: Invalid trustedTypes: pass a Trusted Types policy (an object with createHTML(html)), or false to never use Trusted Types / TypeError: Invalid nonce "<v>": pass the page's CSP nonce as a non-empty string. See Trusted Types and CSP.

unadoptStylesheet(root: Document | ShadowRoot, value: HTMLStylesheet | CSSStyleSheet, options?: { window? }): void

The counterpart of adoptStylesheet(): remove the window’s constructed sheet from root.adoptedStyleSheets, or remove the fallback <style data-html-module> element it inserted. A no-op if the sheet was not adopted there, and it can be adopted again afterwards. Adoption is not reference-counted: two adopters of one sheet in one root lose it together. <html-binding adopt> calls this when the binding is removed from the page (and adopts again when it is put back). Throws TypeError: unadoptStylesheet: not a stylesheet.

isHTMLStylesheet(value), isStylesheet(value)

Section titled “isHTMLStylesheet(value), isStylesheet(value)”

isHTMLStylesheet: an HTMLStylesheet (from any copy of the library). isStylesheet: an HTMLStylesheet or a CSSStyleSheet-like object (has replaceSync and cssRules); what adopt accepts.

defineElement(tag: string, value: HTMLComponent | typeof HTMLElement | HTMLTemplateElement,
options?: { registry?: CustomElementRegistry, window?: Window, conflict?: 'error' | 'reuse' }): CustomElementConstructor

Register an element-like value under tag and return the registered class. window defaults to globalThis, registry to window.customElements, conflict to "error".

  • Every registration is a fresh subclass of the definition’s base element (a registry accepts a constructor only once), named like the base class. So one definition can have many tags.
  • The definition’s module imports are bound first (eager ones now, lazy ones watched for), in the same registry. A failing import throws to the caller (and, with that import’s errors: 'throw', is also reported).
  • The same definition under the same tag again returns the existing class: a no-op.
  • A tag already defined by something else: throws Error: Cannot bind <tag>: it is already defined by "<name>" from <url> (conflict="reuse" keeps the existing definition instead) (the by … part appears when html-modules made that registration in this registry), or, with conflict: 'reuse', returns the existing class unchanged.
  • Throws SyntaxError: "<tag>" is not a valid custom element name: <reason>, SyntaxError: Invalid conflict="…", or the toComponent TypeError.
toComponent(value: unknown, options?: { window?: Window, what?: string }): HTMLComponent

The definition for any element-like value: a definition as-is; a class extending HTMLElement or a <template> element wrapped in a definition (the same wrapper every time, so identity is stable). Anything else throws TypeError: Cannot register <what> as a custom element: it is <kind>, not a component, where <kind> is one of a stylesheet, a function that does not extend HTMLElement (<Name>), null, an array, data (an object), data (a <typeof>).

true for values that can become a custom element: a definition, a <template> element, or a class extending the window’s (or the global) HTMLElement.

bindModule(ns: object, options?: {
as?: string, delimiter?: string /* = "--" */, bindings?: Array<{ export: string, element?: string, adopt?: boolean }>,
from?: string /* = "module" */, registry?, window? /* = globalThis */, root?: Document | ShadowRoot,
conflict?: 'error' | 'reuse',
}): {
elements: Record<string, CustomElementConstructor>, // tag → registered class
values: Record<string, unknown>, // export → value, for each binding
tags: Record<string, { tag: string, namespace: string | null, export: string, reused?: true }>,
}

Bind a module namespace (runtime-loaded HTML, compiled, or plain JS) the way <html-import> does:

  • with bindings: only those, each as applyBinding does it, in order, every binding checked before any is applied: a missing export, an invalid tag, a non-component element, a tag that is already defined (or that two bindings of the list both want) throws and leaves nothing registered or adopted;
  • otherwise, with as: every component of componentsOf(ns) as <as><delimiter><export>, every tag checked before any is registered (invalid names and tags already defined alike, so a conflict on the last tag does not leave <ui--a> and <ui--b> registered; conflict: 'reuse' keeps the existing ones and registers the rest). The same holds for registerComponents;
  • otherwise: nothing ({ elements: {}, values: {}, tags: {} }).

What the check cannot see in advance: a component’s own module imports are bound when that component registers (they may load and register more tags), so a failure there can still leave earlier tags of the batch registered.

as, delimiter and conflict are validated first (SyntaxError). root is where adopt bindings adopt; without it they are applied (validated) but not adopted. tags records what each tag was made from, so nothing needs to split a tag.

applyBinding(ns: object, binding: { export: string, element?: string, adopt?: boolean }, options?: {
as?, delimiter? /* = "--" */, from? /* = "module" */, registry?, window?, root?, conflict?,
}): { export: string, value: unknown, tag: string | null, namespace: string | null,
element: CustomElementConstructor | null, adopted: boolean, reused?: true }

Apply one <html-binding>: look up the export (lookupExport); with adopt, adopt it into root (it must be a stylesheet); with element, register it there (it must be element-like); otherwise, with as and an element-like value, register it as <as><delimiter><export> (a SyntaxError for export: 'default', which needs element). namespace is as when the tag was made from it and null when element chose it (or nothing was registered). The full table is in HTML syntax. Throws SyntaxError for a missing export (<html-binding> requires an "export" attribute), a missing export in the module, an invalid tag, or a default without element; TypeError for a non-stylesheet adopt or a non-component element.

registerComponents(ns: object, options?: { as?, delimiter?, from?, registry?, window?, conflict? }):
Record<string, CustomElementConstructor> // tag → registered class

Register every component of a namespace: with as, as <as><delimiter><export> (all tags checked before any registration); without it, under the export names themselves, which must then be valid custom element names (card throws; plain-card works). This is what compiled register-format modules call: registerComponents({ components: $components }, { as, delimiter, conflict, from: import.meta.url }).

lookupExport(ns: object, name: string, from?: string /* = "module" */): unknown

Find an export by the name used in markup: for "default", ns.default; otherwise the components manifest entry, then ns[name], then ns[camelCase(name)] (own properties only). Throws SyntaxError: The requested module '<from>' does not provide an export named '<name>'.

componentsOf(ns: object, from?: string): Array<[exportName: string, value: unknown]>

The components a module offers to a whole-namespace import: its components manifest if it has one (an object); otherwise its exports made with defineHTMLComponent() (excluding default), keyed by kebab-cased export name. Other exports (constants, functions, plain classes) are never components. Throws TypeError: The module '<from>' does not export any HTML components: export a `components` manifest or definitions made with defineHTMLComponent(), or bind exports explicitly with <html-binding>.

A JavaScript module offers components either way:

export const components = { 'custom-card': CustomCard, 'fancy-button': FancyButton }; // a manifest (classes are fine)
// or
export const customCard = defineHTMLComponent(CustomCard); // → "custom-card"
manifest(locals: Record<string, unknown>, stars?: Array<[namespace: object, from: string]>): Readonly<Record<string, unknown>>

Build an HTML module’s components manifest: the element-like values of locals (export name → value; others skipped), then each star source’s components that locals does not already name. A name two star sources give different components is a SyntaxError: Conflicting star exports for '<name>' from '<a>' and '<b>'. Frozen. Used by the loader and by compiled output.

namespaceComponents(name: string, ns: object): Record<string, unknown>

The manifest entries a namespace re-export (<html-export src="./icons.html" name="icon" import="*">) contributes: each of ns’s components (per componentsOf) keyed <name>--<export>, e.g. { 'icon--star': … }. Returns {} when ns offers no components instead of throwing. Spread into the locals of manifest by the loader and by compiled output.

Template-backed classes delegate to a swappable definition, so live elements can be updated when a module is edited. See Dev server, hot reload and Vite for what is swapped and what needs a reload.

hotReplaceComponent(previous: HTMLComponent, next: HTMLComponent): { ok: true, elements: number } | { ok: false, reason: string }
hotReplaceStylesheet(previous: HTMLStylesheet, next: HTMLStylesheet): number // roots swapped
hotReplaceModule(previous: Namespace, next: Namespace): { reload: boolean, reasons: string[], updated: string[], elements: number }
  • hotReplaceComponent: re-stamps (template changed) or restyles (styles changed) every live element of previous; elements created later use next, and next.define(tag) for a tag previous holds is the same component, not a conflict. Returns { ok: false, reason } and changes nothing for a change that needs a reload.
  • hotReplaceStylesheet: swaps the adopted sheet in every root that adopted previous, and later adoptStylesheet(root, previous) adopts next.
  • hotReplaceModule: all-or-nothing over a whole module’s exports (components, stylesheets; data must be equal). This is what HTMLModules.hotReload() and the Vite plugin’s HMR code call.
supportsScopedRegistries(window = globalThis): boolean

True when the window supports scoped custom element registries: it tries new window.CustomElementRegistry() and attachShadow({ customElementRegistry }) and checks that the shadow root reports that registry (feature-detecting only the constructor would claim support in an engine that has the interface but ignores the option). Cached per window. Used by the runtime for registry="scoped" imports (see Scoped registries), which fall back to the registry the component is registered in, with one warning, where this is false.