Module · reserved layer (Level 2+)

The JavaScript Layer

Almost everything is declarative, and a component definition holds no script at all. When behavior genuinely needs imperative JavaScript, a timer, a stream, an observer, a foreign library, it lives in an ordinary ES module outside the definition and attaches through a small, native-grounded seam: a controller bound to a component instance by tag. The definition stays pure data; the module stays pure behavior; they meet only at the contract.

Proposed direction · the imperative escape hatch

Imperative behavior uses controllers

The declarative layers, templating, bindings, and reactivity, cover the large majority of interface work with no script. But some behavior is irreducibly imperative: a setInterval clock, a Server-Sent-Events stream, an IntersectionObserver, wrapping a mapping or charting library, or computation too involved for a pure expression. That work needs JavaScript, and pretending otherwise would push authors back into inline handlers and eval-shaped escapes.

So HTML Next draws a firm line. A component definition is declarative data: no <script>, no inline handlers, importable and inspectable without executing anything, and renderable on a server. Imperative JavaScript is a separate concern in an ordinary ES module. The two never fuse; they meet at a named seam.

Server rendering supplies the declarative baseline

The JavaScript layer only enhances: a controller adds live imperative behavior on top and is never load-bearing for the first render. But be precise about what "no JS" delivers, because it depends on where the markup comes from. On a server-rendered page the first render is real HTML that displays, is accessible, and is indexable with no script; a controller failing to load leaves that baseline intact. On a client-only page there is no such baseline: an invocation like <x-app> is an unknown element, so without the runtime it renders nothing (its inline children, if any, are all that shows). So the guarantee is: no-JS renders the declarative baseline when the HTML was server-rendered; client-only pages need the runtime to produce first paint. Determinism and adopt-in-place hydration are likewise SSR properties (see Components), not client-only ones.

The controller

A component that needs imperative behavior gets a controller: a function attached to the component tag, run once per instance. It is the decoupled equivalent of a custom element's class plus its ElementInternals1, the same behavior a Web Component would put in a class, attached instead to a definition made of data. The registration verb is deliberately the platform's: defineController(tag, fn) reads like customElements.define(tag, class).

x-map.html
<!-- x-map.html: declarative data plus its optional controller dependency. -->
<template component="x-map" controller="./x-map.js">
  <defs>
    <state name="center" :value="[0, 0]">
  </defs>
  <div $ref="canvas"></div>
</template>
x-map.js
// x-map.js: an ordinary ES module, the imperative controller for <x-map>.
import { defineController } from "@nextweb/html";

export const controller = (host) => {
  let map;
  host.effect(() => {
    map ??= new MapLib(host.refs.canvas);                // recreated after a reconnect
    map.setCenter(host.state.center);                    // re-runs whenever center changes
  });
  host.on("disconnect", () => { map?.destroy(); map = undefined; });
};

// Link the controller to the tag. Mirrors customElements.define(tag, class).
defineController("x-map", controller);

The controller is written against the small host interface and exported, with the tag registration a thin wrapper around it. That convention lets the same controller run unchanged in the browser runtime and compile to a framework target (below). It is a portability contract, not a security boundary: an ES module can still use window, document, storage, network APIs, and anything else page JavaScript can reach. The definition declares its controller URL alongside its <state> and $ref, so the complete component travels together.

Events stay declarative

Event markup references a declarative handler by name (on:click="save" → a <handler>), never an imperative JavaScript function. When the component needs imperative behavior, the carrier names one controller module; functions remain inside that module. The definition therefore exposes one static file dependency without baking framework-specific callbacks into markup.

host: the ElementInternals of a data component

The controller receives one argument, host, its window onto the instance. The name is the platform's: Shadow DOM already calls the component element seen from inside :host3. Every capability on it is a thin, framework-neutral surface with a native anchor, which is precisely what makes it portable across compile targets.

type EffectCallback = () => void | (() => void);

interface ComponentHost<State extends object = Record<string, unknown>> {
  readonly element: Element;  // the connected component invocation
  readonly state: State;      // live values; only declared <state> paths are writable
  readonly refs: Readonly<Record<string, Element>>;
  readonly elements: Readonly<Record<string, Element | RadioNodeList>>;

