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

Why a JavaScript layer at all

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.

The no-JS baseline is an SSR guarantee

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 a controller REQUEST. The link does not authorize code. -->
<link rel="controller" href="./x-map.js">
<template component="x-map">
  <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) => {
  const map = new MapLib(host.refs.canvas);             // a declared $ref, never a querySelector
  host.effect(() => map.setCenter(host.state.center));  // re-runs whenever center changes
  host.on("disconnect", () => map.destroy());           // teardown
};

// 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 requested controller URL as metadata, plus a <state> and a $ref; only the application can authorize that request.

Definitions name declarative behavior, never JavaScript

Markup references a declarative handler by name (on:click="save" → a <handler>). It never names an imperative JavaScript function. The controller attaches the other way around, from the module to the component through its contract, so the definition stays portable data and no JavaScript path is baked into it.

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.

On hostDoesNative anchor
host.stateRead and write the instance's reactive state. A write flows through the dependency graph to every binding that reads it.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 now and re-run it whenever a state path it read changes; auto-disposed on disconnect.TC39 Signals effects4
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.

Grounded in the platform, not a framework

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.

Not part, and not a styling hook

Shadow DOM's part / ::part()3 is a superficially similar idea, a named inner element, but it is a CSS mechanism for piercing a shadow boundary, inert without one, and HTML Next has no shadow boundary. $ref is a JavaScript handle, consumed at lowering. They are different tools; part remains available for its real job, exposing a CSS theming surface to consumers.

Ownership: drive state, not the DOM

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. Markup and behavior are wired the way the platform already wires them: by a tag name in a registry, exactly as custom elements join <my-el> to a class through customElements.define("my-el", …). Three things register under one tag, and none references any other:

<!-- 1. the DEFINITION, registered under the tag "x-chart" -->
<link rel="controller" href="./x-chart.js">  <!-- discovers a request; does not authorize it -->
<template component="x-chart"></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">, the same way the parser acts on <template shadowrootmode>, or imperatively through the registry. The controller 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 join is the string "x-chart"; nothing points at anything else.

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: the application authorizes code

Because registration is by tag, loading a controller reduces to loading a module that calls defineController. What matters is the difference between naming and authorizing. A transitive component definition names the module it requests with <link rel="controller" href>, keeping its dependency local and discoverable. The application independently maps the conventional bare specifier html-next-controller/tag to the approved URL, or loads the module eagerly itself:

<!-- EAGER: the application loads and authorizes the exact module. -->
<script type="module" src="/components/b.js" integrity="sha384-…"></script>

<!-- LAZY: the definition names the controller it requests. Importing this file runs nothing. -->
<link rel="controller" href="./b.js">
<template component="x-chart"></template>

<!-- The application owns the tag-to-module decision. An unmapped request executes nothing. -->
<script type="importmap">
{
  "imports": {
    "html-next-controller/x-chart": "/components/b.js"
  },
  "integrity": {
    "/components/b.js": "sha384-…"
  }
}
</script>

Eagerly, the application loads the controller module like any script and it self-registers, identical to shipping a custom element. Lazily, the runtime records the definition's requested URL but does not import it. On first connect, import.meta.resolve()7 resolves the conventional specifier through the application's ordinary import map. Only an exact URL match can load; an absent mapping throws before fetch and a different mapping fails closed:

// Inside /htmlnext.js. b.html requested /components/b.js for x-chart.
const requested = controllerRequest.get("x-chart");
const specifier = "html-next-controller/x-chart";
const approved = import.meta.resolve(specifier); // resolves through the APPLICATION'S import map

if (requested !== approved) return; // absent or different approval: execute nothing
await import(specifier);             // CSP and CORS apply; import-map integrity pins the bytes

// The approved 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 exact boundary from HTML Imports

HTML Imports6 made one transitive document import responsible for markup, styles, and scripts; scripts in the imported document could execute. HTML Next separates two grants. <link rel="component"> grants permission to fetch and lower data only. A controller link inside that data discovers a requested dependency, but cannot make it execute. Executable authority comes only from the importing application's matching import-map entry or explicit module script. This is a real improvement only if that approval is independently enforced; automatically importing whatever URL a fetched definition names would recreate HTML Imports' most important trust problem under different syntax.

