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.
- Definitions:
HTMLComponent/defineHTMLComponent,isHTMLComponent - Stylesheets:
HTMLStylesheet/defineHTMLStylesheet,adoptStylesheet,unadoptStylesheet,isHTMLStylesheet,isStylesheet - Server-side rendering:
renderDeclarative - Page security:
configureRuntime - Registration:
defineElement,toComponent,isElementLike - Binding:
bindModule,applyBinding,registerComponents - Namespaces:
lookupExport,componentsOf,manifest,namespaceComponents
HTMLComponent
Section titled “HTMLComponent”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): HTMLComponentdefineHTMLComponent() 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:
- It looks for a shadow root it already has:
this.shadowRoot, and for ashadow="closed"component alsoattachInternals().shadowRoot, the only way to see a closed declarative shadow root (a server-rendered<template shadowrootmode="closed">). BecauseattachInternals()can be called once per element, a closed component’s base class calls it itself: a subclass of one that needs its ownElementInternalsshould beshadow="open". If the root’s mode is not the component’sshadow, it throwsError: <tag> already has an open|a closed shadow root (server-rendered?), but "<name>" is shadow="<mode>": render it with shadowrootmode="<mode>" (renderDeclarative() does), …. - Otherwise it attaches a shadow root (
{ mode: shadow, delegatesFocus }). - Either way it adopts the component’s stylesheets (its own
stylesas one shared constructed sheet per window, with relativeurl()s made absolute against the module’s URL, then any stylesheet its module’s importsadopt), 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. - 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 classcard.define('invoice-card'); // another tag: a different class, the same componentcustomElements.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".
isHTMLComponent(value)
Section titled “isHTMLComponent(value)”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.
HTMLStylesheet
Section titled “HTMLStylesheet”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, value, options)
Section titled “adoptStylesheet(root, value, options)”adoptStylesheet(root: Document | ShadowRoot, value: HTMLStylesheet | CSSStyleSheet, options?: { window? }): voidAdopt 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, innerHTML)
Section titled “renderDeclarative(def, innerHTML)”renderDeclarative(def: HTMLComponent, innerHTML?: string): stringDeclarative 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)
Section titled “configureRuntime(window, options)”configureRuntime(window, options: { trustedTypes?: { createHTML(html: string): unknown } | false, nonce?: string }): voidPer-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, value, options)
Section titled “unadoptStylesheet(root, value, options)”unadoptStylesheet(root: Document | ShadowRoot, value: HTMLStylesheet | CSSStyleSheet, options?: { window? }): voidThe 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, value, options)
Section titled “defineElement(tag, value, options)”defineElement(tag: string, value: HTMLComponent | typeof HTMLElement | HTMLTemplateElement, options?: { registry?: CustomElementRegistry, window?: Window, conflict?: 'error' | 'reuse' }): CustomElementConstructorRegister 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)(theby …part appears when html-modules made that registration in this registry), or, withconflict: 'reuse', returns the existing class unchanged. - Throws
SyntaxError: "<tag>" is not a valid custom element name: <reason>,SyntaxError: Invalid conflict="…", or thetoComponentTypeError.
toComponent(value, options)
Section titled “toComponent(value, options)”toComponent(value: unknown, options?: { window?: Window, what?: string }): HTMLComponentThe 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>).
isElementLike(value, window)
Section titled “isElementLike(value, window)”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
Section titled “bindModule”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 asapplyBindingdoes it, in order, every binding checked before any is applied: a missing export, an invalid tag, a non-componentelement, 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 ofcomponentsOf(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 forregisterComponents; - 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
Section titled “applyBinding”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, options)
Section titled “registerComponents(ns, options)”registerComponents(ns: object, options?: { as?, delimiter?, from?, registry?, window?, conflict? }): Record<string, CustomElementConstructor> // tag → registered classRegister 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, name, from)
Section titled “lookupExport(ns, name, from)”lookupExport(ns: object, name: string, from?: string /* = "module" */): unknownFind 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, from)
Section titled “componentsOf(ns, from)”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)// orexport const customCard = defineHTMLComponent(CustomCard); // → "custom-card"manifest(locals, stars)
Section titled “manifest(locals, stars)”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, ns)
Section titled “namespaceComponents(name, ns)”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.
Hot replacement
Section titled “Hot replacement”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 swappedhotReplaceModule(previous: Namespace, next: Namespace): { reload: boolean, reasons: string[], updated: string[], elements: number }hotReplaceComponent: re-stamps (template changed) or restyles (styles changed) every live element ofprevious; elements created later usenext, andnext.define(tag)for a tagpreviousholds 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 adoptedprevious, and lateradoptStylesheet(root, previous)adoptsnext.hotReplaceModule: all-or-nothing over a whole module’s exports (components, stylesheets; data must be equal). This is whatHTMLModules.hotReload()and the Vite plugin’s HMR code call.
supportsScopedRegistries(window)
Section titled “supportsScopedRegistries(window)”supportsScopedRegistries(window = globalThis): booleanTrue 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.