  on(type: string, callback: EventListener): () => void;
  effect(callback: EffectCallback): () => void;
  dispatch(type: string, detail?: unknown): boolean;
}
On hostDoesNative anchor
host.elementThe component invocation element whose connection owns this controller's lifetime.:host / the custom-element instance
host.stateRead the instance's props, state, computed values, and resources. Only paths rooted at a declared <state> are writable; a valid write flows through the dependency graph.ElementInternals state2 (generalized from boolean :state() flags to values)
host.refs.nameThe element declared with $ref="name".captured at lowering (see below)
host.elements.nameA native form control by its name.form.elements9
host.on(event, fn)Run fn on a lifecycle or component event; connect/disconnect included.connectedCallback/disconnectedCallback1
host.effect(fn)Run fn while connected and re-run it whenever a state path it read changes. Returns an early-stop function; connection also owns cleanup.TC39 Signals Watcher4 plus DOM connection
host.dispatch(event, detail)Raise a component event a parent can catch with on:event.CustomEvent / dispatchEvent8

Teardown is either a disposer returned from an host.on("connect", …) callback or an explicit host.on("disconnect", …); host.effect cleans itself up. This is the same connect-and-return-a-disposer shape used across the platform and userland alike.

The companion JavaScript / DOM API

host.effect(callback) is the deliberately small JavaScript companion to declarative bindings. It exists for effects on systems outside the runtime-owned DOM, such as updating a chart, observer, or media object from component state. It is not required to make template bindings reactive; those are wired directly from their declared paths.

  • Connection: an effect registered by the controller runs once when the instance is connected. Reads through host.state during its last successful run are its dependencies.
  • Update: a dependency change marks the effect dirty. The runtime coalesces repeated changes and runs it in the same ordered microtask flush as affected bindings, after state and computed values have settled.
  • Rerun: if the callback returned a disposer, that disposer runs before the next callback. The next run replaces the tracked dependency set, so conditional JavaScript reads remain precise without an authored untrack.
  • Disconnection: the current disposer runs and every observation owned by the effect is removed. It does no work while detached; reconnection runs it once and establishes a fresh dependency set.
  • Early stop: the function returned by host.effect performs the same cleanup permanently and is idempotent.
The reactive API is instance-scoped

The contract exposes values through host.state and lifetime-bound reactions through host.effect; it does not expose an instance's backing Signal.State, Signal.Computed, or Watcher objects. Raw handles would let code retain observations beyond the element's lifetime, bypass declared writability and type rules, and couple HTML Next to a Stage 1 API shape. A browser runtime may use TC39 Signals internally, but that choice is not observable. Page code outside the controller uses the component's declared attributes and properties and listens for DOM events; it cannot retrieve the private host. The raw-signal question can be reopened if cross-system signal identity proves to be a real interoperability requirement.

The controller API follows platform precedents

The surface reads like userland (state, effect, refs), but each name has a standards anchor: defineControllercustomElements.define, host:host, connect/disconnect ↔ the custom-element reactions, effect ↔ the Signals proposal, host.elementsform.elements. The overall shape, a controller object bound to a host with connect and disconnect callbacks, is Lit's Reactive Controller5, adapted to a script-free definition.

Element handles: $ref

A controller often needs a specific element, the node a library mounts into. Rather than a fragile querySelector that reaches into runtime-owned markup, the definition declares the handle with $ref="name", and the controller reads it as host.refs.name.

<!-- $ref is a directive: consumed at lowering, so no non-conforming attribute ships. -->
<div $ref="canvas"></div>
<canvas $ref="surface" width="640" height="480"></canvas>

<!-- in the controller -->
host.refs.canvas    // the <div>
host.refs.surface   // the <canvas>

<!-- native form controls are also reachable by their real name, like form.elements -->
<input name="email" type="email">
host.elements.email // the <input></input>

$ref is a directive, part of the $ family ($if, $each, $value): the runtime reads it, records the node for the controller, and strips it at lowering. So the final DOM carries no ref attribute, which matters because a literal ref (or a name on a non-form element) would be non-conforming HTML. A consumed directive sidesteps that entirely. Native form controls need no $ref: their real name already identifies them, and host.elements reaches them the way form.elements does9.

$ref names a JavaScript handle

$ref is consumed at lowering and gives the controller a JavaScript handle to a named element. Shadow DOM's part / ::part()3 remains the CSS mechanism for exposing a theming surface across a shadow boundary. The two names serve separate APIs.

Controller and runtime ownership

Because a component lowers to real DOM with no shadow boundary, a controller could reach in and mutate bound nodes, and if it did, two writers, the controller and the runtime, would fight over the same DOM and thrash. The rule that prevents this is a single sentence:

// The controller drives STATE. The runtime reflects state to the DOM.
host.state.center = [51.5, -0.1];   // a write; the graph updates every binding that reads center
host.state.center;                  // a read

// It never writes bound DOM directly. That stays the runtime's, so there is one writer
// to the DOM and one source of truth. The map library owns its own (unbound) canvas subtree.

The runtime owns the subtree it lowered and every bound attribute and text node in it. The controller writes state; the runtime reflects state to the DOM. That keeps one writer to the DOM and one source of truth. A controller only touches DOM directly in its own foreign subtree, the canvas a map library renders into, which is unbound and therefore no one else's. User- and browser-driven native state (:checked, <details open>, form values) is treated as input through bind:, not fought.

Updates are batched, so this is cheap

State changes are fine-grained (only the bindings that read a changed path update) and coalesced into one flush per microtask, so a controller setting several state values in a row produces a single DOM update, not a cascade. See Reactivity for the scheduling model.

Communication upward is an event, not a mutated ancestor: host.dispatch raises a component event the parent catches declaratively.

// Raise a component event; a parent listens with on:event, exactly like a <handler> dispatch.
host.dispatch("locationchange", { lat, lng });

// <x-map on:locationchange="recenter">

Registration: joined by tag, like custom elements

None of this is the runtime scanning and guessing. The definition names its controller entry module directly, and the module registers behavior by tag name, exactly as custom elements join <my-el> to a class through customElements.define("my-el", …):

<!-- 1. the DEFINITION, registered under the tag "x-chart" -->
<template component="x-chart" controller="./x-chart.js"></template>

// 2. the CONTROLLER, registered under the SAME tag (an explicit call, like customElements.define)
defineController("x-chart", (host) => { … });

<!-- 3. INSTANCES in the page: they upgrade once a definition and (if any) a controller exist for "x-chart" -->
<x-chart></x-chart>

// Nothing points at anything else. The TAG "x-chart" is the join key, exactly as in custom elements:
//   <my-el>  +  customElements.define("my-el", class)  meet only at the string "my-el".

The definition registers declaratively, by being a parsed <template component="x-chart" controller="./x-chart.js">, the same way the parser acts on <template shadowrootmode>, or imperatively through the registry. Its controller attribute is a static dependency edge to one ES-module entry point. That module registers with an explicit defineController("x-chart", fn) call, shaped precisely like customElements.define and, like it, upgrading every connected instance the moment it runs. Instances upgrade once a definition, and any controller, exist for their tag.

The registry API

The registry mirrors customElements1, with one addition for the behavior half:

// The registry is shaped like customElements. Declarative registration is primary; these
// imperative calls exist for JS-driven registration and for controllers.
components.define("x-chart", templateEl);              // register a definition (cf. customElements.define)
components.defineController("x-chart", (host) => …);   // register behavior; upgrades matching instances
components.get("x-chart");                             // the registered definition
await components.whenDefined("x-chart");               // resolves when registered (cf. whenDefined)

In the polyfill this is an object the runtime provides; the shape is what a native implementation would expose. Declarative registration (<template component>, <link rel="component">) is the primary path; these calls are for JavaScript-driven registration and for controllers.

Sharing the custom-element namespace