ES modules help with standardized fetching, CORS, CSP, dependency graphs, deduplication, and one-time evaluation. They do not sandbox authority. Once an application approves a controller, that controller and its transitive module graph are trusted page JavaScript.

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 — data that NAMES a requested controller but cannot authorize it. -->
<link rel="controller" href="./b.js">

<template component="x-chart">
  <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 is also data. Its <link rel="controller" href="./b.js"> identifies the module it requests, so the component remains self-describing, but the link has no execution semantics. The application independently maps html-next-controller/x-chart → /components/b.js and pins those bytes in its import map. The definition contains no <script> or JavaScript symbol; it records a dependency request and exposes a $ref for an approved controller to use.

/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
  const chart = new Chart(host.refs.surface, { data: host.state.series });
  host.effect(() => chart.update(host.state.series));   // re-plot when series changes
  host.on("disconnect", () => chart.destroy());         // teardown
};

// 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 owns the executable mapping:

index.html
<!doctype html>
<link rel="component" href="/components/a.html">     <!-- resolve only the ENTRY; B comes from A -->
<script type="importmap">                            <!-- the APPLICATION authorizes executable modules -->
{
  "imports": {
    "html-next-controller/x-chart": "/components/b.js",
    "chart-lib": "/vendor/chart-lib.js"
  },
  "integrity": {
    "/components/b.js": "sha384-…",
    "/vendor/chart-lib.js": "sha384-…"
  }
}
</script>
<script type="module" src="/htmlnext.js" integrity="sha384-…"></script>

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

How it loads, step by step

  1. The browser loads /htmlnext.js (the runtime) as a module. The runtime reads <link rel="component" href="/components/a.html"> and fetches a.html. (rel="component" is not a scanner-preloaded rel, 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 (again, inert data), registered, and its controller link records the resolved request x-chart → /components/b.js. Nothing executes. <x-chart> lowers to <figure><canvas>.
  5. The <x-chart> instance connects. Only now does the runtime run:
// Inside /htmlnext.js. b.html requested /components/b.js for x-chart.
const requested = controllerRequest.get("x-chart");
const specifier = "html-next-controller/x-chart";
const approved = import.meta.resolve(specifier); // resolves through the APPLICATION'S import map

if (requested !== approved) return; // absent or different approval: execute nothing
await import(specifier);             // CSP and CORS apply; import-map integrity pins the bytes

// The approved 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: compare the discovered request with import.meta.resolve("html-next-controller/" + tag), then use standard import(specifier) at the moment an instance connects. Deduplication, one-time evaluation, and caching are the ES-module loader's job. If the application did not approve that exact tag and URL, no code runs. 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 because definition discovery and code authorization are separate decisions:

  • Definitions cannot authorize execution. a.html and b.html are fetched and parsed as data; scripts and inline handlers are rejected, expressions cannot eval, and $html is sanitized. A transitive definition can name a requested controller URL, but that metadata cannot make it run.
  • The application owns the executable graph. Its ordinary import map must map the conventional tag specifier to the same resolved URL, or it must load the module eagerly itself. CSP and CORS still apply. Integrity metadata can pin independently hosted bytes without becoming author ceremony: production tooling should generate it where the deployment threat model calls for it. An absent or mismatched approval leaves the declarative component working without its enhancement.
  • ESM is not a sandbox. After approval, the controller and every module it imports have the ordinary authority of page JavaScript. host is a stable adapter API, not a capability membrane. The application's review surface is therefore its controller mappings plus their transitive dependency graphs, not merely the requests in component files.
  • 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.
Why it scales

Composable: each component owns its data dependencies and may request behavior, while the application retains the finite controller allowlist. Adding a purely declarative component changes nothing upstream; adding executable behavior requires an application policy change. Lazy: definitions fetch when a tag first lowers and approved controllers import() 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 an already-authorized critical controller forward as a performance hint, while import-map integrity or an integrity-bearing module script pins executable bytes. SSR can resolve the definition graph server-side and emit preload hints for the approved critical set.

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
state (read/write), refs, elements, on, effect, dispatch
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 (computed values and effects): the standards-track basis for host.effect, a computation re-run when its tracked reads change.
  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, import maps, their integrity metadata, and import.meta.resolve(): the existing name resolution, byte-pinning, and module-loading machinery used for approved 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.