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
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 hatchThe 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 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.
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: pure data. It names a $ref and a state, and mentions no JavaScript. -->
<template component="x-map">
<defs>
<state name="center" :value="[0, 0]">
</defs>
<div $ref="canvas"></div>
</template>// 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 as a pure function of host and exported, with the tag registration a thin wrapper around it. That export is what lets the same controller run unchanged in the browser runtime and compile to a framework target (below). The definition, meanwhile, references no JavaScript symbol: it declares a <state> and a $ref, and the controller reaches in through those.
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.
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 host | Does | Native anchor |
|---|---|---|
host.state | Read 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.name | The element declared with $ref="name". | captured at lowering (see below) |
host.elements.name | A 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.
The surface reads like userland (state, effect, refs), but each name has a standards anchor: defineController ↔ customElements.define, host ↔ :host, connect/disconnect ↔ the custom-element reactions, effect ↔ the Signals proposal, host.elements ↔ form.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.
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.
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.
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.
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">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" (declarative, like <template shadowrootmode>) -->
<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 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.
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.
Because registration is by tag, loading a controller needs no special mechanism, it is just loading a module that calls defineController:
<!-- EAGER: load the controller module like any script. It calls defineController; instances upgrade.
This is identical to how you ship a custom element today. -->
<script type="module" src="/components/b.js"></script>
<!-- LAZY (optional): do not load it yet. The definition hints where the controller lives, and the
runtime defers import() until the first <x-chart> connects. The hint is a loader detail, not the wiring. -->
<link rel="controller" href="./b.js"> <!-- declared inside b.html -->Eagerly, you load the controller module like any script and it self-registers, identical to shipping a custom element. Lazily, the definition carries a <link rel="controller"> hint and the runtime defers the import() until the first instance connects. That deferral is the entire extent of what the runtime adds, and even it resolves to a standard dynamic import:
// Inside /htmlnext.js. b.html's <link rel="controller"> told it: x-chart -> /components/b.js.
// On the FIRST connect of an <x-chart> that has no controller registered yet, it does one thing:
const url = controllerHint.get("x-chart"); // "/components/b.js", from the <link rel="controller">
if (url) await import(url); // runs the module; the module's OWN defineController("x-chart", …)
// call registers the controller and upgrades this instance,
// exactly like a late customElements.define upgrades <my-el>.
// (If a <link rel="modulepreload" integrity> was declared, the
// browser already hash-checked b.js and import() reuses it.)So the design reduces to two registries that never overlap, both keyed by tag:
| Registry | Holds | Populated by | Native kin |
|---|---|---|---|
| Components | data: tag → definition | <template component> (declarative) or components.define | customElements1 |
| Controllers | behavior: tag → controller | a module calling defineController | ES modules7 |
HTML Imports6 failed by fusing markup, style, and executing script into one imported document with global side effects and fragile ordering, then overlapping ES modules. HTML Next inverts every part: registering a component is data (a parsed <template component>, no script executed, idempotent, order-independent), and behavior is a separate ES module that self-registers by tag with an explicit call, loaded by the platform module system, not a second ad-hoc loader. The tag join is the same one custom elements already use.
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 — 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 — the <x-chart> component. Data plus a DECLARED controller. Still no <script>. -->
<link rel="controller" href="./b.js"> <!-- which module is x-chart's controller -->
<link rel="modulepreload" href="./b.js" integrity="sha384-…"> <!-- optional: pin the hash + warm the cache -->
<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 pure data, but it declares a controller with <link rel="controller"> (the tag → module mapping the runtime reads), and optionally pins its bytes with a companion <link rel="modulepreload" integrity>, since a bare import() cannot itself carry SRI. The definition still contains no <script>; it only names where the behavior lives and exposes a $ref for it to grab.
// /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 JavaScript in the tree, an ordinary ES module with a default export. The page wires only the entry:
<!doctype html>
<link rel="component" href="/components/a.html"> <!-- resolve only the ENTRY; B comes from A -->
<script type="module" src="/htmlnext.js"></script> <!-- the runtime: one module -->
<script type="importmap"> <!-- import map: JS bare specifiers, its real job -->
{ "imports": { "chart-lib": "/vendor/chart-lib.js" } }
</script>
<x-dashboard title="Q3 revenue"></x-dashboard>/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.)<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).<x-dashboard>: the <h1> renders, the <data> fetch to /api/metrics starts, and the $match shows Loading… while metrics.pending.$match switches to the <x-chart> arm. Now an x-chart is needed, so b.html is fetched (again, inert data), registered, and its <link rel="controller"> records x-chart → /components/b.js. <x-chart> lowers to <figure><canvas>.<x-chart> instance connects. Only now does the runtime run:// Inside /htmlnext.js. b.html's <link rel="controller"> told it: x-chart -> /components/b.js.
// On the FIRST connect of an <x-chart> that has no controller registered yet, it does one thing:
const url = controllerHint.get("x-chart"); // "/components/b.js", from the <link rel="controller">
if (url) await import(url); // runs the module; the module's OWN defineController("x-chart", …)
// call registers the controller and upgrades this instance,
// exactly like a late customElements.define upgrades <my-el>.
// (If a <link rel="modulepreload" integrity> was declared, the
// browser already hash-checked b.js and import() reuses it.)That is the entire mechanism: import(url), the platform's own dynamic import, on a URL the runtime read from a <link>, at the moment an instance connects. Deduplication, one-time evaluation, and caching are the standard ES-module loader's job because it is the standard loader; the runtime adds nothing. If metrics stay empty and the chart arm never renders, neither b.html nor b.js is ever fetched; and with no runtime at all, steps 1–4 still produce the server-rendered declarative baseline, only the chart library is skipped.
The trust boundary stays exactly where Security puts it, because loading a component graph introduces no new code-execution primitive:
a.html and b.html are fetched and parsed as inert data; a definition contains no <script>, expressions cannot eval, and $html is sanitized. Importing a component can never run code, so it is not an execution vector, the precise opposite of HTML Imports.import(), which is governed by the page's script-src exactly like any module, so a controller pointed at a disallowed origin is blocked; there is no bespoke loader that bypasses CSP. To pin the exact bytes, declare a companion <link rel="modulepreload" integrity>: the browser hash-checks the module at preload and the later import() reuses that verified module. (A bare import(url) carries no SRI on its own, so integrity rides on modulepreload, or an import-map integrity entry, not on rel="controller".)<link rel="controller">, each a normal module. Auditing "what JavaScript runs" is reading that list, not combing a bundle.script-src and pinnable with modulepreload integrity. The coarse gate is per-origin CSP; a finer per-controller allowlist the runtime consults before importing is an open question (transitive deps can carry their own controllers, so "decline this one controller" needs a real mechanism, not just prose). "Do I trust this component" still separates into a safe half (markup) and a dependency half (its controller).Composable: each component owns its edges (<link rel="component">) and its controller (<link rel="controller">); the page names only the entry, and A→B→C→D is discovered transitively like an ES-module graph. Adding a component changes nothing upstream. Lazy: definitions fetch when a tag first lowers, controllers import() on first connect, so a 500-component design where a page uses twelve loads twelve definitions and only the controllers that actually connect; an untaken branch costs nothing. Cached: definitions by HTTP, controllers once per URL by the module loader, so a component used a hundred times on a page loads its controller once. Tunable: <link rel="modulepreload"> pulls a critical controller forward (it is a real, scanner-recognized rel, unlike rel="component"), and, best of all, SSR resolves the whole graph server-side and ships the resolved HTML plus preload hints for the critical set, no bundler required, though a build step may still bundle for production.
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.
A controller is imperative JavaScript, so it is not transpiled into idiomatic framework code; it is run as-is. Because it is a pure function of host, each target ships a small host adapter that builds a host from that framework's own primitives and runs the controller against it. The imperative body ports unchanged.
<!-- 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 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.
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).
defineController(tag, host => { … })customElements.define(tag, class)state (read/write), refs, elements, on, effect, dispatchElementInternals + :hosthost.refs.name is the element$ directive, consumed at lowering; no attribute shipsname via host.elements insteadcustomElements.define, connectedCallback/disconnectedCallback): a controller is the same behavior, attached by tag to a data-defined component instead of authored as a class.part / ::part()) and :host: the platform words for a component element seen from inside, and for a named internal piece exposed to outside CSS.host.effect, a computation re-run when its tracked reads change.hostConnected/hostDisconnected, a controller object attached to a host): direct prior art for the controller-plus-host shape, adapted to a script-free definition.host.dispatch lowers to.name, echoed by host.elements.