A hyphenated tag like x-chart is also a valid custom element name, so the two registries occupy one namespace, and precedence must be defined, not left to a race. The rule: a registered custom element wins. If customElements.get(tag) is defined, the browser owns that tag (it upgrades and runs the element's own lifecycle), and HTML Next does not lower it, an author who registers a custom element for a tag has opted that tag out of HTML Next. The runtime checks this before lowering rather than tearing out a live custom element.

Loading a controller

Because registration is by tag, loading a controller reduces to loading a module that calls defineController. A definition names that module directly on its carrier with controller="…". A URL-like value resolves relative to the definition; a bare value uses the application's ordinary import map, just like a JavaScript import. That HTML-to-JavaScript edge is statically discoverable without executing either file, and the controller's own static imports continue the visible graph through JavaScript. The component author declares dependencies; the application decides how bare specifiers resolve:

<!-- LAZY: the controller loads when the first x-chart connects. -->
<template component="x-chart" controller="./b.js"></template>

<!-- EAGER remains available when the application wants it. -->
<script type="module" src="/components/b.js"></script>

Eagerly, the application loads the controller module like any script and it self-registers, identical to shipping a custom element. Lazily, the runtime resolves the definition's controller specifier and imports it when the first instance connects. CSP, CORS, and the application import map's generated integrity metadata apply through the standard module loader. There is no controller namespace, permission attribute, manifest, or second list:

// Inside /htmlnext.js. URL-like values resolve from the definition;
// bare values resolve through the application's ordinary import map.
const specifier = definition.getAttribute("controller");
const controllerURL = resolveComponentSpecifier(specifier, definitionURL);

// Standard module loading. The application's CSP, CORS rules, and generated
// import-map integrity metadata apply; the component does not carry deployment policy.
await import(controllerURL);

// The module's own defineController("x-chart", …) call registers the controller
// and upgrades this instance, like a late customElements.define upgrades <my-el>.

So the design reduces to two registries that never overlap, both keyed by tag:

RegistryHoldsPopulated byNative kin
Componentsdata: tag → definition<template component> (declarative) or components.definecustomElements1
Controllersbehavior: tag → controllera module calling defineControllerES modules7
The honest boundary from HTML Imports

Importing a component root trusts its dependency closure, including a declared controller. HTML Next does not pretend otherwise. The improvement over HTML Imports6 is architectural: a definition is parsed under one closed declarative grammar; arbitrary classic scripts and inline handlers are rejected; imperative behavior has one explicit controller attribute; and that edge uses the existing ES-module loader instead of an imported-document script lifecycle.

ES modules provide standardized fetching, CORS, CSP, dependency graphs, deduplication, and one-time evaluation. They do not sandbox authority. A controller and its transitive module graph are trusted page JavaScript, just like the JavaScript shipped by a custom element or an installed UI package.

A worked example: a component that uses another

Take a dashboard, <x-dashboard> (component A), that reads metrics and renders a chart, <x-chart> (component B). A is purely declarative and needs no JavaScript at all; B wraps a charting library, so it has a controller. Here are the whole files.

/components/a.html
<!-- /components/a.html — the <x-dashboard> component. Pure data: no <script>, no controller. -->
<link rel="component" href="./b.html">            <!-- A renders <x-chart>: declare the edge -->

<template component="x-dashboard">
  <defs>
    <prop name="title" type="string" default="Overview">Panel heading.</prop>
    <data name="metrics" src="/api/metrics">        <!-- a declared, reactive read -->
  </defs>

  <section>
    <h1 $value="title"></h1>
    <template $match>
      <p $when="metrics.pending">Loading…</p>
      <x-chart $else :series="metrics.value.series"></x-chart>
    </template>
  </section>
</template>

A declares its dependency on B with <link rel="component"> and mentions no script. Its behavior, a reactive <data> read and a $match, is entirely declarative.

/components/b.html
<!-- /components/b.html — declarative markup plus its controller dependency. -->
<template component="x-chart" controller="./b.js">
  <defs>
    <prop name="series" type="array" required>The data to plot.</prop>
  </defs>

  <figure>
    <canvas $ref="surface" role="img" aria-label="Revenue by month"></canvas>
  </figure>
</template>

B remains declarative markup. Its controller="./b.js" attribute declares the one module that supplies its imperative behavior, and the definition exposes a $ref for that controller to use. The specifier resolves relative to b.html, so the component remains self-contained when served live, installed from a package, or moved by a build.

/components/b.js
// /components/b.js — an ordinary ES module: the ONLY JavaScript in the whole tree.
import { defineController } from "html/components";  // the registration API (see below)
import { Chart } from "chart-lib";                  // a bare specifier; the import map resolves it

export const controller = (host) => {               // exported so an ahead-of-time target can lift it
  let chart;
  host.effect(() => {
    const series = host.state.series;
    if (chart) chart.update(series);                 // re-plot when series changes
    else chart = new Chart(host.refs.surface, { data: series });
  });
  host.on("disconnect", () => { chart?.destroy(); chart = undefined; });
};

// The explicit registration call, shaped exactly like customElements.define(tag, class).
// Running it registers the controller and upgrades any connected <x-chart> instances.
defineController("x-chart", controller);

The controller is the sole piece of component JavaScript in the tree, an ordinary ES module with a named export. The page imports only the root component; the controller's own bare JavaScript dependencies still use the ordinary import map in this unbundled example:

index.html
<!doctype html>
<link rel="component" href="/components/a.html">
<script type="importmap">                            <!-- ordinary JS dependencies, when unbundled -->
{
  "imports": {
    "chart-lib": "/vendor/chart-lib.js"
  }
}
</script>
<script type="module" src="/htmlnext.js"></script>

<x-dashboard title="Q3 revenue"></x-dashboard>

How it loads, step by step

  1. The browser loads /htmlnext.js (the runtime). It reads the application's concrete <link rel="component" href="/components/a.html"> root and fetches a.html. (rel="component" is not a scanner-preloaded rel today, so this fetch starts once the runtime runs; to pull it forward, pair it with a <link rel="preload">, or resolve the graph at SSR time, see below.)
  2. The runtime scans the DOM, finds <x-dashboard>, resolves it to a.html, and fetches and parses that file as inert data, executing nothing. Registering it, the runtime reads <link rel="component" href="./b.html"> and records x-chart → /components/b.html (it does not fetch B yet).
  3. It lowers <x-dashboard>: the <h1> renders, the <data> fetch to /api/metrics starts, and the $match shows Loading… while metrics.pending.
  4. When metrics resolve, the $match switches to the <x-chart> arm. Now an x-chart is needed, so b.html is fetched, parsed under the declarative component grammar, and registered. Its controller attribute records x-chart → /components/b.js; <x-chart> lowers to <figure><canvas>.
  5. The <x-chart> instance connects. Only now does the runtime run:
// Inside /htmlnext.js. URL-like values resolve from the definition;
// bare values resolve through the application's ordinary import map.
const specifier = definition.getAttribute("controller");
const controllerURL = resolveComponentSpecifier(specifier, definitionURL);

// Standard module loading. The application's CSP, CORS rules, and generated
// import-map integrity metadata apply; the component does not carry deployment policy.
await import(controllerURL);

// The module's own defineController("x-chart", …) call registers the controller
// and upgrades this instance, like a late customElements.define upgrades <my-el>.

That is the entire lazy mechanism: resolve and import the controller entry when an instance first connects. Deduplication, one-time evaluation, caching, import-map integrity, CSP, and CORS are the platform loader's job. If metrics stay empty and the chart arm never renders, neither b.html nor b.js is fetched; and with no runtime at all, steps 1–4 still produce the server-rendered declarative baseline, only the chart library is skipped.

How the security model holds

The trust boundary stays where Security puts it: at the root component package or live URL the application deliberately imports.

  • Definitions use a closed grammar. Arbitrary <script> elements and inline handlers are rejected, expressions cannot eval, and $html is sanitized. Imperative behavior has one visible, inspectable edge: the carrier's controller attribute.
  • Application policy stays outside the component. Installing a package and importing a concrete subpath trusts that package graph; the install/build resolves it without a browser import map. Live use is a separate application opt-in: its import map resolves a package prefix to a chosen remote root, and declarative definition edges cannot escape that mapped scope without another application-owned mapping. A running controller's imports remain ordinary ESM and are constrained by CSP origins, not by the directory prefix. Production tooling may generate import-map integrity metadata for resolved URLs. Component authors never add consumer approvals or hashes.
  • ESM is not a sandbox. The controller and every module it imports have the ordinary authority of page JavaScript. host is a stable adapter API, not a capability membrane. Package locks, code review, CSP, and optional generated integrity protect the normal software-supply boundary.
  • Isolation requires a different execution environment. Code that must not receive page authority belongs in a Worker or sandboxed iframe behind messages, accepting the corresponding loss of direct DOM access. That is an optional stronger deployment boundary, not something ES modules provide.
The static graph supports incremental loading

Composable: each component owns its complete dependency edges, while the application chooses only direct roots. Lazy: definitions fetch when a tag first lowers and controllers load on first connect, so an untaken branch costs nothing. Cached: definitions use HTTP caching and controllers use the module map, so a component used a hundred times evaluates its controller once. Tunable: <link rel="modulepreload"> may pull a critical controller forward, while integrity metadata can pin independently hosted bytes. SSR or a package build can resolve the graph ahead of time and emit the optimized asset set automatically.

Honest costs: async lowering, and a deep client waterfall

Client-side lowering is asynchronous (fetch B, then continue), the same way a module graph resolves. And because deep dependencies live inside fetched definition files, a chain A→B→C→D is a serial waterfall on a client-only page: nothing can preload a URL it has not discovered yet. This is the strongest argument for SSR, which resolves the entire graph on the server and ships both the finished HTML and modulepreload hints for the controllers, collapsing the waterfall. Demand-driven fetching gated on reactive state (the $match example above) additionally needs lowering to pause and resume mid-subtree; that interleaving of async loading with reactivity is the genuinely hard, still-open part of this layer.

Compiling to React, Vue, and Svelte

A controller is imperative JavaScript, so it is not transpiled into idiomatic framework code; it is run as-is. Because it is authored against host, each target ships a small host adapter that builds that interface from the framework's own primitives and runs the controller against it. The imperative body ports unchanged, provided it observes that portability contract; direct use of browser globals is allowed JavaScript but naturally makes the controller browser-specific.

XMap.vue (generated)
<!-- Vue output. The controller body is reused verbatim; only the host is adapted. -->
<script setup>
import { controller } from "./x-map.js";
import { adaptVue } from "@nextweb/html/vue";

const props = defineProps({ center: { type: Array, default: () => [0, 0] } });
const canvas = ref();
adaptVue(controller, { refs: { canvas }, state: props });
</script>

<template>
  <div ref="canvas"></div>
</template>

This works because host is deliberately tiny, and every target already has all of it natively: lifecycle (Vue onMounted/onUnmounted, Svelte onMount/onDestroy, Solid onMount/onCleanup, React useEffect), effects (Vue watchEffect, Svelte $effect, Solid createEffect), refs (Vue ref, Svelte bind:this, React useRef), and props-or-state for host.state. $ref="name" maps to each target's ref idiom; host.dispatch to its event mechanism.

React is the one that needs a bridge

React has no native fine-grained reactivity, so its adapter backs host.effect and host.state with an external store (via useSyncExternalStore) rather than a signal. That wart is contained to the React adapter; the controller and every other target are unaffected.

Lifecycle and hydration

A controller's connect fires when the instance connects, on first mount and on any later reconnection, matching the custom-element reaction rather than a once-only mount1. On a server-rendered page, the markup arrives already lowered and inert; the controller attaches at hydration, where connect first runs in the browser. disconnect fires on removal, running teardown. Because the first render never depends on the controller, hydration is adopt-in-place: the controller binds to existing nodes rather than rebuilding them (see Components).

Reference

defineController link a controller to a tag

Signature
defineController(tag, host => { … })
Native kin
customElements.define(tag, class)
Runs
once per instance, at connect (and at hydration on SSR pages)
Level
L2+ reserved

host the instance window passed to a controller

Provides
element, state (declared state only is writable), refs, elements, on, effect, dispatch
Effect lifetime
connected only; dependency changes are microtask-batched; cleanup runs before rerun and on disconnect; the returned stop function ends it early
Native kin
ElementInternals + :host
Rule
drives state, never bound DOM; owns only its foreign subtree
Level
L2+ reserved

$ref declared element handle

Value
a name; host.refs.name is the element
Semantics
a $ directive, consumed at lowering; no attribute ships
Form controls
use their native name via host.elements instead
Level
L2+ reserved

References

  1. WHATWG HTML, custom elements (customElements.define, connectedCallback/disconnectedCallback): a controller is the same behavior, attached by tag to a data-defined component instead of authored as a class.
  2. WHATWG HTML, ElementInternals and custom states with CSS :state(): the native model for the internal state of an element, generalized here from boolean flags to values.
  3. CSS Scoping, Shadow Parts (part / ::part()) and :host: the platform words for a component element seen from inside, and for a named internal piece exposed to outside CSS.
  4. TC39, Signals (State, Computed, and Watcher): a candidate low-level basis for host.effect. The proposal explicitly does not provide effects, scheduling, DOM ownership, or automatic disposal; this DOM-facing layer supplies them.
  5. Lit, Reactive Controllers (hostConnected/hostDisconnected, a controller object attached to a host): direct prior art for the controller-plus-host shape, adapted to a script-free definition.
  6. W3C, HTML Imports (discontinued): the approach this layer deliberately avoids, fusing markup, style, and executing script into one imported document.
  7. WHATWG HTML, module-script fetching, Content Security Policy, and Subresource Integrity: the existing loading and deployment controls used by controllers.
  8. WHATWG DOM, CustomEvent and dispatchEvent: what host.dispatch lowers to.
  9. WHATWG HTML, form.elements named access: the native precedent for reaching a control within a container by its name, echoed by host.elements.

Declarative HTML Components: The JavaScript Layer module. Unofficial Editor's Draft · Stage 0. Published in the HTML Next collection by the Next Web Working Group; not a W3C or WHATWG deliverable. Version history.